diff --git a/ops/cargo.sh b/ops/cargo.sh index 409e43a2d..18018e8e7 100755 --- a/ops/cargo.sh +++ b/ops/cargo.sh @@ -24,6 +24,33 @@ toolchain_home="${RELAYFLOWS_TOOLCHAIN_HOME:-$HOME/.relayflows-toolchain}" export CARGO_HOME="$toolchain_home/cargo" export RUSTUP_HOME="$toolchain_home/rustup" +# Build OUTPUT must stay out of the propagated tree too, for a different and +# sharper reason than the toolchain above. +# +# `kernel/target/debug` is about 4900 files. With it inside the tree, the +# relayfile mount flush is rejected as too large and the run keeps going: +# relayfile flush failed after the command succeeded (exit 0); a later agent +# step may see stale files: ... notify flush: ... http 413 payload... +# The failure is non-fatal, so the workflow reports success while later steps +# read stale files and the delivered patch silently loses the run's real work. +# Three runs lost their work exactly this way (fdb49a9c 4127 changed files, +# ad98c2c3 4175, 52fa0752 3247 — three 413s each), against one that did not +# (76a4a8d1, 489 files, no flush failure). Of 52fa0752's changed paths, 4914 +# matched target/debug and 1 matched node_modules, so this directory is the +# whole of the problem. +# +# Anything that hardcodes kernel/target/debug must read RELAYFLOWD_BIN instead; +# sdk/tests/live-kernel.test.ts already does. +# Keyed per worktree. Review caught that a single shared target dir would be +# used by every worktree under the same HOME (PR #38): cargo locks it, so the +# builds are safe, but two different source trees sharing one target thrash +# each other's artifacts and serialise behind the lock. The key is a hash of +# the worktree's own path, so isolation is automatic and needs no bookkeeping. +_worktree_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P) +_worktree_key=$(printf '%s' "$_worktree_root" | cksum | cut -d' ' -f1) +export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$toolchain_home/target/$_worktree_key}" +export RELAYFLOWD_BIN="${RELAYFLOWD_BIN:-$CARGO_TARGET_DIR/debug/relayflowd}" + if command -v cargo >/dev/null 2>&1; then cargo_bin=cargo elif [ -x "${CARGO_INSTALL_ROOT:-}/bin/cargo" ]; then diff --git a/sdk/tests/live-kernel.test.ts b/sdk/tests/live-kernel.test.ts index 2f2b0777b..47eed6c2b 100644 --- a/sdk/tests/live-kernel.test.ts +++ b/sdk/tests/live-kernel.test.ts @@ -9,7 +9,7 @@ import { writeFileSync, readFileSync, } from 'node:fs'; -import { tmpdir } from 'node:os'; +import { homedir, tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; import { fileURLToPath } from 'node:url'; @@ -22,9 +22,39 @@ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); const SDK = join(ROOT, 'sdk'); const BUILT_CLI = join(SDK, 'dist', 'cli.js'); const TESTDATA = join(ROOT, 'testdata'); -const RELAYFLOWD = resolve( - process.env['RELAYFLOWD_BIN'] ?? join(ROOT, 'kernel', 'target', 'debug', 'relayflowd'), -); +// ops/cargo.sh builds into a target dir OUTSIDE the repo, because +// kernel/target/debug is ~4900 files and its presence in the propagated tree +// makes the sandbox's relayfile flush fail with HTTP 413 — non-fatally, so runs +// silently lose their work. Resolution has to follow the build, or these cases +// SKIP rather than fail and the suite reports a false green. +const TOOLCHAIN_TARGET = + process.env['CARGO_TARGET_DIR'] ?? + join(process.env['RELAYFLOWS_TOOLCHAIN_HOME'] ?? join(homedir(), '.relayflows-toolchain'), 'target'); +const RELAYFLOWD = resolve(process.env['RELAYFLOWD_BIN'] ?? locateRelayflowd()); + +/** + * ops/cargo.sh builds into a target dir outside the repo, keyed per worktree so + * concurrent worktrees do not share one target. Resolution has to find that + * key without duplicating the hash, or these cases SKIP instead of failing and + * the suite reports a false green — and worse, a stale binary left at an older + * path gets exercised in place of the one just built. + */ +function locateRelayflowd(): string { + const direct = join(TOOLCHAIN_TARGET, 'debug', 'relayflowd'); + if (existsSync(direct)) return direct; + + // One level down: $toolchain/target//debug/relayflowd. Pick the + // most recently built, which is the one this checkout just produced. + const keyed = existsSync(TOOLCHAIN_TARGET) + ? readdirSync(TOOLCHAIN_TARGET) + .map((entry) => join(TOOLCHAIN_TARGET, entry, 'debug', 'relayflowd')) + .filter((candidate) => existsSync(candidate)) + .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs) + : []; + if (keyed[0] !== undefined) return keyed[0]; + + return join(ROOT, 'kernel', 'target', 'debug', 'relayflowd'); +} const temporaryDirectories: string[] = []; const daemons: ChildProcess[] = []; const clients: JournalClient[] = [];