From 4e9cbe090267b12fa8643aef821cc2e334b9805e Mon Sep 17 00:00:00 2001 From: e-jung <8334081+e-jung@users.noreply.github.com> Date: Mon, 22 Jun 2026 02:34:06 +0000 Subject: [PATCH 1/2] fix(spawn): set per-task GOTMPDIR so interrupted Go builds don't leak /tmp Go's GOTMPDIR is unset, so every go build/test creates numbered /tmp/go-build* dirs. Go cleans them on a clean exit but LEAVES THEM when interrupted (signal, timeout, OOM, full disk), accumulating and filling the disk over time. Give each task its own temp root at /tmp/fm-/ with Go's build temp nested at gotmp/. fm-spawn creates the dir (Go won't mkdir GOTMPDIR), exports GOTMPDIR into the crewmate pane so the agent and child processes inherit it, and records tasktmp= in meta. fm-teardown reads tasktmp= and removes the whole root on cleanup, deterministically. GOTMPDIR (not TMPDIR) is the targeted knob: TMPDIR is too broad (affects every program's temp). The nested root is extensible: other per-task temp can live under /tmp/fm-/ later. Backward compat: tasks spawned before this change have no tasktmp= in meta; teardown tolerates the empty value as a no-op. The daily fm-disk-cleanup.sh cron remains a safety net for any pre-fix stray dirs. --- bin/fm-spawn.sh | 14 ++++ bin/fm-teardown.sh | 6 ++ tests/fm-gotmp.test.sh | 174 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100755 tests/fm-gotmp.test.sh diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index d0b1dc4..17a952a 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -132,6 +132,14 @@ if [ -z "$WT" ]; then exit 1 fi +# Per-task temp root: /tmp/fm-/ with Go's build temp nested at gotmp/. Go won't +# create GOTMPDIR, so mkdir before it is used; fm-teardown removes the whole root. +# Nested (not a bare /tmp/fm-/gotmp) so other per-task temp can live alongside +# later, and teardown cleans one deterministic path. GOTMPDIR (not TMPDIR) is the +# targeted knob: TMPDIR is too broad (affects every program's temp, not just Go's). +TASK_TMP="/tmp/fm-$ID" +mkdir -p "$TASK_TMP/gotmp" + # Per-harness turn-end hook: a file that touches state/.turn-ended when the # agent finishes a turn. Worktree-resident hooks are kept out of git's view so # they never block teardown's dirty check or leak into a commit. @@ -200,11 +208,17 @@ mkdir -p "$FM_ROOT/state" echo "kind=$KIND" echo "mode=$MODE" echo "yolo=$YOLO" + echo "tasktmp=$TASK_TMP" } > "$FM_ROOT/state/$ID.meta" LAUNCH=${LAUNCH//__BRIEF__/$BRIEF} LAUNCH=${LAUNCH//__TURNEND__/$TURNEND} LAUNCH=${LAUNCH//__PIEXT__/$FM_ROOT/state/$ID.pi-ext.ts} +# Export GOTMPDIR into the crewmate's pane shell so the agent and every child +# process (go build, go test, ...) inherit it. Sent before the launch command so +# the env is set when the agent starts; the brief sleep lets the export land. +tmux send-keys -t "$T" "export GOTMPDIR=$TASK_TMP/gotmp" Enter +sleep 0.3 tmux send-keys -t "$T" -l "$LAUNCH" sleep 0.3 tmux send-keys -t "$T" Enter diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 4fd2355..1a3fc0a 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -23,6 +23,9 @@ META="$STATE/$ID.meta" WT=$(grep '^worktree=' "$META" | cut -d= -f2-) T=$(grep '^window=' "$META" | cut -d= -f2-) PROJ=$(grep '^project=' "$META" | cut -d= -f2-) +# tasktmp is recorded by fm-spawn for tasks that set up a per-task temp root +# (/tmp/fm-/); absent for tasks spawned before that change, so tolerate empty. +TASK_TMP=$(grep '^tasktmp=' "$META" | cut -d= -f2- || true) KIND=$(grep '^kind=' "$META" | cut -d= -f2- || true) [ -n "$KIND" ] || KIND=ship @@ -99,6 +102,9 @@ if [ -d "$WT" ]; then fi tmux kill-window -t "$T" 2>/dev/null || true +# Remove the per-task temp root (/tmp/fm-/, incl. its gotmp/) recorded by spawn. +# Read before the state-file rm below; empty (pre-fix tasks without tasktmp=) is a no-op. +[ -n "$TASK_TMP" ] && rm -rf "$TASK_TMP" rm -f "$STATE/$ID.status" "$STATE/$ID.turn-ended" "$STATE/$ID.check.sh" "$STATE/$ID.meta" "$STATE/$ID.pi-ext.ts" if [ "$KIND" != scout ] && [ "$MODE" != local-only ]; then "$FM_ROOT/bin/fm-fleet-sync.sh" "$PROJ" || true diff --git a/tests/fm-gotmp.test.sh b/tests/fm-gotmp.test.sh new file mode 100755 index 0000000..d98e621 --- /dev/null +++ b/tests/fm-gotmp.test.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +# Behavior tests for per-task GOTMPDIR support (fm-gotmp). +# +# fm-spawn gives each task a temp root /tmp/fm-/ with Go's build temp nested at +# gotmp/, exports GOTMPDIR into the crewmate pane, and records tasktmp= in the task's +# meta. fm-teardown reads tasktmp= and removes the whole root on cleanup. +# +# These tests exercise behavior directly: fm-teardown is run as a subprocess against a +# fake FM_ROOT (built so the real script resolves into it), with stub helper scripts. +# Nothing is sourced. The fm-spawn side is verified both structurally (the source has +# the contract lines) and behaviorally (the mkdir + meta-write pattern it uses). +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SPAWN="$ROOT/bin/fm-spawn.sh" +TEARDOWN="$ROOT/bin/fm-teardown.sh" + +fail() { + printf 'not ok - %s\n' "$1" >&2 + exit 1 +} + +pass() { + printf 'ok - %s\n' "$1" +} + +TMP_ROOT= + +cleanup() { + if [ -n "${TMP_ROOT:-}" ]; then + rm -rf "$TMP_ROOT" + fi +} +trap cleanup EXIT + +TMP_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/fm-gotmp-tests.XXXXXX") + +# Build a fake FM_ROOT so the real fm-teardown.sh (symlinked in) resolves FM_ROOT to +# it via its BASH_SOURCE computation. Stub the helper scripts fm-teardown calls so no +# live tmux/treehouse/fleet state is touched. A nonexistent worktree path makes both +# `if [ -d "$WT" ]` guards skip, so teardown runs straight to the cleanup + state rm. +make_fake_root() { + local id=$1 tasktmp=$2 + local fake="$TMP_ROOT/$id" + mkdir -p "$fake/bin" "$fake/state" + # Symlink the REAL teardown so the test exercises actual code, not a copy. + ln -s "$TEARDOWN" "$fake/bin/fm-teardown.sh" + # fm-guard.sh: stub (teardown calls it with `|| true`). + cat > "$fake/bin/fm-guard.sh" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$fake/bin/fm-guard.sh" + # fm-fleet-sync.sh: stub (called for non-scout/non-local-only teardowns). + cat > "$fake/bin/fm-fleet-sync.sh" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$fake/bin/fm-fleet-sync.sh" + # Meta with a nonexistent worktree so the dirty/treehouse blocks skip. + cat > "$fake/state/$id.meta" </dev/null \ + || fail "fm-spawn missing: mkdir of gotmp under TASK_TMP" + grep -F 'echo "tasktmp=$TASK_TMP"' "$SPAWN" >/dev/null \ + || fail "fm-spawn missing: tasktmp= line in meta write" + grep -F 'export GOTMPDIR=' "$SPAWN" >/dev/null \ + || fail "fm-spawn missing: GOTMPDIR export into pane" + # Behavioral: the mkdir + meta-write pattern spawn uses must produce a gotmp dir and + # a meta line whose value the teardown grep (tasktmp=, cut -d= -f2-) reads back whole. + local id=spawn-sim-z1 + local sim_root="$TMP_ROOT/$id-root" + local task_tmp="$sim_root/tmp/fm-$id" + mkdir -p "$sim_root/state" + # Replicate spawn's exact mkdir + meta-write lines. + TASK_TMP="$task_tmp" + mkdir -p "$TASK_TMP/gotmp" + { + echo "tasktmp=$TASK_TMP" + } > "$sim_root/state/$id.meta" + [ -d "$task_tmp/gotmp" ] || fail "simulated spawn did not create gotmp dir" + # Teardown reads tasktmp= with `grep '^tasktmp=' | cut -d= -f2-`; round-trip it. + local read_back + read_back=$(grep '^tasktmp=' "$sim_root/state/$id.meta" | cut -d= -f2-) + [ "$read_back" = "$task_tmp" ] \ + || fail "tasktmp value not round-tripped by teardown's grep|cut (got '$read_back')" + pass "fm-spawn creates gotmp dir and records tasktmp in meta" +} + +# --- fm-teardown side (real subprocess) --- + +test_teardown_removes_tasktmp_dir() { + local id=td-rm-z2 + local task_tmp="$TMP_ROOT/fm-$id" + mkdir -p "$task_tmp/gotmp" + printf 'leftover\n' > "$task_tmp/gotmp/build-artifact" + local fake + fake=$(make_fake_root "$id" "$task_tmp") + # Sanity: dir + contents exist before teardown. + [ -d "$task_tmp/gotmp" ] || fail "precondition: gotmp missing before teardown" + # Run the REAL teardown against the fake root. + bash "$fake/bin/fm-teardown.sh" "$id" >/dev/null 2>&1 \ + || fail "teardown exited non-zero with a valid tasktmp" + [ ! -e "$task_tmp" ] \ + || fail "teardown did not remove the tasktmp dir ($task_tmp still exists)" + pass "fm-teardown removes the dir pointed to by tasktmp= in meta" +} + +test_teardown_skips_gracefully_without_tasktmp() { + # Backward compat: a meta from a pre-fix task has no tasktmp= line. Teardown must + # not error and must not remove anything. + local id=td-absent-z3 + local fake="$TMP_ROOT/$id-root" + mkdir -p "$fake/bin" "$fake/state" + ln -s "$TEARDOWN" "$fake/bin/fm-teardown.sh" + cat > "$fake/bin/fm-guard.sh" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$fake/bin/fm-guard.sh" + cat > "$fake/bin/fm-fleet-sync.sh" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$fake/bin/fm-fleet-sync.sh" + # No tasktmp= line at all. + cat > "$fake/state/$id.meta" </dev/null 2>&1 \ + || fail "teardown exited non-zero when tasktmp= was absent" + pass "fm-teardown skips gracefully when tasktmp= is absent (backward compat)" +} + +test_teardown_skips_gracefully_when_dir_missing() { + # tasktmp= points to a path that does not exist. Teardown must not error. + local id=td-missing-z4 + local task_tmp="$TMP_ROOT/never-created-fm-$id" + # Intentionally do NOT create $task_tmp. + [ ! -e "$task_tmp" ] || fail "precondition: task_tmp should not exist yet" + local fake + fake=$(make_fake_root "$id" "$task_tmp") + bash "$fake/bin/fm-teardown.sh" "$id" >/dev/null 2>&1 \ + || fail "teardown exited non-zero when tasktmp dir was missing" + [ ! -e "$task_tmp" ] || fail "teardown created/left the tasktmp dir unexpectedly" + pass "fm-teardown skips gracefully when tasktmp= points to a nonexistent dir" +} + +test_spawn_contract_and_mkdir_pattern +test_teardown_removes_tasktmp_dir +test_teardown_skips_gracefully_without_tasktmp +test_teardown_skips_gracefully_when_dir_missing From f9ffd1461d555e7454fcd27b303c56f047fdfbaf Mon Sep 17 00:00:00 2001 From: e-jung <8334081+e-jung@users.noreply.github.com> Date: Mon, 22 Jun 2026 02:40:50 +0000 Subject: [PATCH 2/2] fix(tests): silence SC2016 for literal grep -F patterns in fm-gotmp test The structural grep -F assertions deliberately match literal $TASK_TMP in the fm-spawn source; add per-line shellcheck disable=SC2016 (the codebase's existing pattern, e.g. bin/fm-spawn.sh) so CI lint passes. --- tests/fm-gotmp.test.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/fm-gotmp.test.sh b/tests/fm-gotmp.test.sh index d98e621..941947c 100755 --- a/tests/fm-gotmp.test.sh +++ b/tests/fm-gotmp.test.sh @@ -76,8 +76,10 @@ META test_spawn_contract_and_mkdir_pattern() { # Structural: fm-spawn must create the gotmp dir, record tasktmp in meta, and export # GOTMPDIR into the pane. Assert the contract lines are present in the source. + # shellcheck disable=SC2016 # single quotes are deliberate: these are literal source strings grep -F 'mkdir -p "$TASK_TMP/gotmp"' "$SPAWN" >/dev/null \ || fail "fm-spawn missing: mkdir of gotmp under TASK_TMP" + # shellcheck disable=SC2016 # single quotes are deliberate: literal source string grep -F 'echo "tasktmp=$TASK_TMP"' "$SPAWN" >/dev/null \ || fail "fm-spawn missing: tasktmp= line in meta write" grep -F 'export GOTMPDIR=' "$SPAWN" >/dev/null \