Across ~12 supervised MiniMax units, more than half reported a green suite where a manual re-run found red (see memory/gotchas-minimax.md). Add --gate "<command>" (with --gate-timeout, default 900s): after the detached sub-session exits, mm-worktree-task itself re-runs the command in the worktree against the sub-session's LAST commit, in its own reapable process group (setsid + group kill, so a hung gate and everything it forked are bounded and cleaned up), and appends an unambiguous PASSED/FAILED verdict — plus an explicit MISMATCH callout when the agent's own report claimed success — to the result file. A missing command, non-zero exit, timeout, or dirty worktree are all failures, never a silent skip. mm-worktree-task's own exit code is unchanged: the launch already returns before the sub-session (and gate) run, detached. Claude-Session: https://claude.ai/code/session_014KNyfHmHpMbdMrHZXDRkAp
15 KiB
15 KiB
mm-worktree-task
Purpose
Launch a MiniMax M3 implementation sub-session (via ask-minimax-run) in an isolated git worktree of the current repo, with the prompt assembled from a planner work item, so multiple implementation briefs can run detached and in parallel without touching the main working tree.
Usage
mm-worktree-task <item-uuid> [OPTIONS]
Arguments
| Arg | Description |
|---|---|
<item-uuid> |
Full 36-char planner work-item UUID. Required unless --brief is given together with --slug. |
Options
| Flag | Default | Description |
|---|---|---|
--slug <slug> |
first 8 chars of the uuid | Worktree/branch name suffix. Worktree = <repo>/.claude/worktrees/mm-<slug>, branch = mm/<slug>. |
--focus "<line>" |
— | One-line focus appended to the task frame. |
--brief <file> |
— | Escape hatch: use this file as the task body instead of (or in addition to) the fetched item. |
--docs all|recent|none |
recent |
Which item documents to bundle. recent = documents updated in the last 14 days; falls back to all if the CLI does not expose update dates. The full list of document names is always included in the prompt regardless of which are bundled. |
--tools "<list>" |
— | Extra --allowedTools entries merged into the default allowlist (comma-separated). |
--timeout <secs> |
1800 |
Max seconds the sub-session may run (timeout <secs> wrapper). |
--gate "<command>" |
— | Supervisor gate: after the sub-session exits, mm-worktree-task itself re-runs this command in the worktree against the sub-session's LAST commit and appends a ## Supervisor gate section to the result file (see Behaviour §8). Never trust the agent's own "tests pass" claim — this re-run is the check. |
--gate-timeout <secs> |
900 |
Max seconds --gate's command may run before it is killed and recorded as a timed-out (failed) gate. |
--context <name> |
resolved (see §0) | cp-cli --context used for every planner call. |
--upload |
off | After the sub-session exits, upload its report as a document on the item and patch flow_state to impl-complete. Implemented only in combination with --collect (see Behaviour §6) since launch is detached. |
--base <ref> |
main |
Branch or commit the worktree is created from, and the ref --collect logs/diffs against. Needed when work lives on a long-running branch, or when one agent's branch must build on another's (e.g. an implementation agent starting from a test agent's mm/<slug> branch). |
--collect |
off | Don't launch. Print git log --oneline <base>..HEAD, git diff --stat <base>...HEAD, and the report file for an already-run slug's worktree. |
--dryrun, -n |
off | Assemble everything, print the bundle manifest, the full prompt, and the exact command that would run. Create nothing: no worktree, no branch, no launch, no bundle/prompt files left outside the report dir's dryrun preview. |
--help, -h |
— | Show usage and exit 0. |
Environment
| Var | Effect |
|---|---|
MM_WORKTREE_TASK_CP_CLI_DIR=<dir> |
Checkout holding lib/cp_cli.py to run the planner CLI from. Default resolution: this repo if it has lib/cp_cli.py, else ~/dev/claude/projects/agent-runtimes. |
MM_WORKTREE_TASK_CP_CONTEXT=<name> |
Same as --context (the flag wins). |
MM_WORKTREE_TASK_CPCONFIG=<file> |
cpconfig file used for context auto-resolution (default ~/.cpconfig). |
Environment overrides (for testing)
| Var | Effect |
|---|---|
MM_WORKTREE_TASK_FAKE_ITEM=<file> |
Read item JSON from this file instead of calling cp_cli planner items get. |
MM_WORKTREE_TASK_FAKE_DOCS_DIR=<dir> |
Read document list/bodies from this directory instead of calling cp_cli planner items documents *. Expected layout: <dir>/list.json ({"documents": [{"name": ..., "updated_at": ...}, ...]}) and <dir>/<name> per document body. |
Behaviour
- Planner CLI resolution — the script runs from any planner-backed repo;
cp_clilives in agent-runtimes.- CLI dir:
MM_WORKTREE_TASK_CP_CLI_DIR, else<repo>/lib/cp_cli.py(or thelib/cp_cli/package) if present, else~/dev/claude/projects/agent-runtimes. Only fails (exit 1, "cannot find lib/cp_cli.py") when a planner call is actually made — fake-item runs and--brief-only runs never need it. - Context:
--context, elseMM_WORKTREE_TASK_CP_CONTEXT, else the context in~/.cpconfig(orMM_WORKTREE_TASK_CPCONFIG) whoseproject_idequals the repo directory name, else none (cp-cli'scurrent_context). - Every planner call is
cd <cli-dir> && python3 -m lib.cp_cli [--context <ctx>] ….--dryrunprints both resolved values (cp-cli from:,cp-cli ctx:).
- CLI dir:
- Determine repo root:
git rev-parse --show-toplevelfrom cwd. Fail (exit 1) if not inside a git repository. - Resolve
<slug>:--slugif given, else first 8 chars of<item-uuid>(or required alongside--briefwhen no uuid is given). - Resolve report dir:
${XDG_RUNTIME_DIR:-/tmp}/mm-worktree-task/<repo-name>/. Create it (unless--dryrun, which only prints the path). - Fetch the item (through the §0 wrapper), unless
MM_WORKTREE_TASK_FAKE_ITEMis set:python3 -m lib.cp_cli planner items get <uuid> --json→ fields used:id,title,label,state,flow_state,body,tags,parent_id,related_items.python3 -m lib.cp_cli planner items documents list <uuid> --json→ document names (+updated_atif present).- For each document selected by
--docs:python3 -m lib.cp_cli planner items documents get <uuid> <name>(fake dir equivalent: read<dir>/<name>). - If
parent_idis set: fetch the parent item (title + body) viaplanner items get <parent_id> --json. - If
related_itemsincludesblocked_bytargets: fetch each target's title + state. - Fetch sibling tasks under the same parent:
planner items list <project> --parent <parent_id> --jsonifparent_idis set (best-effort; skip silently if the command errors, e.g. flag unsupported for the active project). - Fetching uses the operator's own
cp_cliauth — the sub-session itself never callscp_cli.
- Write
<report-dir>/<slug>-bundle.mdwith##sections:## Item,## Parent(if any),## Blocked By(if any),## Siblings(if any),## Documents(full name list, marking which are bundled below), then one## Document: <name>section per bundled document with its body. In--dryrun, the bundle is still assembled and its manifest (section list + doc names) is printed, but the file is not written to disk — instead its path is printed as the path that would be written. - Create the worktree (skipped entirely under
--dryrunand--collect):git worktree add <repo>/.claude/worktrees/mm-<slug> -b mm/<slug> <base>(--base, defaultmain). Fail clearly (exit 1) if the branch or worktree directory already exists. - Assemble the prompt file
<report-dir>/<slug>-prompt.txt:- Fixed preamble: worktree path and branch; never
cdelsewhere, nevergit push, nevergit stash, never touchgit config; spec-driven (a behaviour change requires the matchingspec/*.mdrequirement in the same commit); Python 3.12 / Pydantic v2 /@pytest.mark.asyncioonly; do NOT run the full test suite, only the touched test files; don't fix test infrastructure, mock it; read specs and existing tests before editing, match existing names; keep the diff minimal; commit withgit add -A && git commit -m "<type>: <summary> (<uuid>)"after verifyinggit rev-parse --abbrev-ref HEADprintsmm/<slug>; write the report to<report-dir>/<slug>-result.mdwith sections Summary / Files changed / Tests run / Decisions / Open questions; one-line stdout ack only. - Task frame:
Implement work item <uuid>: <title>. Read the bundle at <bundle-path> fully before editing. The item body is the requirement; documents are supporting context. Documents available on the item (not all bundled): <names>. --focusline, if given, appended after the task frame.- If
--brief <file>is given, its content is appended (or used standalone as the task body when no uuid was given).
- Fixed preamble: worktree path and branch; never
- Launch detached (skipped under
--dryrunand--collect):wrapped in a backgrounded subshell that, on exit, appendscd <worktree> && setsid nohup timeout <secs> ask-minimax-run --model "MiniMax-M3[1m]" \ --allowedTools "<default-allowlist>[,<--tools>]" --permission-mode acceptEdits \ --add-dir <report-dir> -p "$(cat <prompt-file>)" \ > <report-dir>/<slug>-stdout.txt 2>&1<slug> rc=<rc> elapsed=<s>sto<report-dir>/launch.log. The parent process returns immediately. Default allowlist:Read,Write,Edit,Glob,Grep,Bash(pytest *),Bash(python3 *),Bash(ruff *),Bash(grep *),Bash(ls *),Bash(git add *),Bash(git commit *),Bash(git status*),Bash(git diff*),Bash(git rev-parse*),Bash(git log*).- Supervisor gate (
--gate "<command>", optional). Runs inside the same backgrounded subshell, immediately after the sub-session'sask-minimax-runprocess exits and before thelaunch.logline is appended — so it always evaluates the worktree exactly as the sub-session left it, never a snapshot from mid-run:- Record
git -C <worktree> rev-parse HEADas the gate sha — this is the sub-session's LAST commit, not whatever commit existed when the sub-session claimed success. - Check whether the worktree is dirty (
git status --porcelainnon-empty). A dirty worktree makes the recorded sha not fully represent what was tested, so it is treated as a failed gate regardless of the command's own exit code. - Run the command as
setsid bash -c "<command>"in the worktree (its own process group,pgid == pid), capturing combined stdout+stderr and its exit code, with a watchdog that after--gate-timeout(default900) seconds sendsSIGTERMto the whole group (kill -TERM -- -<pid>), waits 5s, thenSIGKILLs the group — a command that forks children (a test runner's own workers, say) is fully reaped, not just its immediate wrapper. A timed-out gate is recorded as TIMED OUT and failed regardless of the eventual exit code. A command that does not exist exits non-zero (typically127) and is failed the same way — there is no separate "skipped" state. - Append
## Supervisor gate (re-run by mm-worktree-task)to<report-dir>/<slug>-result.md(created if the sub-session never wrote one, noting that explicitly) with: the HEAD sha, the exact command, the clean/dirty flag, the exit code, an explicit PASSED/FAILED verdict, and the tail (last 60 lines) of captured output. - If the verdict is FAILED and the text of
<result-file>as the sub-session left it (before this section was appended) reads as a claim that tests passed, the appended section says so explicitly (**MISMATCH:** the agent's own report ... claims a passing test run; this independent re-run ... FAILED) — the agent's claim is never the last word. mm-worktree-task's own exit code is unaffected by the gate outcome: the launch has already returned 0 before the sub-session (and gate) run at all, detached. The gate's PASSED/FAILED verdict is only ever visible in the result file /--collectoutput — never in a process exit code. With no--gate, none of the above runs and the result file is exactly what the sub-session wrote, unchanged from pre---gatebehaviour.
- Record
- Supervisor gate (
- Print: report dir, prompt file path, stdout file path, launch.log path, and a one-line
poll with: tail -f <stdout file>hint. --collect: instead of steps 6-9, for the given<slug>'s existing worktree, printgit -C <worktree> log --oneline <base>..HEAD,git -C <worktree> diff --stat <base>...HEAD, and the contents of<report-dir>/<slug>-result.mdif present. If--uploadis also given and the report file exists:python3 -m lib.cp_cli planner items documents put <uuid> mm-<slug>-<YYYY-MM-DD>.md --file <report>python3 -m lib.cp_cli planner items patch <uuid> --flow-state impl-complete --description "mm-worktree-task: <slug> committed on mm/<slug>"--uploadwithout--collectis an error (exit 1): "--uploadrequires--collect(launch is detached; upload happens after the sub-session has produced a report)".
Dryrun Behaviour
With --dryrun/-n:
- No worktree, branch, bundle file, prompt file, or report-dir directory is created; no
ask-minimax-runprocess is started. - Prints, in order: resolved slug/uuid/worktree path/branch, the bundle manifest (section names + document names, bundled vs. available-only), the full assembled prompt text, and the exact
ask-minimax-runcommand line that would run (including the resolved allowlist and timeout), plus the resolved--gatecommand and--gate-timeout(or<none>if not given). The gate itself is never executed under--dryrun. - Exit 0.
Edge Cases
| Case | Handling |
|---|---|
| Not inside a git repo | Error, exit 1 |
Neither <item-uuid> nor (--brief + --slug) given |
Error: item-uuid is required unless --brief and --slug are both given, exit 1 |
Worktree dir or branch mm/<slug> already exists (non---dryrun, non---collect) |
Error, exit 1, no partial state left behind |
--collect for a slug with no worktree |
Error: no worktree found for slug '<slug>', exit 1 |
--upload without --collect |
Error, exit 1 |
--docs recent but CLI gives no updated_at |
Fall back to --docs all behaviour, note this in bundle ## Documents section |
| cp_cli sibling-list command unsupported | Skip ## Siblings section silently (best-effort) |
--gate command missing, non-zero, or times out |
Recorded as Verdict: **FAILED** in the ## Supervisor gate section — never a silent skip |
--gate runs against a dirty worktree |
Recorded as Verdict: **FAILED** regardless of the command's own exit code (the sha would not represent everything present) |
Security
- The MiniMax sub-session never calls
cp_cliand never receives an operator token; only files (bundle, prompt) are passed into the worktree/report dir via--add-dir. - The script never reads or prints the MiniMax API key;
ask-minimax-runhandlesprovider.envitself.
Examples
# Dry run against a real item
mm-worktree-task 1f17f61e-317e-4e10-bcd6-bee6a3264379 --slug ci-gate --dryrun
# Launch for real, with an extra focus line and extra tool
mm-worktree-task 1f17f61e-317e-4e10-bcd6-bee6a3264379 --slug ci-gate \
--focus "Only touch .gitea/workflows and pyproject.toml" \
--tools "Bash(scripts/*)"
# Poll progress
tail -f "${XDG_RUNTIME_DIR:-/tmp}/mm-worktree-task/agent-runtimes/ci-gate-stdout.txt"
# Collect the result once done
mm-worktree-task 1f17f61e-317e-4e10-bcd6-bee6a3264379 --slug ci-gate --collect
# Collect and upload the report + mark impl-complete
mm-worktree-task 1f17f61e-317e-4e10-bcd6-bee6a3264379 --slug ci-gate --collect --upload
# Launch with a supervisor gate: re-run the real suite yourself after the
# sub-session's last commit, bounded to 10 minutes
mm-worktree-task 1f17f61e-317e-4e10-bcd6-bee6a3264379 --slug ci-gate \
--gate "python3 -m pytest -q" --gate-timeout 600