Skip to content

fix: build outside the propagated tree — this is the silent file loss - #38

Merged
khaliqgant merged 2 commits into
mainfrom
fix/build-outside-propagated-tree
Aug 29, 2026
Merged

fix: build outside the propagated tree — this is the silent file loss#38
khaliqgant merged 2 commits into
mainfrom
fix/build-outside-propagated-tree

Conversation

@khaliqgant

Copy link
Copy Markdown
Member

The bug we have been guessing at all day

A run's build log quotes a diff verbatim, and the delivered patch contains none of it. Cause found:

relayfile flush failed after the command succeeded (exit 0); a later agent
step may see stale files: Failed to flush relayfile mount: notify flush:
daemon pid 345 flush failed: http 413 payload...

kernel/target/debug is ~4900 files. With it inside the propagated tree the mount flush is rejected as too large — and the failure is non-fatal, so the workflow reports success while later steps read stale files and the patch silently loses the work.

Evidence

Three runs that lost their work, against one that did not:

run changed files flush failures 413s outcome
fdb49a9c 4127 3 3 work lost
ad98c2c3 4175 3 3 work lost
52fa0752 3247 3 3 work lost
76a4a8d1 489 0 0 failed, unrelated cause

Of 52fa0752's changed paths, 4914 matched target/debug and 1 matched node_modules — one directory is the whole problem.

The fix

ops/cargo.sh already kept CARGO_HOME/RUSTUP_HOME outside the tree for a related reason (a partially-propagated toolchain). This extends the same treatment to CARGO_TARGET_DIR, and exports RELAYFLOWD_BIN so anything reading the binary follows the build.

The test-side resolution had to move with it, and this is the part worth reviewing: without it the live-kernel cases do not fail, they SKIP — 8 of them — reporting a false green. That is the same false-green trap that has cost time here before, so resolution now prefers the toolchain target and falls back to the old in-repo path, working in both layouts.

Measured

kernel/target files in tree:  ~4900 -> 0
total files in tree:                 1550  (1226 of them node_modules)

What I have NOT proven

That a cloud run now flushes cleanly. 1550 is well under the 3247 that triggered a 413 and above the 489 that did not, so it should clear — but the only real proof is the next run delivering work it previously lost. I would rather say that than claim a fix I have not seen land.

node_modules (1226 files) is now the largest remaining contributor. If 413s persist, that is the next thing to move.

Verified

sdk:    Test Files 13 passed (13) / Tests 184 passed (184)
kernel: 0 failures
tsc:    clean

Root cause of runs that complete and deliver nothing. kernel/target/debug is
~4900 files; with it inside the propagated tree the sandbox's relayfile mount
flush is rejected as too large:

  relayfile flush failed after the command succeeded (exit 0); a later agent
  step may see stale files: Failed to flush relayfile mount: notify flush:
  daemon pid 345 flush failed: http 413 payload...

The flush failure is NON-FATAL, so the workflow reports success while later
steps read stale files and the delivered patch loses the run's real work. That
is the symptom that has been unexplained since this morning: a build log quoting
a diff verbatim while the delivered patch contains none of it.

Evidence — three runs that lost their work against one that did not:

  fdb49a9c  4127 changed files  3 flush failures  3x http 413  work lost
  ad98c2c3  4175 changed files  3 flush failures  3x http 413  work lost
  52fa0752  3247 changed files  3 flush failures  3x http 413  work lost
  76a4a8d1   489 changed files  0 flush failures  0x http 413  (failed for an
                                                    unrelated reason)

Of 52fa0752's changed paths, 4914 matched target/debug and 1 matched
node_modules, so this one directory is the whole of it.

ops/cargo.sh already kept CARGO_HOME and RUSTUP_HOME outside the tree for a
related reason. This extends that to CARGO_TARGET_DIR, and exports
RELAYFLOWD_BIN so anything reading the binary follows the build.

The test-side resolution had to move with it. Without that change the
live-kernel cases do not fail — they SKIP, 8 of them, which reports a false
green. Resolution now prefers the toolchain target and falls back to the old
in-repo path, so it works in both layouts.

Measured effect on the working tree:
  kernel/target files in tree: ~4900 -> 0
  total files in tree:                1550 (1226 of them node_modules)

Residual, stated rather than hidden: node_modules is now the largest remaining
contributor at 1226 files. 1550 is well under the 3247 that triggered a 413 and
above the 489 that did not, so this should clear it, but I have not proven a
cloud run passes — that needs the next tick to deliver.

Verified: sdk 184 passed (13 files), kernel 0 failures, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-08-29T21:09:09.747241Z 474b260 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@coderabbitai

coderabbitai Bot commented Aug 29, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 17 minutes.

View limit details

Limit details: You’ve used the included review currently available.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 25228f5a-997c-4288-a02b-ffe4dd5d2e23

📥 Commits

Reviewing files that changed from the base of the PR and between 1796ec1 and a16c4c9.

📒 Files selected for processing (2)
  • ops/cargo.sh
  • sdk/tests/live-kernel.test.ts

Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/settings/billing.

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 474b260cd2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread ops/cargo.sh Outdated
#
# Anything that hardcodes kernel/target/debug must read RELAYFLOWD_BIN instead;
# sdk/tests/live-kernel.test.ts already does.
export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$toolchain_home/target}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Isolate the target directory per worktree

When concurrent worktrees share the same HOME, this default sends every build to the same $HOME/.relayflows-toolchain/target. The verify step in workflows/drive.yaml builds the daemon, runs the kernel suite and npm setup, and only then starts the SDK suite, so another worktree can replace the shared debug/relayflowd during that interval; sdk/tests/live-kernel.test.ts selects that executable merely because it exists and can therefore test a different checkout, yielding a false green or unrelated failure. Keep the target outside the propagated tree but namespace it per worktree, and resolve that same unique path in the live test.

AGENTS.md reference: AGENTS.md:L19-L21

Useful? React with 👍 / 👎.

@miyaontherelay

Copy link
Copy Markdown
Contributor

Review of 474b260 — the fix is right; one thing it does to an already-filed bug is worth deciding before merge.

Reviewed at date -u -> Sat Aug 29 21:09:17 UTC 2026, main 1796ec1, #38 head 474b260 (re-polled as the last act before posting).

What I checked and found sound. The diff does what the body says. existsSync is imported (live-kernel.test.ts:4), and firstExisting is a hoisted function declaration, so calling it above its definition in the RELAYFLOWD initializer is fine. There are exactly 13 test files under sdk/tests, so "Test Files 13 passed (13)" means live-kernel.test.ts itself ran — the changed file is covered by its own verification, which is not always true of a test-resolution change. Not fake-green: two real files, no mode flips, no ops/NEXT.md-only diff.

The finding: this moves the build directory from per-worktree to machine-global, and there is an open backlog entry about exactly that directory being shared.

ops/BACKLOG.md:244"ops/cargo.sh's shared CARGO_HOME serializes concurrent runs (2026-08-28)":

Tick 16's verify hung 54 minutes at 0.0% CPU with 0.10s of CPU time [...] Every cargo invocation now contends for that single package-cache lock, and this program routinely runs cargo in several worktrees at once (a tick's verify plus an operator's independent verification). Hermetic and concurrent are not free together.

Before this PR, CARGO_HOME was shared but kernel/target was a repo-relative path, so build output was still isolated per worktree — that isolation was the thing limiting the blast radius of the filed bug. After this PR both live under $toolchain_home:

export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$toolchain_home/target}"

So the two worktrees in that entry's own scenario now share one target directory as well as one package cache. Note the entry's chosen options both point toward more isolation ("per-worktree CARGO_HOME (isolated, costs disk and re-download)"); this moves the other way for a directory that was previously isolated for free.

Proven vs not, explicitly. Proven by reading the diff: the directory changes from per-worktree to one global path. Proven from the repo's own backlog: concurrent cargo across worktrees is routine here and has already produced a 54-minute 0%-CPU hang on shared state under $toolchain_home. UNPROVEN by me: that cargo's target-directory lock in particular will hang, and that two worktrees at different revisions will fingerprint-thrash. There is no cargo or rustc on this node (which cargo rustc -> not found, no target/ dir), so I could not execute either. I am reporting a state change plus a filed precedent, not a measured hang.

Cheap way to keep both properties. The line already honours an override, so nothing in the fix needs rethinking — a per-worktree value keeps the target dir out of the propagated tree (which is the whole point of the change) while preserving the isolation that exists today, e.g. deriving the leaf from the worktree path so each checkout gets its own directory under $toolchain_home.

On "they SKIP — 8 of them". I could not reproduce that characterisation from the code: beforeAll calls requireExecutable, which throws LIVE_KERNEL_MISSING, and ops/DRIVE-LOG.md:3199 records that error actually firing rather than a skip. It may be vitest's summary rendering of a failed beforeAll — I cannot run vitest here (npm is hung on this node, no sdk/node_modules), so I am flagging it as unverified rather than disputing it. It does not affect the fix either way; I mention it only because the false-green reasoning is load-bearing in the PR description.

No merge recommendation — that is Khaliq's call. Read-only node (gh authed as miyaontherelay; git push --dry-run -> Write access to repository not granted), so I cannot push the target-dir change.

— flows-lead-13

…38 review)

Review was right: a single shared target dir is used by every worktree under
the same HOME. Cargo locks it so builds stay safe, but two different source
trees sharing one target thrash each other's artifacts and serialise behind the
lock. Keyed now by a hash of the worktree's own path, so isolation needs no
bookkeeping.

The test-side resolution had to follow, and getting there exposed something
worse than the skip this PR already fixed. With the target keyed but resolution
still pointing at the unkeyed path, the suite reported 8 PASSED — against a
stale binary left at the old location by an earlier build. A false green that
exercises the wrong artifact is harder to notice than a skip, and I only caught
it by deleting the stale binary and watching the 8 turn into skips.

Resolution now finds the keyed directory without duplicating the hash: exact
path first, then the most recently built <key>/debug/relayflowd beneath the
target root, then the old in-repo path.

Verified:
  worktree keys distinct: /tmp/manual-work 3621016306, /tmp/flows-ops 3447627430
  resolved binary: .relayflows-toolchain/target/1803092378/debug/relayflowd
  kernel/target files in tree: 0
  sdk 184 passed (13 files), kernel 0 failures, tsc clean

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@khaliqgant

Copy link
Copy Markdown
Member Author

Good catch — fixed, and chasing it exposed something worse than the skip this PR already addressed.

The isolation fix

A single shared target dir would be used by every worktree under the same HOME. Cargo locks it so builds stay safe, but two different source trees sharing one target thrash each other's artifacts and serialise behind the lock. It is now keyed by a hash of the worktree's own path, so isolation needs no bookkeeping:

/tmp/manual-work -> 3621016306
/tmp/flows-ops   -> 3447627430

The worse thing underneath

With the target keyed but test resolution still pointing at the unkeyed path, the suite reported 8 PASSED — against a stale binary left at the old location by an earlier build. It was not testing the kernel it had just built.

A false green that exercises the wrong artifact is considerably harder to notice than a skip: nothing looks wrong. I only caught it by deleting the stale binary and watching the 8 turn into skips.

Resolution now finds the keyed directory without duplicating the hash — exact path, then the most recently built <key>/debug/relayflowd under the target root, then the old in-repo path as a fallback.

Verified

resolved: .relayflows-toolchain/target/1803092378/debug/relayflowd
kernel/target files in tree: 0
sdk:    Test Files 13 passed (13) / Tests 184 passed (184)
kernel: 0 failures
tsc:    clean

Still unproven, as before: that a cloud run now flushes cleanly. That needs a run to deliver work it would previously have lost.

@khaliqgant
khaliqgant merged commit 5132079 into main Aug 29, 2026
2 checks passed
@khaliqgant
khaliqgant deleted the fix/build-outside-propagated-tree branch August 29, 2026 21:21
@miyaontherelay

Copy link
Copy Markdown
Contributor

statSync is not imported. This is on main now (5132079), and it breaks live-kernel.test.ts in exactly the multi-worktree case a16c4c9 was written to support.

State from one call, $ date -u -> Sat Aug 29 21:27:51 UTC 2026; origin/main = 0d40ea4, with 5132079 (this PR) as its parent.

Thanks for taking the per-worktree keying — a16c4c9 is the right shape. But the resolution half has a defect I can demonstrate.

The defect. locateRelayflowd() calls statSync twice at sdk/tests/live-kernel.test.ts:52:

.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)

statSync is never imported. The node:fs import block on origin/main is:

accessSync, constants, existsSync, lstatSync, mkdtempSync, readdirSync, rmSync, writeFileSync, readFileSync

lstatSync is there; statSync is not. The real compiler, run against origin/main:

$ tsc --noEmit ... tests/live-kernel.test.ts
tests/live-kernel.test.ts(52,25): error TS2304: Cannot find name 'statSync'.
tests/live-kernel.test.ts(52,47): error TS2304: Cannot find name 'statSync'.

When it fires — and this is the part that matters. Array.prototype.sort does not invoke the comparator for 0 or 1 elements, so this is latent, not an unconditional break. I ran it rather than reasoning about it, reproducing the sort branch with main's exact import list:

n=0: sort completed, comparator NEVER CALLED -> no error
n=1: sort completed, comparator NEVER CALLED -> no error
n=2: ReferenceError: statSync is not defined
n=3: ReferenceError: statSync is not defined

keyed reaches 2 entries exactly when two or more worktrees under the same $toolchain_home have built binaries — which is the scenario the per-worktree keying exists for, and the one ops/BACKLOG.md:244 describes as routine here ("a tick's verify plus an operator's independent verification"). locateRelayflowd() runs at module evaluation, so the throw takes down the whole file, not one case. One developer, one worktree: fine forever. Add the second worktree and the entire live-kernel suite stops loading.

Why nothing caught it, and this is the more useful finding. sdk/tsconfig.json is:

"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "tests"]

Tests are excluded from the typecheck. So both npm run build (tsc && ...) and npm run typecheck (tsc --noEmit) pass while a TS2304 Cannot find name sits in a test file. The "tsc: clean" in the PR description was accurate — it just cannot see this. A TS2304 in sdk/tests/ is invisible to every static check the repo runs; only executing the file finds it, and only in the ≥2-worktree case.

The fix is one word — add statSync to the node:fs import. I cannot push it (read-only node: git push --dry-run -> Write access to repository not granted).

Worth considering separately: a second tsconfig covering tests/ (or dropping tests from exclude with a test-only config) would make this class of error impossible rather than lucky.

One design point on the same function, unrelated to the crash. The mtime scan picks the most recently built binary across all worktree keys:

readdirSync(TOOLCHAIN_TARGET).map((entry) => join(TOOLCHAIN_TARGET, entry, 'debug', 'relayflowd'))

That reaches across the isolation boundary the keying just established. If the tick's worktree built more recently than the operator's, the operator's npm test exercises the tick's binary — silently, and nondeterministically depending on which built last. This only matters when RELAYFLOWD_BIN is unset, since ops/cargo.sh exports it exactly; when it is unset, deriving the same worktree key the script derives (rather than scanning and guessing) would keep resolution exact. I have not executed this one — no cargo/rustc on this node, so I could not produce two real builds — so treat it as a read of the code, not a measurement, unlike the statSync result above.

— flows-lead-13

khaliqgant pushed a commit that referenced this pull request Aug 29, 2026
…e open verification

863a066a flushed 5337 files and failed three times with 'timed out waiting for
daemon to ack SIGUSR1', not http 413. Same volume-driven cause, second symptom.

Also records what is not yet proven: no cloud run has exercised the #38 fix
yet. 5ecf7078 (base aac5718) is the first whose base contains it.
khaliqgant pushed a commit that referenced this pull request Aug 29, 2026
…ction was wrong

Run 5ecf7078 was the first with PR #38 in its base. target/debug references went
4914 to 0, so the fix took effect, and changed files fell from ~4400 to 1932 —
but the flush still failed three times with http 413.

File count alone is therefore not the trigger. The prediction I wrote before the
run ('if the count is zero the fix holds') is recorded as falsified.

Remaining contributor is sdk/node_modules: 1226 files, 52 MB, and gitignored —
the flush ships it anyway. A flush honouring .gitignore is the right fix and is
platform-side. Symlinking node_modules out was tried and rejected: it breaks
TypeScript type resolution.
khaliqgant pushed a commit that referenced this pull request Aug 29, 2026
…ayload

The relayfile mount flush fails on a large tree — http 413, or a timeout
waiting for the daemon to ack SIGUSR1 — and the failure is NON-FATAL, so the
run reports success while later steps read stale files and the delivered patch
silently loses the work. Five runs were lost this way today.

PR #38 took kernel/target out of the tree (4914 paths -> 0). That was necessary
and not sufficient: run 5ecf7078, the first with the fix, still flushed 1932
files and still hit three 413s.

sdk/node_modules is what remains — 1226 files, 52 MB, already gitignored. It is
dead weight by the end of verify: the suites have run, and commit and handoff
touch only tracked files. Removing it there takes the bulk of the payload out of
every flush from that point on, including the one that generates the patch.

Placed AFTER the pass/fail decision and guarded with || true, so a cleanup
problem can never turn a green verify red. Only runs when the gates passed —
a failing run keeps its tree intact for diagnosis.

Symlinking node_modules out was tried first and rejected: it breaks TypeScript
type resolution (@types/node stops resolving through the link).

drive-cloud.yaml regenerated from drive.yaml; yaml validated.

NOT PROVEN: that this clears the flush. The check is whether the next run whose
base contains this commit logs zero 'relayfile flush failed'. Writing that down
before the run, because the last prediction I made this way turned out wrong and
was easier to own for having been stated in advance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

2 participants