Skip to content

Provider notification consumers are interrupted when startSession returns (Cursor, Grok, Codex) #5781

Description

@tsouth89

Provider notification consumers are interrupted when startSession returns

Summary

In CursorAdapter, GrokAdapter and CodexAdapter, the fiber that consumes
provider notifications is forked with Effect.forkChild inside startSession.
forkChild makes it a child of the calling fiber, and Effect interrupts a
fiber's children when that fiber completes. So the consumer dies the moment
startSession returns, and every notification the provider streams afterwards
is dropped silently.

The user-visible symptom is a thread that sits on "Working" forever: the request
succeeds, the provider streams its whole turn, and nothing is projected.

Where

File Line Fiber
apps/server/src/provider/Layers/CursorAdapter.ts 877 notification consumer
apps/server/src/provider/Layers/GrokAdapter.ts 879 notification consumer
apps/server/src/provider/Layers/CodexAdapter.ts 1733 runtime event consumer

Why it looks intentional but isn't

CursorAdapter already creates exactly the right scope and never forks into it:

  • L508 const sessionScope = yield* Scope.make("sequential");
  • L773 the scope is stored on the session context as scope: sessionScope
  • L877 the consumer is forked with Effect.forkChild ← here
  • L882 sessionScopeTransferred = true;
  • L468 teardown closes it with Scope.close(ctx.scope, Exit.void)

The scope is built, stored, marked transferred, and closed on teardown. The
consumer is simply forked into the wrong place. GrokAdapter has the identical
shape.

CodexSessionRuntime already does the correct thing one layer down —
Effect.forkIn(runtimeScope) at L1621, L1654 and L1681 — so the fix makes the
adapters consistent with the codebase's own established pattern.

Why tests do not catch it

Every existing test starts a session and prompts from the same fiber. That fiber
stays alive for the duration of the test, so the child consumer stays alive with
it. A real RPC handler fiber finishes as soon as it has returned, which is when
the consumer dies.

A test that reproduces it runs startSession in its own fiber and joins it
before prompting:

const startScope = yield* Scope.make();
const startFiber = yield* adapter.startSession({ ... }).pipe(Effect.forkIn(startScope));
yield* Fiber.join(startFiber);   // the RPC handler has now returned
// prompt here -> no content.delta arrives without the fix

Without the fix this fails with roughly:

expected [ 'session.started', ... ] to include 'content.delta'

Suggested fix

Fork into the session scope instead of the calling fiber:

-          Effect.forkChild,
+          Effect.forkIn(sessionScope),

and the equivalent for CodexAdapter's eventFiber.

Knock-on effect worth noting

A dead consumer also confuses recovery. Compromise marking for silent turns sees
the stalled session, recycles it, and replays session/load for a session the
freshly spawned agent process has never heard of — which surfaces to the user as
a raw Invalid params defect rather than anything diagnosable.

Offer

We hit all three of these downstream and have fixes plus regression tests for
each. Happy to open a PR — one per adapter, or a single one for the family,
whichever you prefer. Equally happy to leave it here if you would rather fix it
yourselves; the locations above are the whole finding.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions