feat(server): OpenCode 2 native steering, fork and rollback - #14468
Conversation
ApprovabilityVerdict: 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 Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more. |
Thread transfer impact✅ Thread transfer remains within every enforced ceiling.
Baseline: unavailable · PR result: Scenario and decoded snapshot size10 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.
Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed. |
a6dcb34 to
3ce44b5
Compare
a9e3659 to
ac13b68
Compare
ac13b68 to
87c07ae
Compare
c330e41 to
ffa1bc8
Compare
87c07ae to
8dd92b3
Compare
ffa1bc8 to
bd15200
Compare
8dd92b3 to
95cf7c5
Compare
95cf7c5 to
1e09f57
Compare
f3afbc5 to
d6c331c
Compare
64caaf2 to
28161c9
Compare
28161c9 to
042d553
Compare
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>
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>
050105d to
0637907
Compare
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.
What changed
All of it is in
OpenCode2AdapterV2.ts. Orchestration and the UI are unchanged.supportsActiveSteering: true). A message steered into a running turn goes out assession.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.session.inbox.cancel.startTurnafter the previous turn's terminal. So OpenCode'sdelivery: "queue"is not used, a cancelled queued message never reaches OpenCode, andinbox.updatehas no V2 counterpart: it changes the delivery mode, while V2's edit changes the text.msg_t3_turn_<attempt>). Each provider turn'snativeTurnRefis therefore the user message it prompted with, which is where fork and rollback cut.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.canForkFromSubagentThreadstays false while subagents are denied (layer 6).canRollbackThread,providerCanRollbackConversation,providerRollbackReturnsSnapshot):revert.stage{files: false}followed byrevert.commit. T3's own checkpoint stays authoritative for files, and a "rewind without files" must not touch them.clearruns.Verification
opencode2_inbox(frominbox.ndjson): a steer into a running shell turn plus two queued messages, one of them cancelled.opencode2_revert(fromrevert.ndjson): two write turns, then a rollback to the first.OpenCode2OrchestratorV2.integration.test.ts(fromfork.ndjson): a realthread.forkfrom the first of two turns.OpenCode2OrchestratorV2.integration.test.tsalso gains an orchestrator test showing a cancelled queued message is never prompted.OPENCODE2_MODEL=openrouter/deepseek/deepseek-v4-flash); the whole live file passes (4 tests). The new live test:reverted.txtgoes back from BETA to ALPHA, and OpenCode's session is left with the first user message plus the new one.unshare -U -p:vp test runonOpenCode2AdapterV2.test.ts,OpenCodeAdapterV2.test.ts,OpenCode2OrchestratorV2.integration.test.ts,OrchestratorReplayFixtures.integration.test.tsandprocessGroup.test.ts: 194 passed.OpenCode2AdapterV2.test.tsplusOpenCode2OrchestratorV2.integration.test.tsgive 71 passed, andOrchestratorReplayFixtures.integration.test.tsgives 111 passed.tsc --noEmit -p apps/serverexits 0;vp linton touched files: one pre-existing warning inshared.ts;vp run knip:checkis clean.Model: Claude Opus 5.5 (Claude Code)
🤖 Generated with Claude Code