Repository navigation
fix(opencode): finish restart-orphaned turns at instance boot - #45
Conversation
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>
|
The following comment was made by an LLM, it may be inaccurate: |
There was a problem hiding this comment.
All reported issues were addressed across 7 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
PierrotAWB
left a comment
There was a problem hiding this comment.
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:
- The
message.updatedbeforesession.errorordering does not actually prevent a duplicate synthetic reply on the live path (details on the publish line). - Drop the new
SessionInterruptedErrorschema type and reuseNamedError.Unknown. It removes thecore,schema, OpenAPI and SDK edits, and leaves two lines inbootstrap.tsas the only upstream-conflict surface. - 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 }) |
There was a problem hiding this comment.
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:
- One line in andytown:
await getEventPersistenceService().flush()at the top of thesession.errorhandler before reading the marker, the same thing_reconcileSessionalready does. That makes the guarantee real for every producer ofsession.error. - Don't publish
session.errorfrom the sweep. Repair the rows and publish onlymessage.updated; the coordinator's reconcile already synthesizes the terminal event from a completed errored message viafindLostTerminalEvent.
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({ |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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>
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)
session/recover.ts(new, ~95 lines) and the 3-line hook inproject/bootstrap.ts. Nothing else changed.session.erroranymore).Your 3 asks
session.errorand nosession.idle, just themessage.updated/message.part.updatedthatupdateMessage/updatePartalready emit. The coordinator's post-restart reconcile replays the completed errored message and synthesizessession.erroritself. 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.SessionInterruptedError. UsesNamedError.Unknownlikeprompt.ts. Core, schema, OpenAPI and SDK edits are gone. Conflict surface isbootstrap.tsplus the new file.orDie. Per-rowcatchCauselogsrecovery failedand moves on.Proof: kill test on a stock coordinator
Setup. Dev sandbox
3007c429-7a63-483b-8e2d-7df61db64061running prereleasev1.17.14-bootrecovery.2(=v1.17.14-13+ this branch's 3 commits). Coordinator = andytownmainate38c64c8c2, no flush. Turn ransleep 120, thens6-svc -k(SIGKILL, the OOM exit shape).Timeline (UTC).
[opencode-exit] signal 9bootstrapping→ 21:36:48.1recovered interrupted turn(sessionses_ddec0515…, messagemsg_0926678f6001PM5uA6nnR9KBC4) → readiness okReplaying messages lost across supervisor reconnect replayedMessageCount=1Synthesizing terminal event lost across reconnect terminal=session.error→ client receivessession.error("The agent restarted before it could finish this turn. Send your message again.")UnknownError,completed_atset), bash parterror+interrupted: true, 0replo-session-error-rowsCheck it yourself.
|~filter to see the whole boot.{job="sandbox", host="3007c429-7a63-483b-8e2d-7df61db64061"} |~ "opencode-exit|message=bootstrapping|recovered interrupted turn|readiness check succeeded".2build. Repeat: send asleep 120turn,pgrep -f "sleep 120", thens6-svc -k /run/service/opencode.How it works
time.completed. One process per sandbox + in-memory run loop = every such message is provably dead.error+metadata.interrupted(abort finalizer's shape;isInterruptedOrphanreads it). Message →NamedError.Unknown+time.completed(finalizeInterruptedAssistantshape). Bad row → logged, skipped.init()in bootstrap.InstanceStore.loadawaits it and the s6 readiness probe'sPOST /sessionboots the instance, so rows are repaired before the box reportsready. SIGHUP reloads never trigger it.Context
question/resume.ts.Testing Done
.1with 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 includemessage.updated+message.part.updatedand excludesession.error/session.idle; a completed message is untouched.bun typecheck(full turbo via pre-push hook) andbun lintclean.After merge
v1.17.14-14fromdevwith the pinned bun 1.3.14 (test builds used 1.4.0).apps/daytona-snapshot/Dockerfileandsandbox-packages/sandbox-upgrader/src/opencode-version.ts. Close [FEATURE]: Add native session goals with /goal anomalyco/opencode#27167.v1.17.14-bootrecovery.2prerelease.Not in this PR