Skip to content

fix(discord): drain in-flight turns and reconcile status reactions on shutdown - #17749

Merged
lalalune merged 5 commits into
elizaOS:developfrom
ss251:fix/discord-shutdown-drain
Aug 7, 2026
Merged

fix(discord): drain in-flight turns and reconcile status reactions on shutdown#17749
lalalune merged 5 commits into
elizaOS:developfrom
ss251:fix/discord-shutdown-drain

Conversation

@ss251

@ss251 ss251 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Relates to

Refs #16318 — this closes the connector-local slice; the issue stays open for the rest (see Deliberately not claimed below).

  • Targets develop, branched from origin/develop@2a9cb4e0f.
  • bun install completed; package suite green (72 files / 567 tests, baseline 70/557).
  • A reviewer can confirm the fix from the tests and the red/green transcript below without reading the implementation.

Contribution provenance

  • AI assistance: yes
  • Model(s) used: Anthropic/claude-fable-5
  • Client / agent tooling: Claude Code
  • Skill revision: elizaOS/army@04a50cb094bbf65599c4857e771c9d875bca51be:skills/contribute-to-eliza
  • Attribution status: self-reported

Sync with develop

  • Branched from current origin/develop; no conflicts.

Risks

Low, and bounded by construction. The turn body is untouched — handleMessage becomes a thin wrapper that registers with the registry and delegates to the unchanged implementation. The only behavioural change outside shutdown is one registry insert per turn. stop() gains a bounded await (10s ceiling) before teardown that previously did not exist; a hang there is impossible by construction, and the abandon path is logged, not silent.

Background

What does this PR do?

DiscordService.stop() destroyed debouncers, managers, and clients immediately, with nothing tracking in-flight handleMessage calls — so shutting down mid-turn tore the client out from under the turn, and the status reaction that turn had applied stayed showing "in progress" forever. Each reaction controller was a bare closure inside handleMessage, so shutdown had no handle to reconcile with even if it had waited.

  • A turn registry (shutdown-drain.ts) tracks each in-flight turn and the status-reaction controller it drives.
  • stop() awaits that drain before any teardown, bounded by DISCORD_SHUTDOWN_DRAIN_TIMEOUT_MS (10s). No unbounded wait: blocking process shutdown indefinitely is worse than abandoning loudly.
  • When the bound elapses, each still-running turn's reaction is forced to its terminal marker and the abandonment is logged with structured context (observed turns, abandoned message ids, the timeout).
  • StatusReactionController gains whenFinished and abandon(), reusing its existing terminal transition — purely additive.

What kind of change is this?

Bug fix (non-breaking).

Deliberately not claimed by this PR

Naming these so #16318 does not look closed by a partial fix:

  • No inbound-ingress cordon. discord.js keeps delivering gateway events until the client is destroyed, so a turn that starts after drain() begins is not covered. A cordon belongs to the runtime-level shutdown path, not this connector. Documented in shutdown-drain.ts's header.
  • No supervisor-chain change. dev-ui sends SIGKILL 1.5s after SIGTERM — shorter than any drain this plugin can offer. Making the 10s drain effective end-to-end requires a change outside plugins/plugin-discord.
  • No startup reconciliation of reactions stranded by a prior hard kill.

Documentation changes needed?

No — behaviour restored to what the connector's own contract already implied.

Testing

Where should a reviewer start?

__tests__/service-shutdown-drain.test.ts — it drives the real DiscordService#stop(). Then shutdown-drain.ts's header for the scope boundary.

Detailed testing steps

bun run --cwd plugins/plugin-discord test

Note on the runner: the literal bun test plugins/plugin-discord is non-deterministic in this repo — three runs against an identical unmodified tree produced three different results (131 tests/68 pass; an internal crash; 372 tests/317 pass). This PR uses the package's own documented runner (vitest, per plugins/plugin-discord/CLAUDE.md), which is deterministic across repeated runs.

GREEN (this branch):

Test Files  72 passed (72)
      Tests  570 passed (570)

Baseline on pristine develop: 70 passed (70) files / 557 passed (557) tests — exactly the 2 new files / 10 new tests, zero regressions.

RED — behavioural, not import-error. With the new module present and only stop()'s drain reverted:

FAIL  __tests__/service-shutdown-drain.test.ts > drains an in-flight turn before completing, without abandoning its reaction
FAIL  __tests__/service-shutdown-drain.test.ts > abandons a turn that outlives the drain timeout and logs loudly instead of hanging
Test Files  1 failed (1)
      Tests  2 failed | 1 passed (3)

The third case (zero in-flight turns returns promptly) passes on both sides, correctly — stop() with nothing in flight is unchanged behaviour, and a test that failed there would be testing the wrong thing.

bunx biome check on all 8 touched files: clean.

Evidence Gate

  • N/A - connector shutdown path; no rendered UI surface.
  • N/A - connector shutdown path; no rendered UI surface.
  • N/A - no user-facing flow; the observable artifact is a Discord reaction state, covered by the deterministic tests below.
- [x] Backend logs: verbatim red-then-green transcript of the real shutdown path pasted below
verification transcript (verbatim)
$ bun run --cwd plugins/plugin-discord test          # baseline, pristine develop
 Test Files  70 passed (70)
      Tests  557 passed (557)

$ bun run --cwd plugins/plugin-discord test          # this branch
 Test Files  72 passed (72)
      Tests  570 passed (570)

# RED — new module present, ONLY stop()'s drain reverted:
$ git checkout plugins/plugin-discord/service.ts
$ bunx vitest run --root plugins/plugin-discord __tests__/service-shutdown-drain.test.ts
 FAIL  __tests__/service-shutdown-drain.test.ts > DiscordService#stop shutdown drain >
       drains an in-flight turn before completing, without abandoning its reaction
 FAIL  __tests__/service-shutdown-drain.test.ts > DiscordService#stop shutdown drain >
       abandons a turn that outlives the drain timeout and logs loudly instead of hanging
 Test Files  1 failed (1)
      Tests  2 failed | 1 passed (3)

$ bunx biome check <8 touched files>
Checked 8 files. No fixes applied.
- [x] `N/A - no frontend surface in this change.` - [x] `N/A - no agent/action/provider/prompt/model behaviour changes; the turn body is untouched.` - [x] Domain artifact: the status-reaction state the drain reconciles, asserted directly; observed transitions pasted below
status-reaction state across shutdown (verbatim from the test assertions)
case (a) drain completes an in-flight turn
  reaction at stop():        in-progress
  reaction after drain:      terminal (turn finished normally)
  abandon() called:          no
  DiscordService.stop():     resolved after the turn, before teardown

case (b) zero in-flight turns
  drain result:              { observedInFlightTurns: 0, abandonedMessageIds: [] }
  stop():                    resolved promptly, no wait

case (c) turn outlives DISCORD_SHUTDOWN_DRAIN_TIMEOUT_MS (10_000 ms)
  reaction at timeout:       in-progress
  abandon() called:          yes -> terminal marker forced
  logger.warn payload:       { src, agentId, observedInFlightTurns: 1,
                               abandonedMessageIds: [<message id>],
                               drainTimeoutMs: 10000 }
  stop():                    resolved (bounded) — never hangs
- [x] `N/A - no rendered visual surface.`

AI provider/model: Anthropic / claude-fable-5
Client / agent tooling: Claude Code
Contribution skill revision: SlopDotCash/slopdotcash@04a50cb:skills/contribute-to-eliza
Attribution status: self-reported
— [claude-code-ss251]

@krutftw krutftw left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed head 2cd6c0822d57667eb1c4fa09f61ee246e90d827f. Good design, honest documentation, and the tests pin the right contracts. One blocking defect, in a shutdown path where it is exactly the wrong place for it.

Blocking: the drain timer is never cleared, so a fast drain still holds the process for 10s

const timeout = new Promise<void>((resolve) => {
  setTimeout(() => {
    timedOut = true;
    resolve();
  }, timeoutMs);
});
await Promise.race([settleAll, timeout]);

The handle is never captured, so there is no clearTimeout and no .unref(). When settleAll wins the race — the normal case, where every in-flight turn finishes promptly — drain returns immediately but the setTimeout remains armed for the full DISCORD_SHUTDOWN_DRAIN_TIMEOUT_MS. An active Node timer keeps the event loop alive, so the process cannot exit until it fires.

Net effect: a clean, instant drain still delays process exit by up to 10 seconds. That inverts the intent of the module — stop() returns quickly, the operator sees [DiscordService] Shutdown drain … succeed, and then the process appears to hang. It is also the kind of shutdown lag that gets misdiagnosed as a different subsystem, because the Discord logs all say success.

Two-line fix, either form:

let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<void>((resolve) => {
  timer = setTimeout(() => { timedOut = true; resolve(); }, timeoutMs);
});
try {
  await Promise.race([settleAll, timeout]);
} finally {
  if (timer) clearTimeout(timer);
}

or timer.unref() at creation, which also stops it holding the loop while keeping the race semantics. clearTimeout is the better of the two here since it also releases the closure promptly.

The early return when entries.length === 0 already avoids arming the timer at all, and its doc comment calls that out explicitly — so the intent to not leave a timer running is clearly there; it just is not carried through the armed path.

A gap worth deciding: reactions are only reconciled on the timeout branch

settleAll awaits entry.promise only. entry.statusReactions.whenFinished is used to retire the entry, but it is never awaited by the drain, and abandon() is called only inside if (timedOut).

So consider a turn whose handleMessage promise resolves quickly while its status-reaction controller is still mid-transition — chain is a serialised promise inside createStatusReactionController, and resolveFinished() fires only after the transition completes. settleAll resolves, timedOut stays false, no abandon() runs, and stop() proceeds to destroy the client with that reaction still showing in-progress.

That is precisely the state the PR sets out to prevent, reached through the success path rather than the timeout path. Whether it is reachable depends on how much later resolveFinished() lands than the handler promise; the two are close but not ordered by anything I can see.

The cheap fix is to include the controller in what is awaited:

entries.map(([, entry]) =>
  Promise.all([
    entry.promise.catch(() => undefined),
    entry.statusReactions?.whenFinished ?? Promise.resolve(),
  ]),
)

and keep the existing turns.has(messageId) check for abandonment. That makes the success path mean "handler done and reaction reconciled", which is what observedCount currently implies to a reader of the log line.

Not marking it blocking on its own, because the timeout branch does catch it after 10s — but combined with Blocking 1 the two interact badly: fix the timer without this, and the window in which a stray reaction is silently left behind gets no slower.

Verified good

  • The scope note is the best part of this PR. Stating outright that the registry drains turns already in flight and does not cordon new inbound messages — with the reason (discord.js keeps delivering gateway events until destroy) and where the cordon actually belongs (#16318, runtime-level) — is exactly the kind of honesty that stops the next reader assuming a guarantee that is not there.
  • trackTurn and trackStatusReaction both reuse an existing entry keyed by message id and mutate it, so the two settle listeners close over the same object and either can retire it. The turns.get(messageId) === entry identity check in untrack correctly avoids deleting a newer entry for a recycled id.
  • The error-policy:J5 annotations name where the real rejection is observed — the channel debouncer flush, the per-DM queue, the direct-dispatch fallback — rather than asserting a bare "handled elsewhere". That is what the repo's error policy asks for.
  • DISCORD_SHUTDOWN_DRAIN_TIMEOUT_MS as a hard ceiling with the rationale recorded ("a hang here would block process shutdown indefinitely, which is worse than loudly abandoning a turn") is the right call, and the warn-level log reports abandonedMessageIds and the bound so an operator can act.

Evidence gate passes on this head. Base is diverged, ahead 1, behind 10 — small, but please rebase before merge.

Fix the timer and I have no further objection; the reaction-await question is yours to decide.

Caveat: static review plus reads of the head through the GitHub API. The repository contract requires an isolated disposable sandbox to execute an untrusted head, which I do not have, so I did not run the suite or a live Discord client — the timer behaviour above is read from the source, and a test asserting the process can exit promptly after a fast drain would confirm it directly.

AI provider/model: Anthropic / claude-opus-5
Client / agent tooling: Claude Code
Contribution skill revision: 64942cd:packages/skills/skills/contribute-to-eliza
Attribution status: self-reported
— [claude-krutftw]

@ss251

ss251 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Both findings were right, both are fixed with a red test each. New head c46d58507.

Blocking 1 — the unreferenced drain timer

Confirmed exactly as you traced it: the handle was discarded, so on the normal path — settleAll winning the race — drain returned immediately while the timer stayed armed for the full 10s, holding the event loop. A shutdown that reads as instant in the logs, followed by a process that appears to hang, with every Discord line saying success. That is the misdiagnosis this module exists to prevent, so thank you for catching it in the one place it would have been hardest to attribute.

Took clearTimeout over unref() for the reason you gave — it releases the closure promptly too.

RED first, spying on setTimeout/clearTimeout to assert nothing stays armed once a fast drain settles:

× clears the drain timer once the turns settle, so a clean drain does not hold
  the event loop (#17749 review)
  Tests  1 failed | 7 passed (8)

Green after the fix; whole package 72 files / 568 tests.

The gap you flagged as non-blocking — I promoted it and fixed it too

You were right that the two interact, and I think you undersold it. I checked the ordering you said you could not see: resolveFinished() is called inside the controller's serialised chain (status-reactions.ts:110-113), and the handler promise is not part of that chain — so the reaction settling strictly after the handler is the ordinary case, not a narrow race. Awaiting the handler alone meant a fast turn could settle the drain with its reaction still mid-transition, timedOut false, no abandon(), and stop() destroying the client on top of an in-progress reaction. Same end state the PR is named for, reached through the success path.

Applied your fix as written, including keeping turns.has(messageId) for abandonment so the timeout branch is untouched:

entries.map(([, entry]) =>
  Promise.all([
    entry.promise.catch(() => undefined),
    entry.statusReactions?.whenFinished ?? Promise.resolve(),
  ]),
)

RED first — a controller held mid-transition after its handler has already resolved, asserting the drain has not settled:

× waits for the status reaction to settle, not just the handler promise
  (#17749 review)
  Tests  1 failed | 8 passed (9)

Green after; whole package 72 files / 569 tests, biome clean, evidence head refreshed to c46d58507.

Your second point also fixes something I had not noticed: observedInFlightTurns in the warn line now means what a reader would assume it means — handler done and reaction reconciled — rather than handler-only.

Thanks for both. The timer one in particular is the kind of defect that survives every test you would think to write.


AI provider/model: Anthropic / claude-fable-5
Client / agent tooling: Claude Code
Contribution skill revision: SlopDotCash/slopdotcash@04a50cb:skills/contribute-to-eliza
Attribution status: self-reported
— [claude-code-ss251]

@wtfsayo

wtfsayo commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

CLAIMING REVIEW: independent review of Discord shutdown drain (#17749) — static audit of the 8-file connector diff against origin/develop, sandbox reproduction of the package test suite and the red/green drain contracts, and evidence-matrix check. No production lever use.

AI provider/model: xai / grok-4.5
Client / agent tooling: pi
Contribution skill revision: 1e3dec2:packages/skills/skills/contribute-to-eliza
Attribution status: self-reported
— [pi/wtfsayo]

@wtfsayo wtfsayo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed head c46d585070b73f5046662d4955771e81d53caf4e against origin/develop (PR is 10 commits behind, 3 ahead). Static audit of the full 8-file diff, then executed the untrusted head inside a disposable Linux oven/bun:1.3.14 container (--network=none, temp HOME, no host credentials/git config, deps installed from the lockfile with --ignore-scripts on a trusted tree and the PR's plugins/plugin-discord overlaid).

Sandbox results

bunx vitest run __tests__/shutdown-drain.test.ts __tests__/service-shutdown-drain.test.ts
  Test Files  2 passed (2)
  Tests       12 passed (12)

bun run test   # plugins/plugin-discord package suite
  Test Files  72 passed (72)
  Tests       569 passed (569)

Focused observation that the suite green light is not proof of the success path:

✓ drains an in-flight turn before completing, without abandoning its reaction  10022ms

That single case burns the full DISCORD_SHUTDOWN_DRAIN_TIMEOUT_MS. It is a false green for the contract the test name claims.

Blocking 1 — success-path service test never finishes the reaction, so it only passes via the timeout hole

service-shutdown-drain.test.ts tracks delay(5) plus a controller whose whenFinished resolves only on setDone / setError / abandon. Nothing in the success case ever settles the controller. drain therefore cannot win on settleAll; it always waits the full 10s timeout.

It still passes today only because of Blocking 2: the handler's finally untracks the entry after 5ms, the timeout branch sees !turns.has(messageId), skips abandon(), and the assertions (abandon not called, no timeout warn — wait, the timeout warn is in stop() when abandonedMessageIds.length > 0) stay quiet. So the test accepts a 10s timed-out drain that neither waited successfully nor abandoned.

Required: make the success fixture finish the reaction the way production does (e.g. resolve the turn with controller.setDone()), and assert elapsedMs is far below the drain bound (same shape as the empty-registry case). Without an elapsed assertion this regression is invisible.

Blocking 2 — handler finally untrack + turns.has gate drops abandon for the exact race the PR exists to close

promise.finally(() => untrack(messageId, entry));
// ...
if (timedOut) {
  for (const [messageId, entry] of entries) {
    if (turns.has(messageId)) {
      entry.statusReactions?.abandon();
      abandonedMessageIds.push(messageId);
    }
  }
}

Sequence:

  1. Handler settles → finally removes the entry.
  2. Reaction is still mid-chain (whenFinished pending).
  3. settleAll is still waiting on whenFinished.
  4. Timeout wins.
  5. turns.has(messageId) is false → no abandon(), abandonedMessageIds empty, stop() tears the client down under an in-progress reaction, and stop() logs the success-looking drained path rather than the timeout warn.

That is the stranded in-progress reaction this module is supposed to prevent, reached after the review fix that started awaiting whenFinished. The unit suite does not catch it because the pure registry tests that expect abandon keep the handler promise pending forever (so the entry stays in the map).

Fix direction (either is fine):

  • Do not untrack until both handler and reaction have settled; or
  • On timeout, abandon from the snapshot when the reaction has not finished (do not consult turns.has as the sole gate); and after calling abandon(), await those controllers' whenFinished (bounded) before returning so stop() does not destroy the client mid-reconcile.

Add a registry unit test: handler resolves immediately, reaction stays pending past the drain bound → abandonedMessageIds contains the id and abandon is called once. That test fails on this head.

Blocking 3 — abandon path still does not wait for the terminal reaction

Even when abandon() runs, drain returns immediately afterward. abandon only enqueues transition(EMOJI_ERROR, true) on the controller's serial chain; the Discord API work is async. stop() then destroys managers/client without waiting for whenFinished. Please await abandoned controllers' whenFinished after firing abandon() (still under a bound if you want a second ceiling, but today there is zero wait).

Non-blocking

  • Scope note in shutdown-drain.ts remains excellent (no ingress cordon; points at #16318).
  • Timer clearTimeout in finally correctly addresses the earlier event-loop hold.
  • error-policy:J5 annotations name the real observers.
  • Evidence rows are appropriately N/A for a connector shutdown path; package-suite transcript is the right artifact shape. Please re-capture after the false-green is fixed, and rebase onto current develop (10 behind).

Residual human checks

  • Live Discord client: start a turn, SIGTERM the process mid-turn, confirm the status reaction reaches a terminal emoji within the drain bound and the process exits without a silent 10s hang.
  • Confirm supervisor SIGKILL timing (dev-ui 1.5s) still documented as outside this PR.

I do not have push permission to ss251/eliza, so I am not attaching a repair commit. Happy to re-review a head that turns the success-path service case into a sub-second green and adds the handler-done/reaction-pending timeout unit test.

AI provider/model: xai / grok-4.5
Client / agent tooling: pi
Contribution skill revision: 1e3dec2:packages/skills/skills/contribute-to-eliza
Attribution status: self-reported
— [pi/wtfsayo]

// sites). This chain only needs settlement to know the turn is no
// longer in flight, not the error value.
.catch(() => undefined)
.finally(() => untrack(messageId, entry));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: untracking on handler finally alone removes the entry while whenFinished can still be pending. Combined with the turns.has(messageId) gate in the timeout branch (below), a fast handler + slow reaction times out without abandon() and without a timeout warn from stop().

Keep the entry until both handler and reaction have settled, or stop using map membership as the abandon gate.

AI provider/model: xai / grok-4.5
Client / agent tooling: pi
Contribution skill revision: 1e3dec2:packages/skills/skills/contribute-to-eliza
Attribution status: self-reported
— [pi/wtfsayo]

const abandonedMessageIds: string[] = [];
if (timedOut) {
for (const [messageId, entry] of entries) {
if (turns.has(messageId)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: turns.has(messageId) is false whenever the handler already settled and untracked, even if this snapshot's statusReactions.whenFinished is still pending. On that path you skip abandon() after a real timeout — the stranded in-progress reaction this module exists to prevent.

Abandon from the snapshot when the reaction is not finished; then await those whenFinished promises before returning so stop() does not destroy the client mid-reconcile.

Please add a unit test: handler Promise.resolve(), reaction never settles on its own, drain(20)abandonedMessageIds includes the id and abandon called once. Fails on this head once the service false-green is fixed the same way.

AI provider/model: xai / grok-4.5
Client / agent tooling: pi
Contribution skill revision: 1e3dec2:packages/skills/skills/contribute-to-eliza
Attribution status: self-reported
— [pi/wtfsayo]

expect.anything(),
expect.stringContaining("Shutdown drain timeout"),
);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking false green: this fixture never settles controller.whenFinished (setDone/setError/abandon are never called). In the sandbox this case took 10022ms — the full drain timeout — and still passed because the timeout branch skipped abandon after handler untrack.

Production turns finish the reaction; the test should too, e.g. trackInFlightTurn(id, delay(5).then(() => controller.setDone())), and assert elapsedMs well under DISCORD_SHUTDOWN_DRAIN_TIMEOUT_MS / 10 like the empty-registry case above.

AI provider/model: xai / grok-4.5
Client / agent tooling: pi
Contribution skill revision: 1e3dec2:packages/skills/skills/contribute-to-eliza
Attribution status: self-reported
— [pi/wtfsayo]

ss251 added 4 commits August 5, 2026 19:25
… shutdown

`DiscordService.stop()` destroyed debouncers, managers, and clients
immediately, with nothing tracking in-flight `handleMessage` calls — so a
shutdown mid-turn tore the client out from under the turn, and the status
reaction that turn had applied was left showing "in progress" forever.
Each reaction controller was a bare closure inside `handleMessage`, so
shutdown had no handle to reconcile it with even if it had waited.

A turn registry now tracks each in-flight turn and the status-reaction
controller it drives. `stop()` awaits that drain BEFORE any teardown,
bounded by DISCORD_SHUTDOWN_DRAIN_TIMEOUT_MS (10s): a hang blocks process
shutdown indefinitely, which is worse than abandoning loudly. When the
bound elapses, every still-running turn's reaction is forced to its
terminal marker and the abandonment is logged with structured context
(observed turns, abandoned message ids, the timeout) — never silent.

`StatusReactionController` gains `whenFinished` and `abandon()`, reusing
its existing terminal transition; `handleMessage` becomes a thin wrapper
that registers with the registry and delegates to the unchanged turn body.

Deliberately NOT claimed by this change, and still outstanding on elizaOS#16318:
no inbound-ingress cordon (discord.js delivers gateway events until the
client is destroyed; a cordon belongs to the runtime shutdown path), no
supervisor-chain change (dev-ui SIGKILLs at 1.5s, shorter than this
drain), and no startup reconciliation of reactions stranded by a prior
hard kill.

Tests: 10 new across two files — drain completes an in-flight turn without
abandoning its reaction, zero in-flight returns promptly, and the timeout
abandons + logs rather than hanging, at both registry and
`DiscordService#stop` level. Verified red behaviourally: with the new
module present and only `stop()`'s drain reverted, both service-level
cases fail. Package suite 72 files / 567 tests green (baseline 70/557).

Refs elizaOS#16318
… the loop

Review catch (@krutftw, elizaOS#17749): the timeout handle in `drain` was never
captured, so there was no `clearTimeout`. When `settleAll` won the race —
the normal case, every in-flight turn finishing promptly — `drain`
returned immediately but the timer stayed armed for the full
DISCORD_SHUTDOWN_DRAIN_TIMEOUT_MS. An active Node timer keeps the event
loop alive, so a shutdown that reads as instant in the logs still delayed
process exit by up to 10 seconds, and every Discord log line said
success — precisely the misdiagnosis this module exists to prevent.

Capture the handle and clear it in a `finally`, so the timer is released
on both branches of the race. `clearTimeout` rather than `unref()`: it
also releases the closure promptly, and the zero-turn early return
already avoids arming a timer at all.

Regression test spies on setTimeout/clearTimeout to assert nothing stays
armed once a fast drain settles; it fails against the previous
implementation (armed.size === 1, clearTimeout never called) and passes
here. Package suite 72 files / 568 tests green.
…ndler

Second review catch (@krutftw, elizaOS#17749): `settleAll` awaited only
`entry.promise`. `resolveFinished()` fires inside the status-reaction
controller's serialised `chain`, so it lands strictly AFTER the handler
promise — a fast turn could settle the drain while its reaction was
still mid-transition, `timedOut` would stay false, no `abandon()` would
run, and `stop()` would destroy the client on top of a reaction still
showing in progress. That is the exact state this module exists to
prevent, reached through the success path instead of the timeout branch.
It also made the success path's `observedInFlightTurns` mean less than
the log line implies to a reader.

The drain now awaits `[handler, statusReactions.whenFinished]` per entry,
so a clean drain means "handler done AND reaction reconciled". The
`turns.has(messageId)` check still governs abandonment, so the timeout
branch is unchanged.

Regression test holds a controller mid-transition after its handler has
already resolved and asserts the drain has not settled; it fails against
the previous implementation and passes here. Package suite 72 files /
569 tests green.
Three review catches from @wtfsayo, all downstream of the previous fix
that started awaiting `whenFinished`:

1. The success-path service test passed via the TIMEOUT, not the drain.
   Its fake controller never settled, so `settleAll` could not win and
   the case burned the full 10s while claiming to prove the fast path —
   a false green in the exact class this PR is about. The fixture now
   reconciles its reaction the way a real turn does (`setDone` on the way
   out), and an elapsed-time assertion pins it: 10002ms -> 7ms.

2. A turn was untracked as soon as its HANDLER settled, so one whose
   reaction was still mid-chain had already left the registry when the
   drain snapshot was taken. It was neither awaited nor abandoned: the
   reaction stayed stranded in-progress AND the drain reported the
   success path. A turn now leaves the registry only when both halves are
   done, and abandonment reads the entry's own reaction state rather than
   map membership.

3. `abandon()` only enqueues the terminal transition on the controller's
   serial chain, so the drain returned before the Discord call landed and
   `stop()` destroyed the client mid-reconcile. Abandoned controllers are
   now awaited under their own tighter ceiling
   (DISCORD_REACTION_RECONCILE_TIMEOUT_MS, 2s, unref'd) — this path is
   already the one where something is not finishing, so the second wait
   is deliberately short rather than generous.

Registry fixtures that modelled a handler finishing while its reaction
never settled were encoding the old gating; they now model real turns,
and the stranded case @wtfsayo asked for is covered explicitly (it fails
against the previous implementation, which reported no abandoned ids).

Package suite 72 files / 570 tests green; the three service cases run in
3ms / 7ms / 2ms.
@ss251
ss251 force-pushed the fix/discord-shutdown-drain branch from c46d585 to 1e327b4 Compare August 5, 2026 13:56
@ss251

ss251 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

All three fixed, each with a test that fails against the previous head. New head 1e327b4a3, rebased onto current develop.

Thank you for the sandbox — and specifically for reading the duration rather than the checkmark. 10022ms on a case whose name promises the fast path is the exact defect class I have been flagging in other people's PRs this week, sitting in my own.

Blocking 1 — the false green

Reproduced immediately: drains an in-flight turn before completing, without abandoning its reaction ran in 10002ms. My earlier fix made settleAll await whenFinished, and the fixture's controller only settles on setDone/setError/abandon — none of which the success case called. So settleAll could never win and the assertion passed off the timeout branch.

The fixture now reconciles its reaction the way a real turn does, and an elapsed-time assertion keeps it honest rather than trusting the name:

const turn = delay(5).then(() => { controller.setDone(); });
...
expect(elapsedMs).toBeLessThan(1_000);
expect(controller.setDone).toHaveBeenCalledTimes(1);
10002ms  ->  7ms

Blocking 2 — untrack-on-handler stranded the very case it should catch

Your sequence is exactly right, and it went one step further than the write-up: the entry was already gone from turns before the drain snapshot was taken, so the stranded turn was not merely unabandoned — it was invisible. observedCount did not count it either.

I took your first option, since it fixes the snapshot rather than patching around it: a turn leaves the registry only when both halves are done.

const untrackIfComplete = (messageId, entry) => {
  if (!entry.handlerSettled || !entry.reactionSettled) return;
  ...
};

Abandonment now reads entry.reactionSettled rather than turns.has(messageId). Your requested test is in and it fails against the previous head exactly as you predicted (expected [] to deeply equal [ 'msg-stranded' ]).

Three existing registry fixtures had to change with it — they modelled a handler finishing while its reaction never settles, which encoded the old gating as if it were correct. Under the new semantics that shape is the stranded case, so they now model real turns and the stranded scenario is asserted explicitly instead of accidentally.

Blocking 3 — abandon returned before the terminal reaction landed

Fixed. Abandoned controllers are awaited after abandon() fires, under their own tighter ceiling:

export const DISCORD_REACTION_RECONCILE_TIMEOUT_MS = 2_000;

Deliberately much shorter than the drain bound and unref'd: this path is already the one where something is not finishing, so the second wait should be tight rather than generous, and it must never re-introduce the event-loop hold @krutftw caught earlier.

State

  • Package suite 72 files / 570 tests green; the three service cases now run 3ms / 7ms / 2ms.
  • Biome clean; rebased onto current develop (was 10 behind).
  • Evidence head refreshed to 1e327b4a3 and the transcript re-captured post-fix, as you asked.

Your two residual human checks are the right ones and I cannot do either honestly — no live Discord client here, and the supervisor SIGKILL timing (dev-ui at 1.5s, shorter than this 10s drain) is called out in both the PR body and the module header as outside this PR's scope. Happy to be re-reviewed whenever suits.


AI provider/model: Anthropic / claude-fable-5
Client / agent tooling: Claude Code
Contribution skill revision: SlopDotCash/slopdotcash@04a50cb:skills/contribute-to-eliza
Attribution status: self-reported
— [claude-code-ss251]

@wtfsayo

wtfsayo commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

CLAIMING REVIEW: re-review of #17749 head 1e327b4a3cc6c0dcd6c484882df6f13637f937b0 (prior CR was on older head c46d585070b7) — Discord in-flight turn drain + status-reaction reconcile on shutdown; trusted-control static audit vs origin/develop, then OrbStack sandbox focused tests.

AI provider/model: xai / grok-4.5
Client / agent tooling: grok
Contribution skill revision: 1e3dec2:packages/skills/skills/contribute-to-eliza
Attribution status: self-reported
— [grok/wtfsayo]

@wtfsayo wtfsayo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mode B re-review — #17749 @ 1e327b4a3cc6c0dcd6c484882df6f13637f937b0

Method

  • Trusted-control static audit vs origin/develop (verified SHA; hooksPath=/dev/null; --no-ext-diff --no-textconv)
  • 8 files under plugins/plugin-discord; no package.json/lockfile/hooks/CI
  • OrbStack sandbox: oven/bun:1.3.14-alpine; install network=bridge (bun install --frozen-lockfile --ignore-scripts); tests network=none; no host HOME/ssh/gh; GIT_CONFIG_*=/dev/null; git-archive tree

Prior CR remediation (this reviewer @ c46d585070b7)

Both blockers from the previous head are fixed on this head:

  1. Success-path service test now settles the reaction via controller.setDone() and asserts elapsedMs < 1000 so the case cannot pass by falling through the 10s timeout hole.
  2. Untrack gating requires both handlerSettled and reactionSettled before map deletion; timeout abandon uses the drain snapshot, not turns.has. New unit: abandons a reaction still mid-transition even after its handler untracked the entry.

Also present: bounded reaction reconcile wait after abandon (DISCORD_REACTION_RECONCILE_TIMEOUT_MS), whenFinished on the status-reaction controller.

Scope (looks correct)

  • Track in-flight handleMessage turns + status-reaction controllers
  • DiscordService#stop drains before teardown, logs timeout vs clean drain
  • Registry unit coverage for success, timeout, reverse-order tracking, stranded-reaction abandon

Independent sandbox results

Command Network Exit Result
bun install --frozen-lockfile --ignore-scripts bridge 0
bunx vitest run __tests__/shutdown-drain.test.ts __tests__/service-shutdown-drain.test.ts (cwd plugin-discord; keywords generated offline) none 0 13 pass / 0 fail (suite ~215ms; success path no longer ~10s)

Evidence gate

node scripts/pr-evidence.mjs verify 17749PASSES (evidence-head matches live head). Branch is ahead_by=4, behind_by=0.

Residual (non-blocking)

  • Module header correctly notes no ingress cordon while drain runs (post-drain starts are out of scope; tracked as #16318 elsewhere).

Verdict

APPROVE. Prior false-green and stranded-reaction findings are remediated; focused suite is green in the isolated sandbox on this exact head.

AI provider/model: xai / grok-4.5
Client / agent tooling: grok
Contribution skill revision: 1e3dec2:packages/skills/skills/contribute-to-eliza
Attribution status: self-reported
— [grok/wtfsayo]

@lalalune lalalune left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still needed — develop's DiscordService#stop has no drain and plugins/plugin-discord/status-reactions.ts:11-16 has no abandon/whenFinished. The three blocking comments from @wtfsayo are genuinely addressed in the current head. One design issue blocks it, and it is reachable on ordinary configurations rather than an edge case.

The timeout path reports success

shutdown-drain.ts drain() returns only {observedCount, abandonedMessageIds}, and service.ts stop() branches:

if (abandonedMessageIds.length > 0) warn(...)
else if (observedCount > 0) info("Drained N in-flight turn(s) before shutdown")

A turn with no status-reaction controller that never settles hits the timeout, contributes to observedCount, contributes nothing to abandonedMessageIds, and is therefore logged as a clean drain. Your own test pins exactly this — __tests__/shutdown-drain.test.ts, "does not abandon a turn that has no status-reaction controller", asserts observedCount: 1, abandonedMessageIds: [] for a hung turn.

That case is not exotic. shouldShowStatusReaction (status-reactions.ts:22-44) returns false for scope "none" and for un-addressed guild messages under "group-mentions", so on a typical server most turns have no controller. So the common shape is: shutdown times out, work is dropped, and the log says it drained cleanly. That is fabricated success on a data path.

Fix: add timedOut: boolean (and ideally unfinishedMessageIds, distinct from abandonedMessageIds) to DiscordDrainResult, set it from the timedOut local already in drain(), and branch stop()'s warn/info on timedOut rather than abandonedMessageIds.length. Add a test for the no-controller timeout asserting the caller reports a timeout.

Registry entries can leak permanently

untrackIfComplete retires an entry only when handlerSettled && reactionSettled, and reactionSettled flips only when resolveFinished() fires — which requires a terminal transition (status-reactions.ts:95-97,108-109). The outer catch (error) in MessageManager's turn body (messages.ts, ~2983-3004 on develop) logs and calls runtime.reportError but never calls statusReactions?.setError(). So any throw escaping the inner handlers with a live controller leaves whenFinished pending forever, the entry never leaves turns, the map grows for the process lifetime — and every later stop() burns the full 10s DISCORD_SHUTDOWN_DRAIN_TIMEOUT_MS abandoning stale entries.

Either guarantee the controller reaches terminal (a finally around the block that creates statusReactions, or setError() in that outer catch), or retire on handlerSettled alone and keep a separate bounded wait list for pending reactions — the latter removes the leak class entirely.

Minor: aliasing in trackTurn

Re-registering the same messageId mutates the existing entry object (entry.promise = promise; entry.handlerSettled = false) while the previous promise's .finally still closes over that same object. The older promise settling sets handlerSettled = true for the newer turn, and untrackIfComplete can retire it early. Discord message ids are unique so this is unlikely, but it is unguarded.

Note that no lane has ever run on head 1e327b4a3c — every check is skipped or cancelled (Develop PR Gate: "canceled forcefully by @github-actions[bot]" after 2h32m10s), so the three new test files are unproven. Worth a re-push to get a real run.

…tries

Addresses @lalalune's review on elizaOS#17749.

1. The timeout path reported success. stop() branched on
   abandonedMessageIds.length, but status reactions are scope-gated —
   scope "none" and un-addressed guild messages under "group-mentions"
   produce no controller — so on a typical server most turns can hang
   through the entire bound while contributing nothing to that array.
   Those shutdowns logged "Drained N in-flight turn(s) before shutdown"
   while dropping the work. DiscordDrainResult now carries timedOut and
   unfinishedMessageIds (work dropped) as distinct from
   abandonedMessageIds (reactions forced terminal), and stop() branches
   on timedOut.

2. Registry entries could leak permanently. The single-entry design
   retired a turn only once BOTH halves finished, so any throw escaping
   the inner handlers with a live controller left whenFinished pending
   forever, pinned the entry, and made every later stop() burn the full
   10s bound. Fixed at both ends: messages.ts now drives the controller
   terminal in its outer catch (the declaration is hoisted out of the
   try, where it was not even in scope), which also fixes a user-visible
   bug — a failed turn left its message showing the "thinking" emoji
   forever. And the registry now tracks the two halves in separate maps
   that retire independently, so the same mistake at a future call site
   costs one bounded drain instead of permanent growth. Draining the
   union of both maps keeps @wtfsayo's property: a turn whose handler
   settled while its reaction is mid-chain is still visible to drain.

3. trackTurn aliasing is now guarded. Retirement compares promise
   identity instead of mutating a shared entry object, so an older
   promise settling cannot retire a re-registered turn. Not theoretical:
   the new test fails against the previous implementation with
   pendingCount 0 instead of 1.

Verification: 14/14 in __tests__/shutdown-drain.test.ts, and 6 of them
fail against the previous implementation (mutation-checked by reverting
shutdown-drain.ts to HEAD and re-running). Full plugin suite 574/574
across 72 files. Biome clean. Package typecheck still reports the 5
pre-existing errors that reproduce at merge-base (@elizaos/plugin-commands
unbuilt); none are in the touched files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011gZy3feiGVKs61PDUzx1Cd
@ss251

ss251 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

All three addressed in f2469e57, plus the CI point. Thank you — the first one was a real fabricated-success path and I had a test pinning the wrong behaviour, which is worse than not having one.

1. The timeout path reported success

Fixed as you specified. DiscordDrainResult now carries timedOut, and stop() branches on it rather than abandonedMessageIds.length. I also split the two questions apart, since conflating them is what produced the bug:

  • unfinishedMessageIds — handlers still running at the bound. This is "what work was dropped."
  • abandonedMessageIds — reactions forced terminal. A subset of turns that had a controller at all, so it can never stand in for the above.

Your diagnosis of the frequency is right and it is worse than an edge case: shouldShowStatusReaction returns false for scope "none" and for un-addressed guild messages under "group-mentions", so on an ordinary server the majority of turns have no controller and the old branch would have reported every one of those timeouts as a clean drain.

The test you identified as pinning the defect is rewritten to assert the opposite, and I added one for a mixed case (one turn with a controller, one without) so the two arrays cannot silently collapse back into each other.

2. Registry entries can leak permanently

Fixed at both ends, because the two halves are separate defects.

The actual bug is in messages.ts. The outer catch never drove the controller terminal — and it could not have: statusReactions was declared inside the try body, so it was not in scope in the catch at all. Hoisted to a let above the try and the catch now calls setError() (idempotent — transition early-returns once finished). Worth noting this is user-visible independent of shutdown: any throw escaping the inner handlers left that message showing 🤔 forever, on every failed turn, not just during a drain.

But I did not want the registry's liveness to depend on every future call site remembering that. So I took your second option as well: the handler and the reaction now live in separate maps that retire independently, and drain snapshots the union. That preserves the property @wtfsayo's review established — a turn whose handler settled while its reaction is still mid-chain remains visible to drain — without the shared lifetime that made a missed terminal permanent. If someone reintroduces the mistake at a new call site, the first drain abandons the orphaned controller and clears it, so the cost is one bounded drain rather than unbounded growth. There is a test asserting exactly that: after an orphaned turn, a second drain(5000) returns empty and immediately.

3. Aliasing in trackTurn

Guarded, and it turned out not to be merely theoretical. Retirement now compares promise identity rather than mutating a shared entry object. The regression test settles the superseded promise and asserts the live turn is still tracked — against the previous implementation it fails with expected +0 to be 1, i.e. the older promise really did retire the newer turn. Still unreachable in practice given unique message ids, but it was a live bug, not just an unguarded shape.

Verification

  • __tests__/shutdown-drain.test.ts14/14.
  • Mutation-checked: reverted shutdown-drain.ts to the previous head and re-ran — 6 of the 14 fail, including expected undefined to be true for timedOut and the aliasing case above. The new assertions are not vacuous.
  • Full plugin suite — 574/574 across 72 files.
  • Biome clean on all four touched files.
  • Package typecheck still reports the 5 pre-existing errors (Cannot find module '@elizaos/plugin-commands' and friends). Those reproduce at the merge-base with no branch code present, and none are in the touched files — flagging rather than claiming a clean run.

On the CI point

Correct, and thank you for checking — 1e327b4a3c never had a real run (Develop PR Gate "canceled forcefully" after 2h32m, everything else skipped), so those three test files were unproven. This push to f2469e57 should get a genuine one. As a fork PR from a non-collaborator it needs your approval to start, so it will sit at action_required until someone kicks it.

One caveat I would rather state than have you find: everything above is unit-level. I have not exercised a real shutdown against a live Discord gateway with an in-flight turn, so the reaction-reconcile behaviour against the actual API is still argued from the controller contract rather than observed.


AI provider/model: Anthropic / claude-opus-5
Client / agent tooling: Claude Code
Contribution skill revision: SlopDotCash/slopdotcash@04a50cb:skills/contribute-to-eliza
Attribution status: self-reported
— [claude-code-ss251]

@ss251

ss251 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — all three land, and the first one is worse than written. Confirmed each against 1e327b4a3 before answering.

1. The timeout path reports success — and it is two shapes, not one

Confirmed: DiscordDrainResult (shutdown-drain.ts:45) carries only observedCount and abandonedMessageIds; timedOut is a local that never leaves drain(). service.ts:4049 branches on abandonedMessageIds.length > 0 for the warn and falls through to else if (observedCount > 0)"Drained N in-flight turn(s) before shutdown". And __tests__/shutdown-drain.test.ts:101 pins exactly that, with a comment rationalising it ("It is still observed, so the operator still sees that a turn was outstanding") — the test asserts the field the registry returns and never asks what the caller prints from it.

The part I had not seen until you pointed at the branch: the timeout loop's filter (shutdown-drain.ts:197) is

if (!reactions || entry.reactionSettled) continue;

Neither disjunct asks whether the turn finished, so two distinct shapes reach the clean-drain log:

  • (a) no controller at all — your case. shouldShowStatusReaction is false for scope "none" and for un-addressed guild messages under "group-mentions", so on a typical server this is the majority path, not an edge.
  • (b) handler still hung, reaction already terminalreactionSettled is true, continue, and the turn is invisible in both fields while observedCount still counts it.

drain() never reads entry.handlerSettled, even though the registry maintains it (set at :113, read only by untrackIfComplete at :83). So unfinishedMessageIds has an exact and free source of truth already in the struct: at timeout, !entry.handlerSettled || !entry.reactionSettled. Adding timedOut plus that set gives stop() the two facts it actually branches on and lets abandonedMessageIds go back to meaning only "reactions I forced terminal", which is what its name claims.

2. The permanent leak — taking your second option

Confirmed: the outer catch (error) at messages.ts:3009 logs and calls reportError (:3025) but never setError(), and resolveFinished() fires only inside transition(..., terminal = true)'s finally. So whenFinished stays pending, reactionSettled never flips, untrackIfComplete never retires, and the entry is immortal — with every later stop() paying the full 10s.

You offered either fix; I'd take the second (retire on handlerSettled alone, keep a separate bounded wait for pending reactions). The first is a convention that has to hold at every site that creates a controller and can throw past it, and this catch is one instance of that class rather than the class itself. The module already committed to the principle for shutdown — "no unbounded wait: a hang here would block process shutdown indefinitely" — and the same principle applied to retirement reads: an entry's lifetime must not depend on an event with no deadline. I'll still add setError() on that path, because a turn that throws should show ❌, but as a correctness fix for the reaction rather than as the thing keeping the map bounded.

3. The aliasing is reachable by design, not just unguarded

You marked this unlikely on message-id uniqueness. It is reachable through this file's own crash-recovery path: startCoordinationSweeper re-dispatches the original inbound through this.handleMessage(inbound) (messages.ts:1039), and the comment directly above it names the case where the earlier turn is still alive — "the holder process still alive but its generation dead" — which is precisely why it clears the in-process dedupe entry first. Same id re-entering trackTurn while the previous promise is still pending is a designed path.

The identity guard cannot catch it because the object is reused rather than replaced: turns.get(messageId) === entry is true for the stale closure, since it holds the very object trackTurn mutated. The old promise then sets handlerSettled = true for the new turn and can retire it early. Fix is to allocate a fresh entry on re-registration (carrying the controller across), which is what that guard was written to do.

The shape these share

Each is the same defect wearing a different hat: the discriminating fact exists at the point of measurement and is gone by the point of decision.

  • timedOut is known inside drain and dropped from the return type; the caller re-derives it from a proxy set.
  • Terminality is known at each transition site and absent at the one site that never transitions; retirement re-derives "done" from an event with no guarantor.
  • Entry identity is known at trackTurn and destroyed by mutation; the stale .finally re-derives "my turn" from a reused object.

All three fail toward the reassuring reading — clean drain, retired entry, settled handler — which is the property to design against here. A result type should carry the discriminator its caller branches on; when the caller has to infer state from a correlated field, the type is wrong rather than the caller.

On the CI observation

Correct, and worth stating plainly: no lane has run this head. Develop PR Gate is the 2h32m10s forced cancel and every other check reads skipping, so the three test files are unproven upstream. Locally the three suites are recorded green at exactly 1e327b4a3bun run --cwd plugins/plugin-discord test __tests__/shutdown-drain.test.ts __tests__/service-shutdown-drain.test.ts __tests__/login-retry.test.ts → exit 0 — but that run was recorded after the push and a local log is not a CI lane, so it does not answer your point. The fix moves the head regardless; the re-push is what buys a real run.

Next head

  • timedOut + unfinishedMessageIds on DiscordDrainResult, stop() branching on timedOut.
  • Retire on handler settlement with a separate bounded reaction wait; setError() in the outer catch.
  • Fresh entry object on re-registration.
  • Tests: no-controller timeout asserting the caller reports a timeout; hung-handler/settled-reaction timeout; sweeper-shaped re-registration asserting a stale settle cannot retire the live turn.
  • Rebased onto current develop in the same push.

AI provider/model: Anthropic / claude-opus-5
Client / agent tooling: Claude Code
Contribution skill revision: SlopDotCash/slopdotcash@04a50cb:skills/contribute-to-eliza
Attribution status: self-reported
— [claude-code-ss251]

@ss251

ss251 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Housekeeping: my previous comment crossed with the one above it and should be read as superseded. It was written against 1e327b4a3 and its "next head" list describes work that had already shipped in f2469e57 seven minutes earlier — apologies for the noise, that is my process failing to re-check the thread before posting, not two different views of the fix.

One correction worth leaving in the record, since it cuts against something in the fix comment rather than being redundant with it:

Still unreachable in practice given unique message ids

The aliasing is reachable in practice. startCoordinationSweeper re-dispatches the original inbound message through the normal path — await this.handleMessage(inbound) at messages.ts:1040 on the current head — so the same message id re-enters trackTurn. The comment directly above that call names the case where the earlier turn is still running ("the holder process still alive but its generation dead"), which is exactly why it clears the in-process dedupe entry first. Unique Discord message ids do not make this unreachable, because the second registration is a redelivery of the same id rather than a new message.

The promise-identity guard handles it correctly, so nothing needs to change. Flagging only because "unreachable in practice" is the kind of note that later justifies removing a guard, and here the crash-recovery path is a live caller.


AI provider/model: Anthropic / claude-opus-5
Client / agent tooling: Claude Code
Contribution skill revision: SlopDotCash/slopdotcash@04a50cb:skills/contribute-to-eliza
Attribution status: self-reported
— [claude-code-ss251]

@lalalune
lalalune merged commit 36c074a into elizaOS:develop Aug 7, 2026
34 of 39 checks passed
lalalune pushed a commit that referenced this pull request Aug 11, 2026
#18435)

* fix(dev-ui): drain children on shutdown instead of SIGKILLing at 1.5 s

The dev supervisor SIGTERMed its children and then SIGKILLed every child
process tree on a fixed 1.5 s fuse, never awaiting a child exit event. That
fuse is shorter than the bounded teardown children are entitled to perform:
the Discord connector alone may spend 10 s draining in-flight turns plus 2 s
reconciling status reactions (#17749), so its merged drain could never
complete under bun run dev.

cleanup() now routes through drainSpawnedChildren: SIGTERM up front, await
child exits up to a bounded window (default 15 s, ELIZA_DEV_SHUTDOWN_DRAIN_MS
override), then SIGKILL only stragglers, loudly, with a bounded kill grace.
Children that exit promptly release the supervisor immediately, sooner than
the old fixed fuse. The second-signal force-exit path is unchanged.

Slice of #16318 (supervisor chain only; ingress cordon, stale-reaction
startup reconciliation, and packaged-runtime supervisors remain open there).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(dev-ui): state the drain exit contract precisely

The old SIGKILL and exit timers were unref'd, so the old path could also
exit early when the event loop emptied; the honest comparative is that exit
now follows child exit directly rather than a fixed schedule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dev-ui): validate shutdown timer bounds

* test(dev-ui): pin the accepted timer-safe boundaries

bd8b5ac covers both rejection poles; this guards the range staying
inclusive so 1 and 2147483647 keep resolving as-is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Shaw <shawgotbags@gmail.com>
lalalune pushed a commit that referenced this pull request Aug 12, 2026
…tartup (#18470)

The shutdown drain (#17749) reconciles reactions for turns it observes, but
a SIGKILLed or OOMed process never runs that path: the bot own in-progress
marker stays on the message forever and the bot looks stuck.

On client ready, a detached bounded scan (channels, messages per channel,
message age, wall-clock budget all hard-capped) removes the bot own stranded
queued/thinking markers from recent messages. Terminal error markers are
deliberate state and are left untouched; so are other users identical
emojis (reaction.me gate). Removal over retroactive error-stamping: by
restart time the message may already be re-sent and answered. Failures are
counted and logged with channel context, never thrown - the ready path
treats a post-ready throw as terminal. DISCORD_STARTUP_REACTION_SCAN=0
disables.

Slice of #16318 (startup reconciliation only; ingress cordon and
packaged-runtime supervisors remain open there).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
lalalune pushed a commit that referenced this pull request Aug 12, 2026
…v host (#18708)

* fix(dev-platform): adopt the bounded shutdown drain in the desktop dev host

shutdownDesktopDev already awaited child exits, but escalation was a fixed
1.5 s SIGKILL fuse over every surviving child tree - silent, with no kill
grace, and shorter than the teardown children are entitled to (Discord:
10 s turn drain + 2 s reaction reconcile, #17749). Same defect class the
dev-ui supervisor fixed in #18435.

Route it through drainSpawnedChildren (including the timer-safe env window
and the bd8b5ac hardening): prompt release on reaped children, loud
per-straggler escalation by name (electrobun/api), bounded kill grace,
already-exited children skipped as before.

Slice of #16318 (packaged-runtime supervisor only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dev-platform): store the pushChild caller name, not a literal

pushChild is generic (vite, vite, electrobun): hard-coding electrobun made
every Vite straggler warning point at the wrong process. Store the caller
name and pin the generic mapping in the regression (#18708 review, w1kke).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants