[Bug] Workflow by-name dispatch executed stale, async-sampled script content — a mid-edit working-tree state from 16 minutes before the call, never present in any commit
Environment
- Claude Code 2.1.214 at incident time (recorded in the launching session's transcript, 2026-07-18); workaround verified on 2.1.215
- macOS (darwin 25.5.0)
- Project-local workflow at
<repo>/.claude/workflows/<workflow>.js(~230 KB single file, mostly embedded agent prompts and schemas), dispatched withWorkflow({name: '<workflow>', args: ...})
Summary
Workflow({name}) executed a version of the workflow script that did not exist on disk at call time and was never in any git commit: it matched a transient mid-edit working-tree state that had existed for only ~8 seconds, 16 minutes before the call. At call time the file on disk was committed and the tree was clean.
The persisted per-run script copy (<session-dir>/workflows/scripts/<name>-<runId>.js) made this forensically provable — that mechanism is excellent and is the only reason we could pin the executed version. The defect is in when the by-name registry samples the file.
Forensic timeline (all times UTC, 2026-07-18; two sessions, same machine)
| Time | Event |
|---|---|
| 08:15:32 | Session A launches run N by name. Persisted copy == commit C1 == disk. Baseline sane. |
| 08:55:44 | Run N completes (machine-written run table + result-record mtime agree). No workflow run is in flight from here until the next launch. |
| 14:11–14:24 | Session B (a different Claude Code session) edits the workflow file through ~20 Edit calls. |
| 14:13:18.4 – 14:13:26.4 | An intermediate edit state exists on disk for ~8 seconds (bounded by Edit-call timestamps in session B's transcript). |
| 14:24:37 | Session B commits the finished file as commit C2. Working tree clean from here on. |
| 14:29:27.6 | Session A (idle since ~13:43) launches run N+1 by name. |
| 14:29:27 | Persisted copy for run N+1 is written (same second as the call) — but its content is the 8-second intermediate state from 14:13, not the clean C2 content on disk. |
Content proof (line counts from diff):
- run N+1 persisted copy vs
C1(prior commit): 68 lines differ - run N+1 persisted copy vs
C2(on disk, committed, at call time): 13 lines differ — exactly the hunks added by the edits at 14:13:26, 14:13:52 and 14:24:06 - run N+1 persisted copy vs run N persisted copy (same session, 6 h earlier): 68 lines differ
The sequence was operationally clean: the edits sat between runs, were committed five minutes before the launch, and the launching session verified a clean tree at C2 seconds before the call — the staleness is not attributable to editing-while-running or a dirty tree.
What this establishes about the cache semantics
- Script content freezes per run at some sample time ≤ the call — but the sample time is decoupled from the call (16-minute lag observed).
- The sample is not pinned to the session's first launch (the two runs in the same session executed different content).
- Refresh triggers are not what a user would expect: three subsequent file edits and a
git commitdid not cause the cache to refresh before the next launch.
Isolation expectations this violates
Each of these events is independent by design, and none should have been able to influence the others:
- Cross-session isolation. Sessions A and B are separate Claude Code sessions. Their only legitimate coupling is the persisted filesystem/git state at the moment one of them acts. Instead, B's private mid-edit transient — never committed, alive on disk for ~8 seconds — leaked into A's execution 16 minutes later.
- Action–effect locality. Session A took no action between 13:43 and 14:27 (transcript-verified idle). The content of its 14:29 launch was nevertheless determined during that idle window, by a background sampling event A neither triggered nor could observe. Nothing a session does at call time — including full pre-launch verification — can influence which content runs.
- The publication boundary.
git commitis the natural point where working-tree state becomes "published" for others to consume. The cache captured pre-publication state and then ignored the actual publication (the commit at 14:24:37) that happened before the launch. - Launch independence. Two launches from the same session, hours apart, should each resolve the def independently. In fact they executed two different contents — and the second matched neither the first launch's content nor the disk at its own call time, but a third, unrelated transient.
Impact
The workflow was our fleet orchestrator. The stale intermediate version was missing a variable-backfill block, so an authorization gate compared against an empty list and produced false verdicts — a 40-minute autonomous run was voided, and the run's self-reported version stamp (computed from live git rev-parse HEAD at result-write time) claimed it had run C2, which poisoned the subsequent diagnosis for a full session.
Expected behavior
Either of:
- by-name resolution reads the def file at call time (matching the mental model the persisted-copy feature implies), or
- the caching semantics are documented, with an explicit invalidation/refresh trigger available to the user.
Workaround we adopted (verified on 2.1.215)
- Dispatch with
scriptPathpointing at the repo file instead ofname. Two-round probe: args do arrive underscriptPath(as a JSON string — upstream #63876's args-drop did not reproduce here), and an edit was picked up seconds later on relaunch. - Post-call reconcile: diff the persisted witness against
git show <HEAD>:<path>and kill the run on mismatch.
One asymmetry worth fixing while in there: scriptPath dispatch does not write the launch-time persisted copy under <session-dir>/workflows/scripts/ — the executed content only becomes inspectable at completion, embedded in <session-dir>/workflows/<runId>.json (script field). Persisting the launch-time copy in both dispatch modes would make the witness uniform, and available while a long run is still executing.
Related
- #73731 —
Workflow({name})fails to discover.claude/workflows/defs at all in some environments (different failure: discovery vs staleness; both point at the by-name registry layer) - #63876 —
scriptPathdispatch dropsargs(did not reproduce on 2.1.215 / macOS; args arrived as a JSON string)