Skip to content

fix(harness): derive resumable runs from log positions - #146

Merged
sanchitrk merged 7 commits into
canaryfrom
claude/cod-247-e2e-tests-pi-08808c
Nov 8, 2026
Merged

sanchitrk merged 7 commits into
canaryfrom
claude/cod-247-e2e-tests-pi-08808c

Conversation

@sanchitrk

@sanchitrk sanchitrk commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Each thread row had a recovery mark: execution IS NOT NULL meant "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:

Position Where Meaning
tail inbox.tail seq of a prompt's admission or its latest move to steer, written in that event's commit
head thread.head seq of the last unanswered run end (run.failed, execution.failed, user execution.interrupted), written only by the projector, as MAX(head, seq)
  • runnable: a pending prompt with tail > head.
  • unfinished: the last run was interrupted by crash or shutdown, or the thread has a runnable prompt.
  • Startup: the sweep turns open runs into crash. A run a crash or shutdown cut off is claimed (claimRestart counts 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.
  • Move to steer: asking for the steer lane is recorded even for a prompt already in it, so "send now" moves its tail and runs a parked steer.
  • One run per drain that runs: execution.started is 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.
  • A failed run parks its queue: a run that ends without an answer never starts the next one. Prompts queued before or during its end stay parked, and run with your next prompt. A user stop parks too, even one accepted before its run opened or landing after its run ended. A stop parks the queue rather than withdrawing it; to clear it, cancel first, then stop.
  • Removed: the execution column, markRunning, endExecution, idle, the in-memory stop position, and the coordinator's started hook.

updated_at. A thread's updated_at moves 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's updated_at, read at query time. It is one rule in Store.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. The sync.updated E2E records which rows each change moves.

Two related client fixes in codework run --server (Client.run):

  • It now ends on session.run.failed. Before, it waited forever, and the E2E reproduces that hang on the old client.
  • It attributes a failure from before promotion by log seq, not by notification order.

Design doc: "Durable Loop Execution & Interrupts Handling" (Linear).

Tests

E2E, all spawning the real CLI and SIGKILLing or SIGTERMing it:

Test Covers
recovery.parked prompts and a steer move during a failing run stay parked across a kill; the next prompt runs them all
recovery.after-failure a prompt after run.failed runs and resumes after a kill
recovery.stop a stop parks the queued prompt and one sent while it winds down, across a kill
recovery.moved a parked prompt moved to steer runs at once and resumes after a kill; a same-id resend changes nothing
recovery.shutdown a clean shutdown resumes with restarts 0, checked while the resumed request is in flight
cli.run-failed codework run --server exits with error[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, VM cw-a), all passing:

  • a SIGKILLed server;
  • a real machine shutdown and boot (orb stop / orb start);
  • a stop then crash;
  • a crash loop to the restart limit;
  • a network cut and a slow link.

The sync machines suite also passes, 12/12.

Isolated tests, only for races an E2E can't time:

  • a stop landing while run.failed commits;
  • a stop accepted before the run opened;
  • a stop before the run opened on a thread a crash cut off, with nothing runnable: it stands across a restart;
  • a stop landing after a run's success, before its drain returns;
  • a parked steer asked for the steer lane again;
  • a prompt accepted after a failure whose host died before its run: it resumes as its wake would, parked follow-up first;
  • client notifications reordered.

Each was checked to fail against the old logic.

Checks:

  • harness: 616 passed, with 4 settings.llm tests needing a generated model catalog locally;
  • codework recovery, client and CLI E2Es: 47 passed;
  • vp check and vp run typecheck: clean.

Reviewed three times by Codex gpt-6.1-sol at 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

head and tail are folded into the single init migration, following the repo's convention (as attempt was in #124). A database created before this change must be recreated.

🤖 Generated with Claude Code

sanchitrk and others added 2 commits November 8, 2026 06:20
…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>
@sanchitrk sanchitrk changed the title fix(harness): keep the recovery mark for a prompt that wakes an ending run fix(harness): derive resumable runs from log positions Nov 8, 2026
sanchitrk and others added 5 commits November 8, 2026 11:53
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>
@sanchitrk
sanchitrk changed the base branch from main to canary November 8, 2026 17:13
@sanchitrk
sanchitrk merged commit 4f68789 into canary Nov 8, 2026
1 check passed
@sanchitrk
sanchitrk deleted the claude/cod-247-e2e-tests-pi-08808c branch November 8, 2026 17:13
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.

1 participant