From e73f6a77abfe71f4075ee652fc89d0926525e68a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 15:58:22 +0000 Subject: [PATCH 1/2] Follow SDD: the E2E test dir gets a README, not a spec.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sdd.md scopes spec.md files to business logic and explicitly excludes test code, and specs are plain-language product docs — the e2e spec.md was both misplaced and written as technical harness documentation. - src/e2e/spec.md becomes src/e2e/README.md (developer doc, where telefunction names, env vars, and file references belong); the five source-comment references follow the rename. - The two product behaviors #1528 shipped move to the specs that own them, in spec language: acting on a session the instant it finishes is safe (product spec, teardown) and a live stream follows the log into the archive without dropping the ending (dashboard-rpc spec). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XKihCTfcazhDgt4dhUmfM9 --- packages/the-framework/spec.md | 2 +- .../the-framework/src/dashboard-rpc/spec.md | 2 +- packages/the-framework/src/e2e/README.md | 21 ++++++++++++++++++ packages/the-framework/src/e2e/harness.ts | 2 +- packages/the-framework/src/e2e/spec.md | 22 ------------------- .../e2e/story-projects-and-settings.test.ts | 2 +- .../src/e2e/story-session-lifecycle.test.ts | 2 +- .../src/e2e/story-steering-and-gates.test.ts | 2 +- .../src/e2e/story-tickets-and-queue.test.ts | 2 +- 9 files changed, 28 insertions(+), 29 deletions(-) create mode 100644 packages/the-framework/src/e2e/README.md delete mode 100644 packages/the-framework/src/e2e/spec.md diff --git a/packages/the-framework/spec.md b/packages/the-framework/spec.md index 52e8b865..48f3b1bb 100644 --- a/packages/the-framework/spec.md +++ b/packages/the-framework/spec.md @@ -49,7 +49,7 @@ flowchart TD **Settle and handoff.** Settling is strictly ordered: a final quality turn (which queues the quality presets as backlog entries rather than running them, and folds new learnings into the project docs) → the git handoff → close and archive the session history. The handoff runs only on the success path (a user-stopped or failed session skips it) and first decides whether the session is *empty* — no commits, or only bookkeeping files changed. Empty sessions are never published; otherwise pending work is committed, the branch pushed, and a PR opened. Both halves default on — that is the zero-config promise: a session left alone publishes itself. They form a ladder, not a pair: pushing is the rung under the PR, so turning push off publishes nothing, and "PR without push" is not a state a session can be asked for. -**Teardown and retention.** A session that finished cleanly has its worktree removed; one that failed or was stopped keeps its checkout, because that is exactly when you want to inspect the half-finished tree. The branch and the archived session history always survive the worktree. A session that died on a transient error (connection drop, rate limit) is retried in the same worktree before being declared failed, and a finished session can be reopened later — its history restored so it continues as the same conversation rather than starting empty. +**Teardown and retention.** A session that finished cleanly has its worktree removed; one that failed or was stopped keeps its checkout, because that is exactly when you want to inspect the half-finished tree. The branch and the archived session history always survive the worktree. A session that died on a transient error (connection drop, rate limit) is retried in the same worktree before being declared failed, and a finished session can be reopened later — its history restored so it continues as the same conversation rather than starting empty. Acting on a session the instant it finishes is safe: everything that touches that session's checkout — the teardown itself, publishing, reopening, deleting — takes its turn rather than racing, so a click that lands mid-teardown waits a beat instead of failing. ### Autonomy diff --git a/packages/the-framework/src/dashboard-rpc/spec.md b/packages/the-framework/src/dashboard-rpc/spec.md index 1e848939..db1aead7 100644 --- a/packages/the-framework/src/dashboard-rpc/spec.md +++ b/packages/the-framework/src/dashboard-rpc/spec.md @@ -2,7 +2,7 @@ The browser's call surface into the daemon: every dashboard read and write arriv ## TLDR -- Reads are thin projections of the daemon's read models; live session events stream over one channel per session, tailing that session's own log. +- Reads are thin projections of the daemon's read models; live session events stream over one channel per session, tailing that session's own log. A watcher never misses a session's ending: when the log is archived out from under a live stream at teardown, the stream follows it into the archive and delivers exactly what it had not yet shown — once. - Writes are commands: stop, answer a choice, send a message, arm the handoff, push, open PR, merge, start a session, queue a ticket. Each becomes an entry in the target session's control file, which the session tails — there is no direct channel into the running process. - Two routing decisions live here and nowhere else: which checkout a session-scoped call resolves to (the session's worktree vs. the project root), and whether a call is local or belongs to a relayed session on a remote device — in which case it is forwarded, against a deliberate allowlist on the device side. diff --git a/packages/the-framework/src/e2e/README.md b/packages/the-framework/src/e2e/README.md new file mode 100644 index 00000000..4252b410 --- /dev/null +++ b/packages/the-framework/src/e2e/README.md @@ -0,0 +1,21 @@ +# Backend E2E story tests + +Each test walks one dashboard user story through the real daemon runtime — real registry, real git repos, real spawned run processes — with the deterministic `--fake` driver standing in for the coding agent, so the whole flow runs offline in seconds and no browser is involved. They run as ordinary tests under `scripts/run-tests.mjs`, alongside the unit suites. + +## What "end-to-end" means here + +- **The entry points are the dashboard's own RPCs.** Stories call the same telefunctions the browser invokes over `/_telefunc` (`sendStart`, `sendChoice`, `onRuns`, `onQueue`, …), with the request context wired exactly the way `runDaemon` wires it: the runtime's `onStart`/`onAddProject`/`preview` closures, the registry-backed preferences store, and stub quota/auto-PM reporters where the daemon would hold live pollers. +- **Runs are real processes.** `createProjectRuntime` spawns each run detached, exactly as the daemon does; the harness's `binPath` points at `fake-agent-bin.ts` (compiled beside it), which forwards to the real CLI with `--fake` appended. Everything between the Start click and the archived run row — worktree allocation, the run store, `events.jsonl`, the control watcher, gates, teardown, retention — is the production code path; only the agent turn is scripted. +- **The Telefunc transport hop is out of scope.** Telefunc `Channel`s only pump over a real wire, so live-stream assertions ride `tailRunEvents` — the same tailer `onEvents` wraps. The mount, CSRF/rebinding guards, and channel plumbing have their own tests (`dashboard/server.test.ts`, `dashboard-rpc/stream-channel.test.ts`). + +## Isolation + +`harness.ts` points `$XDG_CONFIG_HOME` at a fresh temp dir per test process, so a story file's registry (projects, preferences, daemon state) can never see — or be seen by — the sibling test files running concurrently. Every world lives in temp dirs and `close()` kills whatever runs it spawned. + +## Scripting the fake agent + +`FRAMEWORK_FAKE_AWAIT=choices|multiselect|confirmation` (set before a Start; spawned children inherit the env) makes the fake agent's first turn end on that gate, which is how stories park a run on a question deterministically. Without it the fake agent answers every prompt with one scripted build turn and the run ends on its own. + +## The finished-session seam + +The publish and resume stories fire *at the moment* a run's meta flips `done` — inside what used to be a race window — and then assert teardown still retires the worktree. They are the regression tests for the per-run checkout lock (`run-locks.ts`) and the archive-following event tail (`events-tail.ts`). `waitRetired` remains for stories about the retired state itself. diff --git a/packages/the-framework/src/e2e/harness.ts b/packages/the-framework/src/e2e/harness.ts index e8876a03..b001e553 100644 --- a/packages/the-framework/src/e2e/harness.ts +++ b/packages/the-framework/src/e2e/harness.ts @@ -1,4 +1,4 @@ -// The world behind the backend E2E story tests (see spec.md): the daemon's business logic wired +// The world behind the backend E2E story tests (see README.md): the daemon's business logic wired // exactly as `runDaemon` wires it, against throwaway state, with runs spawned through // `fake-agent-bin.js` so the full production lifecycle executes offline. import { mkdtempSync } from 'node:fs' diff --git a/packages/the-framework/src/e2e/spec.md b/packages/the-framework/src/e2e/spec.md deleted file mode 100644 index 788bc181..00000000 --- a/packages/the-framework/src/e2e/spec.md +++ /dev/null @@ -1,22 +0,0 @@ -Backend end-to-end story tests: each test walks one dashboard user story through the real daemon runtime — real registry, real git repos, real spawned run processes — with the deterministic `--fake` driver in place of a coding agent, so the whole flow runs offline in seconds and no browser is involved. - -## What "end-to-end" means here - -- **The entry points are the dashboard's own RPCs.** Stories call the same telefunctions the browser invokes over `/_telefunc` (`sendStart`, `sendChoice`, `onRuns`, `onQueue`, …), with the request context wired exactly the way `runDaemon` wires it: the runtime's `onStart`/`onAddProject`/`preview` closures, the registry-backed preferences store, and stub quota/auto-PM reporters where the daemon would hold live pollers. -- **Runs are real processes.** `createProjectRuntime` spawns each run detached, exactly as the daemon does; the harness's `binPath` points at `fake-agent-bin.js`, which forwards to the real CLI with `--fake` appended. Everything between the Start click and the archived run row — worktree allocation, the run store, `events.jsonl`, the control watcher, gates, teardown, retention — is the production code path; only the agent turn is scripted. -- **The Telefunc transport hop is out of scope.** Telefunc `Channel`s only pump over a real wire, so live-stream assertions tail the run's `events.jsonl` — the very source `onEvents` wraps. The mount, CSRF/rebinding guards, and channel plumbing have their own tests (`dashboard/server.test.ts`, `dashboard-rpc/stream-channel.test.ts`). - -## Isolation - -`harness.ts` points `$XDG_CONFIG_HOME` at a fresh temp dir per test process, so a story file's registry (projects, preferences, daemon state) can never see — or be seen by — the sibling test files that run concurrently under `scripts/run-tests.mjs`'s shared config home. Every world lives in temp dirs and `close()` kills whatever runs it spawned. - -## Scripting the fake agent - -`FRAMEWORK_FAKE_AWAIT=choices|multiselect|confirmation` (set before a Start, spawned children inherit the env) makes the fake agent's first turn end on that gate, which is how stories park a run on a question deterministically. Without it the fake agent answers every prompt with one scripted build turn and the run ends on its own. - -## Two product races these stories surfaced — and now pin down - -Writing the stories surfaced two real windows in the finished-session seam. Both are fixed in the product, and the stories are their regression tests: - -- **Acting on a run the moment its meta flips `done` used to race teardown.** The child writes `status: done` and exits; the daemon then archives the history, commits the bookkeeping to the run branch, and retires the worktree. A Push/Resume fired inside that window ran its own commit against the same checkout, the loser reported "could not commit the work this session left uncommitted", and a losing teardown stranded the worktree. Now every mutator of one run's checkout — teardown, the handoff's commit step, Remove/Delete, continuation reuse — serializes on the run lock (`run-locks.ts`). The publish and resume stories act *at* `done` on purpose; `waitRetired` remains for stories about the retired state itself. -- **A live tail whose file was retired used to go silent without the final events.** Teardown *moves* `events.jsonl` into the archive; when fs.watch missed the last appends, the 1s poll found the file gone and delivered nothing again — including the `end` the transcript needs. `tailRunEvents` (events-tail.ts) now re-resolves the journal's home when the tailed file disappears and carries its read offset across the move — the archive is a verbatim copy, so exactly the missed lines arrive, once. `onEvents`, the relay tail, and this harness's `tailRun` all ride it. diff --git a/packages/the-framework/src/e2e/story-projects-and-settings.test.ts b/packages/the-framework/src/e2e/story-projects-and-settings.test.ts index badeae9a..653d641c 100644 --- a/packages/the-framework/src/e2e/story-projects-and-settings.test.ts +++ b/packages/the-framework/src/e2e/story-projects-and-settings.test.ts @@ -14,7 +14,7 @@ import { import { onQuota, onAutoPm, sendAutoPmSweep } from '../dashboard-rpc/quota.telefunc.js' import { sendStart } from '../dashboard-rpc/control.telefunc.js' -// The projects & settings stories (spec.md): registering a repo, what the sidebar then shows, +// The projects & settings stories (README.md): registering a repo, what the sidebar then shows, // how settings written in the dashboard reach the runs the daemon starts, and the usage panel. test('add a project: it is installed, registered, and readable like the sidebar reads it (#396)', async () => { diff --git a/packages/the-framework/src/e2e/story-session-lifecycle.test.ts b/packages/the-framework/src/e2e/story-session-lifecycle.test.ts index 4f320246..bd849e47 100644 --- a/packages/the-framework/src/e2e/story-session-lifecycle.test.ts +++ b/packages/the-framework/src/e2e/story-session-lifecycle.test.ts @@ -14,7 +14,7 @@ import { } from '../dashboard-rpc/reads.telefunc.js' import { sendChoice, sendPushBranch } from '../dashboard-rpc/control.telefunc.js' -// The session lifecycle stories (spec.md): what a user sees between clicking Start and reading +// The session lifecycle stories (README.md): what a user sees between clicking Start and reading // the archived session row — through the same RPCs the dashboard calls, with real spawned run // processes and the fake driver in the agent seat. diff --git a/packages/the-framework/src/e2e/story-steering-and-gates.test.ts b/packages/the-framework/src/e2e/story-steering-and-gates.test.ts index 591258b8..073cb08e 100644 --- a/packages/the-framework/src/e2e/story-steering-and-gates.test.ts +++ b/packages/the-framework/src/e2e/story-steering-and-gates.test.ts @@ -11,7 +11,7 @@ import { sendDeleteSession, } from '../dashboard-rpc/control.telefunc.js' -// The steering stories (spec.md): everything the user does TO a live session — answer its +// The steering stories (README.md): everything the user does TO a live session — answer its // question, chat with it, change its handoff, stop it — flows browser -> RPC -> control.jsonl -> // the run process, and the observable answer comes back through the run's own event log. diff --git a/packages/the-framework/src/e2e/story-tickets-and-queue.test.ts b/packages/the-framework/src/e2e/story-tickets-and-queue.test.ts index f6278d60..21364aad 100644 --- a/packages/the-framework/src/e2e/story-tickets-and-queue.test.ts +++ b/packages/the-framework/src/e2e/story-tickets-and-queue.test.ts @@ -12,7 +12,7 @@ import { import { sendChoice, sendQueueTicket } from '../dashboard-rpc/control.telefunc.js' import { presets } from '../preset-catalog.js' -// The roadmap stories (spec.md): tickets are proposals, the flat TODO queue holds confirmed +// The roadmap stories (README.md): tickets are proposals, the flat TODO queue holds confirmed // work, and a drain run claims the queue's next entry — the propose -> decide -> work loop the // Tickets and Queue pages drive. From 5957d36b73c3cbd1aaeef32b2d3b6a0b0d941131 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 16:24:58 +0000 Subject: [PATCH 2/2] Resume at the instant of finish: wait out the exiting leg, don't refuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings story flaked on CI (#1529): sendStart with continueRunId hit the busy guard while the finished first leg's child was still mid-exit — the slot held a live pid, and the continuation of a session that was over by its own account was refused as "already active". daemon-runtime now parks each leg's exit → retirement chain per run slot (retiring), and a continuation of a run whose meta reads terminal (via findRun — the live copy wins, since the just-written `done` is not archived until teardown) waits out the exit and the queued retirement, bounded, before the guard judges. A refusal is reserved for sessions genuinely still running, and the checkout reuse always reads a settled archive. waitOutFinishedLeg carries six unit tests; the settings story pins the full path (15× stress green, from 5/15 red). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XKihCTfcazhDgt4dhUmfM9 --- .changeset/resume-at-finish-not-refused.md | 5 ++ .../the-framework/src/daemon-runtime.test.ts | 75 +++++++++++++++++++ packages/the-framework/src/daemon-runtime.ts | 72 ++++++++++++++++-- .../e2e/story-projects-and-settings.test.ts | 7 +- 4 files changed, 151 insertions(+), 8 deletions(-) create mode 100644 .changeset/resume-at-finish-not-refused.md create mode 100644 packages/the-framework/src/daemon-runtime.test.ts diff --git a/.changeset/resume-at-finish-not-refused.md b/.changeset/resume-at-finish-not-refused.md new file mode 100644 index 00000000..61595674 --- /dev/null +++ b/.changeset/resume-at-finish-not-refused.md @@ -0,0 +1,5 @@ +--- +'@gemstack/the-framework': patch +--- + +Resuming a session the instant it finishes is no longer spuriously refused as "already active". A run's row flips `done` the moment the child writes its ending, but the child takes a beat more to actually exit — and a Resume landing in that gap found the run's slot still holding a live pid and was turned away by the busy guard, though the session was over by its own account (the E2E settings story caught this on a slow CI runner). The start path now waits out a finished leg's exit and the retirement queued behind it — the settle chain is parked per run slot so the continuation can await it — and only then judges the guard, so a refusal is reserved for sessions that are genuinely still running and the checkout reuse always reads a settled archive. diff --git a/packages/the-framework/src/daemon-runtime.test.ts b/packages/the-framework/src/daemon-runtime.test.ts new file mode 100644 index 00000000..87fda209 --- /dev/null +++ b/packages/the-framework/src/daemon-runtime.test.ts @@ -0,0 +1,75 @@ +import { strict as assert } from 'node:assert' +import { test } from 'node:test' +import { waitOutFinishedLeg } from './daemon-runtime.js' + +const sleep = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)) + +const emptySlots = (): { starting: Set; activeRuns: Map; retiring: Map> } => ({ + starting: new Set(), + activeRuns: new Map(), + retiring: new Map>(), +}) + +// The seam behind #1529: a Resume fired the instant a run flips `done` used to reach the busy +// guard while the child that wrote that ending was still mid-exit, and the guard refused a +// session that was over by its own account. These pin the wait's decision table; the settings +// story exercises the full daemon path. + +test('a free slot returns immediately, without even asking whether the leg ended', async () => { + let asked = 0 + await waitOutFinishedLeg( + 'p::r1', + emptySlots(), + async () => { + asked += 1 + return true + }, + 5_000, + ) + assert.equal(asked, 0) +}) + +test('a leg still calling itself running is a real collision: no wait, the refusal stands', async () => { + const slots = emptySlots() + slots.activeRuns.set('p::r1', process.pid) + const before = Date.now() + await waitOutFinishedLeg('p::r1', slots, async () => false, 5_000) + assert.ok(Date.now() - before < 1_000, 'must not sit out the grace period') + assert.ok(slots.activeRuns.has('p::r1'), 'the slot is left for the busy guard to judge') +}) + +test('a finished leg is waited out: the wait ends once the exit clears the slot', async () => { + const slots = emptySlots() + slots.activeRuns.set('p::r1', process.pid) + setTimeout(() => slots.activeRuns.delete('p::r1'), 60) + await waitOutFinishedLeg('p::r1', slots, async () => true, 5_000) + assert.equal(slots.activeRuns.has('p::r1'), false) +}) + +test('the wait is bounded: a finished leg whose process never exits falls back to the guard', async () => { + const slots = emptySlots() + slots.activeRuns.set('p::r1', process.pid) + const before = Date.now() + await waitOutFinishedLeg('p::r1', slots, async () => true, 120) + assert.ok(Date.now() - before >= 120, 'the grace period was sat out') + assert.ok(slots.activeRuns.has('p::r1'), 'the still-occupied slot reaches the busy guard') +}) + +test('a retirement already in flight is awaited even after the exit cleared the slot', async () => { + const slots = emptySlots() + let retired = false + slots.retiring.set( + 'p::r1', + sleep(60).then(() => { + retired = true + }), + ) + await waitOutFinishedLeg('p::r1', slots, async () => true, 5_000) + assert.equal(retired, true) +}) + +test('a retirement that failed does not fail the continuation waiting on it', async () => { + const slots = emptySlots() + slots.retiring.set('p::r1', Promise.reject(new Error('archive on a full disk'))) + await waitOutFinishedLeg('p::r1', slots, async () => true, 5_000) +}) diff --git a/packages/the-framework/src/daemon-runtime.ts b/packages/the-framework/src/daemon-runtime.ts index bf3f36eb..58054eb7 100644 --- a/packages/the-framework/src/daemon-runtime.ts +++ b/packages/the-framework/src/daemon-runtime.ts @@ -14,6 +14,7 @@ import { attachWorktree, worktreePath, listRuns, + findRun, archivedRunPaths, commitPendingWork, currentBranch, @@ -238,6 +239,9 @@ export const MAX_TRANSIENT_RETRIES = 2 /** The pause before a retry (#1281): long enough for a dropped connection to be worth re-trying. */ const TRANSIENT_RETRY_DELAY_MS = 15_000 +/** How long a continuation waits for its finished previous leg to exit and retire (#1529). */ +const FINISHED_LEG_EXIT_GRACE_MS = 15_000 + /** What the continued session is told (#1281), in the #923 resume prompt's shape. */ const RETRY_PROMPT = 'This session died to a transient connection error, not because anyone asked it to stop. Look at what you had already done, then carry on from there and finish the work.' @@ -344,6 +348,30 @@ async function waitForExit(pid: number, timeoutMs: number): Promise { return !isPidAlive(pid) } +/** + * Wait out the previous leg of the run a continuation is aimed at (#1529). A Resume clicked the + * instant a run's row flips `done` can land while the child that wrote that ending is still + * mid-exit: the run's slot then still holds a live pid, and the busy guard read "already active" + * off a session that is over by its own account — a spurious refusal the E2E settings story + * caught on a slow runner. A finished child's exit is imminent and its retirement is queued + * right behind it (see `retiring` in {@link createProjectRuntime}), so wait for both, bounded by + * `graceMs`, and let the reuse read a settled archive. A leg still calling itself `running` is a + * genuine collision: not waited on, so the guard's refusal stands. + */ +export async function waitOutFinishedLeg( + key: string, + slots: { starting: Set; activeRuns: Map; retiring: Map> }, + legHasEnded: () => Promise, + graceMs: number, +): Promise { + const occupied = (): boolean => slots.starting.has(key) || slots.activeRuns.has(key) + if (!occupied() && !slots.retiring.has(key)) return + if (!(await legHasEnded())) return + const deadline = Date.now() + graceMs + while (occupied() && Date.now() < deadline) await delay(25) + await slots.retiring.get(key)?.catch(() => {}) +} + /** Inputs to {@link createProjectRuntime}. */ export interface ProjectRuntimeOptions { /** The daemon's home workspace; a run/preview with no project id targets it. */ @@ -402,6 +430,15 @@ export function createProjectRuntime({ cwd, env, binPath, retryDelayMs, agentPre // Live run pids, keyed per run rather than per project (#736) — see onStart for the key. const activeRuns = new Map() const starting = new Set() // reserved keys mid-spawn, to close the async gap + // A finished leg's exit → retirement chain, parked per run slot so a continuation that raced + // the exit (#1529) can await the retirement instead of reusing a checkout mid-removal. + const retiring = new Map>() + const parkRetirement = (key: string, retired: Promise): void => { + retiring.set(key, retired) + void retired.finally(() => { + if (retiring.get(key) === retired) retiring.delete(key) + }) + } // Runs this daemon is relaying to/from a connected device (#1067): the local half of a remote run. const relayedRuns = new RelayedRuns() // The relayed-run lookup the dashboard's read RPCs consult (#1067 slice 2): is this runId remote, and @@ -693,7 +730,12 @@ export function createProjectRuntime({ cwd, env, binPath, retryDelayMs, agentPre if (!movedRunId) return // The moved meta is normally already there, so the marker no-ops; it only writes when the // copy was torn AND the resumed child died at boot (#1261) — the same hang either way. - void markFailedStart(checkout, movedRunId, '', detail).finally(() => void tearDownWorktree(projectCwd, checkout, movedRunId)) + parkRetirement( + key, + markFailedStart(checkout, movedRunId, '', detail) + .catch(() => {}) + .then(() => tearDownWorktree(projectCwd, checkout, movedRunId)), + ) } continued.once('error', err => settle(`its process could not be spawned (${errorMessage(err)})`)) continued.once('exit', (code, signal) => settle(exitDetail(code, signal))) @@ -855,6 +897,23 @@ export function createProjectRuntime({ cwd, env, binPath, retryDelayMs, agentPre // its options client-side and sends them whole. if (options.continueRunId) { options = { ...(await resolveProjectRunOptions(projectKey, env)), ...options } + // A Resume fired the instant its run flips `done` can also land while the child that wrote + // that ending is still mid-exit (#1529): the slot then still holds a live pid, and the busy + // guard below refused a continuation of a session that is over by its own account. Wait the + // exit and its queued retirement out, so the guard judges only real collisions and the + // checkout reuse reads a settled archive. + const { continueRunId } = options + await waitOutFinishedLeg( + scopedKey(projectKey, continueRunId), + { starting, activeRuns, retiring }, + async () => { + // The composed read (live meta wins over archive): the leg just wrote `done` into its + // worktree and teardown has not archived it yet, so the archive-only list cannot see it. + const meta = continueRunId ? await findRun(projectCwd, continueRunId).catch(() => undefined) : undefined + return meta !== undefined && meta.status !== 'running' + }, + FINISHED_LEG_EXIT_GRACE_MS, + ) } // A run must not spend a branch and a worktree on an agent that can never start (#1326). @@ -918,10 +977,13 @@ export function createProjectRuntime({ cwd, env, binPath, retryDelayMs, agentPre // The failed marker lands before the teardown reads the meta (#1261), so a boot death is // archived as `failed` and the worktree is then kept for inspection, not removed. After // teardown the archive is readable, which is when a transient death earns a retry (#1281). - void markFailedStart(checkout, runId, prompt, detail).finally(() => - void tearDownWorktree(projectCwd, checkout, runId).finally(() => - void retryTransientDeath(projectCwd, targetProjectId, runId, options), - ), + parkRetirement( + key, + markFailedStart(checkout, runId, prompt, detail) + .catch(() => {}) + .then(() => tearDownWorktree(projectCwd, checkout, runId)) + .then(() => retryTransientDeath(projectCwd, targetProjectId, runId, options)) + .catch(() => {}), ) } child.once('error', err => settle(`its process could not be spawned (${errorMessage(err)})`)) diff --git a/packages/the-framework/src/e2e/story-projects-and-settings.test.ts b/packages/the-framework/src/e2e/story-projects-and-settings.test.ts index 653d641c..7d367367 100644 --- a/packages/the-framework/src/e2e/story-projects-and-settings.test.ts +++ b/packages/the-framework/src/e2e/story-projects-and-settings.test.ts @@ -85,9 +85,10 @@ test('settings written in the dashboard reach the next resumed run (#858/#1467)' // The composer's Resume sends only its seed (#1467); the daemon overlays the project's // resolved options, so the model chosen in Settings reaches the continued session's argv. - // Fired the instant the row flips done — deliberately inside teardown's window: the run - // lock makes the continuation wait out the archive it is about to reopen, where it used to - // reuse a checkout mid-retirement. + // Fired the instant the row flips done — deliberately inside teardown's window: the busy + // guard waits out the still-exiting first leg instead of refusing (#1529), and the run + // lock makes the continuation wait out the archive it is about to reopen, where it used + // to reuse a checkout mid-retirement. const resumed = await rpc(sendStart)(project.id, 'Keep going', 'prompt', { continueRunId: runId }) assert.equal(resumed.ok, true) await world.waitRun(project, runId, 'done')