Skip to content

fix(opencode): finish restart-orphaned turns at instance boot - #45

Merged
kevoconnell merged 3 commits into
devfrom
claude/recover-interrupted-turns-on-boot
Sep 11, 2026
Merged

kevoconnell merged 3 commits into
devfrom
claude/recover-interrupted-turns-on-boot

Conversation

@kevoconnell

@kevoconnell kevoconnell commented Sep 10, 2026 •

Copy link
Copy Markdown
Member

Opencode now finishes its own restart-orphaned turns at boot. A turn killed mid-flight (OOM, crash, restart) ends with a visible error within seconds instead of a 30-minute spinner.

TL;DR for review (~10 min)

  • Read: session/recover.ts (new, ~95 lines) and the 3-line hook in project/bootstrap.ts. Nothing else changed.
  • Since your last pass: all 3 asks done. Ask 1 went your option 2, so andytown needs no coordinator change, only the version pin bump.
  • Proof: kill test re-run on a stock coordinator (no flush). Error reached the client 2.7 s after the kill. Zero synthetic rows. Grafana link below.
  • Cubic: both comments closed (ordering assert, and its marker-race P1 is moot because nothing publishes session.error anymore).

Your 3 asks

  1. ✅ Marker race (option 2). The sweep only repairs rows. It publishes no session.error and no session.idle, just the message.updated / message.part.updated that updateMessage / updatePart already emit. The coordinator's post-restart reconcile replays the completed errored message and synthesizes session.error itself. Trade-off you named: the terminal event depends on that reconcile, which ran 0.6 s after readiness in the test. andytown #27275 (the flush variant) is closed.
  2. ✅ No SessionInterruptedError. Uses NamedError.Unknown like prompt.ts. Core, schema, OpenAPI and SDK edits are gone. Conflict surface is bootstrap.ts plus the new file.
  3. ✅ No per-row orDie. Per-row catchCause logs recovery failed and moves on.

Proof: kill test on a stock coordinator

Setup. Dev sandbox 3007c429-7a63-483b-8e2d-7df61db64061 running prerelease v1.17.14-bootrecovery.2 (= v1.17.14-13 + this branch's 3 commits). Coordinator = andytown main at e38c64c8c2, no flush. Turn ran sleep 120, then s6-svc -k (SIGKILL, the OOM exit shape).

Timeline (UTC).

  • 21:36:46.5 kill → sandbox [opencode-exit] signal 9
  • 21:36:47.9 opencode bootstrapping → 21:36:48.1 recovered interrupted turn (session ses_ddec0515…, message msg_0926678f6001PM5uA6nnR9KBC4) → readiness ok
  • 21:36:48.7 coordinator Replaying messages lost across supervisor reconnect replayedMessageCount=1
  • 21:36:49.1 coordinator Synthesizing terminal event lost across reconnect terminal=session.error → client receives session.error ("The agent restarted before it could finish this turn. Send your message again.")
  • Postgres: 1 user row, 1 assistant row (UnknownError, completed_at set), bash part error + interrupted: true, 0 replo-session-error- rows

Check it yourself.

  • Grafana Explore: the sandbox boot lines (21:36:20 to 21:37:00 UTC). Drop the |~ filter to see the whole boot.
  • LogQL by hand: {job="sandbox", host="3007c429-7a63-483b-8e2d-7df61db64061"} |~ "opencode-exit|message=bootstrapping|recovered interrupted turn|readiness check succeeded"
  • Daytona sandbox still runs the .2 build. Repeat: send a sleep 120 turn, pgrep -f "sleep 120", then s6-svc -k /run/service/opencode.
  • Coordinator and Postgres lines above are from Kevin's local stack, so they are quoted, not linked.

How it works

  • Boot: select this project's assistant messages with no time.completed. One process per sandbox + in-memory run loop = every such message is provably dead.
  • Per message: running tool parts → error + metadata.interrupted (abort finalizer's shape; isInterruptedOrphan reads it). Message → NamedError.Unknown + time.completed (finalizeInterruptedAssistant shape). Bad row → logged, skipped.
  • When: one more init() in bootstrap. InstanceStore.load awaits it and the s6 readiness probe's POST /session boots the instance, so rows are repaired before the box reports ready. SIGHUP reloads never trigger it.

Context

  • Replaces andytown #27167 per #engineering: opencode owns this state, not the coordinator.
  • Incident: USE-2679 and the #alerts firing: opencode OOM-killed 2 s after the prompt, nothing ended the turn for 39 min.
  • Same pattern as question/resume.ts.

Testing Done

  • Live kill test above. (Round 1 on .1 with the flush coordinator passed the same way; round 2 is the one that counts.)
  • test/session/recover.test.ts: orphan gets stamped, tool part marked interrupted, events include message.updated + message.part.updated and exclude session.error / session.idle; a completed message is untouched.
  • bun typecheck (full turbo via pre-push hook) and bun lint clean.

After merge

  1. Cut v1.17.14-14 from dev with the pinned bun 1.3.14 (test builds used 1.4.0).
  2. andytown: bump apps/daytona-snapshot/Dockerfile and sandbox-packages/sandbox-upgrader/src/opencode-version.ts. Close [FEATURE]: Add native session goals with /goal anomalyco/opencode#27167.
  3. Delete the v1.17.14-bootrecovery.2 prerelease.

Not in this PR

  • Auto-resuming the interrupted turn: a mid-mutation tool would be re-run by the model.
  • A trailing user message with no assistant row: not observable as in flight; andytown watchdogs backstop it.

Only one opencode process serves a sandbox and a turn's run loop lives in
that process, so at instance boot every assistant message without a
completion time is provably dead. Finish it the way an abort would (tool
parts interrupted, SessionInterruptedError, time.completed), then publish
session.error and idle, so clients end the turn instead of spinning until
the coordinator's 30-minute sweep.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

The following comment was made by an LLM, it may be inaccurate:

@kevoconnell
kevoconnell marked this pull request as ready for review September 10, 2026 23:02

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 7 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

@PierrotAWB PierrotAWB left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does what we agreed on in the thread: opencode repairs its own dead turns at boot, and the coordinator's existing session.error handler and reconcile do the rest. I traced the coordinator side to check the claims in the description. Bootstrap timing holds (InstanceStore.load awaits bootstrap.run, and the s6 readiness probe's POST /session?directory=/workspace is what boots the instance, so the sweep finishes before the sandbox is reported ready). The metadata.interrupted marking is load-bearing for isInterruptedOrphan in the prompt loop, so keep it.

Three asks before merge, all inline:

  1. The message.updated before session.error ordering does not actually prevent a duplicate synthetic reply on the live path (details on the publish line).
  2. Drop the new SessionInterruptedError schema type and reuse NamedError.Unknown. It removes the core, schema, OpenAPI and SDK edits, and leaves two lines in bootstrap.ts as the only upstream-conflict surface.
  3. Catch per-row failures in the sweep instead of Effect.orDie.

Also: please run the sandbox kill test from the Rollout section before merging and check Postgres opencode_message for a replo-session-error- row on that session. That is the direct check for (1).

}).toObject()
info.time.completed = end
yield* sessions.updateMessage(info)
yield* events.publish(Session.Event.Error, { sessionID: info.sessionID, error: info.error })

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ordering claim above does not hold on the coordinator's live SSE path. The "no reply yet" marker (inFlightPromptSentAt) is cleared in _logTimeToFirstToken, which runs inside the persistence drain after a Postgres write, and handleEvent starts that persistence in waitUntil without awaiting it (opencode.ts:1775, event-persistence.ts:715). The session.error handler reads the marker synchronously from SQLite (opencode.ts:2010). In a normal turn the first assistant message lands seconds before any error, so the race never bites. This sweep publishes the two events in the same tick, so the error handler will usually win and emit the synthetic replo-session-error-* message on top of the repaired one.

Whether the live path is hit at all depends on whether the SSE producer has reconnected to /global/event before the readiness probe boots the instance. If it has not, the reconcile path runs instead, and that one flushes persistence before deciding, so it is clean.

Two ways to close it, in order of preference:

  1. One line in andytown: await getEventPersistenceService().flush() at the top of the session.error handler before reading the marker, the same thing _reconcileSession already does. That makes the guarantee real for every producer of session.error.
  2. Don't publish session.error from the sweep. Repair the rows and publish only message.updated; the coordinator's reconcile already synthesizes the terminal event from a completed errored message via findLostTerminalEvent.

Either way the sandbox kill test will show it: look for a replo-session-error- row in opencode_message after the restart.

}
// message.updated before session.error: clients that key "no reply yet" off the first
// assistant message must see this one land before the turn is declared failed.
info.error = new SessionV1.InterruptedError({

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest new NamedError.Unknown({ message }) here (already the pattern in prompt.ts for the agent-not-found case) and dropping SessionInterruptedError from core, schema, openapi.json and types.gen.ts.

The name is invisible everywhere it could matter: the coordinator and the UI type the assistant error as z.unknown(), getFailedTurnReason treats any non-abort error as errored, and the turn-health query and the "Agent Not Responding" alert count on error != null. The recovered interrupted turn log line already gives us the count in Loki. The schema union line is exactly the kind of place upstream edits, so this is four files of merge surface for nothing user-visible.

for (const row of rows) {
const { info, parts } = yield* MessageV2.get({ sessionID: row.sessionID, messageID: row.id }).pipe(
Effect.provideService(Database.Service, database),
Effect.orDie,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Effect.orDie here (and on the select above) means one unreadable message row aborts the whole sweep. Bootstrap catches it and logs init failed, but every other orphan in the project stays stuck. We have had session-DB corruption before (REPL-29772), so wrap the per-row body in a catchCause that logs and continues to the next row.

Per PierrotAWB: the error name is invisible to every consumer, so drop the
schema/SDK surface and use the prompt.ts pattern; catch per-row failures so
one unreadable message cannot strand the other orphans; the ordering test
now requires both events to be present before comparing their order.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 6 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Publishing session.error from the sweep races a client that clears its no-reply marker on the first assistant message.updated; the coordinator's post-restart replay already synthesizes the terminal event from a completed errored message, so the sweep now publishes only the message and part updates.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@kevoconnell
kevoconnell merged commit c8bfbec into dev Sep 11, 2026
14 checks passed
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.

[FEATURE]: Add native session goals with /goal

2 participants