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.
Provider notification consumers are interrupted when
startSessionreturnsSummary
In
CursorAdapter,GrokAdapterandCodexAdapter, the fiber that consumesprovider notifications is forked with
Effect.forkChildinsidestartSession.forkChildmakes it a child of the calling fiber, and Effect interrupts afiber's children when that fiber completes. So the consumer dies the moment
startSessionreturns, and every notification the provider streams afterwardsis 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
apps/server/src/provider/Layers/CursorAdapter.tsapps/server/src/provider/Layers/GrokAdapter.tsapps/server/src/provider/Layers/CodexAdapter.tsWhy it looks intentional but isn't
CursorAdapteralready creates exactly the right scope and never forks into it:const sessionScope = yield* Scope.make("sequential");scope: sessionScopeEffect.forkChild← heresessionScopeTransferred = true;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.
GrokAdapterhas the identicalshape.
CodexSessionRuntimealready does the correct thing one layer down —Effect.forkIn(runtimeScope)at L1621, L1654 and L1681 — so the fix makes theadapters 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
startSessionin its own fiber and joins itbefore prompting:
Without the fix this fails with roughly:
Suggested fix
Fork into the session scope instead of the calling fiber:
and the equivalent for
CodexAdapter'seventFiber.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/loadfor a session thefreshly spawned agent process has never heard of — which surfaces to the user as
a raw
Invalid paramsdefect 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.