Skip to content

fix(bin): tear down each task's own temp root and agent scratch - #3457

Closed
jason-curtis wants to merge 21 commits into
kunchenguid:mainfrom
jason-curtis:fm/fm-prune-task-scratch
Closed

jason-curtis wants to merge 21 commits into
kunchenguid:mainfrom
jason-curtis:fm/fm-prune-task-scratch

Conversation

@jason-curtis

@jason-curtis jason-curtis commented Sep 1, 2026 •

Copy link
Copy Markdown

Intent

Rebind the no-mistakes pipeline attestation to the current tip of an unchanged change set.

Underlying work (unchanged, already fully validated on the immediately preceding head): bin/fm-teardown.sh removes its own task's scratch directory so agent scratch stops leaking until reboot. The safe unit is the TASK, never the worktree slot, because the treehouse pool reuses slot numbers; teardown removes exactly the path its own task's record names and only when it equals the path bin/fm-task-tmp-lib.sh derives for that id, with no deletion by slot, age, or cross-session pattern. The TMPDIR pin is OPT-IN and OFF BY DEFAULT behind the gitignored config/task-tmpdir-pin presence flag; that opt-in shape is an upstream captain product decision and MUST NOT be changed here. Also included and unchanged: home-scoped roots via fm_backend_hometag, the deliberate refusal to remove legacy undiscriminated roots, forced-secondmate child cleanup deriving the child home tag, validated private creation (plain mkdir that fails rather than following a symlink, reuse only of a directory this user owns, mode 0700), an abort trap armed only for roots the spawn actually created, and removal of an aborted spawn's root BEFORE the per-task lock is released so a waiting same-id spawn cannot adopt a doomed directory.

WHY THIS RUN SKIPS TEST, DOCUMENT AND LINT: upstream main advanced from 8988af2 to 77ee3c8 and the PR went conflicting again. The branch was rebased onto 77ee3c8 with a single conflict in docs/verification/runtime-backends.md, where main added a new dated Pi verification section and this branch appends a 'Per-task temp root and harness scratch' section; both were kept whole and neither was altered. Verified after the rebase: the branch's own bin/ and tests/ content is byte-identical to the previously validated head, the only differences being main's own new Pi test files; 21 commits, 0 behind main, no Co-Authored-By trailer on any commit, exactly one scout-scratch evidence claim commit, and bin/fm-lint.sh exits 0 locally with ShellCheck 0.11.0 and actionlint 1.7.12 already verified on this exact tree. The content therefore carries the full validation it already passed, and the only thing that changed is the commit SHAs the rebase produced. The sole purpose here is to bind the attestation to the new tip so upstream's body-compliance check sees a matching head_sha.

Make NO code, test or documentation change, and no design change to the opt-in pin shape. If review raises a finding that requires a real fix, say so rather than editing silently. Push to the fork https://github.com/jason-curtis/firstmate and update PR #3457 against origin. Do not merge; upstream owns that.

What Changed

  • Added bin/fm-task-tmp-lib.sh as the single owner of the per-task temp root: a home-scoped <base>/fm-<tag>-<id> path derived with fm_backend_hometag, guarded creation (plain mkdir -m 700 that fails rather than following a symlink, reusing an existing root only when this user owns it), and removal that acts only on the exact path the task's own tasktmp= record names — never by worktree slot, age, or sibling pattern, and never on the older undiscriminated <base>/fm-<id> shape, which is refused and reported instead. FM_TASK_TMP_BASE overrides the /tmp parent for tests.
  • bin/fm-spawn.sh now derives and validates the root through that library, arms an abort cleanup only for a root the spawn actually created, and removes it before releasing the per-task lock so a waiting same-id spawn cannot adopt a doomed directory; the launch TMPDIR pin to that root is opt-in behind the gitignored config/task-tmpdir-pin presence flag (off by default, never applied to a raw launch command, inherited by secondmate homes). bin/fm-teardown.sh removes its own task's root through the same ownership check, feeds the validated path to its process reaper, and applies the same removal to each child of a forced secondmate retirement using that child's own home tag; refusals and failures warn on stderr without failing teardown.
  • Documented the flag, the upgrade-time refusal warning, the creation refusal, and the FM_TASK_TMP_BASE override in docs/configuration.md, AGENTS.md, docs/scripts.md, the secondmate-provisioning skill, and a new "Per-task temp root and harness scratch" verification record; extended bin/fm-test-run.sh change-path routing for the new library, the home-tag library, and teardown, and added coverage across tests/fm-gotmp.test.sh, fm-spawn-dispatch-profile, fm-kimi-harness, fm-control-relaunch, fm-teardown, and fm-backend-orca.

Risk Assessment

✅ Low: The destructive path is tightly bounded by an exact-match ownership guard with a single owning library, every intent-required behavior is present and covered by concrete regressions (removal, ownership refusals, symlink/non-directory creation refusals, both sides of the opt-in pin, and both aborted-spawn arming outcomes), and the only two findings are non-reachable-in-practice hygiene items in an unchanged-content rebase.

Testing

  • ⏭️ Test - skipped

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 2 infos
  • ℹ️ bin/fm-spawn.sh:2517 - The abort-cleanup arming reads FM_TASK_TMP_CREATED only after fm_task_tmp_create returns success, but the library sets that flag to 1 as soon as mkdir -m 700 -- &#34;$root&#34; succeeds and can still return 1 afterwards if mkdir -p -- &#34;$root/gotmp&#34; fails (bin/fm-task-tmp-lib.sh:159). Concrete sequence: spawn creates /tmp/fm-<tag>-<id> successfully, the nested gotmp/ mkdir fails (ENOSPC, quota, or a transient filesystem error), fm_task_tmp_create returns 1, line 2517 exits 1 before line 2525 runs, so TASK_TMP_ABORT_CLEANUP stays 0 and the trap leaves behind a root this spawn created and nothing will ever name. The leaked directory is empty, so impact is a stray 0-byte directory rather than retained scratch, and the failure mode is narrow. The mechanical remedy is to arm from FM_TASK_TMP_CREATED regardless of the return status (set the flag, then check the status), which preserves the "only roots this spawn created" contract exactly. Noted rather than requested: this run's intent forbids code changes, and the exposure does not justify one.
  • ℹ️ tests/fm-gotmp.test.sh:409 - test_teardown_survives_an_unremovable_tasktmp brackets the teardown invocation with set +e (line 405) and set +e again (line 409), where the closing line reads as an intended set -e. The suite only ever sets set -u (line 29) and never enables errexit, so both lines are no-ops and no assertion is weakened - restoring set -e there would actually be wrong, since the rest of the suite runs without it. The defect is purely that the pair reads as a guarded region it is not, inviting a future edit to "restore" errexit into a suite that has never used it. The honest remedy is to drop both lines. Noted only; this run's intent forbids test changes.
⏭️ **Test** - skipped

Step was skipped.

⏭️ **Document** - skipped

Step was skipped.

⏭️ **Lint** - skipped

Step was skipped.

✅ **Push** - passed

✅ No issues found.

@greptile-apps

greptile-apps Bot commented Sep 1, 2026 •

Copy link
Copy Markdown

Confidence Score: 4/5

The PR is not yet safe to merge because a failed fresh-spawn backlog commit can remove the task record while leaving its temporary root undiscoverable.

Fresh metadata publication disables root cleanup before the final backlog transition, but failure of that transition rolls the metadata back and exits, leaving no durable owner that teardown can use to reclaim the root.

Files Needing Attention: bin/fm-spawn.sh

Reviews (9): Last reviewed commit: "no-mistakes(document): attribute the rel..." | Re-trigger Greptile

Comment thread bin/fm-spawn.sh Outdated
Comment thread bin/fm-spawn.sh Outdated
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: first-time fork CI approved after a full-thread + full-diff review (no .github/workflows writes). Runs 33537679175 (CI, in_progress after approve) and 33537679159 (Require no-mistakes / body compliance synchronize, queued after approve).

Inspected HEAD cf38ab5f78c9a1f452393e3c03439b86ac49f978 vs main 355f46fe5528ccc9790481171bf9da48dee2e90d: NEW bin/fm-task-tmp-lib.sh, bin/fm-teardown.sh (validated remove + forced-secondmate children), bin/fm-spawn.sh (TMPDIR pin + create guard + abort cleanup), bin/fm-brief.sh, docs, tests. +776/-63, 18 files. MERGEABLE/UNSTABLE. workflow-zero. Greptile SUCCESS 5/5 on this HEAD (outdated P1s on 06846936 — planted-symlink mkdir -p and abort leak — are fixed here by fm_task_tmp_create and TASK_TMP_ABORT_CLEANUP). jason-curtis is not a blocked author.

Attestation: MISSING. Full PR body (38316 chars, GraphQL + REST) has zero HTML comments and zero no-mistakes-pipeline-attestation. Pipeline Push passed but never wrote <!-- no-mistakes-pipeline-attestation:v1 ... head_sha ... -->. That is blocking. Re-run the pipeline (or emit the comment) so the body carries an attestation whose head_sha is exactly cf38ab5f78c9a1f452393e3c03439b86ac49f978.

Contract-class: new-default. Main already specified a per-task temp root and teardown of it, but the specified spawn env was GOTMPDIR-only: bin/fm-spawn.sh on main (2476–2482) says GOTMPDIR (not TMPDIR) is the targeted knob: TMPDIR is too broad and sets TASK_TMP=\"/tmp/fm-$ID\". This PR pins TMPDIR on every non-raw launch (bin/fm-spawn.sh:3025-3026) and changes the default path to home-scoped /tmp/fm-<hometag>-<id>. That is a new unconfigured spawn environment, not a broken specified path. Motivating leak (harness scratch under /tmp/claude-<uid>/) was never in the promised root. Unlike merged #2792, isolation of agent TMPDIR was not the old default. Home-scope + exact-match rm is sound hardening, but it does not reclassify the TMPDIR pin. Not opt-in (no off switch). No auto-merge.

VISION.md per-rule

  • One captain, one interface — aligns. Below-deck leak; captain sees less leftover RAM / quota noise.
  • Authority is explicit and never inferred — tension. new capability ships as an option to enable, never as behavior that assumes consent: pinning TMPDIR for all spawned agents is a new default without an opt-in.
  • Scripts own the mechanics, agents own the judgment — aligns. Path shape, create, owned-check, and rm -rf live in one library.
  • A restart is a non-event — aligns. Scratch no longer has to wait for reboot (or tmpfs quota) to go away.
  • Delegation with a spine — aligns. Unlanded / live sibling work is never the deletion unit; slot reuse cannot take a live session with it.
  • The fleet outlives any vendor — aligns. TMPDIR pin is harness-agnostic; Claude Code 2.1.251 is verified; a harness that ignores TMPDIR still tears down the recorded root.
  • Scope — aligns. Field incident → regression coverage; no new task shape, no cron sweep, no treehouse-pool change.

Deletion-safety (security-sensitive rm -rf): cannot delete sibling / live / foreign paths.

  • bin/fm-task-tmp-lib.sh:64-71 fm_task_tmp_root — id/tag reject empty / . / .. / */\*; base must be absolute; shape <base>/fm-<hometag>-<id>.
  • bin/fm-task-tmp-lib.sh:80-86 fm_task_tmp_owned — recorded path must be byte-equal to that derivation; no glob, find, -mtime, or slot scan.
  • bin/fm-task-tmp-lib.sh:96-117 fm_task_tmp_remove — refuse symlink (-L before -d), refuse non-directory, then rm -rf -- \"$root\" on that exact path only; failure warns and returns 1.
  • bin/fm-teardown.sh:758 / :2759 reaper uses TASK_TMP_OWN (validated), never raw tasktmp=.
  • bin/fm-teardown.sh:2909 fm_task_tmp_remove \"$ID\" \"$TASK_TMP\" || true — failure does not fail teardown.
  • bin/fm-teardown.sh:2561 forced-secondmate children: ( unset FM_ROOT_OVERRIDE; FM_HOME=$home FM_ROOT=$home fm_task_tmp_remove \"$child_id\" \"$child_tasktmp\" ) || true — derived as the child home so the tag matches; sibling home with the same task id is a different path.
  • Legacy <base>/fm-<id> is refused by the exact-match guard (documented).
  • Create-side: bin/fm-task-tmp-lib.sh:134-145 mkdir -m 700 fails on an existing path instead of following it; accept reuse only when real directory -O this user. Spawn abort trap (bin/fm-spawn.sh:878-880) removes an unrecorded fresh root through the same validator.
  • Main's [ -n \"$TASK_TMP\" ] && rm -rf \"$TASK_TMP\" (fm-teardown.sh:2871) had none of this; HEAD is strictly narrower.
  • Tests: live sibling in a reused slot survives; foreign / legacy / symlink refused; unremovable root does not fail teardown.

Pipeline review claims verified on HEAD (not just claimed):

  • Forced-secondmate child temp roots: production path is bin/fm-teardown.sh:2561; test test_forced_secondmate_teardown_removes_child_task_temp_roots captures literal seeded paths via seed_task_tmp_for (echoes the string; comment forbids re-derive after the home is gone), asserts existence before teardown, then [ ! -e \"$child_a_tmp\" ] / [ ! -e \"$child_b_tmp\" ] and sibling scratch still present. Not vacuous.
  • Raw launch and remote-secondmate tasktmp= (spawn:668) remain deliberate escape hatches.

Not merging. Not Firstmate-flagging (new-default, and not otherwise ready: attestation missing, CI/NM not green yet). After a MATCH attestation lands, this still will not auto-merge; it would wait on green CI+NM and a Firstmate flag only when completely ready.

@jason-curtis
jason-curtis force-pushed the fm/fm-prune-task-scratch branch from cf38ab5 to a93be1f Compare September 2, 2026 04:22
@jason-curtis jason-curtis changed the title fix(bin): tear down each task's own agent scratch fix(bin): tear down each task's own temp root Sep 2, 2026
Comment thread bin/fm-spawn.sh Outdated
Comment thread bin/fm-spawn.sh Outdated
@jason-curtis
jason-curtis force-pushed the fm/fm-prune-task-scratch branch 2 times, most recently from d4f757b to fbf0cfe Compare September 2, 2026 05:28
@jason-curtis jason-curtis changed the title fix(bin): tear down each task's own temp root fix(bin): tear down each task's own agent scratch Sep 2, 2026
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: tip moved since the 2026-09-01T18:32:35Z waiting-author stamp. Re-reviewed full thread + full diff on HEAD fbf0cfec751ba7c9e72484e3d10e41d755dea80e vs main 1c41029972f84cce5eb32b94fbb2d68edb2d4397 (ahead 18 / behind 0). +988/−68, 22 files. MERGEABLE/UNSTABLE. workflow-zero (no .github/workflows writes). jason-curtis is not a blocked author. Fork CI re-approved this pass for the moved tip: CI 33594809961 → SUCCESS; Require no-mistakes 33594851758 → SUCCESS (body compliance). Greptile SUCCESS. Prior Greptile P1s (planted-symlink mkdir, abort leak, relaunch abort, abort-vs-lock race) are addressed on this HEAD by fm_task_tmp_create, TASK_TMP_ABORT_CLEANUP only when FM_TASK_TMP_CREATED=1, and lock-held removal before SPAWN_TASK_LOCK release.

Attestation: MATCH. Body <!-- no-mistakes-pipeline-attestation:v1 {"head_sha":"fbf0cfec751ba7c9e72484e3d10e41d755dea80e",...} --> equals HEAD.

Contract-class: new-default. Main's specified default path is TASK_TMP="/tmp/fm-$ID" with GOTMPDIR-only (bin/fm-spawn.sh on main ~2479–2482: "GOTMPDIR (not TMPDIR) is the targeted knob: TMPDIR is too broad"). This tip always-on replaces that path with home-scoped <base>/fm-<hometag>-<id> via bin/fm-task-tmp-lib.sh / fm_task_tmp_root. Charter: replacing the specified default path is new-default, even when motivated as a multi-home collision fix. The agent TMPDIR pin is correctly opt-in/default-off via gitignored config/task-tmpdir-pin (inherited like trace-context); unconfigured launches add no TMPDIR= prefix. That answers the prior consent tension for the pin, but does not reclassify the always-on path-shape change. Exact-match teardown, create guards, abort cleanup, and forced-secondmate child cleanup are sound hardening on top of that new default. No auto-merge.

VISION.md per-rule

  • One captain, one interface — aligns. Below-deck leak/quota noise; captain sees less leftover tmpfs pressure when the pin is on; default-off pin keeps unconfigured surface quiet.
  • Authority is explicit and never inferred — aligns for the pin (config/task-tmpdir-pin presence flag, off by default). Residual new-default tension is the always-on home-scoped path replacing main's documented /tmp/fm-$ID without an off switch.
  • Scripts own the mechanics, agents own the judgment — aligns. Path shape, create, owned-check, and rm -rf live in one library; agents are not asked to guess cleanup.
  • A restart is a non-event — aligns. Recorded tasktmp= + exact-match remove; abort removes only a freshly created unrecorded root while the spawn lock is still held.
  • Delegation with a spine — aligns. Deletion unit is the task, never the reused worktree slot; live sibling in the same slot survives (covered in tests/fm-gotmp.test.sh).
  • The fleet outlives any vendor — aligns. TMPDIR pin is harness-agnostic and documented against Claude Code 2.1.251; a harness that ignores TMPDIR still tears down the recorded root only.
  • Scope — aligns. Field incident → regression coverage; no new task shape; no cron sweep; no treehouse-pool change; no workflow writes.

Deletion-safety (security-sensitive rm -rf): cannot delete sibling / live / foreign / legacy paths.

  • fm_task_tmp_root — id/tag reject empty / . / .. / */\*; base must be absolute; shape <base>/fm-<hometag>-<id>.
  • fm_task_tmp_owned — recorded path must be byte-equal to that derivation; no glob, find, -mtime, or slot scan.
  • fm_task_tmp_remove — refuse symlink (-L before -d), refuse non-directory, then rm -rf -- "$root" on that exact path only; failure warns and returns 1; callers || true.
  • Teardown reaper uses TASK_TMP_OWN (validated), never raw tasktmp=.
  • Forced-secondmate children: ( unset FM_ROOT_OVERRIDE; FM_HOME=$home FM_ROOT=$home fm_task_tmp_remove "$child_id" "$child_tasktmp" ) || true — derived as the child home so the tag matches.
  • Legacy <base>/fm-<id> refused by exact-match (documented one-time upgrade leak; Go-temp only).
  • Create-side: plain mkdir -m 700 fails on existing path instead of following a planted symlink; reuse only when real directory -O this user. Abort trap removes only FM_TASK_TMP_CREATED=1 roots, before releasing the per-task spawn lock.
  • Main's [ -n "$TASK_TMP" ] && rm -rf "$TASK_TMP" had none of this; HEAD is strictly narrower.
  • Residual info (pipeline review, non-blocking): if mkdir -m 700 succeeds and nested gotmp/ mkdir fails ENOSPC, FM_TASK_TMP_CREATED=1 but spawn exits before arming abort cleanup → one empty orphan directory. Not a sibling-delete risk.

Overlap: open #3483 (jleemcf, claimed random task-owned temp roots) also replaces main's predictable /tmp/fm-<id> shape. Landing either is a captain path-shape decision; they should not both land without reconciling.

Not merging (new-default). This is otherwise ready: attestation MATCH, CI+NM green, workflow-zero, deletion-safety sound, author blockers cleared. Waiting on the captain, not the author. Firstmate-flagged for the always-on home-scoped default path (and the related #3483 overlap).

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: tip is no longer otherwise-ready. This is waiting on the author, not the captain, until conflicts are cleared.

HEAD still fbf0cfec751ba7c9e72484e3d10e41d755dea80e. MERGEABLE is now CONFLICTING/DIRTY vs main 8988af2aec351fa4ce30ef9655e3e2b7dd4fc912 (ahead 18 / behind 7; diverged). Firstmate will not rebase or merge while a captain product call is still owed on the always-on path-shape change.

Contract-class: new-default unchanged (always-on home-scoped <base>/fm-<hometag>-<id> replaces main /tmp/fm-$ID; TMPDIR pin stays opt-in/default-off). Overlap with open #3483 unchanged.

Please rebase/resolve onto current main and keep the no-mistakes attestation bound to the new tip. Once CLEAN + attestation MATCH + CI/NM green again, Firstmate will restamp for the captain default-behavior decision (not auto-merge). Do not treat this comment as a captain grant.

@jason-curtis
jason-curtis force-pushed the fm/fm-prune-task-scratch branch from fbf0cfe to 42681fe Compare September 2, 2026 12:15
@jason-curtis jason-curtis changed the title fix(bin): tear down each task's own agent scratch feat(bin): give each task an owned temp root and opt-in TMPDIR pin Sep 2, 2026
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: tip moved since the 2026-09-02T11:15:00Z waiting-author stamp, then main moved under it again. This is waiting on the author, not the captain, until conflicts are cleared.

HEAD fac5ab70727ce2f09fef328636ebde79c5a81c25 (was fbf0cfec751ba7c9e72484e3d10e41d755dea80e). Author cleared the prior conflict set (briefly MERGEABLE vs main 8988af2a, ahead 21 / behind 0). Main then advanced to 77ee3c82 (fix(pi): settle watcher delivery on Pi accepting the follow-up #3513). Now CONFLICTING/DIRTY again (ahead 21 / behind 1; diverged). Likely touchpoint includes docs/verification/runtime-backends.md (both tips diverge from 8988af2a). Firstmate will not rebase or merge while a captain product call is still owed on the always-on path-shape change.

Attestation: MATCH on this tip (head_sha = fac5ab70727ce2f09fef328636ebde79c5a81c25). Require no-mistakes body-compliance 33635363158 → SUCCESS (edited). Synchronize 33635284515 failed on a stale attestation race (body still bound to prior fbf0cfec at sync; later edit fixed it) — non-blocking once MATCH.

CI: fork CI re-approved this pass (33635284573). Run reached near-green (Herdr + Lint + portable 2–5 + coverage + invariants + macOS snapshot SUCCESS) then cancelled when the base advanced / mergeability flipped; serial shard 1 cancelled mid-run — not treated as a product red, but CI is not green for a captain flag either.

Contract-class: new-default unchanged. Main still specifies TASK_TMP="/tmp/fm-$ID" with GOTMPDIR-only (bin/fm-spawn.sh on main). This tip always-on replaces that with home-scoped <base>/fm-<hometag>-<id> via bin/fm-task-tmp-lib.sh. TMPDIR pin stays opt-in/default-off via gitignored config/task-tmpdir-pin (inherited like trace-context). workflow-zero. Greptile SUCCESS on this HEAD. Deletion-safety (exact-match owned remove, create refuse symlink/foreign, abort remove before lock release when FM_TASK_TMP_CREATED=1) still looks sound on this tip — no new security blocker.

Overlap: open #3483 (jleemcf, claimed random task-owned temp roots) still replaces main's predictable /tmp/fm-<id> shape. Unchanged: landing either is a captain path-shape decision; they should not both land without reconciling.

Please rebase/resolve onto current main 77ee3c82 and keep the no-mistakes attestation bound to the new tip. Once CLEAN + attestation MATCH + CI/NM green again, Firstmate will restamp for the captain default-behavior decision (not auto-merge). Do not treat this comment as a captain grant. Not merging. Not Firstmate-flagging while conflicted.

Firstmate already gave every task a temp root at /tmp/fm-<id>/ and removed it
at cleanup, but only Go's build temp was pointed there. The agent's own harness
scratch kept landing under /tmp/claude-<uid>/<worktree-slot>/<session>/, outside
anything teardown owned, so every task leaked its scratch until reboot - and on
a RAM-backed /tmp that is leaked memory, which is why it went unnoticed until a
command failed with "Disk quota exceeded" beside 234 GB of free disk.

fm-spawn now pins the launched agent's TMPDIR to that task's own temp root, so
its scratch is confined to the one directory teardown already removes.

Removal is task-scoped on purpose. The worktree pool reuses slot numbers, so a
single slot's scratch path accumulates sessions from many tasks, live ones
included; deleting by slot, by age, or by pattern across sessions would destroy
a running task's work. bin/fm-task-tmp-lib.sh owns the path shape for both
scripts, and teardown removes only the path this task's own record names, only
when it is exactly that derivation, never through a symlink. A mismatch or a
failed removal is reported and cleanup continues, because losing worktree
cleanup over a leftover temp directory would be worse than the leak.

A raw launch command is the unverified-adapter escape hatch and is still passed
through exactly as given.

Scout reports keep no exception: 2 of 26 reports on disk cite a scratch path,
both labeling it disposable and reproducible, and both cited directories are
already gone. The scout brief now states the predictable rule instead - evidence
a report depends on is copied next to the report, which survives cleanup.

Verified against real Claude Code 2.1.251 that a pinned TMPDIR moves the whole
scratch tree (docs/verification/runtime-backends.md), and covered by portable
regressions including a live sibling task in a reused slot that must survive.
The scratch-teardown commit's message said "both cited directories are
already gone, since /tmp is a RAM disk that does not survive reboot."
The counts hold; that explanation does not, and the corrected account is
the stronger one.

Rechecked on this host: 26 scout reports under data/*/report.md, 7 cite
any /tmp path, and 2 cite an actual agent scratch directory. Both of
those citations are already dead as evidence - one path no longer
exists, and the other survives only as an empty directory whose contents
the 15 Aug manual cleanup removed. Crucially, `uptime -s` reports
2026-06-01, so there has been no reboot since the incident: these
citations died during ordinary operation, not at a restart. That is what
carries the argument. Had they died at a reboot, the case for copying
evidence beside the report would rest on a machine restarting; because
they died in normal operation, a scout report that points at scratch is
unreliable even on a host that never reboots. Tmpfs volatility is an
additional reason such a citation cannot be relied on, not the observed
cause here.

To recheck cheaply: grep data/*/report.md for /tmp paths, then ls the two
cited scratch paths and run `uptime -s`.

The answer to whether a torn-down scout's scratch should be preserved
when its report cites it is still no, and the shape of the answer is
that the NEED for a preservation rule is removed rather than a rule
invented: bin/fm-brief.sh's scout scaffold states the predictable rule
that evidence a report depends on is copied into data/<id>/ beside the
report, which survives cleanup, and anything left in scratch is
disposable.

Legacy undiscriminated <base>/fm-<id> temp roots are left alone on
purpose. Ownership cannot be established across firstmate homes for that
shape: two homes with colliding task ids both recorded exactly it, and
nothing available at teardown distinguishes them, so deleting one risks a
live sibling home's directory. The forfeited material is small and
bounded - those old roots hold Go build temp only, because agent scratch
was never pinned there before this change - and the historic
/tmp/claude-<uid>/... scratch is not reachable by any task-scoped rule.

Two test failures a reviewer will see on a host like this one are
pre-existing and independent of this change. These causes supersede the
earlier check that ran `bash tests/fm-teardown.test.sh` on a clean
detached worktree at origin/main f66be0f: that check established only
THAT the case fails without this change, while these establish WHY.

1. tests/fm-teardown.test.sh "leaked-process-reap" fails because lsof is
   NOT INSTALLED on this host, so bin/fm-teardown.sh's reaper falls back
   to its tmux process-group path, which the fixture's fake tmux does not
   satisfy. That code path is untouched by this change. Command: `bash
   tests/fm-teardown.test.sh`. With a /proc-based `lsof -a -d cwd -Fpn`
   stand-in on PATH the entire suite passes, including
   test_leaked_tasktmp_process_is_reaped - the case this change modified,
   which never ran otherwise because the suite stops at the first failure.

2. tests/fm-test-run.test.sh "portable shard union, disjointness, and
   coverage guard" fails under LANG=en_US.UTF-8: run_coverage_guard()
   (untouched by this change) writes its lane lists with `LC_ALL=C sort`
   but compares them with plain `comm`, which validates sortedness under
   the ambient locale. `LC_ALL=C bash bin/fm-test-run.sh --check-coverage`
   prints "FM_TEST_COVERAGE ok" and the suite passes under LC_ALL=C.

No code change: the claim was wrong, not the behavior.
The per-task temp root's name is deterministic and its default parent /tmp
is world-writable, and the launch now pins the agent's TMPDIR to that root,
so a symlink planted at the name would have redirected the whole scratch
tree to a directory this user neither controls nor tears down. Teardown's
symlink refusal runs too late to prevent those writes, so fm_task_tmp_create
creates the root with a plain mkdir - which fails on an existing path rather
than following it - accepts an already-present root only when it is a real
directory owned by this user, and gives it mode 0700.

A fresh spawn also created the root before anything recorded it, so an abort
between creation and the meta publish left a root no teardown could find -
the same leak this root exists to close. The abort trap now removes it,
through the same validation teardown uses, and only for a fresh spawn: a
relaunch's root is already named by the record that outlives the abort.
Upstream deliberately pinned GOTMPDIR only, with a comment saying TMPDIR is
too broad, so pinning TMPDIR on every non-raw launch shipped a new default
that assumes consent. It now ships as a flag to enable instead.

config/task-tmpdir-pin is a gitignored presence flag in the same shape as
config/trace-context and config/herdr-presentation-spaces, and it is inherited
by secondmate homes so a secondmate's own crewmates follow the primary's
choice. With the flag absent the launch adds no TMPDIR prefix and GOTMPDIR
behaves exactly as it does on main.

Everything else is unchanged: the per-task temp root is still created with the
validated private creation, still recorded as tasktmp=, still holds Go's build
temp at gotmp/, and is still removed by teardown through the exact-match
ownership check, including forced-secondmate children and the aborted-spawn
cleanup.

The docs say plainly what the default does not solve: with the flag off,
harness scratch under /tmp/claude-<uid>/ is not redirected into the task root
and is not removed by teardown, so that part of the original leak remains and
only Go's build temp is bounded.

The spawn suite pins both sides of the flag so neither can go vacuous, and the
suites that assert the enabled pin now enable it explicitly.
A relaunch of a task recorded before tasktmp= existed creates the temp
root itself, and nothing names that path until the replacement record is
published. The abort trap was armed only for non-relaunch spawns, so an
abort in that window left an undiscoverable root in the RAM-backed temp
filesystem, which is the leak this whole path exists to close.

Arm the trap on the outcome that actually matters - whether this spawn
created the root - and disarm it once the relaunch has published a record
naming the path, mirroring the fresh-spawn publication. A root that was
already there is still left alone, since it can be a live task's own.
The abort cleanup released the per-task spawn lock and only then removed
the root it had created. In that window another spawn of the same id could
take the lock and adopt the directory, because fm_task_tmp_create accepts
an existing directory this user owns so a relaunch can reuse its own root.
The aborting process then deleted it underneath the new spawn, leaving a
live agent whose TMPDIR and GOTMPDIR no longer exist - the same "never
delete a live task's scratch" rule this whole path exists to keep.

Move the removal above the lock release so adoption and removal are
mutually exclusive. It stays below the Orca recovery record, which names
the root, and a root already removed is a silent no-op at teardown.

This is an ordering fix, not a new guard: nothing else changes, and the
existing ownership, symlink, non-directory and created-fresh checks remain
the only conditions under which anything is removed.
The record described both relaunch cases as if each started from a task
recorded before tasktmp= existed. Only the pre-publication abort case uses
that legacy fixture; the post-publication case starts from a record that
already names the root and asserts the root survives. Split the sentence so
each half names what its own test does.
@jason-curtis
jason-curtis force-pushed the fm/fm-prune-task-scratch branch from fac5ab7 to 4e57c71 Compare September 2, 2026 15:56
Comment thread bin/fm-spawn.sh
Comment on lines +2958 to +2960
# The published record now carries tasktmp=, so teardown can find the root; a
# later abort must leave it for teardown rather than remove it here.
TASK_TMP_ABORT_CLEANUP=0

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 Backlog rollback leaks temp root

When a fresh spawn publishes its metadata but both attempts to move its backlog item to In flight fail, this code has already disabled TASK_TMP_ABORT_CLEANUP, while spawn_fresh_commit_rollback removes the record before exit. The abort trap then leaves the created task root without any durable metadata through which teardown can discover it, causing its scratch directory to leak indefinitely.

@jason-curtis jason-curtis changed the title feat(bin): give each task an owned temp root and opt-in TMPDIR pin fix(bin): tear down each task's own temp root and agent scratch Sep 2, 2026
@Thoughts-One

Copy link
Copy Markdown
Contributor

A data point for this PR: we hit the same fixed /tmp/fm-<id> path from another side.
On a machine with more than one local account, a stale /tmp/fm-<id> directory owned by a different account blocks every later spawn of a task with that id, because the existing ownership and mode checks correctly refuse to reuse it.
The home-derived tag in this PR's task temp root also removes that collision, since two accounts never share a home, so we did not open a separate PR for it.

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: closing this as stale. It has been waiting on a contributor update for 14+ days with no author push or comment. Reopen if you want to pick it back up.

Why close: last author activity was jason-curtis force-push 2026-09-02T15:56:44Z (~20 days). The 2026-09-23T14:59:30Z thread bump is from Thoughts-One (not the author) and does not reset the clock. Still CONFLICTING/DIRTY vs main — waiting-author, not CLEAN, not a captain hold.

contract-class: new-default (own tip-vs-main from prior full review; reconfirmed by prior stamps and body). Tip adds per-task temp-root library + teardown of task temp roots / agent scratch on a path the unconfigured product did not previously clean that way; TMPDIR pin is claimed opt-in but the new teardown/temp-root surface is a default cleanup path change. Prior stamps already classed new-default; not re-litigated for stale close.

VISION (brief, for the close): One captain/interface — aligns (less leftover scratch noise). Authority — aligns (no new consent surface). Scripts/mechanics — aligns (deterministic teardown). Restart — aligns (durable cleanup). Delegation — aligns. Fleet/vendor — aligns. Scope — aligns (command-layer temp hygiene). Stale waiting-author overrides merge consideration.

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.

3 participants