Skip to content

session.completed from expiry, reset or close reaches no authored handler: hooks aren't dispatched and channel handlers throw on the missing session context #3401

Closed

Description

@jfonturbel

Version

Read at eve@0.53.1 and eve@0.56.0 (packages/eve/src); the relevant files are unchanged between the two tags. The channel-handler failure in (2) was observed on a Vercel deployment at 0.53.1.

What happens

A session can end outside a turn: the expiry deadline, POST /eve/v1/session/:id/reset, a closed session, or the workflow entry's safety net catching a throw out of the driver loop. All of these go through emitTerminalSessionEvent (execution/terminal-session-event.ts), which calls the channel adapter and writes the durable stream — and nothing else:

  1. Authored hooks are never dispatched. dispatchStreamEventHooks is called only from the turn step (execution/workflow-steps.ts) and execution/settle-cancelled-turn-step.ts. A defineHook handler for session.completed or session.failed does not run for these endings. In a conversation session, session.completed is emitted only on this path (harness/emission.ts: conversation mode ends a turn with session.waiting), so a session.completed hook never fires for a conversation at all.

  2. A channel session.completed handler throws before it runs. The defineChannel event wrapper exempts only session.failed from buildCallbackContext() (public/definitions/channel.ts). buildCallbackContext does ctx.require(SessionKey); SessionKey is produced by sessionProvider, which is only installed by the turn step's provider set (context/run-step.ts). On the terminal path it is absent, so the wrapper throws Context key "eve.session" is not set. and callAdapterEventHandler logs adapter event handler threw — event swallowed.

Net effect: no authored surface can observe that a session expired, was reset or was closed. The session.completed event lands on the durable stream and nowhere else. (session.failed is reachable, but only through a channel handler — not a hook.)

Minimal repro

// agent/hooks/ending.ts
export default defineHook({
  events: {
    "session.completed"(_event, ctx) { console.log("hook: completed", ctx.session.id); },
  },
});

// agent/channels/eve.ts
export default eveChannel({
  events: {
    "session.completed"(_data, _channel, ctx) { console.log("channel: completed", ctx.session.id); },
  },
});

Send one message to a conversation session, let the turn settle (session.waiting), then POST /eve/v1/session/<id>/reset (or wait past the session timeout). The stream ends with session.completed; neither log line prints, and the runtime log shows adapter event handler threw — event swallowed for session.completed.

Expected

The hooks guide says hooks can subscribe to "any event in the runtime stream vocabulary", that each event goes adapter → durable write → hooks, and that hook and channel handlers "both fire when both are registered". The sessions doc says that at expiration eve "emits session.completed". The channel docs single out only session.failed as running "outside session context". So an authored session.completed handler — hook or channel — should run when a session expires, is reset or is closed.

Suggested change

  • In emitTerminalSessionEvent, when the serialized context restores, install the framework providers the turn step installs (at least sessionProvider) and dispatch hooks after the durable write, in the same order as the in-turn path.
  • If the context cannot be restored, at minimum give a channel session.completed handler the same no-ctx shape as session.failed on this path instead of throwing.
  • If hooks are meant to stay turn-scoped, say so in the hooks guide and the event table.

Related: #2223 (another event in the hook event map that never reaches hooks).

Activity

  1. roboeve commented on Sep 15, 2026

    @roboeve
    Contributor

    Triage is underway for #3401. I’ve started an evidence-based reproduction on origin/main, duplicate search, assessment, issue record, and label updates.

  2. roboeve commented on Sep 15, 2026

    @roboeve
    Contributor

    Reproduced on origin/main at bca1b4ab5b3285ba71b8929c8fd5a09a37d66942 with a focused unit-tier reproduction of the actual out-of-turn terminal emitter.

    • The emitter wrote a durable stream record containing "type":"session.completed".
    • A registered authored session.completed channel handler was called 0 times, and the hook-dispatch spy was called 0 times (both expected one call).
    • The captured adapter log was adapter event handler threw — event swallowed for eventType: "session.completed"; its error was Error: Context key "eve.session" is not set. The stack reaches buildCallbackContext from the session.completed wrapper in public/definitions/channel.ts.

    The direct path is confirmed: expiry/reset/closed finalization invokes emitTerminalSessionCompletionStep, which delegates to emitTerminalSessionEvent. That helper restores the durable context, calls the adapter, and writes the stream, but does not call dispatchStreamEventHooks; it also runs without the step provider scope that supplies SessionKey, so the channel wrapper fails before the authored handler can run.

    No duplicate was found. #2223 is related (a different subagent.called emitter bypasses hooks) but has a distinct event and code path. Recorded as a p2 core bug: it breaks documented terminal lifecycle observability but has no data-loss or security impact and stream consumers can recover the event directly. The smallest useful follow-up is a regression test covering terminal session.completed delivery after reset/expiry with both a hook and a channel handler.

  3. roboeve commented on Sep 15, 2026

    @roboeve
    Contributor

    Implemented and published the fix for issue #3401.

    • PR: #3402
    • Status: open and ready for review
    • Current head: 6926f23f92dcd08bc0aa750783cc603bbbaeb54c
    • Review owner: ruiconti
    • Monitoring restarted against the current green head

    The fix:

    • Reconstructs the virtual session context for terminal events.
    • Dispatches channel and hook handlers after the durable stream write.
    • Adds regression coverage for session.completed delivery and the missing-session error.
    • Includes an eve patch changeset.

    Validation:

    • Focused tests: 71 passed
    • Typecheck: passed
    • Lint: passed
    • Formatting: passed
    • Invariant checks: passed
    • Full unit suite after building compiled output: 8,849 passed, 1 skipped
    • The full suite still has two failures in the unrelated src/instrumentation/config.test.ts module-registration tests, including one timeout. The changed terminal event tests pass.

    PR CI is still running. Completed checks are green, with analyze, typecheck, unit/integration jobs, E2E jobs, and Vercel Agent Review still pending.

  4. roboeve commented on Sep 15, 2026

    @roboeve
    Contributor

    The original watcher failed because it was tied to the stale red head and no longer had an active eve context. The replacement watcher for current head 6926f23f... is still pending registration.

    PR #3402 itself is unaffected. Its code and CI state remain as reported: green focused/local validation, with GitHub checks and review still pending.

  5. roboeve commented on Sep 15, 2026

    @roboeve
    Contributor

    Both PR watchers failed to register with the same No active eve context error. There is currently no active automated watcher.

    PR #3402 remains open at 6926f23f..., ready for review, with CI still pending. The watcher failure is infrastructure-related and does not affect the branch or PR.

  6. modisulak commented on Oct 2, 2026

    @modisulak

    Still reproduces on eve@0.69.0 and on main today, for the channel half of this issue (point 2). We haven't rechecked the hooks half.

    0.69 restores the serialized context for terminal events and publishes inside contextStorage.run, but eve.session is never in that context. sessionProvider sets it as a per-step virtual value (withContextScope in context/run-step.ts), and serializeContext writes only durable values. So the defineChannel wrapper still calls buildCallbackContext() for session.completed and throws Context key "eve.session" is not set. before the handler runs (public/definitions/channel.ts, right after the if (eventType === "session.failed") branch).

    Minimal check against the published 0.69.0 package, run from inside node_modules/eve:

    import { defineChannel } from "#public/definitions/channel.js";
    import { ContextContainer, contextStorage } from "#context/container.js";
    import { SessionIdKey } from "#context/keys.js";
    let calls = 0;
    const channel = defineChannel({ events: { "session.completed": () => { calls += 1; } } });
    const ctx = new ContextContainer();
    ctx.set(SessionIdKey, "s1"); // what the terminal path has: durable keys only
    try {
      await contextStorage.run(ctx, () => channel.adapter["session.completed"]({ type: "session.completed" }, {}));
      console.log("handler ran, calls =", calls);
    } catch (e) { console.log("threw before handler ran:", e.message, "| calls =", calls); }
    // threw before handler ran: Context key "eve.session" is not set. | calls = 0

    Impact for us: we close our own task record from a channel session.completed handler. When a session ends by expiry or reset, the handler never runs and the task stays open. We ship a postinstall patch that builds ctx lazily (getters for session and getSandbox), so a handler that doesn't read ctx.session runs. Could #3402, or that smaller change, land this week?

  7. mlegls commented on Oct 19, 2026

    @mlegls

    Confirmed the same terminal-session-event gap in pinned eve 0.51.1 while diagnosing arkhai-io/webapp#382: revoked-key admission throws and terminal session.failed reaches the chat stream but never the routine outcome hook, leaving persisted runs RUNNING. Local workaround restores SessionKey from serialized auth/session identity, stamps and writes the terminal event, then dispatches authored hooks. Regression drives the real terminal emitter + real hook registry + Postgres (both failed and completed endings), without a model. Broader upgrade is out of scope for that application fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingcorep2

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions