fix(runtime): a pnpm install elsewhere on the host no longer fails a launch - #1206
Conversation
…launch The launch seals every execution source through a stable read that compared the file's link count and ctime before and after reading it. pnpm hard-links node_modules files from its shared store, so a `pnpm install` anywhere on the host adds a link to those same inodes: both values change, no byte does, and the launch failed with WORKFLOW_SUBMISSION_FAILED "workflow execution file <path> changed while reading". The artifacts reader behind package manifests, schema files and run documents made the same comparison. Both readers now compare identity, size and mtime only. A write updates mtime along with ctime, and sealed bytes are still digest-checked at verify. A rename over a path that the artifacts reader has open leaves its descriptor on the complete original, so that read now returns the original document instead of reporting a race; the workflow-control reader still rejects a path that names another inode once the read completes. Refs #921 Co-Authored-By: Claude Opus 5.5 <[email protected]>
…benign rename `ps`, `status` and pending-run health read run.json and state.json through retryTransientSnapshotRead, so that a rename landing mid-read was re-read instead of failing the command (#1054). Both documents are only ever republished whole by rename, and the artifacts reader no longer reports that rename, so the retry could not fire any more: the three call sites now read once and the function is gone. The asynchronous retry around observe-only synchronization and the re-derivation in readLinkedWorkflowEvidence stay. The workflow-control reader still reports a replaced path, and an in-place write under one of the synchronization's own strict reads is still a race. Co-Authored-By: Claude Opus 5.5 <[email protected]>
After the stable readers stopped comparing link count and ctime, the artifacts reader behind readRunState and readRunMetadataDocument returns the complete pre-rename document instead of reporting the rename. Two comments still gave the old reason for the retries they sit on: - readLinkedWorkflowEvidence: only the workflow-control reader, which re-checks that the path still names the inode it read, reports the rename. - synchronizeObservedWorkflowRun: the pass is retried because its own strict reads include the event journal and node-attempt ledger, which writers append to in place. getRunHealth's direct reads no longer retry. Co-Authored-By: Claude Opus 5.5 <[email protected]>
… launch tests The run-document replacement helper and the launch test each wrapped fs.openSync, readSync and closeSync to act on a file after a strict reader consumed its bytes and before its closing stat. actDuringStrictRead now holds that wrapping once. observeWhileReplacingRunDocument keeps its signature on top of it, so the #1054 test is unchanged. The launch test calls the harness directly; its assertions only follow the result's field names (run -> value, touched -> acted). Both tests still fail on origin/main and pass here. Co-Authored-By: Claude Opus 5.5 <[email protected]>
| // Link count and ctime are not compared: a hard link made anywhere on the host (pnpm linking the | ||
| // same store inode into another node_modules) changes both without changing a byte. A write | ||
| // updates mtime along with ctime, and sealed bytes are digest-checked when they are verified. | ||
| if ( | ||
| !current.isFile() || | ||
| current.isSymbolicLink() || | ||
| opened.dev !== completed.dev || | ||
| opened.ino !== completed.ino || | ||
| opened.size !== completed.size || | ||
| opened.nlink !== completed.nlink || | ||
| opened.ctimeNs !== completed.ctimeNs || | ||
| opened.mtimeNs !== completed.mtimeNs || | ||
| opened.dev !== current.dev || | ||
| opened.ino !== current.ino || | ||
| opened.size !== current.size || | ||
| opened.nlink !== current.nlink || | ||
| opened.ctimeNs !== current.ctimeNs || | ||
| opened.mtimeNs !== current.mtimeNs |
There was a problem hiding this comment.
Control files lose ctime protection
Execution sources allow hard links, but run control files still require a single link. Removing the ctime check for both means a same-size in-place write followed by restoring the original mtime can pass the control-file stability check. Keeping the ctime check for single-link reads would preserve that protection without bringing back the pnpm launch failure.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/runtime/src/workflow-integrity.ts
Line: 2161-2174
Comment:
**Control files lose ctime protection**
Execution sources allow hard links, but run control files still require a single link. Removing the ctime check for both means a same-size in-place write followed by restoring the original mtime can pass the control-file stability check. Keeping the ctime check for single-link reads would preserve that protection without bringing back the pnpm launch failure.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| // A write in place under one of those reads is still a race the strict reader rejects. `status` | ||
| // retries the whole synchronization within the same budget, and once that is spent it still reports | ||
| // the run and says its local state may be stale, instead of failing. | ||
| const raced = await observeWhileReplacingRunDocument( | ||
| { documentPath: statePath, replacements: 3, onlyWhen: synchronizationOwnRead }, | ||
| { documentPath: statePath, replacements: 3, append: true, onlyWhen: synchronizationOwnRead }, | ||
| () => getRunHealth({ projectRoot: project, runId, env }) |
There was a problem hiding this comment.
This test appends to state.json, but the runtime publishes that document by rename. The in-place appends that still justify the synchronization retry occur in the event journal and node-attempt ledger. Racing one of those ledgers would cover the production behavior; as written, the test could pass even if the retry stopped handling ledger appends.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/runtime/test/runtime.test.ts
Line: 12861-12866
Comment:
**Retry test races wrong file**
This test appends to `state.json`, but the runtime publishes that document by rename. The in-place appends that still justify the synchronization retry occur in the event journal and node-attempt ledger. Racing one of those ledgers would cover the production behavior; as written, the test could pass even if the retry stopped handling ledger appends.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
…ce trigger The comment on synchronizeObservedWorkflowRun said the pass is retried because its own strict reads include the event journal and the node-attempt ledger. Only the first is true. Every attempt-ledger read in the pass sits inside a catch that turns any error, a mid-read append included, into a warning (WORKFLOW_ATTEMPT_INSPECT_FAILED around inspectTerminalAttemptAuthorities, NODE_ATTEMPT_LEDGER_WRITE_FAILED around appendTerminalTaskAttempts), so it never reaches the retry. The event journal's reads (appendEvent's tail read, and the replays once the workflow has stopped) are not caught, so an append that straddles one retries the whole pass. The status test keeps racing an in-place write to state.json rather than one of the journals, and now says why. Racing attempts.jsonl instead yields one WORKFLOW_ATTEMPT_INSPECT_FAILED warning and no retry, and this live, unchanged pass never reads events.jsonl. state.json is read on every pass, so it exercises the retry budget and the WORKFLOW_STATE_SYNC_RACED warning deterministically. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Problem
A launch fails with
WORKFLOW_SUBMISSION_FAILED: workflow execution file <path> changed while readingif apnpm installruns anywhere else on the host while the launch seals its execution closure. Agents have reported this as a recurring flake. On this host, one package.json I checked undernode_modules/.pnpmhas 27 hard links: every checkout that shares the pnpm store links the same inode. An install in any worktree therefore touches the inodes a concurrent launch is reading.Refs #921.
Root cause
At launch, every execution source (most of them are
node_modulesfiles) is read throughreadOpenedRegularFileinpackages/runtime/src/workflow-integrity.ts, once to seal and once to verify. The dependency manifests, schema files,ajv/package.json(read when the validator build identity is computed) and run documents are read throughreadRegularFileSnapshotinpackages/artifacts/src/schema-registry.ts. Both functions comparednlinkandctimebefore and after the read.pnpm hard-links
node_modulesfiles from its content-addressed store. An install in another project therefore adds a link to the same inode, which changesnlinkandctimeand leaves every byte as it was. The runtime reader's own comment already accepted hard-linked package content ("require a stable regular inode here without rejecting that layout"); its stability check still rejected it.trusted-cli-closure.tshas left this comparison out since it was written (#900), for the same reason.Change
fix(runtime)readRegularFileSnapshot: it no longer detects a rename over a path it has open. The descriptor stays on the complete original file, so the read returns the document as it was when opened. That rename detection is what Detect an atomic path replacement by link count, not by ctime #887 addednlinkfor.lstatidentity check still rejects a path that names a different inode after the read.refactor(runtime)retryTransientSnapshotReadexisted to re-read run.json and state.json when that rename was reported (status fails on transient state snapshot replacement during live execution #1054).readRunListEntry,getRunHealth,readPendingRunHealth) could therefore no longer retry anything.docs(runtime): the comments on the two kept retries (readLinkedWorkflowEvidence,synchronizeObservedWorkflowRun) now name what still triggers each one, instead of the rename the artifacts reader no longer reports.test(runtime): the run-document replacement helper and the new launch test share one mid-read harness,actDuringStrictRead, instead of two copies of thefs.openSync/readSync/closeSyncwrapping. Test-only;runtime.test.tsis 11 lines shorter than before this commit.Deliberately not built
retryTransientSnapshotObservation(observe-only sync) and the re-derivation retry inreadLinkedWorkflowEvidence. Each one still has a trigger.appendEvent's tail read, and the replays once the workflow has stopped. The pass catches its node-attempt and usage ledger reads and reports them as warnings, so those never reach this retry.stats-snapshot.ts).statsreads JSONL ledgers that the controller appends to in place, and that can still race.operator-npm.ts's reader alone, although it also compares nlink and ctime. pnpm installs the patched[email protected]as copies: every file I checked there has nlink 1, so this failure does not reach it.safe-paths' singly linked snapshot, theworkflow-syncauthority snapshot,verified-output's publication snapshot and the snapshot-copy verification. They require nlink 1 on run-owned files, which pnpm never links, and they keep their ownlstatreplacement checks.templates/smithers/agents/strict-json.tsxalone, although it compares ctime. It reads run-owned files. Editing a stock adapter template would also change the controller source digest that every project's.smithers/agentsmust match.lstatidentity check or any size/mtime comparison.workflow-sync.ts.workflow-sync.ts:899, or a node patch at 3109.events.jsonlis raced, the retry finds the change already persisted and records nothing. The event is lost, and no warning is reported. I reproduced this for the controller transition (see Verification); the node case is from reading the code.workflow-syncedevent is not lost this way: the pass compares it against the journal, so the retry records it.Verification
Discriminating tests were run against origin/main at 2cacf4c with only the branch's test files copied in. The two runtime.test.ts tests were re-run that way with the final test file (head d9fdb42), and both failed as described below. I also confirmed the launch failure on the earlier base, beaf2c3.
a hard link added to an execution source while the launch seals it does not fail the launch(runtime.test.ts).WORKFLOW_SUBMISSION_FAILED/workflow execution file tsconfig.json changed while reading, which is the reported failure. With this PR the launch succeeds.readOpenedRegularFilealso removed, the guard fails. The seal accepts the torn read, so verify re-reads the file andactedbecomes 2 instead of 1.a runtime document read ignores a hard link or an atomic replacement made during its snapshot(artifacts run-documents.test.ts) replaces the Detect an atomic path replacement by link count, not by ctime #887 test that pinned rename rejection.file changed while it was read; with this PR it passes.runtime document reads detect same-file mutation and refuse symlinksstill passes.listRuns, observers and status survive live run documents replaced while they were read(renamed from…re-read…).pspart now races every read of state.json and run.json three times. On origin/main this exhausts the retry and lists the run asunreadable(replaced: 3). With this PR each document is read once and the run is listed asrunning.statussynchronization part, renaming state.json under the sync's own reads is no longer reported as a race. The retry budget and theWORKFLOW_STATE_SYNC_RACEDwarning are now exercised by an in-place write instead. That write stands in for the event-journal appends that can trigger the retry in production. state.json itself is only ever replaced by rename.runtime.test.js --test-name-pattern='listRuns|getRunHealth|[Pp]ending|hard link added|survive live run documents|startRun|launch observation|reads as incomplete': 51/52.startRun injects the configured Forge guard into the workflow environment and metadata. It fails the same way on origin/main 2cacf4c: it expects the run'ssafe-bin/forgeand resolves the fake-bin one.forgein~/.local/bin. I suspect that is the cause but have not confirmed it. The test does not touch the code changed here.runtime.test.js, sealed-evidence and observer tests (divergent control file, diverged execution files, manifest that stops re-deriving, validator rebuild after launch, extra snapshot generations, leftover controller-generation journal, repeated observations): 9/9.--test-concurrency=1:run, ps, status, inspect, report, …end to end,ps text prefers linked workflow terminal status…,status observes an incomplete launch…(the pending-run-health path),status surfaces a terminal product…, and the stats and statistics-snapshot tests: 10/10.srcand the shared test harness.runtime.test.js --test-name-pattern='listRuns|getRunHealth|[Pp]ending|hard link added|survive live run documents|launch observation|reads as incomplete|diverged execution|divergent control|stops re-deriving': 21/21.WORKFLOW_ATTEMPT_INSPECT_FAILEDorNODE_ATTEMPT_LEDGER_WRITE_FAILED), so the race never reached the retry:replacedwas 1, not 3. An unchanged live pass never readsevents.jsonl. A pass that records events reads it once per event, inappendEvent's tail read. If one of those reads is raced, the retry finds the change already persisted and does not read the journal again. Only a stopped workflow's pass replays the journal on every attempt (workflow-sync.ts:919), so only that pass could exhaust the budget through it. ThesynchronizeObservedWorkflowRuncomment had named the ledger as a trigger; it and the test comment are corrected.survive live run documentsandhard link added(2/2), prettier, eslint,lint:strict:ci,pnpm -w lint, runtimetsc(src and tests) and knip.events.jsonl.events.jsonlon a live fake-Smithers run. An unchanged pass read it 0 times. A pass that records a controller-lease lapse (lease shortened to 1 s) read it once.workflow-syncedevent was recorded, either by that pass or by the two passes after it. That is the pre-existing loss listed under Deliberately not built.survive live run documentsandhard link addedat 291f158: 2/2.--check,pnpm -w lint,CI=1 ESLINT_PLUGIN_DIFF_COMMIT=origin/main pnpm -w lint:strict:ciandpnpm -w knip.tsc --noEmit: artifacts, runtime and cli at 91cab2a. At d9fdb42, runtimetsconfig.jsonandtsconfig.test.jsononly, since artifacts and cli did not change after 91cab2a.readOpenedRegularFiledrops from 25 to 21 andreadRegularFileSnapshotfrom 17 to 15. Other functions set the global ceiling of 83, so it stays.Risk / compatibility
Validator build identity rotates.
schema-registry.jsis one of the modulesVALIDATOR_BUILD_IDENTITYdigests. Since fix: a validator rebuild no longer strands in-flight runs (validator build becomes provenance) #1188 that identity is provenance only, and no test pins its value.Strict artifacts reads now tolerate a rename. A read that overlaps a rename over its path returns the file it opened instead of throwing. The durable writer only renames a complete, fsynced temp file into place (
writeFileDurable, safe-paths.ts:219-238), so the returned document is a complete earlier version. A write into the opened file during the read is still caught by size and mtime. Readers that need "the path still names these bytes" have their own checks:lstat.readAuthoritySnapshothas nolstatcheck of its own. Instead the capture compares state.json and the graph against a fresh parse right after reading them. At the end it re-reads those two, the task manifest, the seal and the graph fingerprint, and byte-compares each (verified-output.ts:235-244 and 289-316).VERIFIED_OUTPUT_CHANGEDerror (… changed while its exact bytes were being capturedor… changed while run outputs were being read). Before, a republish that landed inside one of those reads failed first with the reader's ownfile changed while it was read. A republish between reads was already caught only by these comparisons. I verified this by reading the code, not by running it.Two cases are no longer rejected mid-read by these two readers:
Sealed sources are still digest-checked at verify. Readers that enforce single-link policy check
nlink === 1themselves and are unchanged.Not verified end to end. I did not run a real
pnpm installconcurrently with a realultrafuzz run. The tests reproduce it by linking the file at the exact point inside the read.Changelog entry
A
pnpm installanywhere on the host during a campaign launch no longer fails the launch with "workflow execution file … changed while reading". Launch-time and run-document reads no longer treat a changed link count or ctime as a changed file. A strict run-document read that overlaps an atomic rename now returns the complete pre-rename document, sops,statusand pending-run health no longer need to retry it.🤖 Generated with Claude Code
The PR appears safe to merge with two previously reported, non-blocking concerns still open.
Fix with agent prompt
Summary
The PR stops launch-time and run-document reads from treating hard-link count and ctime changes as file-content changes. It also removes retries for run documents that are published by atomic rename and updates the associated tests and comments.
Reviews (2) · Last reviewed commit: "docs(runtime): name only the event journ..."