Skip to content

Correlate Codex turn dispatches with the turn the turn/start response names - #3503

Merged
SawyerHood merged 7 commits into
bb/3462-fix-stop-leaving-threads-stuck-thr_vdv2rx6c7wfrom
bb/2580-codex-turn-start-correlation-thr_vdv2rx6c7w
Sep 16, 2026
Merged

SawyerHood merged 7 commits into
bb/3462-fix-stop-leaving-threads-stuck-thr_vdv2rx6c7wfrom
bb/2580-codex-turn-start-correlation-thr_vdv2rx6c7w

Conversation

@SawyerHood

@SawyerHood SawyerHood commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Human comments

What was wrong

Stacked on #3502. Codex can return turn/start before emitting turn/started. The bridge discarded the response and fabricated a completed turn after 250 ms, attaching accepted input to that synthetic turn while the real work ran in a second, unaccepted turn (#2580 and consolidated #2634). Compaction's empty response has the same delayed-notification problem.

What changed

Preserves Ratul Sarna's original #2639 implementation and authorship, with lifecycle and failure-path fixes:

  • Open and correlate the turn named by turn/start; deduplicate its later native lifecycle notifications. Dedupe is scoped by provider thread, cleared on exit, and bounded to 256 pending lifecycle records per thread.
  • Await compaction's actual turn. An idle/system-error report or process exit can settle an unopened dispatch. Register it before sending the request, buffer early status until RPC acceptance, and remove it as soon as its input is accepted. A rejected request cannot fabricate an accepted/completed turn.
  • Retry an interrupt once only for the exact -32600 / no active turn to interrupt rejection when that same response-opened turn lacked native activation at the initial attempt. Wait for activation, return the original error on timeout, and avoid retrying a turn that already settled.
  • Preserve the legacy zero-work fallback only for a turn/start result that names no turn.
  • Bump HOST_DAEMON_PROTOCOL_VERSION from Reconcile daemon turns the server lost track of on explicit stop #3502's 212 to 213. Update lifecycle regressions to use the current test harness after rebasing onto main.

How you verified

  • Nine new failure-path regressions fail against the previous Correlate Codex turn dispatches with the turn the turn/start response names #3503 implementation and pass with these fixes: unrelated interrupt errors, interrupt after native activation, completion-before-error, activation timeout, compact idle/error before response, cross-thread dedupe, and exit cleanup. Additional cases exercise a single retry, activation-before-rejection, delayed terminal notifications, compaction exit, and consecutive requests' tool attribution.
  • pnpm exec turbo run test typecheck --filter=bb-plugin-provider-codex --filter=@bb/provider-bridge-protocol --filter=@bb/provider-parity --filter=@bb/host-daemon-contract passes on the rebased layer. Codex: 317 tests; protocol: 279; parity: 56; contract: 59.
  • Real Codex 0.154.0 via the configured account pool, with fresh empty CODEX_HOME directories and workspaces: a relay delays native start notifications 400 ms. Reverting this layer to Reconcile daemon turns the server lost track of on explicit stop #3502 reproduces two lifecycles for both an ordinary turn and compaction; the final layer produces one accepted lifecycle. Delaying completion notifications also proves the review fix: the prior bridge sends two interrupts after native activation/real completion; the fixed bridge sends one and preserves the real -32600 rejection. No BB core or imported store was started, and no credentials were copied.

Fixes #2580
Closes #2639

AGENT GENERATED

@SawyerHood
SawyerHood added this pull request to stack #3504 September 11, 2026 17:30
@SawyerHood
SawyerHood force-pushed the bb/2580-codex-turn-start-correlation-thr_vdv2rx6c7w branch 2 times, most recently from 7cc1ee3 to 54cafa4 Compare September 11, 2026 17:34
@SawyerHood
SawyerHood force-pushed the bb/2580-codex-turn-start-correlation-thr_vdv2rx6c7w branch 2 times, most recently from 96f54be to 4c2be65 Compare September 11, 2026 17:48
@SawyerHood
SawyerHood force-pushed the bb/2580-codex-turn-start-correlation-thr_vdv2rx6c7w branch 2 times, most recently from df67e58 to ebcd0e5 Compare September 11, 2026 23:03
ratulsarna and others added 7 commits September 16, 2026 20:12
… names

The bridge ignored the turn/start result and guessed at settlement with a
250 ms timer. When codex answered before emitting turn/started and the
notification lagged past the grace window, the timer fabricated a completed
zero-work turn and the real turn arrived later as a second lifecycle, so a
parent could see its child complete with no output while the child was still
working (#2580).

Every supported codex (0.136.0 up) answers turn/start with the turn it
opened. The bridge now opens that turn from the response, attaches the
accepted input to it, and suppresses the provider's own later turn/started.
A response reporting an already-terminal turn settles the full lifecycle at
once, with the later notifications suppressed the same way. A dispatch codex
steers into the already-running turn only acknowledges the input against
that turn. The zero-work timer remains for results that name no turn
(thread/compact/start's empty result and a malformed body). A crash between
the answer and the notification now settles the real turn as failed instead
of fabricating a completed one.

Cherry-picked from #2639 (Ratul Sarna) onto the #3462 fix; the host-daemon
protocol version moves from #3462's 204 to 205.

Co-Authored-By: Claude Code <noreply@anthropic.com>
thread/compact/start answers with an empty result, and codex then opens the
compaction as a turn: the committed real recording shows turn/started 192 ms
after the {} result, and a real app-server opens a turn even when there is
nothing to compact (it ends as failed with a systemError status). The bridge
sent compaction through the 250 ms zero-work timer, so a turn/started that
lagged past the grace window produced a fabricated completed turn followed
by the real compaction as an unrequested turn, the #2580 pattern.

A compaction dispatch now stays unopened until its turn/started claims it.
It settles synthetically only on a signal from codex: a thread/status/changed
to idle or systemError while no turn is open (completed or failed), or the
app-server exiting before the turn starts (failed). The zero-work timer is
kept only for a turn/start result that names no turn.

The fake app-server now runs compaction as a delayed turn, with modes for an
idle report without a turn and an exit before the turn starts, and the
regressions cover a turn/started 600 ms after the empty result, both signal
paths, and the exit. The already-open branch of openTurnFromStartResponse no
longer records a turn id that no later notification would remove.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Opening a turn from the turn/start response exposes it to bb before codex
activates it. A real app-server answers turn/start with {turn: inProgress},
then emits turn/started a few milliseconds later, and a turn/interrupt sent
in that gap fails with "no active turn to interrupt". A stop that arrived
right after bb saw the turn therefore failed, which the agent-runtime codex
integration test "stops an active turn and recovers with a follow-up" hit
under load.

The bridge now remembers which turns it opened from a response and whether
codex's own turn/started has arrived. When an interrupt of such a turn is
rejected, it waits for that notification (or for the turn to settle or the
app-server to exit, within the interrupt settlement timeout) and interrupts
again; it does not retry any other failure. The fake app-server gains a
prompt that rejects an interrupt before turn/started like a real
app-server, and a regression covers the stop.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@SawyerHood
SawyerHood force-pushed the bb/2580-codex-turn-start-correlation-thr_vdv2rx6c7w branch from ebcd0e5 to cd67ddb Compare September 16, 2026 20:20
@SawyerHood
SawyerHood merged commit 834b7d8 into main Sep 16, 2026
25 of 26 checks passed
@SawyerHood
SawyerHood deleted the bb/2580-codex-turn-start-correlation-thr_vdv2rx6c7w branch September 16, 2026 21:44
SawyerHood added a commit that referenced this pull request Sep 16, 2026
## Human comments

## What was wrong

When the server considered a thread idle or failed while its daemon
still held a root turn, Stop sent a release that deliberately retained
the turn. The daemon returned the same result as a completed release, so
Stop did nothing and subsequent sends kept failing as competing turns
(#3462). A competing-turn refusal could also fail the server run despite
a stored root turn still running.

## What changed

The daemon reports `activeTurnRetained` on a declined release and
identifies competing-turn refusals with `competing_turn`. Explicit Stop
reconciles the retained turn into the normal interrupt path. Concurrent
awaited stops share the complete operation, including an escalated
interrupt; machine-preservation callers requiring a stopped thread
receive any failure. Context clear cannot proceed while the thread
remains stopping. An explicit Stop also interrupts a turn that starts
while its release is pending.

A declined release no longer marks background commands stopped. A
regression covers an interrupt failure followed by a successful Stop
retry. Competing-turn refusals with a stored running root turn retain
the active run; negative controls still fail ordinary refusals and
refusals without a running root turn. Automatic release protection
remains intact.

Rebased onto main `c663ff1911496870026c779911ff874fb2a43f24`, preserving
the shared daemon Stop/storage-deletion helper, lifecycle ownership, and
writable queues during Stop. Protocol 211 → 212 and plugin SDK 0.4.98 →
0.4.99 cover the contract/declaration changes. SDK, thread guide, and
CLI skill document the behavior. The scripted Codex test server also
accepts main's new configuration arguments so this layer passes
independently.

Existing failure policy remains: an unsuccessful interrupt leaves the
thread `stopping`; the public Stop request can still return success, so
callers must inspect thread status before treating interruption as
confirmed. This is explicitly documented. `requireStopped` callers
reject on the command failure.

This is the bottom layer of #3502#3503#3512. #3503 repairs the
Codex response/notification correlation race; #3512 protects
provider-session ownership.

## How you verified

- Fresh Turbo tests/typechecks for server, daemon, runtime, daemon
contract, SDK, and templates. Runtime 331, contract 59, SDK 113, and
templates 55 passed. Server broad run passed 2,893 tests; three timeout
suites passed a focused rerun, and both inherited Codex fixture failures
passed after the argument parser fix. Stop/checkpoint suites passed 23
tests; the timeout/Stop rerun passed 108. Daemon passed 717 tests; its
two repository-discovery failures came from the filesystem sandbox's
synthetic `/tmp/.git` marker, and all 26 discovery tests passed outside
that sandbox.
- New retained-background-work regression fails before the settlement
guard and passes after it, including successful retry. Existing coverage
exercises idle/error retained turns, concurrent stops,
turn-start/release races, context clear, and escalated interrupt failure
for `requireStopped` callers.
- Real Codex through the configured account pool, fresh isolated BB
store and session home: with `sleep 90` running, injected the server's
erroneous `error` status, then invoked CLI Stop. The thread became idle
in under a second with an interrupted completion, and the next turn
answered `STOP_RECOVERY_CONFIRMED`. In a separate turn, injected idle
during a foreground wait: the competing send recorded `competing_turn`
without `system/error`, the original turn completed to idle, and a
follow-up answered `COMPETING_RECOVERY_CONFIRMED`. Test server and
daemon were shut down; no production store or process was changed. This
verifies provider behavior through normal pool routing, not a separate
scratch login.

Fixes #3462

> AGENT GENERATED

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
SawyerHood added a commit that referenced this pull request Sep 16, 2026
## Human comments

## What was wrong

After the runtime identity mix-up in #3461, the server could resume a
thread into whichever provider session its newest event named. Ordinary
completion events and contaminated identity history could therefore
redirect messages into another conversation. Current main still has this
selector.

The independent safety review also reproduced a second path: editing a
message can delete the original owner's identity event, making a
contaminated later claimant appear to own that session.

## What changed

Third layer of #3502#3503#3512. Review this layer against #3503;
it retains the lower layers' Stop and Codex correlation behavior.

- Resolve sessions only from identity events after the last completed
context clear. Compare claims within the provider and host; unknown
hosts are treated conservatively. An earlier foreign identity is skipped
in favor of an owned identity. Earliest claims tied in the same
millisecond are ambiguous, regardless of insertion order.
- Refuse foreign-only, ambiguous, and invalid (null or empty
authoritative handle) resume attempts with 409
`provider_session_unavailable`. No turn is dispatched or history
appended. Queued sends record the refusal. `/clear` or `bb thread clear`
starts a fresh session on the next send while retaining history.
- Check ownership on edit checkpoints and anchored/tip fork sources.
Refuse an edit that would erase the earliest shared ownership evidence,
both before provider rewind preparation and atomically before suffix
deletion. If another claimant arrives while a rewind is being staged,
discard that staged session and retain the original history.
- Record identities for empty-input starts. Require start/fork identity
announcements in the public conformance kit and document unique, durable
provider session handles. Scripted multi-process fixtures mint unique
handles.
- Regenerate the identity index migration after current main as
`0125_silent_guardian`. Preserve idempotent index creation for migration
replay. Update pruning tests to keep their retention assertions while no
longer treating ordinary event stamps as authoritative identities.
- Resolve the real-provider integration manifest after test TMPDIR
isolation so setup and workers use the same manifest.

No daemon wire fields change in this layer. Existing stored history is
not rewritten on upgrade.

## How you verified

Independent review covered the complete layer, #3461 and original
investigation history, current-main changes, ownership queries/index
plans, context clear, provider/host isolation, tied claims, legacy
checkpoint lineage, and fork/rewind paths.

A new migrated-SQLite regression failed before the edit guard: removing
the owner's identity changed the other thread from `foreign` to `owned`.
Additional regressions fail before and pass after for null/empty
identity handles; these cannot silently replace the conversation. Guard
tests cover tied/strictly ordered claims, provider/host/unknown-host
scoping, retained duplicate claims, clear-context retention, pre-rewind
refusal, and a competing claim arriving before the atomic edit commit.
Server tests assert no history mutation or replacement start after
refusal, and successful staged-session discard.

Final head: `55097953348f0d583e34317375ed693d4d130716`, based on #3503
`cd67ddbb5735e4153e506bceec1a8c6a665dc2ef`. Its tree exactly matches the
reviewed local tree. Fresh forced Turbo checks ran on integrated
implementation head `e23319817a80042971fcf1e40036dda9b11ae5e6`; the
final follow-up changes only fixtures and formatting:

- DB: 573 tests and typecheck passed, including the generated migration,
replay, query plans, new edit guard and invalid-history regressions.
- Codex: 317 tests and typecheck passed. The unit run excludes inherited
account-pool environment variables because one missing-variable test
assumes they are absent.
- Server: typecheck passed. The full run passed 2,909 tests with one
skipped; two failed. One new-main fork metadata fixture lacked an
identity announcement and was corrected. The other, an unchanged
machine-environment test, exceeded its five-second limit under
full-suite load. All nine affected fixture tests then passed with
default timeouts; the 57-test migration suite also passed after
formatting. The final follow-up changes tests and formatting only.
- Other unchanged surfaces were freshly verified before final
lower-layer integration: protocol 282, SDK 291, Claude Code 364, ACP
319, Pi 167 (one skipped), echo 15, scripted echo 1, bb-guide 5, and
server/bridge integration 82. Corresponding typechecks passed.
- Real `codex-cli 0.154.0`: isolated session/workspace tests through the
configured account pool. All 12 tests passed again with the final
integrated implementation, serially: eight basic lifecycle and four
cross-process resume tests. The final fixture-only commit does not
change provider code. These are live provider calls, with no production
BB process or imported store started.
- The SDK npm version guard passes for unpublished 0.4.99. Changed-file
formatting passes under repository configuration; generated snapshots
were not manually edited.

Commands:

```sh
pnpm exec turbo run test typecheck --filter=@bb/db --filter=@bb/server --filter=bb-plugin-provider-codex --force --continue=always
pnpm exec turbo run test --filter=@bb/integration-tests --force
pnpm exec turbo run test:integration --filter=@bb/agent-runtime --force -- integration.provider-basic.test.ts integration.resume.test.ts -t '^codex' --maxConcurrency 1
```

Local runs used a writable npm cache. Tests were forced rather than
relying on prior CI or cache results. The full server run is not
represented as wholly green; the two failures and focused confirmations
are reported above. Final-head CI remains the landing gate.

## Limits

- Ownership is inferred from retained records, not an independent
durable ownership ledger. Purging all original-owner records still
removes that protection.
- Existing legacy fork/rewind checkpoints whose session was never
announced remain usable when no conflicting claim exists. Missing
announcement history is not proof of cross-thread ownership.
- Falling back to an older owned session cannot recover context written
into another rollout.
- A late edit refusal leaves the source session and stored history
intact. Discarding an unused staged rewind is best-effort; a provider
cleanup failure can leave an unused staged session.
- Third-party bridges must provide handles unique across that provider's
sessions on a host.

Fixes #3461

> AGENT GENERATED

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
@ratulsarna

Copy link
Copy Markdown
Contributor

Yay!

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Codex bridge fabricates a completed turn when turn/started is delayed

2 participants