feat(cli): run and resume a suite from a checkout (Task 2.2, part 3a) - #10
Conversation
`run --project --candidate --profile --suite` runs a consumer's automated scenarios against a verified local candidate; `resume --run` continues an interrupted run; `reset` clears what a crashed run left in the test root. - Local candidate manifest (model/local-candidate.ts): one artifact per profile, a path relative to the manifest that cannot leave its directory, and a SHA-256. The chosen profile's file is hashed before anything is installed; other profiles' files are never read. It carries no provenance, so it cannot stand in for a GitHub candidate. - Hooks now receive `candidate` as an identity (a full GitHub candidate still fits) and the verified `artifact` (name, absolute path, sha256), as the design spec asks. - Everything checkable without touching the machine is checked first (project, plan, candidate bytes, the consumer's lifecycle and scenario modules), so a mistake is reported before any run state exists. - Journal: run-started, then per scenario a `scenario-started` checkpoint and an attempt, chained by `prev`. The machine id is a random token, never the host name. The run id is announced on stderr before anything runs. - Resume carries passed and failed forward (a failure cannot disappear by rerunning), reruns everything else as a retry, turns a started-but-unrecorded scenario (the process died) into an explicit interrupted attempt first, refuses a changed candidate or an inconsistent journal. - Exit codes: any failed 1; else interrupted, cancelled or not-run 3; else blocked or manual 2; else 0. - SIGINT/SIGTERM cancel cleanly; a second one exits at once. `reset` refuses undesignated roots and roots a run holds. Also makes timing-sensitive executor tests hold under load (this PR's process-spawning tests add load): the two tests that reap a real process got a 2 s cleanup budget, which Windows identity lookups can exceed, so reaping was cut off and finished in the background (confirmed with a 50 ms budget); they now use 20 s and assert cleanup succeeded. Two tests sharing a 20/80 ms phase budget with prerequisites now have 300 ms, and the cut-off test asserts it was install that was cut off. Mutation-checked 22 rules with a throwaway script; two survivors (exit-code precedence with a failure and an interruption together, resuming an inconsistent journal) were closed with new tests and reconfirmed killed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
3 issues found across 25 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/qa/src/cli/run.ts">
<violation number="1" location="packages/qa/src/cli/run.ts:147">
P2: This loop awaits every scenario before starting the next, so multi-scenario suites are always serialized and cannot provide the advertised parallel execution. Implement the intended concurrent scheduling with safe per-scenario resource/journal handling, or remove that feature claim.</violation>
</file>
<file name="packages/qa/src/cli/main.ts">
<violation number="1" location="packages/qa/src/cli/main.ts:122">
P1: A scenario can pass its steps while cleanup fails, leaving the test root dirty; this returns exit code 0 because `summary.exitCode` ignores cleanup failures. Treat any cleanup failure as `EXIT.infrastructure` while preserving scenario-failure precedence.</violation>
</file>
<file name="packages/qa/test/cli/main.test.ts">
<violation number="1" location="packages/qa/test/cli/main.test.ts:216">
P3: If `await eventually(...)` throws, `controller.abort()` never runs and `running` is never awaited, so the hang scenario's pending promise keeps the run alive and the test fails only via the 30s test timeout instead of the assertion that was violated. Wrap the poll/abort/await sequence in try/finally: abort and await `running` in the finally so a pre-condition failure still cancels the run and resolves the promise, then rethrow.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| return EXIT.infrastructure; | ||
| } | ||
| printRun(io, command.json, result.summary); | ||
| return result.summary.exitCode; |
There was a problem hiding this comment.
P1: A scenario can pass its steps while cleanup fails, leaving the test root dirty; this returns exit code 0 because summary.exitCode ignores cleanup failures. Treat any cleanup failure as EXIT.infrastructure while preserving scenario-failure precedence.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/qa/src/cli/main.ts, line 122:
<comment>A scenario can pass its steps while cleanup fails, leaving the test root dirty; this returns exit code 0 because `summary.exitCode` ignores cleanup failures. Treat any cleanup failure as `EXIT.infrastructure` while preserving scenario-failure precedence.</comment>
<file context>
@@ -67,11 +78,68 @@ export async function main(argv: readonly string[], io: Io = defaultIo, cwd: ()
+ return EXIT.infrastructure;
+ }
+ printRun(io, command.json, result.summary);
+ return result.summary.exitCode;
+ }
}
</file context>
| return result.summary.exitCode; | |
| return result.summary.results.some((r) => r.cleanup !== undefined && !r.cleanup.ok) ? (result.summary.exitCode === EXIT.scenarioFailure ? EXIT.scenarioFailure : EXIT.infrastructure) : result.summary.exitCode; |
There was a problem hiding this comment.
Fixed in 488e47d. A failed cleanup now makes the run exit 3, even if every scenario passed; a failure still decides 1. Tests in the exit-code table.
| let attemptCount = state.attempts.length; | ||
| const newAttemptId = (): string => `${runId}.a${++attemptCount}`; | ||
|
|
||
| for (const scenario of prepared.consumer.scenarios) { |
There was a problem hiding this comment.
P2: This loop awaits every scenario before starting the next, so multi-scenario suites are always serialized and cannot provide the advertised parallel execution. Implement the intended concurrent scheduling with safe per-scenario resource/journal handling, or remove that feature claim.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/qa/src/cli/run.ts, line 147:
<comment>This loop awaits every scenario before starting the next, so multi-scenario suites are always serialized and cannot provide the advertised parallel execution. Implement the intended concurrent scheduling with safe per-scenario resource/journal handling, or remove that feature claim.</comment>
<file context>
@@ -0,0 +1,300 @@
+ let attemptCount = state.attempts.length;
+ const newAttemptId = (): string => `${runId}.a${++attemptCount}`;
+
+ for (const scenario of prepared.consumer.scenarios) {
+ const key = scenario.requirement.key;
+ const history = historyOf(state, key);
</file context>
There was a problem hiding this comment.
Not changing this: nothing claims parallel execution. I checked the code, README, docs/decisions/local-runs.md, the plan and the design spec, and none of them mention it. Scenarios in one run are serial on purpose: they share one test root, which only one run may hold at a time (the Task 2.1 lock), and each scenario's reset/install/cleanup would interfere with another's running in the same root.
| await designated(consumer.dir); | ||
| const controller = new AbortController(); | ||
| const out = io(); | ||
| const running = main([...runArgs(consumer), '--json'], out.sink, () => consumer.dir, controller.signal); |
There was a problem hiding this comment.
P3: If await eventually(...) throws, controller.abort() never runs and running is never awaited, so the hang scenario's pending promise keeps the run alive and the test fails only via the 30s test timeout instead of the assertion that was violated. Wrap the poll/abort/await sequence in try/finally: abort and await running in the finally so a pre-condition failure still cancels the run and resolves the promise, then rethrow.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/qa/test/cli/main.test.ts, line 216:
<comment>If `await eventually(...)` throws, `controller.abort()` never runs and `running` is never awaited, so the hang scenario's pending promise keeps the run alive and the test fails only via the 30s test timeout instead of the assertion that was violated. Wrap the poll/abort/await sequence in try/finally: abort and await `running` in the finally so a pre-condition failure still cancels the run and resolves the promise, then rethrow.</comment>
<file context>
@@ -149,3 +154,87 @@ describe('designate and status', () => {
+ await designated(consumer.dir);
+ const controller = new AbortController();
+ const out = io();
+ const running = main([...runArgs(consumer), '--json'], out.sink, () => consumer.dir, controller.signal);
+ await eventually(async () => (await readFile(consumer.logPath, 'utf8').catch(() => '')).includes('steps:persistence'));
+ controller.abort();
</file context>
There was a problem hiding this comment.
Fixed in 488e47d. The abort is now in a finally, so a failed wait still cancels and settles the run.
…run ids and artifact paths contained - resume compared only the manifest id: a manifest edited to name new bytes under the same id would have had earlier results carried forward for a different build. The artifact's SHA-256 is now kept with the invocation at start and must match on resume. - A scenario that passed but whose cleanup failed left the root dirty and still exited 0. A failed cleanup now makes the run exit 3 (a failure still decides 1). - `resume --run ../../x` would have read and appended to a journal outside the state directory. The run id must now match the journal's id grammar, which has no separators. - The artifact path check was lexical: a link inside the manifest directory could lead to a file elsewhere, which would then be hashed and tested. Both real paths are now compared. - Consumer modules are inspected inside the loader's error boundary (a throwing getter is a refusal, not a crash); resume reads the journal inside its own; the process entry reports an unexpected rejection as exit 3. - The resume hint printed at start now includes a custom --state. - Tests: the SIGINT child is tracked and awaited on 'close' (flushed output); the in-process cancellation test always aborts in a finally; spawning steps in the reaping tests get the same budget as cleanup; the consumer fixture validates its options and its hang settles on an already-aborted signal; more manifest entry cases; README grammar. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
All reported issues were addressed across 16 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
… a link-free artifact path, safe resume hint - A scenario that passed but whose cleanup failed was carried forward by resume without its cleanup failure, so the resumed run could exit 0. A `cleanup-failed` checkpoint is now journaled after such an attempt and resurfaced on the carried result, which keeps the run at exit 3. - The artifact is hashed at its resolved real path, the resolution is re-checked after hashing (a link swapped in the meantime is refused), and hooks get that real path, which has no link left in it to swap. A realpath failure is a refusal, keeping loadCandidate's never-throws contract. - The resume hint quotes the state directory safely for bash and PowerShell: plain paths as they are, anything else in single quotes, and a path containing a single quote named in prose instead of embedded in the command. - Tests: the run test compares logged artifact paths against the real path (they differ under 8.3 short names on CI), a describe block named after the review round now says what it tests, and three test cases whose Windows paths had lost their backslashes to shell quoting now really contain them. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…th the review fixes Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
1 issue found across 10 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/qa/src/cli/candidate.ts">
<violation number="1" location="packages/qa/src/cli/candidate.ts:62">
P3: The new link-swap refusal ("changed location while it was being verified") has no test. The added tests only cover a `realpath` that throws, a link leading outside the manifest directory, and a link inside it being resolved — none trigger this branch, which is the main new defense against a link swapped mid-verification. Add a test that swaps the directory link between two targets (or mocks `realpath` to return a different path on the re-check call) and asserts the `changed location` refusal.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| try { | ||
| actual = await sha256Of(realFile); | ||
| // A link swapped while the file was being hashed would make the check above describe some other file. | ||
| if ((await realpath(path)) !== realFile) return { ok: false, error: `the artifact ${path} changed location while it was being verified` }; |
There was a problem hiding this comment.
P3: The new link-swap refusal ("changed location while it was being verified") has no test. The added tests only cover a realpath that throws, a link leading outside the manifest directory, and a link inside it being resolved — none trigger this branch, which is the main new defense against a link swapped mid-verification. Add a test that swaps the directory link between two targets (or mocks realpath to return a different path on the re-check call) and asserts the changed location refusal.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/qa/src/cli/candidate.ts, line 62:
<comment>The new link-swap refusal ("changed location while it was being verified") has no test. The added tests only cover a `realpath` that throws, a link leading outside the manifest directory, and a link inside it being resolved — none trigger this branch, which is the main new defense against a link swapped mid-verification. Add a test that swaps the directory link between two targets (or mocks `realpath` to return a different path on the re-check call) and asserts the `changed location` refusal.</comment>
<file context>
@@ -42,22 +42,31 @@ export async function loadCandidate(manifestPath: string, profile: string): Prom
- actual = await sha256Of(path);
+ actual = await sha256Of(realFile);
+ // A link swapped while the file was being hashed would make the check above describe some other file.
+ if ((await realpath(path)) !== realFile) return { ok: false, error: `the artifact ${path} changed location while it was being verified` };
} catch (error) {
return { ok: false, error: `could not read the artifact ${path}: ${message(error)}` };
</file context>
There was a problem hiding this comment.
Fixed in b7143e3. The new test mocks realpath so the artifact resolves to its real location before hashing and somewhere else afterwards, and asserts the 'changed location' refusal and that the re-check ran. I confirmed it fails when the re-check is removed.
…o path separators Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Summary
Task 2.2 of the plan, part 3a: running a suite from the CLI.
Part 3b (next PR) adds the Tauri driver adapter and the
examples/tauri-smokeconsumer, and runs the real sample on Windows and Ubuntu/Xvfb, which is what meets the plan's exit criterion. This PR is tested end to end against generated fixture consumers, including through the real executable. Full design notes:docs/decisions/local-runs.md.What
rundoes{ schemaVersion, id, artifacts: [{ profile, name, path, sha256 }] }), picks this profile's artifact (path relative to the manifest), and hashes the file. A mismatch names both hashes. Other profiles' files are never read. The manifest carries no provenance, so it can never stand in for a GitHub candidate at the gate.candidateas an identity (a full GitHub candidate record still fits) plus the verifiedartifact(name, absolutepath,sha256), as the design spec says they should.run-started, then per scenario ascenario-startedcheckpoint and an attempt, chained byprev. The machine id is a random token kept in the state directory, never the host name. The run id is announced on stderr before anything runs, so a run can be resumed even if the process dies.not-run). A second interrupt exits at once.resumeretryOf) of its latest attempt.scenario-startedwith no attempt after it means the process died. That becomes an explicitinterruptedattempt first, so the crash stays in history.resetA crash can leave owned resources and a dirty marker behind, which blocks later runs.
resetreaps the ledger and clears the marker only when that fully succeeds. It refuses an undesignated root, and a root a run currently holds.Exit codes
The highest rule that applies wins:
13not-run; also configuration or verification problems20Also in this PR: timing-sensitive executor tests
This PR's executable tests spawn extra Node processes, and the added load exposed two weak spots in existing tests. Both are fixed here:
result.cleanupreported the cut-off and the test still passed. They now use 20 s and assertresult.cleanupsucceeded, so a cut-off fails with its own message. My earlier memory note had misdiagnosed this as a ledger write race; that was wrong, becauseexecuteScenarioawaits the rewrite.Testing
Written test-first. Coverage:
mainexit codesprocess.exitin the middle of a scenario followed byresume, and SIGINT cancellationMutation-checked 22 rules with a throwaway script. Two survived: exit-code precedence when a run has a failure and an interruption together, and resuming an inconsistent journal. Both were closed with new tests and reconfirmed killed. Files were verified restored byte for byte and the script was deleted.
npm ci,npm run typecheck,npm test: 639 passed, 3 skipped (Windows 11, Node 24.13). No leaked processes, temp directories, home markers or stray.release-qastate.ubuntu-24.04andwindows-2025, where the Linux job runs the real SIGINT cancellation and the mid-scenario crash followed byresumethrough the executable.Review rounds
cubic raised 23 comments over three rounds. 22 were fixed test-first; one, a claim of parallel execution that nothing makes, was declined with reasons in its thread. The main fixes:
resumeis tied to the tested bytes: the artifact's SHA-256 is kept at start, and a rebuild under the same id is refused.resume: it is journaled as acleanup-failedcheckpoint and resurfaced on the carried result.--runcan't be a path.Each round's new rules were mutation-checked: 7 and 6 mutants, all killed, plus the round-3 re-check test verified to fail without its check.
Not verified
kill()terminates it outright). A console Ctrl+C reaches the same handler there, but a test can't send one. The in-process cancellation path is tested on both OSes.evidence: []; screenshots and logs come with the driver adapter.process.exittakes the CLI down with it, which is exactly the crashresumehandles. Isolating consumer code in a child process is not done.🤖 Generated with Claude Code
Summary by cubic
Adds
run,resume, andresetso a suite can be run and resumed from a checkout against a locally built candidate, verified by SHA-256 before anything is installed. The CLI previously stopped atdoctor,designate, andstatus; it now executes real scenarios and records them in the durable run journal.runchecks the project, plan, candidate, and consumer modules before touching the machine, and refuses a suite with nothing to do for the profile.prev; the machine id is a random token, never the hostname, and the run id is announced on stderr before anything runs.resumere-verifies the manifest id and the artifact's SHA-256 — both kept with the invocation — carries passed and failed forward (a failure can't be erased by rerunning), reruns everything else as retries, turns a started-but-unrecorded scenario into an explicit interrupted attempt, and refuses journals with conflicting or cyclic events. A recorded cleanup failure is journaled and resurfaced on the carried result so the run keeps exit 3. The run id must match the journal's id grammar so it can't leave the state directory, and both consumer loading and journal reads happen inside error boundaries so a throwing getter or unexpected rejection is a refusal, not a crash.resetclears the dirty marker only when reaping fully succeeds, and refuses an undesignated root or one a run currently holds.not-run, a failed cleanup, or a configuration/verification problem; 2 for blocked items or manual checks left; 0 all passed.process.exittakes the CLI down too — the crashresumehandles. Attempts record no evidence files yet; those come with the driver adapter.Bug Fixes
Written for commit b7143e3. Summary will update on new commits.