Repository navigation
fix(harness): derive resumable runs from log positions - #146
Merged
Merged
Conversation
…g run A run that ends visibly (truncated, interrupted, crashloop) cleared the thread's recovery mark unconditionally. A prompt admitted while that run was still going woke the thread again, so its run followed in the same busy period without the mark, and a crash in it was never resumed. idle now keeps the mark while a prompt admitted after the run began is pending, sharing endExecution's pending-prompt rule. A queue that was already parked when the run began stays parked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The recovery mark on thread rows was a stored copy of "will another run follow?", and it drifted: a run that ended visibly cleared it while a prompt that had already woken the thread ran next, unmarked, and a crash in that run was never resumed. Replace it with two positions on the session log. inbox.tail is the seq of a prompt's admission or latest move to steer; thread.head is the seq of the last unanswered run end (run.failed, execution.failed, a user stop), written by the projector in that event's commit. A pending prompt past the head is runnable; a thread is unfinished when its last run was cut off by a crash or shutdown, or it has a runnable prompt. - One run per drain that runs: execution.started before anything is promoted, one terminal decided from the stored run state. - A run that ends without an answer never starts the next one: prompts queued before or during its end stay parked for the next prompt. A user stop is recorded even before its run opened. - Crash vs shutdown and restart counting come from the run row; the execution column, markRunning, endExecution, idle and the in-memory stop position are gone. - codework run --server ends on session.run.failed, and attributes a failure before promotion by log seq, not notification order. E2E coverage in recovery.test.ts, cli.test.ts and client.test.ts, plus a real-machine suite on OrbStack (recovery.machines.live.test.ts). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A user stop that landed after a run published its own terminal, but before its drain returned, recorded nothing: head stayed put, while the stop cleared the pending wake. A prompt sent as the run ended was left runnable but not run, and started on its own at the next server start. With no run open, a user stop is now recorded whenever a prompt would otherwise still run, so it parks like any other stop. A stop after run.failed with nothing new queued still adds no second terminal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a wake Asking for the steer lane on a prompt already in it recorded nothing, so its tail never moved and the wake was rejected: "send now" on a parked steer did nothing, even across a restart. The request is now recorded as an update, which moves the tail. On startup, a thread listed only for a runnable prompt went through forced recovery, which treated the failed run's unanswered reply as cut off and asked for another answer before placing the parked queue. Such a thread now gets a plain wake, as a live prompt would; forced recovery stays for runs a crash or shutdown cut off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A user stop that landed before a drain opened its run was recorded only when a prompt was runnable. On a thread a crash had cut off, with nothing runnable, the stop left no trace: the thread stayed cut off and the next server start resumed the run the user had stopped. Such a stop is now recorded whenever the thread is resumable, cut off or runnable, so it stands across a restart. A stop after run.failed still adds no second terminal: nothing is resumable then. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A session's updated_at now moves with any write to it or anything under it, and a thread's with any write to it or anything under it: entries, parts, queued prompts and documents. Before, only conversation writes moved them, and every one also bumped the session. The rule lives in one place, Store.write: rows a write touches carry its revision, so before commit it moves threads stamped with that revision or owning rows stamped with it, then their sessions, and stamps the moves so sync sends them. The hand-written bumps in thread writes are gone. entry and inbox get rev indexes for the lookup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…new thread Rolling every thread, entry and inbox write up into the session rewrote and re-synced the session row on every message. A session's activity is its newest thread's, so read it at query time instead. - Store.write moves a session only when its own row carries the transaction's rev; threads still move with any change to them or below them. - Creating a thread stamps its session, so a new thread or fork moves it. - Session lists order by their newest thread's updated_at, with a thread (session_id, updated_at) index; link and relink drop their hand-written updated_at. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Each thread row had a recovery mark:
execution IS NOT NULLmeant "resume me on the next start". A run that ended visibly (truncated, interrupted, crashloop) cleared it unconditionally. But a prompt sent while that run was still going had already woken the thread, so its run followed straight away in the same busy period, without the mark. A host crash in that run lost the prompt, because nothing resumed it.A first fix kept the mark for prompts admitted after the run began. Review found two holes in it: the run-start position was read after sandbox/MCP setup, and a move to steer or a same-id resend wakes the thread without a new admission. Both come from the same root cause: the real question is "did the in-memory doorbell ring?", and a stored copy of that drifts.
Design: head and tail on the session log
Two positions on the session log decide what runs:
tailinbox.tailheadthread.headrun.failed,execution.failed, userexecution.interrupted), written only by the projector, asMAX(head, seq)tail > head.crashorshutdown, or the thread has a runnable prompt.crash. A run a crash or shutdown cut off is claimed (claimRestartcounts only crashes), then resumes, or ends as crashloop at the limit. A thread listed only for a runnable prompt gets a plain wake, as a live prompt would.tailand runs a parked steer.execution.startedis published before anything is promoted, and the run gets exactly one terminal. When a drain fails or is stopped, the stored run state decides whether that terminal is still owed.executioncolumn,markRunning,endExecution,idle, the in-memory stop position, and the coordinator'sstartedhook.updated_at. A thread'supdated_atmoves with any write to it or anything under it (entries, parts, queued prompts, documents). A session's moves only when its own row is written or a thread is created in it; session lists order by their newest thread'supdated_at, read at query time. It is one rule inStore.write: rows a write touches carry its revision, so before commit it moves the threads that own them, and the sessions whose own row it wrote, and stamps the moves for sync. Thesync.updatedE2E records which rows each change moves.Two related client fixes in
codework run --server(Client.run):session.run.failed. Before, it waited forever, and the E2E reproduces that hang on the old client.Design doc: "Durable Loop Execution & Interrupts Handling" (Linear).
Tests
E2E, all spawning the real CLI and SIGKILLing or SIGTERMing it:
recovery.parkedrecovery.after-failurerun.failedruns and resumes after a killrecovery.stoprecovery.movedrecovery.shutdownrestarts0, checked while the resumed request is in flightcli.run-failedcodework run --serverexits witherror[run.truncated]The existing recovery E2Es now wait for each request to reach the provider before killing the host, which fixes kill-versus-request races.
Real machines on OrbStack (
apps/server/test/recovery.machines.live.test.ts, VMcw-a), all passing:orb stop/orb start);The sync machines suite also passes, 12/12.
Isolated tests, only for races an E2E can't time:
run.failedcommits;Each was checked to fail against the old logic.
Checks:
settings.llmtests needing a generated model catalog locally;vp checkandvp run typecheck: clean.Reviewed three times by Codex
gpt-6.1-solat high effort, the last for runtime parity with the reference durable harness, and once by Fable 5.1 as a distributed systems review; all findings in scope are fixed except the migration note below. The rest predate this PR and are tracked separately.Note
headandtailare folded into the singleinitmigration, following the repo's convention (asattemptwas in #124). A database created before this change must be recreated.🤖 Generated with Claude Code