Skip to content

feat(server): OpenCode 2 native steering, fork and rollback - #14468

Merged
juliusmarminge merged 7 commits into
t3code/codex-turn-mappingfrom
v2/oc2-7-inbox-fork-revert
Oct 1, 2026
Merged

juliusmarminge merged 7 commits into
t3code/codex-turn-mappingfrom
v2/oc2-7-inbox-fork-revert

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

OpenCode 2 sessions steered by interrupt-and-restart and couldn't fork or roll back. Layer 7 of the OpenCode 2 stack adds native steering, fork, and rollback through OpenCode's staged revert.

Restacked on layer 6 (#14474). Layer 6 treats any execution OpenCode starts on its own as a subagent's follow-up. So revert.clear's empty execution is now swallowed before that check, or it would have offered a spurious continuation turn (an adapter test fails without this). Continuation turns get their own turn id (<session>:wake:<attempt>), and fork and rollback skip them. Rollback is refused while background work is pending.

Review fixes. Each has an adapter test that fails without its fix.

  • A rollback after a Stop that timed out asks the server whether that run is still going, and refuses while it is.
  • A stranded steer whose inbox cancel failed is cancelled again before the next prompt.
  • The snapshot a rollback returns lists only the provider turns it kept.
  • T3's prompt and steer ids now include the OpenCode session id. OpenCode answers a message id reused in another session with a 409, and two T3 databases on one external server can repeat thread ids and run ordinals.
  • A fork whose target thread runs in another worktree is moved there when it is made, since its first turn starts without a resume.
  • Starting a turn and rolling back share a per-session gate, so a turn sent during a rollback waits for the cut.
  • A fork of a session with a running turn is refused, as on 1.x, under the same gate.
  • A rollback of a session this runtime hasn't loaded yet asks the server whether it runs, and its snapshot keeps the recorded turns up to the target.
  • Fork and rollback share one busy check: a running turn, a follow-up OpenCode runs on its own, or a run on the server this runtime doesn't own. Revert stage and commit are bounded, so a hung server can't block later turns.

What changed

All of it is in OpenCode2AdapterV2.ts. Orchestration and the UI are unchanged.

  • Steering (supportsActiveSteering: true). A message steered into a running turn goes out as session.prompt{delivery: "steer"}. OpenCode reads it at the running execution's next step boundary, so the steer joins that turn: one provider turn, one terminal.
    • The prompt id is derived from T3's message id, so a retried steer effect lands on the same inbox item. OpenCode answers a repeated id with the item it already has (checked live on 2.0.18).
    • If the execution ends before reading the steer, OpenCode wakes the session into another execution for it. The turn stays open until that one ends.
    • A Stop leaves undelivered steers in OpenCode's inbox, where the next prompt would deliver them first. The next turn cancels them with session.inbox.cancel.
  • Queue. Queueing stays app-owned, as it is for every provider in V2. Queued, cancelled, edited and reordered runs never reach an adapter; a queued run calls startTurn after the previous turn's terminal. So OpenCode's delivery: "queue" is not used, a cancelled queued message never reaches OpenCode, and inbox.update has no V2 counterpart: it changes the delivery mode, while V2's edit changes the text.
  • Turn boundaries. Prompts now go out under T3-chosen ids (msg_t3_turn_<attempt>). Each provider turn's nativeTurnRef is therefore the user message it prompted with, which is where fork and rollback cut.
  • Fork (canForkThread, canForkFromTurn). session.fork{before} cuts at the next turn's prompt, provided it is still in the session. The fork gets the target thread's rules. canForkFromSubagentThread stays false while subagents are denied (layer 6).
  • Rollback (canRollbackThread, providerCanRollbackConversation, providerRollbackReturnsSnapshot):
    • Rollback is revert.stage{files: false} followed by revert.commit. T3's own checkpoint stays authoritative for files, and a "rewind without files" must not touch them.
    • If the stage or commit fails or is interrupted, the adapter clears the stage, because OpenCode commits a staged revert on the next prompt. It also skips the spurious empty execution that clear runs.
    • If the clear fails too, it is retried before the next prompt.
    • Turns recorded before this layer have no message id, so a cut behind one of them is refused with a clear error.

Verification

  • Replay fixtures from the spike recordings, run through the whole orchestrator:
    • opencode2_inbox (from inbox.ndjson): a steer into a running shell turn plus two queued messages, one of them cancelled.
    • opencode2_revert (from revert.ndjson): two write turns, then a rollback to the first.
    • The fork scenario in OpenCode2OrchestratorV2.integration.test.ts (from fork.ndjson): a real thread.fork from the first of two turns.
    • All three fail against layer 5's adapter.
  • OpenCode2OrchestratorV2.integration.test.ts also gains an orchestrator test showing a cancelled queued message is never prompted.
  • An adapter test covers a failed commit whose clear also fails. A real server can't fail on demand, so this one is not a replay. It checks that the stage is cleared before the next prompt.
  • Live runs on 2.0.18 through the real driver (OPENCODE2_MODEL=openrouter/deepseek/deepseek-v4-flash); the whole live file passes (4 tests). The new live test:
    • steers a running shell turn, and the steer lands in that run with one attempt;
    • queues two messages, which run as their own turns;
    • forks from the first turn; the fork doesn't know the later turns;
    • rolls back the source to the first turn: reverted.txt goes back from BETA to ALPHA, and OpenCode's session is left with the first user message plus the new one.
  • Tests run inside unshare -U -p:
    • vp test run on OpenCode2AdapterV2.test.ts, OpenCodeAdapterV2.test.ts, OpenCode2OrchestratorV2.integration.test.ts, OrchestratorReplayFixtures.integration.test.ts and processGroup.test.ts: 194 passed.
    • After the review fixes and the restack onto V2 5eae8ff: OpenCode2AdapterV2.test.ts plus OpenCode2OrchestratorV2.integration.test.ts give 71 passed, and OrchestratorReplayFixtures.integration.test.ts gives 111 passed.
  • Checks:
    • tsc --noEmit -p apps/server exits 0;
    • vp lint on touched files: one pre-existing warning in shared.ts;
    • vp run knip:check is clean.
  • Not run: repo-wide checks.

Model: Claude Opus 5.5 (Claude Code)

🤖 Generated with Claude Code


Devin Review

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Sep 30, 2026
Comment thread apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts
Comment thread apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts Outdated
@macroscopeapp

macroscopeapp Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR adds substantial OpenCode runtime capabilities for steering, forking, rollback, and nested background execution, with complex state and history-concurrency behavior. An unresolved Medium-severity replay concern also affects the new continuation path.

No code changes detected at 0637907. Prior analysis still applies.

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ℹ️ No successful main baseline artifact is available yet. This run establishes the initial measurement.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire — 4.9 KiB — 6.8 KiB ✅
Codex Thread snapshot wire — 3.7 KiB — 4.9 KiB ✅
Codex Live turn WebSocket wire — 1.2 KiB — 2.0 KiB ✅
Codex Live turn WebSocket decoded — 20.4 KiB — 29.3 KiB ✅
Codex Live turn messages — 2 — 8 ✅
Claude Total thread wire — 4.9 KiB — 6.8 KiB ✅
Claude Thread snapshot wire — 3.7 KiB — 4.9 KiB ✅
Claude Live turn WebSocket wire — 1.2 KiB — 2.0 KiB ✅
Claude Live turn WebSocket decoded — 20.7 KiB — 29.3 KiB ✅
Claude Live turn messages — 1 — 8 ✅

Baseline: unavailable · PR result: 0637907 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 106.1 KiB
  • Claude decoded thread snapshot: 106.4 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

@juliusmarminge
juliusmarminge force-pushed the v2/oc2-7-inbox-fork-revert branch from a9e3659 to ac13b68 Compare September 30, 2026 21:59
@juliusmarminge
juliusmarminge changed the base branch from v2/oc2-5-approvals to v2/oc2-6-subagents September 30, 2026 22:00
Comment thread apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts
Comment thread apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts Outdated
Comment thread apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts
Comment thread apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts
@juliusmarminge juliusmarminge added the macroscope-review Opt PRs made by unvouched contributors in for Macroscope review. Vouched contributors auto-reviews label Sep 30, 2026 — with ChatGPT Codex Connector
@juliusmarminge
juliusmarminge force-pushed the v2/oc2-7-inbox-fork-revert branch from ac13b68 to 87c07ae Compare October 1, 2026 03:41
Comment thread apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts Outdated
Comment thread apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts
@juliusmarminge
juliusmarminge force-pushed the v2/oc2-7-inbox-fork-revert branch from 95cf7c5 to 1e09f57 Compare October 1, 2026 04:24
Comment thread apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts
Comment thread apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts
Comment thread apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts
Comment thread apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts Outdated
@juliusmarminge
juliusmarminge force-pushed the v2/oc2-7-inbox-fork-revert branch from 64caaf2 to 28161c9 Compare October 1, 2026 07:36
Base automatically changed from v2/oc2-6-subagents to t3code/codex-turn-mapping October 1, 2026 07:44
@juliusmarminge
juliusmarminge force-pushed the v2/oc2-7-inbox-fork-revert branch from 28161c9 to 042d553 Compare October 1, 2026 07:44
Comment thread apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts
Comment thread apps/server/src/orchestration-v2/testkit/fixtures/index.ts
juliusmarminge and others added 3 commits October 1, 2026 09:03
OpenCode 2 sessions steered by interrupt-and-restart, and could neither
fork nor roll back.

- Steer: a message steered into a running turn goes out as
  `session.prompt{delivery: "steer"}` under an id T3 derives from its
  message, so a retried steer is the same inbox item. OpenCode delivers it
  at the next step boundary of the running execution, so it joins that
  turn. If an execution ends before reading a steer, OpenCode runs another
  for it and the turn spans both. A Stop leaves undelivered steers in the
  inbox; the next prompt cancels them first.
- Queue: stays T3's own, as for every provider. A queued message starts
  as its own turn once the previous one ends; a cancelled one never
  reaches OpenCode.
- Prompts use T3-chosen ids, so each provider turn's nativeTurnRef is the
  user message it prompted with. That is where fork and rollback cut.
- Fork: `session.fork{before}` at the next turn's prompt still in the
  session. The fork gets the target thread's rules.
- Rollback: `revert.stage{files: false}` then `revert.commit`. T3's own
  checkpoint restores files. A failed stage or commit is cleared (OpenCode
  commits a staged revert on the next prompt), and the empty execution
  `clear` runs is skipped: it never becomes a subagent follow-up. A clear
  that fails is retried before the next prompt.
- With subagents: a continuation turn prompts nothing, so it records no
  message and fork and rollback pass over it. A steer into a continuation
  turn joins OpenCode's follow-up execution and ends with it. Rollback
  waits for background subagents and held follow-ups to settle.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ks hold up on failures

- A rollback after a Stop that timed out asks the server whether that run
  still goes and refuses while it does, instead of cutting the history
  under it. A run that has left the server no longer blocks the rollback.
- A stranded steer whose inbox cancel failed or timed out stays stranded
  and is taken back again before the next prompt, so it cannot land in
  that turn.
- The snapshot a rollback returns lists only the provider turns it kept.
- T3's prompt and steer ids name the OpenCode session. OpenCode refuses a
  reused message id in any other session with 409, and two T3 databases or
  environments on one external server repeat thread ids and run ordinals.
  The id stays deterministic per session, so a retry is still recognized
  and `nativeTurnRef` still names the cut point.
- A fork whose target thread runs in another worktree is moved there when
  it is made: its first turn starts without the resume that moves one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…sion

A rollback checked that no turn ran, then read and cut the history across
several requests. A turn started meanwhile would prompt into history being
cut. Starting a turn and rolling back now take one per-session gate, so
each sees the other's result.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
juliusmarminge and others added 4 commits October 1, 2026 09:04
OpenCode forks whatever history the source session has at that moment, so
a fork taken mid-turn copied a half-finished turn. A fork of a session with
a running turn is refused, as on 1.x, and it takes the session's gate so a
turn cannot start between the check and the fork.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s the server and keeps its turns

- A runtime that has not loaded the session (after a T3 restart, against a
  server that kept running) asks the server whether the session runs, and
  refuses the rollback while it does, as it already did after a timed-out
  Stop.
- The snapshot a rollback returns lists the turns T3 recorded up to the
  target, which a runtime that loaded the session later never saw, not an
  empty list.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ing still writes to

An adversarial pass over layer 7's rollback, fork, steer and clear paths:
- Fork and rollback share one check. They refuse a session with a turn of
  T3's, an execution seen running on the stream (a follow-up OpenCode
  started on its own), or a run on the server this runtime does not own: a
  timed-out Stop's, or any on a session loaded after the server outlived
  T3. Fork used to check only the turn. A rollback still also waits for
  background subagents, and checks again after it loads the session.
- Revert stage and commit are bounded. A server that never answered held
  the session's gate, and with it every later turn.
- A turn takes the session's gate only for its checks and setup, not for
  the prompt request.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e subagent

Ending a foreground subagent call settled every call under it, background
ones included. OpenCode lets a subagent's background subagent run on after
that subagent returns (recorded live on 2.0.18 with subagent_depth 2), so T3
marked it interrupted while it still ran, dropped its report, and released
the thread before the work ended.

A background call now survives the end of the call that made it, as it
survives the end of a turn, unless that call failed. The pending-work probes
and Stop walk every subagent session under the thread instead of only
running calls, so they still see it, and they count the turn OpenCode runs
on that subagent's session to answer the report. A user Stop stops that turn
as well.

The opencode2_nested_background fixture replays the live recording through
the orchestrator; two adapter tests cover Stop before and during that
follow-up turn.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@juliusmarminge
juliusmarminge force-pushed the v2/oc2-7-inbox-fork-revert branch from 050105d to 0637907 Compare October 1, 2026 16:09
@juliusmarminge
juliusmarminge merged commit 40920d9 into t3code/codex-turn-mapping Oct 1, 2026
30 checks passed
@juliusmarminge
juliusmarminge deleted the v2/oc2-7-inbox-fork-revert branch October 1, 2026 16:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

macroscope-review Opt PRs made by unvouched contributors in for Macroscope review. Vouched contributors auto-reviews size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant