Skip to content

feat(spawn): support batch task dispatch - #33

Merged
kunchenguid merged 3 commits into
mainfrom
fm-spawn-batch-dispatch
Jun 21, 2026
Merged

feat(spawn): support batch task dispatch#33
kunchenguid merged 3 commits into
mainfrom
fm-spawn-batch-dispatch

Conversation

@kunchenguid

Copy link
Copy Markdown
Owner

Intent

The captain asked to make bin/fm-spawn.sh support spawning several tasks in one call by accepting multiple id=repo pairs, removing the recurring need to hand-write multi-task dispatch loops in the tool shell. Motivation: the interactive tool shell is zsh, which (unlike bash) does not word-split an unquoted $var, so ad-hoc 'for ... in $pairs' / 'set -- $pair' loops silently mangled arguments and repeatedly broke multi-task dispatch. Decisions/tradeoffs: (1) chose unambiguous id=repo pair syntax so single-task invocations ( [harness|--scout|raw-cmd]) are detected as NOT batch and stay byte-for-byte unchanged - detection requires the first positional to contain '=' with a slash-free id part; (2) implemented batch as a re-exec of this same script in single-task mode per pair (via the FM_ROOT path, not $0) so the single-task path stays the one source of truth instead of duplicating spawn logic; (3) a shared --scout applies to every pair; (4) continue-on-failure - a failed pair is reported ('batch: FAILED to spawn ...') and skipped while the rest still launch, non-zero exit if any failed; (5) the watcher guard runs once per batch (not per pair) via an FM_SPAWN_NO_GUARD env flag on the re-execed children. Scope deliberately limited to this batch helper: the captain explicitly chose NOT to add a broader zsh word-splitting convention note elsewhere. Added tests/fm-spawn-batch.test.sh (behavior tests exercising arg routing only - each spawn fails fast at the missing-brief check before any tmux/treehouse side effect, so no real worktrees/windows are created) and documented the batch form in AGENTS.md section 7.

What Changed

  • Added batch dispatch support to bin/fm-spawn.sh using id=repo pairs, with shared --scout, continue-on-failure behavior, and a single watcher guard for the batch.
  • Documented the new batch spawn form in AGENTS.md and README.md.
  • Added batch spawn behavior coverage and wired all shell behavior tests into CI.

Risk Assessment

✅ Low: Captain, the change is narrow, keeps single-task spawning as the source of truth, and the earlier CI coverage gap is now addressed.

Testing

Inspected the changed files, ran the targeted batch-spawn test and full behavior-test loop without lint/static analysis, captured CLI success and failure transcripts in the evidence directory, cleaned temporary data, state, and projects fixtures from the worktree, and ended with no actionable test failures.

Evidence: Targeted batch-spawn test output

ok - batch dispatch re-execs and reports every id=repo pair ok - a single id=repo pair routes through batch dispatch ok - single-task invocation (no '=') is untouched by batch detection ok - batch dispatch rejects an argument that is not id=repo ok - an arg whose id part contains '/' is not treated as a batch pair

ok - batch dispatch re-execs and reports every id=repo pair
ok - a single id=repo pair routes through batch dispatch
ok - single-task invocation (no '=') is untouched by batch detection
ok - batch dispatch rejects an argument that is not id=repo
ok - an arg whose id part contains '/' is not treated as a batch pair
Evidence: All behavior tests output

Behavior test loop covering batch spawn and wake queue tests passed.

ok - batch dispatch re-execs and reports every id=repo pair
ok - a single id=repo pair routes through batch dispatch
ok - single-task invocation (no '=') is untouched by batch detection
ok - batch dispatch rejects an argument that is not id=repo
ok - an arg whose id part contains '/' is not treated as a batch pair
ok - concurrent append plus drain preserves queue records
ok - signal written while no watcher runs is caught on next run
ok - stale wake is queued before suppressor state is advanced
ok - check output is queued before cadence suppression
ok - simultaneous watcher starts leave exactly one live process
ok - two atomic drains cannot consume the same records twice
ok - drain collapses obvious duplicate heartbeat and signal records
ok - killed watcher stale lock is reclaimed
ok - live watcher lock with stale heartbeat is actionable
ok - guard warns when queued wakes are pending
ok - guard orders watcher re-arm after queued wake drain
Evidence: CLI batch spawn success transcript

$ PATH=<fake-tmux>:$PATH bin/fm-spawn.sh batch-a=projects/demo-a batch-b=projects/demo-b --scout spawned batch-a harness=codex kind=scout mode=direct-PR yolo=off window=firstmate:fm-batch-a worktree=/var/folders/5x/4nqprlbx0518k3ybcb1sz6gr0000gn/T/no-mistakes-evidence/01KVNX6QWGFN889JRHRQTH6A1Z/fake-worktrees/firstmate_fm-batch-a spawned batch-b harness=codex kind=scout mode=local-only yolo=on window=firstmate:fm-batch-b worktree=/var/folders/5x/4nqprlbx0518k3ybcb1sz6gr0000gn/T/no-mistakes-evidence/01KVNX6QWGFN889JRHRQTH6A1Z/fake-worktrees/firstmate_fm-batch-b exit=0

$ PATH=<fake-tmux>:$PATH bin/fm-spawn.sh batch-a=projects/demo-a batch-b=projects/demo-b --scout
spawned batch-a harness=codex kind=scout mode=direct-PR yolo=off window=firstmate:fm-batch-a worktree=/var/folders/5x/4nqprlbx0518k3ybcb1sz6gr0000gn/T/no-mistakes-evidence/01KVNX6QWGFN889JRHRQTH6A1Z/fake-worktrees/firstmate_fm-batch-a
spawned batch-b harness=codex kind=scout mode=local-only yolo=on window=firstmate:fm-batch-b worktree=/var/folders/5x/4nqprlbx0518k3ybcb1sz6gr0000gn/T/no-mistakes-evidence/01KVNX6QWGFN889JRHRQTH6A1Z/fake-worktrees/firstmate_fm-batch-b
exit=0

$ sed -n "1,20p" state/batch-a.meta state/batch-b.meta
window=firstmate:fm-batch-a
worktree=/var/folders/5x/4nqprlbx0518k3ybcb1sz6gr0000gn/T/no-mistakes-evidence/01KVNX6QWGFN889JRHRQTH6A1Z/fake-worktrees/firstmate_fm-batch-a
project=/Users/kunchen/.no-mistakes/worktrees/016d88035d58/01KVNX6QWGFN889JRHRQTH6A1Z/projects/demo-a
harness=codex
kind=scout
mode=direct-PR
yolo=off
window=firstmate:fm-batch-b
worktree=/var/folders/5x/4nqprlbx0518k3ybcb1sz6gr0000gn/T/no-mistakes-evidence/01KVNX6QWGFN889JRHRQTH6A1Z/fake-worktrees/firstmate_fm-batch-b
project=/Users/kunchen/.no-mistakes/worktrees/016d88035d58/01KVNX6QWGFN889JRHRQTH6A1Z/projects/demo-b
harness=codex
kind=scout
mode=local-only
yolo=on

$ cat fake-tmux-calls.log
tmux has-session -t firstmate
tmux list-windows -t firstmate -F \#\{window_name\}
tmux new-window -d -t firstmate -n fm-batch-a -c /Users/kunchen/.no-mistakes/worktrees/016d88035d58/01KVNX6QWGFN889JRHRQTH6A1Z/projects/demo-a
tmux send-keys -t firstmate:fm-batch-a treehouse\ get Enter
tmux display-message -p -t firstmate:fm-batch-a \#\{pane_current_path\}
tmux send-keys -t firstmate:fm-batch-a -l codex\ --dangerously-bypass-approvals-and-sandbox\ -c\ \"notify=\[\\\"bash\\\"\,\\\"-c\\\"\,\\\"touch\ /Users/kunchen/.no-mistakes/worktrees/016d88035d58/01KVNX6QWGFN889JRHRQTH6A1Z/state/batch-a.turn-ended\\\"\]\"\ \"\$\(cat\ /Users/kunchen/.no-mistakes/worktrees/016d88035d58/01KVNX6QWGFN889JRHRQTH6A1Z/data/batch-a/brief.md\)\"
tmux send-keys -t firstmate:fm-batch-a Enter
tmux has-session -t firstmate
tmux list-windows -t firstmate -F \#\{window_name\}
tmux new-window -d -t firstmate -n fm-batch-b -c /Users/kunchen/.no-mistakes/worktrees/016d88035d58/01KVNX6QWGFN889JRHRQTH6A1Z/projects/demo-b
tmux send-keys -t firstmate:fm-batch-b treehouse\ get Enter
tmux display-message -p -t firstmate:fm-batch-b \#\{pane_current_path\}
tmux send-keys -t firstmate:fm-batch-b -l codex\ --dangerously-bypass-approvals-and-sandbox\ -c\ \"notify=\[\\\"bash\\\"\,\\\"-c\\\"\,\\\"touch\ /Users/kunchen/.no-mistakes/worktrees/016d88035d58/01KVNX6QWGFN889JRHRQTH6A1Z/state/batch-b.turn-ended\\\"\]\"\ \"\$\(cat\ /Users/kunchen/.no-mistakes/worktrees/016d88035d58/01KVNX6QWGFN889JRHRQTH6A1Z/data/batch-b/brief.md\)\"
tmux send-keys -t firstmate:fm-batch-b Enter
Evidence: CLI batch failure transcript

$ FM_SPAWN_NO_GUARD=1 bin/fm-spawn.sh missing-a=projects/no-a missing-b=projects/no-b error: no brief at /Users/kunchen/.no-mistakes/worktrees/016d88035d58/01KVNX6QWGFN889JRHRQTH6A1Z/data/missing-a/brief.md batch: FAILED to spawn missing-a (projects/no-a) error: no brief at /Users/kunchen/.no-mistakes/worktrees/016d88035d58/01KVNX6QWGFN889JRHRQTH6A1Z/data/missing-b/brief.md batch: FAILED to spawn missing-b (projects/no-b) exit=1

$ FM_SPAWN_NO_GUARD=1 bin/fm-spawn.sh missing-a=projects/no-a missing-b=projects/no-b
error: no brief at /Users/kunchen/.no-mistakes/worktrees/016d88035d58/01KVNX6QWGFN889JRHRQTH6A1Z/data/missing-a/brief.md
batch: FAILED to spawn missing-a (projects/no-a)
error: no brief at /Users/kunchen/.no-mistakes/worktrees/016d88035d58/01KVNX6QWGFN889JRHRQTH6A1Z/data/missing-b/brief.md
batch: FAILED to spawn missing-b (projects/no-b)
exit=1

$ FM_SPAWN_NO_GUARD=1 bin/fm-spawn.sh mixed-a=projects/no-a bogus-no-equals
error: no brief at /Users/kunchen/.no-mistakes/worktrees/016d88035d58/01KVNX6QWGFN889JRHRQTH6A1Z/data/mixed-a/brief.md
batch: FAILED to spawn mixed-a (projects/no-a)
error: batch dispatch expects every argument as id=repo; got 'bogus-no-equals'
exit=2
Evidence: Fake tmux calls from CLI success check

Fake tmux call log showing two batch children created windows, sent treehouse get, and launched codex from the normal single-spawn path.

tmux has-session -t firstmate
tmux list-windows -t firstmate -F \#\{window_name\}
tmux new-window -d -t firstmate -n fm-batch-a -c /Users/kunchen/.no-mistakes/worktrees/016d88035d58/01KVNX6QWGFN889JRHRQTH6A1Z/projects/demo-a
tmux send-keys -t firstmate:fm-batch-a treehouse\ get Enter
tmux display-message -p -t firstmate:fm-batch-a \#\{pane_current_path\}
tmux send-keys -t firstmate:fm-batch-a -l codex\ --dangerously-bypass-approvals-and-sandbox\ -c\ \"notify=\[\\\"bash\\\"\,\\\"-c\\\"\,\\\"touch\ /Users/kunchen/.no-mistakes/worktrees/016d88035d58/01KVNX6QWGFN889JRHRQTH6A1Z/state/batch-a.turn-ended\\\"\]\"\ \"\$\(cat\ /Users/kunchen/.no-mistakes/worktrees/016d88035d58/01KVNX6QWGFN889JRHRQTH6A1Z/data/batch-a/brief.md\)\"
tmux send-keys -t firstmate:fm-batch-a Enter
tmux has-session -t firstmate
tmux list-windows -t firstmate -F \#\{window_name\}
tmux new-window -d -t firstmate -n fm-batch-b -c /Users/kunchen/.no-mistakes/worktrees/016d88035d58/01KVNX6QWGFN889JRHRQTH6A1Z/projects/demo-b
tmux send-keys -t firstmate:fm-batch-b treehouse\ get Enter
tmux display-message -p -t firstmate:fm-batch-b \#\{pane_current_path\}
tmux send-keys -t firstmate:fm-batch-b -l codex\ --dangerously-bypass-approvals-and-sandbox\ -c\ \"notify=\[\\\"bash\\\"\,\\\"-c\\\"\,\\\"touch\ /Users/kunchen/.no-mistakes/worktrees/016d88035d58/01KVNX6QWGFN889JRHRQTH6A1Z/state/batch-b.turn-ended\\\"\]\"\ \"\$\(cat\ /Users/kunchen/.no-mistakes/worktrees/016d88035d58/01KVNX6QWGFN889JRHRQTH6A1Z/data/batch-b/brief.md\)\"
tmux send-keys -t firstmate:fm-batch-b Enter

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 1 issue found → auto-fixed ✅
  • ⚠️ tests/fm-spawn-batch.test.sh:1 - The new batch-dispatch behavior test is not wired into the existing Behavior tests job, which still only runs tests/fm-wake-queue.test.sh; this means the branch's main regression coverage will be skipped by CI and can silently rot. Add tests/fm-spawn-batch.test.sh to the CI test step, or change the job to run all intended behavior tests.

🔧 Fix: Wire batch behavior tests into CI
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • tests/fm-spawn-batch.test.sh
  • for test_script in tests/*.test.sh; do "$test_script"; done
  • bin/fm-harness.sh crew
  • PATH=&lt;fake-tmux&gt;:$PATH bin/fm-spawn.sh batch-a=projects/demo-a batch-b=projects/demo-b --scout with temporary briefs/projects and captured state/*.meta plus fake tmux calls
  • FM_SPAWN_NO_GUARD=1 bin/fm-spawn.sh missing-a=projects/no-a missing-b=projects/no-b
  • FM_SPAWN_NO_GUARD=1 bin/fm-spawn.sh mixed-a=projects/no-a bogus-no-equals
  • git status --short && find data state projects config -maxdepth 1 -type e 2>/dev/null | sort
✅ **Document** - passed

✅ No issues found.

⚠️ **Lint** - 1 warning
  • ⚠️ .github/workflows/ci.yml:18 - Configured ShellCheck lint could not be run because shellcheck is not installed in PATH or common local locations; unresolved ShellCheck findings may remain.
✅ **Push** - passed

✅ No issues found.

fm-spawn.sh now accepts multiple id=repo pairs in one call, looping internally
(in bash) and re-execing the proven single-task path per pair. This removes the
need to hand-write multi-task dispatch loops in the tool shell (zsh), which does
not word-split unquoted $vars and silently mangles ad-hoc loops. A shared --scout
applies to all pairs; single-task invocations are unchanged. Adds behavior tests.
@kunchenguid
kunchenguid merged commit ceba3c8 into main Jun 21, 2026
4 checks passed
@kunchenguid
kunchenguid deleted the fm-spawn-batch-dispatch branch June 21, 2026 20:47
vipentti pushed a commit to vipentti/firstmate that referenced this pull request Aug 5, 2026
* feat(spawn): batch dispatch via id=repo pairs

fm-spawn.sh now accepts multiple id=repo pairs in one call, looping internally
(in bash) and re-execing the proven single-task path per pair. This removes the
need to hand-write multi-task dispatch loops in the tool shell (zsh), which does
not word-split unquoted $vars and silently mangles ad-hoc loops. A shared --scout
applies to all pairs; single-task invocations are unchanged. Adds behavior tests.

* no-mistakes(review): Wire batch behavior tests into CI

* no-mistakes(document): Document spawn batching
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.

1 participant