feat(mm-worktree-task): run from any planner-backed repo — cp_cli dir + context resolution (--context, MM_WORKTREE_TASK_CP_*), empty-docs guard, tests + spec

cp_cli lives in agent-runtimes; previously every planner call ran 'python3 -m lib.cp_cli' from the current repo with no --context, so the script only worked inside agent-runtimes. The context now resolves from ~/.cpconfig by matching project_id to the repo directory name. Verified live from hatchmate (item + parent + documents fetched via the hatchmate context).

Claude-Session: https://claude.ai/code/session_01JgJbWzVJioVznVTFyQFEfx
This commit is contained in:
Paul O'Reilly
2026-09-11 16:27:08 +12:00
parent 9488335713
commit f50561ea78
9 changed files with 969 additions and 10 deletions

158
specs/bg-model-call.spec.md Normal file
View File

@@ -0,0 +1,158 @@
# bg-model-call
## Purpose
Submit a prompt to a third-party model via the agent-runtimes control plane, wait for completion, and write the result to a file — enabling the `ScheduleWakeup` polling pattern for second-opinion consultations without blocking the main Claude Code session.
## Usage
```
bg-model-call [OPTIONS]
```
### Required flags
| Flag | Description |
|------|-------------|
| `--model <name>` | Model alias: `minimax`, `sonnet`, `haiku`, `opus` |
| `--prompt <file>` | Path to a file containing the prompt text |
| `--output <file>` | Path where the result will be written |
### Optional flags
| Flag | Default | Description |
|------|---------|-------------|
| `--template <name>` | (derived from `--model`) | Override the task template name (without `.yaml`). Takes precedence over the `--model` → template mapping. |
| `--project <id>` | `bg-model-call` | Project ID sent to the CP, used for task grouping in agent-monitor |
| `--url <cp-url>` | `$CP_URL`, then `http://localhost:8100` | Control plane base URL |
| `--task-name <name>` | `bg-model-call-<model>` | Short task name shown in agent-monitor |
| `--timeout <seconds>` | `1800` | Max seconds to wait for the task before declaring it failed |
| `--poll-interval <seconds>` | `10` | Seconds between CP state polls |
| `--dryrun`, `-n` | — | Resolve all values and print what would be submitted; do not call the CP or write any files |
| `--help`, `-h` | — | Show usage information and exit 0 |
## Model-to-Template Mapping
A constant defined at the top of the script:
| Model alias | Template name |
|-------------|---------------|
| `minimax` | `minimax-planning` |
| `sonnet` | `sonnet-code-repo` |
| `haiku` | `haiku-code-repo` |
| `opus` | `opus-code-repo` |
The `--template` flag overrides this mapping entirely.
## Behaviour
1. Parse and validate all flags. Exit 1 with an error message if required flags are missing or values are invalid.
2. Resolve the CP URL: `--url` flag → `$CP_URL` env var → `http://localhost:8100`.
3. Resolve the template name: `--template` flag if given, otherwise look up `--model` in the mapping table. If the model alias is not in the table, exit 1.
4. Read the prompt text from the `--prompt` file. If the file is not readable, exit 1.
5. Locate the `dispatch-task` CLI:
- Check if `dispatch-task` is on PATH.
- Otherwise try `<script-dir>/../../../projects/agent-runtimes/scripts/dispatch-task` as a fallback (for dev environments).
- If neither is found, fall back to direct `curl` POST to `$CP_URL/tasks`.
6. Submit the task to the CP via the resolved `dispatch-task` (or curl fallback):
- `--name <task-name>`
- `--project-id <project>`
- `--prompt-file <prompt-file>`
- `--template <template>`
- `--cp-url <cp-url>`
- `--format json` (to capture the task_id from stdout)
- The `dispatch-task` tool uses `CP_AUTH_TOKEN` from the environment if present.
7. Extract the `task_id` from the submission response.
8. Write a `.bg-model-call-<task-id>` tracking file in the same directory as `--output` (content: task ID, model, template, timestamp). This avoids collisions between concurrent calls.
9. Poll the CP using `wait-for-tasks` (or the `dispatch-task --wait` pattern):
- Locate `wait-for-tasks` using the same PATH + fallback logic as `dispatch-task`.
- If not found, poll manually via `curl GET $CP_URL/tasks/<task-id>` in a sleep loop.
- Poll every `--poll-interval` seconds up to `--timeout` seconds total.
10. Once the task reaches a terminal state:
- **Succeeded:** Fetch logs via `GET $CP_URL/tasks/<task-id>/logs`. Write the log content to `--output`. Write `<output>.done` sentinel (content: `task_id=<id>` + newline + `completed_at=<timestamp>`). Remove the tracking file.
- **Failed / timed_out / cancelled:** Write `<output>.failed` sentinel (content: `task_id=<id>`, `state=<state>`, `error=<detail if available>`). Remove the tracking file. Exit 1.
11. Exit 0 on success.
## Output Files
| File | When written | Content |
|------|-------------|---------|
| `<output>` | On success | Raw text from `GET /tasks/<id>/logs` |
| `<output>.done` | On success | `task_id=<id>`, `completed_at=<timestamp>` |
| `<output>.failed` | On failure/timeout | `task_id=<id>`, `state=<state>`, `error=<detail>` |
| `.bg-model-call-<task-id>` | After submission | `task_id=<id>`, `model=<alias>`, `template=<name>`, `submitted_at=<timestamp>` |
The `.done` sentinel is the signal the caller polls for. The caller reads `<output>` once `.done` appears.
## Dryrun Behaviour
When `--dryrun` or `-n` is passed, the script:
1. Resolves all values (CP URL, template, task name, project ID).
2. Reads (and validates) the prompt file.
3. Prints a summary of what would be submitted — no CP calls, no file writes.
Example output:
```
[dryrun] bg-model-call — no changes will be made
CP URL: http://localhost:8100
Model: minimax
Template: minimax-planning
Task name: bg-model-call-minimax
Project ID: bg-model-call
Prompt file: /tmp/my-prompt.txt
Prompt size: 142 bytes
Output file: /tmp/result.txt
Sentinel: /tmp/result.txt.done
dispatch-task: /home/user/dev/claude/projects/agent-runtimes/scripts/dispatch-task
```
## Edge Cases
| Case | Handling |
|------|----------|
| `--model` missing and `--template` missing | Error: `--model is required (or use --template to specify a template directly)`, exit 1 |
| `--prompt` missing | Error: `--prompt is required`, exit 1 |
| `--output` missing | Error: `--output is required`, exit 1 |
| `--model` not in mapping table | Error: `Unknown model alias '<name>'. Valid values: minimax, sonnet, haiku, opus`, exit 1 |
| Prompt file not readable | Error: `Cannot read prompt file: <path>`, exit 1 |
| `--timeout` not a positive integer | Error: `--timeout must be a positive integer`, exit 1 |
| `--poll-interval` not a positive integer | Error: `--poll-interval must be a positive integer`, exit 1 |
| CP submission fails | Write `<output>.failed`, exit 1 |
| Task times out (wait exceeds `--timeout`) | Write `<output>.failed` with `state=timed_out`, exit 1 |
| Output directory does not exist | Error: `Output directory does not exist: <dir>`, exit 1 |
## Security
- **No credentials in the script.** CP authentication (if required) is handled by `dispatch-task` reading `CP_AUTH_TOKEN` from the environment. The `bg-model-call` script only passes `--cp-url` and never handles tokens directly.
- **Prompt content is passed via file** (`--prompt-file`), never as a command-line argument. This avoids the prompt appearing in `ps` output.
## Examples
```bash
# Submit to MiniMax and poll in the background; check for result from a wakeup
echo "Review this spec for security issues: $(cat spec/auth.md)" > /tmp/minimax-prompt.txt
bg-model-call \
--model minimax \
--prompt /tmp/minimax-prompt.txt \
--output /tmp/minimax-result.txt \
--project my-project &
# Later (from ScheduleWakeup), check for completion:
if [[ -f /tmp/minimax-result.txt.done ]]; then
cat /tmp/minimax-result.txt
fi
# Dryrun to inspect resolved values
bg-model-call \
--model sonnet \
--prompt /tmp/my-prompt.txt \
--output /tmp/result.txt \
--dryrun
# Use a specific template instead of the model mapping
bg-model-call \
--template minimax-planning \
--prompt /tmp/prompt.txt \
--output /tmp/result.txt \
--dryrun
```

View File

@@ -26,11 +26,20 @@ mm-worktree-task <item-uuid> [OPTIONS]
| `--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). |
| `--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. |
| `--collect` | off | Don't launch. Print `git log --oneline main..HEAD`, `git diff --stat main...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 |
@@ -40,10 +49,14 @@ mm-worktree-task <item-uuid> [OPTIONS]
## Behaviour
0. **Planner CLI resolution** — the script runs from *any* planner-backed repo; `cp_cli` lives in agent-runtimes.
- CLI dir: `MM_WORKTREE_TASK_CP_CLI_DIR`, else `<repo>/lib/cp_cli.py` (or the `lib/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`, else `MM_WORKTREE_TASK_CP_CONTEXT`, else the context in `~/.cpconfig` (or `MM_WORKTREE_TASK_CPCONFIG`) whose `project_id` equals the repo directory name, else none (cp-cli's `current_context`).
- Every planner call is `cd <cli-dir> && python3 -m lib.cp_cli [--context <ctx>] …`. `--dryrun` prints both resolved values (`cp-cli from:`, `cp-cli ctx:`).
1. Determine repo root: `git rev-parse --show-toplevel` from cwd. Fail (exit 1) if not inside a git repository.
2. Resolve `<slug>`: `--slug` if given, else first 8 chars of `<item-uuid>` (or required alongside `--brief` when no uuid is given).
3. Resolve report dir: `${XDG_RUNTIME_DIR:-/tmp}/mm-worktree-task/<repo-name>/`. Create it (unless `--dryrun`, which only prints the path).
4. **Fetch the item** (run from repo root, needs the repo's `lib`), unless `MM_WORKTREE_TASK_FAKE_ITEM` is set:
4. **Fetch the item** (through the §0 wrapper), unless `MM_WORKTREE_TASK_FAKE_ITEM` is 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_at` if present).
- For each document selected by `--docs`: `python3 -m lib.cp_cli planner items documents get <uuid> <name>` (fake dir equivalent: read `<dir>/<name>`).

View File

@@ -0,0 +1,125 @@
# wait-for-version
## Purpose
Poll a Gitea repository's Actions runs until the CI workflow for HEAD of the main branch completes successfully, then print the latest release tag. Solves the race condition where a dependency's main branch has been updated but CI has not yet produced a release.
## Usage
```
wait-for-version [OPTIONS] REPO
```
### Arguments
| Argument | Description |
|----------|-------------|
| `REPO` | Gitea repo in `owner/name` form (e.g. `skynet/hugo-accelerator`). |
### Flags
| Flag | Description |
|------|-------------|
| `-t`, `--token TOKEN` | Gitea access token. Fallback order: `$GITEA_TOKEN`, `$GITHUB_TOKEN`. |
| `--workflow NAME` | Name of the CI workflow to wait for (default: `version`). Matched against the `name` field of each workflow run. |
| `--timeout SECONDS` | Max total wait time (default: 300). `0` means poll once then give up. |
| `--interval SECONDS` | Polling interval (default: 15; min: 1). |
| `--server URL` | Gitea base URL. Fallback order: `$GITHUB_SERVER_URL`, `https://gitea.oreillyit.nz`. |
| `-v`, `--verbose` | Emit polling progress to stderr. |
| `-n`, `--dryrun` | Print resolved configuration and exit; make no API calls. |
| `-h`, `--help` | Show usage. |
## Behaviour
1. Validate inputs: REPO must be `owner/name`; `--timeout` is a non-negative integer; `--interval` is an integer ≥ 1.
2. Compute `API_BASE = <server>/api/v1/repos/<owner>/<name>`.
3. Compute `DEADLINE = now + timeout`.
4. Resolve HEAD SHA: `GET <API_BASE>/branches/main`. Extract `.commit.id`. On HTTP 401/403 exit 3; on HTTP 404 exit 3; on any other failure exit 3.
5. Emit `Waiting for '<workflow>' CI on <repo> @ <sha[0:8]>...` to stderr.
6. Loop:
1. `GET <API_BASE>/actions/runs?head_sha=<SHA>&limit=50`. Add `Authorization: token <TOKEN>` when a token is available.
2. From the JSON response, filter `.workflow_runs[]` where `.name == <workflow>`. Take the first (index 0) result.
3. If no matching run: treat as `pending`, continue polling.
4. If `status == "completed"` and `conclusion == "success"`: fetch `GET <API_BASE>/releases/latest`, extract `.tag_name`, print to stdout, exit 0.
5. If `status == "completed"` and `conclusion != "success"`: print error to stderr (include conclusion value), exit 1.
6. Otherwise (queued / in_progress / waiting / etc.): continue polling.
7. On HTTP 401 or 403: print auth error to stderr, exit 3.
8. On HTTP 404: print not-found error to stderr, exit 3.
9. On any other HTTP code or network error: keep retrying (log to stderr when `--verbose`).
7. Between polls, sleep for `--interval` seconds, capped so sleep never overshoots the deadline.
8. When the deadline passes without success, print timeout error to stderr, exit 1.
## Dryrun Behaviour
When `--dryrun` is passed, no HTTP requests are made. Output (to stdout):
```
[dryrun] Server: https://gitea.oreillyit.nz
[dryrun] Repo: skynet/hugo-accelerator
[dryrun] Workflow: version
[dryrun] Timeout: 300s
[dryrun] Interval: 15s
[dryrun] Auth: yes
[dryrun] Actions API: https://gitea.oreillyit.nz/api/v1/repos/skynet/hugo-accelerator/actions/runs
```
`Auth: yes` when a token was supplied or present in the environment; otherwise `Auth: no`.
## Exit Codes
| Code | Meaning |
|------|---------|
| 0 | CI succeeded; latest release tag printed to stdout. |
| 1 | Timeout elapsed, CI concluded with a non-success result, or no release tag found. |
| 2 | Usage error (missing / invalid arguments). |
| 3 | Terminal API error (401, 403, 404, or failure resolving HEAD SHA). |
## Edge Cases
| Case | Handling |
|------|----------|
| REPO missing the slash | Error, exit 2. |
| No matching workflow run found | Treated as pending; continue polling until timeout. |
| CI run fails (non-success conclusion) | Print error including conclusion value, exit 1. |
| Latest release has no `tag_name` | Error to stderr, exit 1. |
| No token for a private repo | API returns 401/403 → exit 3. |
| Invalid token | HTTP 401 → exit 3. |
| Repo doesn't exist | HTTP 404 → exit 3. |
| Transient 5xx or network error | Retry each `--interval` seconds until timeout. |
| `--timeout 0` | Resolve SHA, poll once, exit 1 if not yet complete. |
| `--interval 0` | Error, exit 2. |
## Examples
```bash
# Wait for the version CI workflow to complete on HEAD of main
wait-for-version skynet/hugo-accelerator
# Capture version and update a consumer project
VERSION=$(wait-for-version skynet/hugo-accelerator)
sed -i "s/hugo-accelerator: .*/hugo-accelerator: $VERSION/" config.yaml
# With explicit token for a private repo
wait-for-version -t "$MY_TOKEN" skynet/private-lib
# Longer timeout for slow CI (10 minutes)
wait-for-version --timeout 600 skynet/hugo-accelerator
# Verbose polling progress
wait-for-version -v skynet/hugo-accelerator
# Custom workflow name
wait-for-version --workflow release skynet/hugo-accelerator
# Dryrun preview
wait-for-version --dryrun skynet/hugo-accelerator
```
### In a Claude Code session
Invoke via the Bash tool with `run_in_background=true`. The session is notified when the script exits. On success stdout holds the new version tag.
```bash
VERSION=$(wait-for-version skynet/hugo-accelerator) || exit 1
echo "Dependency released as $VERSION"
```