Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,14 @@ if [ -z "$WT" ]; then
exit 1
fi

# Per-task temp root: /tmp/fm-<id>/ 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-<id>/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/<id>.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.
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions bin/fm-teardown.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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-<id>/); 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
Expand Down Expand Up @@ -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-<id>/, 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
Expand Down
176 changes: 176 additions & 0 deletions tests/fm-gotmp.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
#!/usr/bin/env bash
# Behavior tests for per-task GOTMPDIR support (fm-gotmp).
#
# fm-spawn gives each task a temp root /tmp/fm-<id>/ 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" <<META
window=fakeses:fm-$id
worktree=$TMP_ROOT/nonexistent-worktree-$id
project=$TMP_ROOT/nonexistent-project-$id
harness=claude
kind=ship
mode=no-mistakes
yolo=off
tasktmp=$tasktmp
META
printf '%s' "$fake"
}

# --- fm-spawn side ---

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 \
|| 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" <<META
window=fakeses:fm-$id
worktree=$TMP_ROOT/nonexistent-wt-$id
project=$TMP_ROOT/nonexistent-proj-$id
harness=claude
kind=ship
mode=no-mistakes
yolo=off
META
bash "$fake/bin/fm-teardown.sh" "$id" >/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
Loading