Tighten the shutdown finalizer so it actually waits for plugin handlers
under its bounded budget and so it covers every session lifecycle path,
not just the centralized emitters in `session-reset-service.ts`.
- `drainActiveSessionsForShutdown` previously called
`emitGatewaySessionEndPluginHook`, which fires `runSessionEnd` as
fire-and-forget (`void hookRunner.runSessionEnd(...)`). The bounded
2 s timeout then raced only the synchronous for-loop, so the close
handler could proceed to subsystem teardown while a database-writing
`session_end` plugin was still in flight -- the exact ghost-session
failure this PR is supposed to fix. Inline the emit path: build the
`buildSessionEndHookPayload` + `resolveStableSessionEndTranscript`
payload directly in the drain and `await hookRunner.runSessionEnd(...)`
under the bounded race. A never-resolving handler now surfaces as
`timedOut=true` and the close handler records `session-end-drain` as
a warning, but is never blocked.
- The channel reply path in `src/auto-reply/reply/session.ts` and the
compaction lifecycle helper in `src/auto-reply/reply/session-updates.ts`
emit `session_start` / `session_end` directly through the global hook
runner without going through `emitGatewaySessionStartPluginHook`, so
the shutdown tracker never saw normal channel sessions or rolled-over
compacted sessions. Wire the tracker `note` / `forget` calls into both
paths so every public lifecycle emitter participates in the same
tracker, and so a compacted session is both forgotten (previous id)
and re-noted (new id) on rollover.
Tests:
- `src/gateway/drain-active-sessions-for-shutdown.test.ts` gains two
cases: one proves the drain genuinely waits for an in-flight handler
to settle before returning, the other proves a never-resolving handler
is cut off at the configured budget with `timedOut=true`.
Refs #57790.
Regression: the drain IIFE finally (`drain.ts:263-271`) performed an
unconditional `FOLLOWUP_QUEUES.delete(key)` + `clearFollowupDrainCallback(key)`
using only the key, without checking whether the captured `queue` still matched
the map entry. Under the `/stop` + immediate followup sequence, a late-returning
D1 finally could delete the map entry for a fresh Q2 and orphan it until the
next enqueue.
Fix: only remove the map entry and drain callback when `FOLLOWUP_QUEUES.get(key)
=== queue`. Mirrors the identity pattern noted in
`subagent-announce-queue.ts:62-64`.
Adds `src/auto-reply/reply/queue/drain.identity-guard.test.ts` which uses real
`enqueueFollowupRun` / `scheduleFollowupDrain` / `clearSessionQueues` (no
module mocks) and a Deferred gate to park D1 inside `runFollowup`. The test
uses `restartIfIdle=false` on the Q2 enqueue so D1's finally is the only
mutator that can touch the map entry, producing deterministic pre/post-fix
differentiation:
pre-fix : get(key) === undefined (Q2 orphaned), depth === 0
post-fix : get(key) === Q2, depth === 1
AI-assisted (fully tested). 1082 auto-reply/reply tests pass, pnpm check +
pnpm build clean.