
Dispatch Opencode
- 11 installs
- 1 repo stars
- Updated July 7, 2026
- cristoslc/dispatch-opencode-skill
Dispatches background subagent tasks through the opencode CLI using an async .subagents/ lock-watch protocol with plan YAML, polling, and cleanup scripts.
About
Hands work off to a background subagent via opencode using file-based lock-watch signaling, writing a plan YAML and polling lockfiles until FINAL_OUTPUT.md is ready. A developer uses it to parallelize independent tasks or run worktree-isolated, long-lived investigations.
- Async .subagents/ lockfile protocol with run-plan.sh dispatch and cleanup scripts
- Optional --attach mode against a running opencode serve daemon
Dispatch Opencode by the numbers
- 11 all-time installs (skills.sh)
- Ranked #11,740 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cristoslc/dispatch-opencode-skill --skill dispatch-opencodeAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 11 |
|---|---|
| repo stars | ★ 1 |
| Last updated | July 7, 2026 |
| Repository | cristoslc/dispatch-opencode-skill ↗ |
What it does
Dispatches background subagent tasks through the opencode CLI using an async .subagents/ lock-watch protocol with plan YAML, polling, and cleanup scripts.
Files
dispatch-opencode
Routes subagent dispatch through opencode using async file-based signaling. The skill does NOT implement ACP (Agent Client Protocol) or HTTP serve mode — those were dropped per ADR-001. Instead, it uses CLI opencode run as the invocation transport, wrapped in a .subagents/ lock-watch protocol.
For previous versions of this skill (ACP/CLI/HTTP), see ADR-001.
When to use
- The task takes long enough that the agent should not block (refactors,
dependency upgrades, research sprints).
- The agent wants to parallelize N independent tasks with per-task worktree
isolation.
- The agent wants per-task model selection (e.g., cheap model for fixes,
capable model for design).
- The operator wants to attach to a running subagent from another terminal.
- The agent wants the dispatch artifact (prompt, event log, lock file) on
disk for replay or audit.
- You are the orchestrator in a sashay: the branch, worktree, and draft PR
already exist on the forge. Use this skill to dispatch a subagent into the worktree with sashay chronicle instructions in the prompt. The subagent will commit, push, and post PR comments throughout its work.
When NOT to use
- The task is trivial and finishes in seconds — the lock-watch overhead
exceeds any benefit. Use opencode run directly.
- The host runtime forbids spawning background subprocesses.
Four design constraints (non-negotiable)
1. Every dispatch takes an explicit absolute path; verification fails closed. No defaults, no inference. If the path is wrong, the script exits non-zero before anything runs.
2. Every handoff is an on-disk artifact. Every dispatch writes the prompt, start script, event log, and lock file under .subagents/<task-id>/. The task directory is the source of truth for replay and audit.
3. One template per dispatch kind. Templates are typed by what the dispatch is for (e.g., single-file-fix, headless-spike), not parameterized into a single megatemplate.
4. Smart orchestrator, dumb subagents. The agent decides what is ready and fires only ready tasks. The skill dispatches what it is given. Subagents are single-responsibility and should work on the cheapest model that can do the job. The agent continues running, polls, and kicks off the next wave. Subagents never coordinate with each other or trigger subsequent work.
Agent workflows
Workflow 1: Single task
1. Write a 1-task plan YAML. 2. Call run-plan.sh --plan plan.yaml. 3. Parse JSON output for lockfile path and PID. 4. Call poll-subagent.sh --task-id <id> --root <path>. It logs event line count each iteration, detects stuck tasks (exit 2), and times out (exit 3). 5. On completion (exit 0): read FINAL_OUTPUT.md, merge work, call subagent-cleanup.sh. 6. On failure or stuck (exit 2/3): call subagent-abandon.sh.
Workflow 2: Parallelize N tasks
1. Write an N-task plan YAML (only tasks whose dependencies are satisfied). 2. Call run-plan.sh --plan plan.yaml. 3. Parse JSON output for N lockfile paths and PIDs. 4. For each dispatched task, call poll-subagent.sh --task-id <id> --root <path>. It logs event line count per iteration, detects stuck tasks, and times out. 5. Per completed task: read FINAL_OUTPUT.md, merge work, call subagent-cleanup.sh. 6. Per failed or stuck task: call subagent-abandon.sh.
Workflow 3: Stale resource recovery
Call cleanup-stale.sh [--abandon] after a crash or long idle period.
- Without
--abandon: reports stale locks and orphaned worktrees. - With
--abandon: callssubagent-abandon.shfor each.
Script inventory
| Script | Agent-facing | Purpose |
|---|---|---|
run-plan.sh | yes | Validate plan, prepare worktrees, dispatch, return lockfile list as JSON |
poll-subagent.sh | yes | Poll subagent lockfile until completion, stuck, or timeout |
subagent-cleanup.sh | yes | Remove completed task's artifacts + worktree |
subagent-abandon.sh | yes | Kill PID, force-remove failed task + worktree |
cleanup-stale.sh | yes | Scan for stale locks and orphaned worktrees |
dispatch.sh | no (internal) | Single-task prepare, spawn, confirm .lock appeared |
verify-cwd.sh | no (internal) | Fail-closed CWD verification |
validate-run.sh | no (internal) | Post-hoc event stream validation |
Plan schema
tasks:
- id: fix-auth
kind: single-file-fix
model: ollama-cloud/glm-5.1
agent: build
prompt: prompts/fix-auth.md
target: src/auth.py
worktree: fix-auth-branch # optional. Branch name for worktree creation.
- id: refactor-api
kind: multi-file-fix
model: ollama-cloud/deepseek-v4-flash:cloud
agent: build
prompt: prompts/refactor-api.md
worktree: refactor-api-branch # optionalFields: id (required), kind (required), model (required), prompt (required, path to prompt file), target (required for single-file-fix, path inside cwd; not used for multi-file-fix), worktree (optional, branch name), agent (optional, defaults per kind).
Store prompt files in prompts/<task-id>.md at the project root. For example, a plan referencing prompt: prompts/fix-auth.md has the prompt file at <project>/prompts/fix-auth.md and the task directory at <project>/.subagents/fix-auth/.
No depends field. The agent writes only tasks that are ready to run right now.
Structured output from run-plan.sh
run-plan.sh returns JSON on stdout (all other output goes to stderr):
{
"plan_id": "20260528T151600Z",
"tasks": [
{
"id": "fix-auth",
"lockfile": "/abs/path/.subagents/fix-auth/.lock",
"task_dir": "/abs/path/.subagents/fix-auth",
"pid": 48912,
"worktree": "/abs/path/.worktrees/fix-auth",
"status": "dispatched"
},
{
"id": "fix-api",
"status": "skipped",
"reason": "worktree creation failed: branch already exists"
}
]
}Directory structure
<project-root>/
.subagents/
<task-id>/
prompt.md
start-subagent.sh
.lock ← exists while subagent runs
events.jsonl
FINAL_OUTPUT.md
worktree/ ← real git worktree (if task declared one)
.worktrees/
<task-id> ← symlink → ../.subagents/<task-id>/worktree/.subagents/ is gitignored. The real worktree lives inside the task directory so its lifecycle is bound to the task. The symlink in .worktrees/ lets other tooling discover active worktrees.
poll-subagent.sh
poll-subagent.sh --task-id <id> --root <project-root> \
[--interval <sec>] [--max-polls <n>] [--stale-threshold <sec>]Monitors a dispatched subagent by polling its lockfile and events.jsonl. Each iteration logs the event line count and mtime. Exits with:
- 0 — task completed (lockfile gone)
- 2 — stuck (events line count unchanged and mtime stale past threshold)
- 3 — timeout (max polls reached, lockfile still present)
- 1 — error (bad args, missing task dir)
Defaults: --interval 15, --max-polls 12 (up to 180s), --stale-threshold 60. Use --max-polls 16 for complex tasks (up to 240s).
subagent-cleanup.sh
subagent-cleanup.sh --task-id <id> --root <project-root>Removes .lock, removes .worktrees/ symlink, git worktree removes the real tree (no force — should be clean after merge), removes task dir.
subagent-abandon.sh
subagent-abandon.sh --task-id <id> --root <project-root>Kills PID (TERM then KILL), removes .lock, removes .worktrees/ symlink, force-removes worktree + deletes branch, removes task dir.
Permission model
The async mode does not use ACP permission relay. Permission policy is enforced by:
1. Prompt design — the agent writes a tightly-scoped prompt that constrains the subagent's behaviour. 2. Config gating — the consumer project's opencode.json can set per-command rules. 3. Post-hoc validation — scripts/validate-run.sh checks events.jsonl for unexpected tool calls.
Default failure-mode mitigations
OPENCODE_DISABLE_AUTOCOMPACT=trueset in the start script.OPENCODE_DISABLE_AUTOUPDATE=trueset in the start script.- Auto-detects server mode. When
OPENCODE_SERVER_URLis set, the
template uses --attach. When unset, falls back to local --dir mode. Avoids session-in-session env-var leak (issue #24747).
cleanup-stale.sh— cleans up stale.lockfiles and orphaned
worktrees whose PIDs are dead.
Dispatch kinds
| Kind | Status | Use for |
|---|---|---|
single-file-fix | available | One agent edits one file from a focused prompt. Required: target. |
multi-file-fix | available | Full-directory fix/refactor with no single-file target. Works on the entire CWD. No target needed. |
headless-spike | available | Read-only investigation; agent writes a report file but does not edit source. Required: target (report path). Defaults to --agent explore (opencode's read-only built-in). |
Sashay dispatch pattern
In a sashay, the calling agent has already created the branch, worktree, and draft PR. The calling agent then dispatches a subagent into the existing worktree using this skill. The calling agent includes chronicle instructions (commit, push, post PR comments) directly in the prompt file — the subagent follows them.
The subagent's --cwd must point to the worktree directory, not the project root. Use multi-file-fix with worktree pointing to an existing branch name. The skill creates a worktree from that branch and sets CWD to it automatically.
Example plan YAML from the calling agent:
tasks:
- id: implement-fix
kind: multi-file-fix
model: ollama-cloud/deepseek-v4-flash:cloud
agent: build
prompt: prompts/implement-fix.md
worktree: fix-branch-nameThe prompt file (prompts/implement-fix.md) includes the sashay chronicle instructions:
You are working in a PR-tracked worktree. The draft PR URL is
https://github.com/org/repo/pull/123.
Chronicle rules:
1. Commit and push your changes regularly.
2. After each checkpoint, add a PR comment via the forge CLI.
3. When done, ensure all tests pass and signal completion.Add a kind by:
1. Drop a <kind>.sh.j2 in templates/cli/. 2. Add a row to the table above. 3. Add an example invocation to references/examples.md.
What this skill does NOT do
- Run inside an editor as an ACP agent. Editor flows should call
opencode acp directly.
- Expose opencode via MCP. opencode is an MCP client only.
- Manage opencode authentication. Run
opencode auth loginseparately. - Coordinate between parallel agents beyond per-task isolation and
shared .subagents/ directory. The agent owns all coordination.
- Merge worktree results. The agent decides merge semantics (commit, PR,
squash, etc.).
References
- ADR-001: async
.subagents/lock-watch as primary dispatch mode. - Trove:
async-subagent-dispatch@5ca7b44— async dispatch patterns. - Trove:
opencode-runtime-integration@d9bad44— failure-mode catalogue. - SPIKE-001:
--attachsession visibility on serve daemon. - Examples:
references/examples.md.
opencode CLI syntax reference
Commands and flags relevant to dispatch-opencode. Full docs at <https://opencode.ai/docs/cli/>.
Global flags
| Flag | Description |
|---|---|
--help / -h | Display help |
--version / -v | Print version number |
--print-logs | Print logs to stderr |
--log-level | DEBUG, INFO, WARN, ERROR |
--pure | Run without external plugins |
opencode run
Non-interactive prompt execution — the core invocation for subagent dispatch. Pass a prompt via stdin or as arguments.
opencode run [message..]opencode run < prompt.mdFlags
| Flag | Description |
|---|---|
--model / -m | provider/model — e.g. ollama-cloud/deepseek-v4-flash:cloud |
--agent | Agent to use: build (default), explore (read-only), or a custom agent |
--attach | Attach to a running server — e.g. --attach http://localhost:4096 |
--password / -p | Basic auth password (defaults to OPENCODE_SERVER_PASSWORD) |
--username / -u | Basic auth username (defaults to OPENCODE_SERVER_USERNAME or opencode) |
--dir | Working directory (or path on remote server when attaching) |
--file / -f | Attach file(s) to the prompt |
--format | default (formatted) or json (raw JSON events) |
--thinking | Show thinking blocks |
--continue / -c | Continue the last session |
--session / -s | Resume a specific session by ID |
--fork | Fork session when continuing (use with --continue or --session) |
--dangerously-skip-permissions | Auto-approve permissions not explicitly denied |
--title | Title for the session |
opencode serve
Headless HTTP server. Required for --attach mode. Set OPENCODE_SERVER_PASSWORD to enable basic auth.
opencode serve [--port <n>] [--hostname <host>] [--mdns]opencode models
List available models from configured providers. Format: provider/model.
opencode models [provider]--refresh updates the cached model list. --verbose includes cost metadata.
opencode auth login
Authenticate with an LLM provider. Credentials stored at ~/.local/share/opencode/auth.json.
opencode auth login [--provider <id>] [--method <label>]Provider env vars (ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.) also work without running auth login.
opencode attach
Attach TUI to a running server.
opencode attach <url> [--dir <path>] [--continue]Relevant environment variables
Dispatched subagents set these automatically (templates):
| Variable | Purpose |
|---|---|
OPENCODE_DISABLE_AUTOCOMPACT=true | Prevent context compaction in subagent |
OPENCODE_DISABLE_AUTOUPDATE=true | Prevent update checks in subagent |
Server-attach variables (set by the operator before running opencode serve):
| Variable | Purpose |
|---|---|
OPENCODE_SERVER_URL | URL of the headless server for --attach mode |
OPENCODE_SERVER_PASSWORD | Basic auth password |
OPENCODE_SERVER_USERNAME | Basic auth username (default opencode) |
Troubleshooting
See references/troubleshooting.md for detailed solutions to common issues. Trigger keywords: session not found, blank FINAL_OUTPUT.md, dispatch skipped, timeout, stuck, exit code 3, exit code 2, branch not found, orphaned symlink, worktree symlink, prompt file resolves.
Examples
Concrete invocations of the dispatch-opencode skill.
Single task via run-plan.sh
The agent writes a 1-task plan, dispatches it, and polls the lockfile.
# 1. Write the plan
cat > plan.yaml <<'YAML'
tasks:
- id: fix-foo
kind: single-file-fix
model: ollama-cloud/deepseek-v4-flash:cloud
agent: build
prompt: prompt-fix-foo.md
target: src/foo.py
YAML
# 2. Write the prompt
cat > prompt-fix-foo.md <<'MD'
Fix the arithmetic bug in src/foo.py. The `add` function uses subtraction
instead of addition. Change `a - b` to `a + b`.
MD
# 3. Dispatch
result=$(bash skills/dispatch-opencode/scripts/run-plan.sh --plan plan.yaml)
lockfile=$(echo "$result" | python3 -c "import json,sys; print(json.load(sys.stdin)['tasks'][0]['lockfile'])")
task_dir=$(echo "$result" | python3 -c "import json,sys; print(json.load(sys.stdin)['tasks'][0]['task_dir'])")
# 4. Poll for completion (recommended: use poll-subagent.sh)
# Exit 0 = completed, 2 = stuck, 3 = timeout
bash skills/dispatch-opencode/scripts/poll-subagent.sh \
--task-id fix-foo --root "$(git rev-parse --show-toplevel)" \
--max-polls 12 --stale-threshold 60
# Or, for complex tasks:
# --max-polls 16
# 5. Read result
cat "$task_dir/FINAL_OUTPUT.md"
# 6. Merge work and clean up
# (agent's choice: merge, PR, squash, etc.)
bash skills/dispatch-opencode/scripts/subagent-cleanup.sh --task-id fix-foo --root "$(git rev-parse --show-toplevel)"Parallelize N tasks via run-plan.sh
cat > plan.yaml <<'YAML'
tasks:
- id: fix-auth
kind: single-file-fix
model: ollama-cloud/glm-5.1
agent: build
prompt: prompts/fix-auth.md
target: src/auth.py
worktree: fix-auth-branch
- id: fix-logging
kind: single-file-fix
model: ollama-cloud/deepseek-v4-flash:cloud
agent: build
prompt: prompts/fix-logging.md
target: src/logging.py
worktree: fix-logging-branch
YAML
result=$(bash skills/dispatch-opencode/scripts/run-plan.sh --plan plan.yaml)
# Poll each task (recommended: use poll-subagent.sh per task)
# Exit codes: 0 = completed, 2 = stuck, 3 = timeout
tasks=$(echo "$result" | python3 -c "
import json, sys
for t in json.load(sys.stdin)['tasks']:
if t['status'] == 'dispatched':
print(t['id'])
")
for tid in $tasks; do
bash skills/dispatch-opencode/scripts/poll-subagent.sh \
--task-id "$tid" --root "$(git rev-parse --show-toplevel)" \
--max-polls 12 --stale-threshold 60 &
done
wait
# Read results, merge, clean up each task
# ...
bash skills/dispatch-opencode/scripts/subagent-cleanup.sh --task-id fix-auth --root "$(git rev-parse --show-toplevel)"
bash skills/dispatch-opencode/scripts/subagent-cleanup.sh --task-id fix-logging --root "$(git rev-parse --show-toplevel)"Abandon a failed task
bash skills/dispatch-opencode/scripts/subagent-abandon.sh --task-id fix-auth --root "$(git rev-parse --show-toplevel)"
# Kills PID, force-removes worktree, deletes branch, removes task dir.Stale resource recovery
# Report only
bash skills/dispatch-opencode/scripts/cleanup-stale.sh /path/to/repo
# Report and clean up
bash skills/dispatch-opencode/scripts/cleanup-stale.sh --abandon /path/to/repoAttaching to a subagent
Since the subagent uses --attach to the serve daemon, the operator can attach from another terminal:
opencode attach http://localhost:4096 --session <session-id>The session ID is in the first line of events.jsonl:
head -1 .subagents/<task-id>/events.jsonl | jq -r '.sessionID'Sashay dispatch
Dispatch a subagent into an existing sashay worktree (branch, worktree, and draft PR already created by the calling agent):
# 1. Write the prompt with chronicle instructions
cat > prompts/implement-fix.md <<'MD'
You are working in a PR-tracked worktree. The draft PR URL is
https://github.com/org/repo/pull/123.
Chronicle rules:
1. Commit and push your changes regularly.
2. After each checkpoint, add a PR comment via the forge CLI.
3. When done, ensure all tests pass and signal completion.
MD
# 2. Write the plan — worktree branch must already exist on remote
cat > plan.yaml <<'YAML'
tasks:
- id: implement-fix
kind: multi-file-fix
model: ollama-cloud/deepseek-v4-flash:cloud
agent: build
prompt: prompts/implement-fix.md
worktree: fix-branch-name
YAML
# 3. Dispatch (creates worktree from the existing branch)
result=$(bash skills/dispatch-opencode/scripts/run-plan.sh --plan plan.yaml)
task_dir=$(echo "$result" | python3 -c "import json,sys; print(json.load(sys.stdin)['tasks'][0]['task_dir'])")
# 4. Poll for completion
bash skills/dispatch-opencode/scripts/poll-subagent.sh \
--task-id implement-fix --root "$(git rev-parse --show-toplevel)" \
--max-polls 24
# 5. Read result
cat "$task_dir/FINAL_OUTPUT.md"
# 6. Clean up
bash skills/dispatch-opencode/scripts/subagent-cleanup.sh \
--task-id implement-fix --root "$(git rev-parse --show-toplevel)"dispatch-opencode Troubleshooting
Spoke file for dispatch-opencode skill. Loaded when troubleshooting subagent dispatch issues. Trigger keywords: session not found, blank FINAL_OUTPUT.md, dispatch skipped, timeout, stuck, exit code 3, exit code 2, branch not found, orphaned symlink, worktree symlink, prompt file resolves.
---
"Session not found" or blank FINAL_OUTPUT.md
The --attach mode tells opencode to connect to a running server at OPENCODE_SERVER_URL. When the server uses password auth but the subagent does not have the password, opencode silently creates a _local_ session instead of attaching. The subagent finishes, but FINAL_OUTPUT.md is empty or contains no text events.
Fix: Set OPENCODE_SERVER_PASSWORD to match the server's password before running run-plan.sh. Both OPENCODE_SERVER_URL and OPENCODE_SERVER_PASSWORD must be set in the parent shell environment. The templates pass them through to the subagent automatically.
Confirm the server has auth enabled by checking its startup log for "HTTP basic auth enabled". If it is missing, restart opencode serve with OPENCODE_SERVER_PASSWORD=<password> set.
Dispatch skipped with "prompt file resolves to the same path"
The plan's prompt path points into .subagents/<task-id>/ which is the same directory dispatch.sh copies it to. BSD cp on macOS rejects this.
Fix: Store prompt files in prompts/<task-id>.md at the project root and reference them as prompt: prompts/<task-id>.md in the plan.
Subagent times out (exit code 3 from poll-subagent.sh)
The poll-subagent.sh default is 12 polls at 15s intervals = 180s. Complex tasks may need more time.
Fix: Pass --max-polls 40 to poll-subagent.sh, or set the timeout primitive in the dispatch template for longer wall-clock time.
Subagent stuck (exit code 2 from poll-subagent.sh)
The events.jsonl line count and mtime have not changed for --stale-threshold seconds (default 60). The subagent process may be hung.
Fix: Check stderr.log and events.jsonl in .subagents/<id>/ for errors. Kill the PID manually and call subagent-abandon.sh --task-id <id> --root <project-root>.
subagent-abandon.sh reports "branch not found"
The task did not use a worktree branch — only pr-work and tasks with an explicit worktree: field create one. The "not found" message is informational; cleanup still succeeds.
Worktree symlink points to a missing directory
The .worktrees/<id> symlink survives when the .subagents/<id> task dir is removed without calling subagent-cleanup.sh.
Fix: Run cleanup-stale.sh from the project root to detect and remove orphaned symlinks.
#!/usr/bin/env bash
# cleanup-stale.sh — scan for stale locks and orphaned worktrees.
#
# A lock is stale if:
# 1. The PID inside .lock is dead (kill -0 fails), OR
# 2. The lock file mtime exceeds TIMEOUT and the process is still alive
# (zombie / hung — kill and clean).
#
# Orphaned worktrees: symlinks in .worktrees/ whose target task dir no
# longer exists or whose lockfile PID is dead.
#
# Usage: cleanup-stale.sh [--dry-run] [--abandon] [--timeout <sec>] [<root>]
# --abandon: call subagent-abandon.sh for each stale/orphaned task
# --dry-run: report only, do not remove or abandon
# <root>: project root (default: $PWD)
set -uo pipefail
TIMEOUT=3600
DRY_RUN=0
ABANDON=0
ROOT="$PWD"
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
--abandon) ABANDON=1; shift ;;
--timeout) TIMEOUT="$2"; shift 2 ;;
*) ROOT="$1"; shift ;;
esac
done
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ABANDON_SCRIPT="$SCRIPT_DIR/subagent-abandon.sh"
SUBA_DIR="$ROOT/.subagents"
WT_DIR="$ROOT/.worktrees"
NOW=$(date +%s)
CLEANED=0
# Phase 1: scan .subagents/ for stale lock files
if [ -d "$SUBA_DIR" ]; then
for task_dir in "$SUBA_DIR"/*/; do
[ -d "$task_dir" ] || continue
lock="$task_dir/.lock"
[ -f "$lock" ] || continue
task_id="$(basename "$task_dir")"
read -r LOCK_LINE < "$lock" 2>/dev/null || continue
LOCK_PID="${LOCK_LINE#PID=}"
LOCK_PID="${LOCK_PID%%[!0-9]*}"
PID_DEAD=0
if [ -z "$LOCK_PID" ] || ! kill -0 "$LOCK_PID" 2>/dev/null; then
PID_DEAD=1
fi
LOCK_MTIME=$(stat -f "%m" "$lock" 2>/dev/null || echo "0")
STALE=0
if [ "$PID_DEAD" -eq 1 ]; then
STALE=1
REASON="PID $LOCK_PID is dead"
elif [ "$((NOW - LOCK_MTIME))" -gt "$TIMEOUT" ]; then
STALE=1
REASON="lock mtime exceeds ${TIMEOUT}s — hung"
fi
if [ "$STALE" -eq 1 ]; then
if [ "$DRY_RUN" -eq 1 ]; then
echo "[cleanup-stale] would clean $task_id ($REASON)"
elif [ "$ABANDON" -eq 1 ] && [ -x "$ABANDON_SCRIPT" ]; then
"$ABANDON_SCRIPT" --task-id "$task_id" --root "$ROOT"
echo "[cleanup-stale] abandoned $task_id ($REASON)"
else
kill "$LOCK_PID" 2>/dev/null || true
sleep 1
kill -0 "$LOCK_PID" 2>/dev/null && kill -9 "$LOCK_PID" 2>/dev/null || true
rm -rf "$task_dir"
echo "[cleanup-stale] cleaned $task_id ($REASON)"
fi
CLEANED=$((CLEANED + 1))
fi
done
fi
# Phase 2: scan .worktrees/ for orphaned symlinks
if [ -d "$WT_DIR" ]; then
for wt_link in "$WT_DIR"/*; do
[ -L "$wt_link" ] || continue
task_id="$(basename "$wt_link")"
target=$(readlink "$wt_link" 2>/dev/null || echo "")
ORPHAN=0
if [ ! -d "$target" ]; then
ORPHAN=1
REASON="worktree target does not exist: $target"
elif [ -f "$SUBA_DIR/$task_id/.lock" ]; then
read -r LOCK_LINE < "$SUBA_DIR/$task_id/.lock" 2>/dev/null || continue
LOCK_PID="${LOCK_LINE#PID=}"
LOCK_PID="${LOCK_PID%%[!0-9]*}"
if [ -z "$LOCK_PID" ] || ! kill -0 "$LOCK_PID" 2>/dev/null; then
ORPHAN=1
REASON="worktree PID $LOCK_PID is dead"
fi
fi
if [ "$ORPHAN" -eq 1 ]; then
if [ "$DRY_RUN" -eq 1 ]; then
echo "[cleanup-stale] would remove orphaned worktree $task_id ($REASON)"
elif [ "$ABANDON" -eq 1 ] && [ -x "$ABANDON_SCRIPT" ]; then
"$ABANDON_SCRIPT" --task-id "$task_id" --root "$ROOT"
echo "[cleanup-stale] abandoned orphaned worktree $task_id"
else
rm -f "$wt_link"
echo "[cleanup-stale] removed orphaned symlink $task_id ($REASON)"
fi
CLEANED=$((CLEANED + 1))
fi
done
fi
[ "$CLEANED" -eq 0 ] && echo "[cleanup-stale] no stale resources found"
exit 0#!/usr/bin/env bash
# dispatch.sh — internal engine: prepare and spawn a single subagent task.
#
# Called by run-plan.sh. NOT agent-facing.
#
# Creates .subagents/<task-id>/, writes prompt + start-subagent.sh, spawns
# it in the background, confirms .lock appeared, returns task metadata
# as JSON on stdout.
#
# Usage:
# dispatch.sh --root <project-root> --cwd <worktree-or-project-dir> \
# --kind <kind> --model <model> --agent <agent> \
# --prompt-file <path> [--target <path>] --task-id <id> \
# [--worktree <branch>] [--pr-title <title>]
#
# Note: --target is required for single-file-fix and headless-spike.
#
# Exit codes:
# 0 — task dispatched, .lock confirmed
# 1 — error (bad args, spawn failure)
#
# Environment:
# OPENCODE_SERVER_URL — if set, start-subagent.sh uses --attach mode
# OPENCODE_SERVER_PASSWORD — required when OPENCODE_SERVER_URL is set
set -euo pipefail
err() { printf 'dispatch: %s\n' "$*" >&2; exit 1; }
ROOT=""
CWD=""
KIND=""
MODEL=""
AGENT=""
PROMPT_FILE=""
TARGET=""
TASK_ID=""
WORKTREE_BRANCH=""
PR_TITLE=""
while [ "$#" -gt 0 ]; do
case "$1" in
--root) ROOT="$2"; shift 2 ;;
--cwd) CWD="$2"; shift 2 ;;
--kind) KIND="$2"; shift 2 ;;
--model) MODEL="$2"; shift 2 ;;
--agent) AGENT="$2"; shift 2 ;;
--prompt-file) PROMPT_FILE="$2"; shift 2 ;;
--target) TARGET="$2"; shift 2 ;;
--task-id) TASK_ID="$2"; shift 2 ;;
--worktree) WORKTREE_BRANCH="$2"; shift 2 ;;
*) err "unknown flag: $1" ;;
esac
done
[ -n "$ROOT" ] || err "--root is required"
[ -n "$CWD" ] || err "--cwd is required"
[ -n "$KIND" ] || err "--kind is required"
[ -n "$MODEL" ] || err "--model is required"
: "${AGENT:=default}"
[ "$AGENT" != "-" ] || AGENT="default"
[ -n "$PROMPT_FILE" ] || err "--prompt-file is required"
if [ "$KIND" != "multi-file-fix" ]; then
[ -n "$TARGET" ] || err "--target is required"
fi
# Validate worktree branch if provided
if [ -n "$WORKTREE_BRANCH" ]; then
case "$WORKTREE_BRANCH" in
*[!A-Za-z0-9_./-]*|"") err "unsafe worktree branch: '$WORKTREE_BRANCH'" ;;
esac
fi
[ -n "$TASK_ID" ] || err "--task-id is required"
[ -d "$ROOT" ] || err "root does not exist: $ROOT"
[ -d "$CWD" ] || err "cwd does not exist: $CWD"
[ -f "$PROMPT_FILE" ] || err "prompt-file does not exist: $PROMPT_FILE"
case "$TASK_ID" in
*[!A-Za-z0-9_.-]*|"") err "unsafe task-id: '$TASK_ID'" ;;
esac
ROOT="$(cd "$ROOT" && pwd)"
CWD="$(cd "$CWD" && pwd)"
# Verify CWD
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
bash "$SKILL_DIR/scripts/verify-cwd.sh" "$CWD" >/dev/null || err "cwd verification failed"
# Allocate task directory
SUBAGENTS_DIR="$ROOT/.subagents"
TASK_DIR="$SUBAGENTS_DIR/$TASK_ID"
mkdir -p "$TASK_DIR"
# Copy prompt (guard against source == destination)
if [ "$(cd "$(dirname "$PROMPT_FILE")" && pwd)/$(basename "$PROMPT_FILE")" = "$TASK_DIR/prompt.md" ]; then
err "prompt file $PROMPT_FILE resolves to the same path as $TASK_DIR/prompt.md — place prompts outside .subagents/"
fi
cp "$PROMPT_FILE" "$TASK_DIR/prompt.md"
# Prepare worktree if declared
WORKTREE_DIR=""
if [ -n "$WORKTREE_BRANCH" ]; then
WORKTREE_DIR="$TASK_DIR/worktree"
# Check for an existing worktree on this branch first
EXISTING_WT=$(git worktree list 2>/dev/null | awk -v b="$WORKTREE_BRANCH" '{ gsub(/[\[\]]/, "", $3); if ($3 == b || $3 == "refs/heads/"b) { print $1; exit } }' || true)
if [ -n "$EXISTING_WT" ] && [ -d "$EXISTING_WT" ]; then
# Use existing worktree — don't create a new one
WORKTREE_DIR="$EXISTING_WT"
else
# Check for existing worktree symlink to prevent duplicate dispatch
[ -L "$ROOT/.worktrees/$TASK_ID" ] && err "worktree already exists for task=$TASK_ID"
git worktree add -b "$WORKTREE_BRANCH" "$WORKTREE_DIR" HEAD >/dev/null 2>&1 \
|| err "worktree creation failed for task=$TASK_ID branch=$WORKTREE_BRANCH"
mkdir -p "$ROOT/.worktrees"
ln -sf "$WORKTREE_DIR" "$ROOT/.worktrees/$TASK_ID"
fi
# CWD becomes the worktree
CWD="$WORKTREE_DIR"
fi
# Determine template and render
TEMPLATES_DIR="$SKILL_DIR/templates/cli"
TS=$(date -u +%Y%m%dT%H%M%SZ)
case "$KIND" in
single-file-fix)
TPL="$TEMPLATES_DIR/single-file-fix.sh.j2"
[ -f "$TPL" ] || err "no template for kind=$KIND: $TPL"
export TPL TASK_ID TS CWD TASK_DIR MODEL AGENT TARGET
python3 << 'PYEOF' 2>"$TASK_DIR/template-render-errors.log" || err "template rendering failed (see template-render-errors.log)"
import os, shlex, sys
tpl_path = os.environ['TPL']
task_id = os.environ['TASK_ID']
ts = os.environ['TS']
cwd = os.environ['CWD']
task_dir = os.environ['TASK_DIR']
model = os.environ['MODEL']
agent = os.environ['AGENT']
target = os.environ['TARGET']
with open(tpl_path) as f:
tpl = f.read()
vars = {
'task_id': shlex.quote(task_id),
'generated_at': ts,
'cwd': shlex.quote(cwd),
'task_dir': shlex.quote(task_dir),
'model': shlex.quote(model),
'agent': shlex.quote(agent),
'target_file': shlex.quote(target),
}
for k, v in vars.items():
tpl = tpl.replace('{{ ' + k + ' | shellquote }}', v)
tpl = tpl.replace('{{ ' + k + ' }}', v)
with open(os.path.join(task_dir, 'start-subagent.sh'), 'w') as f:
f.write(tpl)
PYEOF
;;
headless-spike)
TPL="$TEMPLATES_DIR/headless-spike.sh.j2"
[ -f "$TPL" ] || err "no template for kind=$KIND: $TPL"
export TPL TASK_ID TS CWD TASK_DIR MODEL AGENT TARGET
python3 << 'PYEOF' 2>"$TASK_DIR/template-render-errors.log" || err "template rendering failed (see template-render-errors.log)"
import os, shlex, sys
tpl_path = os.environ['TPL']
task_id = os.environ['TASK_ID']
ts = os.environ['TS']
cwd = os.environ['CWD']
task_dir = os.environ['TASK_DIR']
model = os.environ['MODEL']
agent = os.environ['AGENT']
target = os.environ['TARGET']
with open(tpl_path) as f:
tpl = f.read()
vars = {
'task_id': shlex.quote(task_id),
'generated_at': ts,
'cwd': shlex.quote(cwd),
'task_dir': shlex.quote(task_dir),
'model': shlex.quote(model),
'agent': shlex.quote(agent),
'report_path': shlex.quote(target),
}
for k, v in vars.items():
tpl = tpl.replace('{{ ' + k + ' | shellquote }}', v)
tpl = tpl.replace('{{ ' + k + ' }}', v)
with open(os.path.join(task_dir, 'start-subagent.sh'), 'w') as f:
f.write(tpl)
PYEOF
;;
multi-file-fix)
TPL="$TEMPLATES_DIR/multi-file-fix.sh.j2"
[ -f "$TPL" ] || err "no template for kind=$KIND: $TPL"
export TPL TASK_ID TS CWD TASK_DIR MODEL AGENT
python3 << 'PYEOF' 2>"$TASK_DIR/template-render-errors.log" || err "template rendering failed (see template-render-errors.log)"
import os, shlex, sys
tpl_path = os.environ['TPL']
task_id = os.environ['TASK_ID']
ts = os.environ['TS']
cwd = os.environ['CWD']
task_dir = os.environ['TASK_DIR']
model = os.environ['MODEL']
agent = os.environ['AGENT']
with open(tpl_path) as f:
tpl = f.read()
vars = {
'task_id': shlex.quote(task_id),
'generated_at': ts,
'cwd': shlex.quote(cwd),
'task_dir': shlex.quote(task_dir),
'model': shlex.quote(model),
'agent': shlex.quote(agent),
}
for k, v in vars.items():
tpl = tpl.replace('{{ ' + k + ' | shellquote }}', v)
tpl = tpl.replace('{{ ' + k + ' }}', v)
with open(os.path.join(task_dir, 'start-subagent.sh'), 'w') as f:
f.write(tpl)
PYEOF
;;
*) err "unknown kind: $KIND (single-file-fix | multi-file-fix | headless-spike)" ;;
esac
chmod +x "$TASK_DIR/start-subagent.sh"
# Spawn — redirect all subagent output to log files, not parent stdout
bash "$TASK_DIR/start-subagent.sh" >"$TASK_DIR/dispatch-stdout.log" 2>"$TASK_DIR/dispatch-stderr.log" &
PID=$!
# Wait for .lock to appear (spawn confirmation)
for i in $(seq 1 15); do
[ -f "$TASK_DIR/.lock" ] && break
sleep 0.5
done
if [ ! -f "$TASK_DIR/.lock" ]; then
# Spawn failed — clean up
kill "$PID" 2>/dev/null || true
err "subagent failed to write .lock within 7.5s — check stderr.log"
fi
# Return task metadata as JSON
LOCKFILE="$TASK_DIR/.lock"
WT_JSON="null"
if [ -n "$WORKTREE_BRANCH" ] && [ -L "$ROOT/.worktrees/$TASK_ID" ]; then
WT_JSON="\"$ROOT/.worktrees/$TASK_ID\""
fi
printf '{"id":"%s","lockfile":"%s","task_dir":"%s","pid":%d,"worktree":%s,"status":"dispatched"}\n' \
"$TASK_ID" "$LOCKFILE" "$TASK_DIR" "$PID" "$WT_JSON"#!/usr/bin/env bash
# poll-subagent.sh — monitor a subagent task until completion, stuck, or timeout.
#
# Polls the lockfile and events.jsonl for a dispatched subagent task.
# Logs progress (events line count) each iteration. Exits when the
# lockfile disappears (completed), events stall past the stale threshold
# (stuck), or the maximum poll count is reached (timeout).
#
# Usage:
# poll-subagent.sh --task-id <id> --root <project-root> \
# [--interval <sec>] [--max-polls <n>] [--stale-threshold <sec>]
#
# Exit codes:
# 0 — task completed (lockfile gone, FINAL_OUTPUT.md present)
# 2 — task stuck (events.jsonl stale past threshold)
# 3 — timeout (max polls reached, lockfile still present)
# 1 — error (bad args, task dir not found)
#
# Output (stderr):
# Progress lines: poll <i>/<max> task=<id> lines=<n> mtime=<epoch>
# Status lines: COMPLETED / STUCK / TIMEOUT
set -euo pipefail
err() { printf 'poll-subagent: %s\n' "$*" >&2; exit 1; }
TASK_ID=""
ROOT=""
INTERVAL=30
MAX_POLLS=20
STALE_THRESHOLD=60
while [ "$#" -gt 0 ]; do
case "$1" in
--task-id) TASK_ID="$2"; shift 2 ;;
--root) ROOT="$2"; shift 2 ;;
--interval) INTERVAL="$2"; shift 2 ;;
--max-polls) MAX_POLLS="$2"; shift 2 ;;
--stale-threshold) STALE_THRESHOLD="$2"; shift 2 ;;
*) err "unknown flag: $1" ;;
esac
done
[ -n "$TASK_ID" ] || err "--task-id is required"
[ -n "$ROOT" ] || err "--root is required"
case "$TASK_ID" in
*[!A-Za-z0-9_.-]*|"") err "unsafe task-id: '$TASK_ID'" ;;
esac
ROOT="$(cd "$ROOT" 2>/dev/null && pwd)" || err "root does not exist: $ROOT"
TASK_DIR="$ROOT/.subagents/$TASK_ID"
LOCKFILE="$TASK_DIR/.lock"
EVENTS="$TASK_DIR/events.jsonl"
[ -d "$TASK_DIR" ] || err "task dir does not exist: $TASK_DIR"
# Poll loop
PREV_LINES=""
STALL_EPOCH=0
for i in $(seq 1 "$MAX_POLLS"); do
# Check lockfile — gone means completed
if [ ! -f "$LOCKFILE" ]; then
printf 'poll %d/%d task=%s COMPLETED\n' "$i" "$MAX_POLLS" "$TASK_ID" >&2
exit 0
fi
# Count events lines for progress signal
LINES=0
if [ -f "$EVENTS" ]; then
LINES=$(wc -l < "$EVENTS" | tr -d ' ')
fi
# Get mtime of events.jsonl (epoch seconds)
MTIME=0
if [ -f "$EVENTS" ]; then
MTIME=$(stat -f %m "$EVENTS" 2>/dev/null || stat -c %Y "$EVENTS" 2>/dev/null || echo 0)
fi
NOW=$(date +%s)
printf 'poll %d/%d task=%s lines=%d mtime=%d\n' "$i" "$MAX_POLLS" "$TASK_ID" "$LINES" "$MTIME" >&2
# Stuck detection: line count unchanged and stale mtime
if [ "$LINES" = "$PREV_LINES" ] && [ "$PREV_LINES" != "" ]; then
if [ "$STALL_EPOCH" -eq 0 ]; then
STALL_EPOCH=$MTIME
fi
AGE=$(( NOW - STALL_EPOCH ))
if [ "$AGE" -gt "$STALE_THRESHOLD" ]; then
printf 'poll %d/%d task=%s STUCK: no progress for %ds\n' "$i" "$MAX_POLLS" "$TASK_ID" "$AGE" >&2
exit 2
fi
else
STALL_EPOCH=0
fi
PREV_LINES=$LINES
sleep "$INTERVAL"
done
# Max polls reached — timeout
printf 'poll %d/%d task=%s TIMEOUT: lockfile still present after %ds\n' \
"$MAX_POLLS" "$MAX_POLLS" "$TASK_ID" "$((MAX_POLLS * INTERVAL))" >&2
exit 3#!/usr/bin/env bash
# run-plan.sh — agent entry point: validate plan, prepare worktrees, dispatch
# tasks, return lockfile list.
#
# Reads a plan YAML, validates it, dispatches each task via dispatch.sh,
# and returns structured JSON on stdout. Exits immediately — does NOT
# poll or wait for tasks to complete.
#
# The agent parses the JSON output, extracts lockfile paths and PIDs,
# and monitors them on its own interval.
#
# Usage:
# run-plan.sh --plan <plan.yaml>
#
# Exit codes:
# 0 — plan processed (some tasks may have been skipped)
# 1 — error (bad plan, no valid tasks)
set -euo pipefail
err() { printf 'run-plan: %s\n' "$*" >&2; exit 1; }
log() { printf 'run-plan: %s\n' "$*" >&2; }
PLAN_FILE=""
while [ "$#" -gt 0 ]; do
case "$1" in
--plan) PLAN_FILE="$2"; shift 2 ;;
*) err "unknown flag: $1" ;;
esac
done
[ -n "$PLAN_FILE" ] || err "--plan is required"
[ -f "$PLAN_FILE" ] || err "plan file does not exist: $PLAN_FILE"
PLAN_DIR="$(cd "$(dirname "$PLAN_FILE")" && pwd)"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DISPATCH="$SCRIPT_DIR/dispatch.sh"
[ -x "$DISPATCH" ] || err "dispatch.sh not found or not executable: $DISPATCH"
# Parse and validate plan YAML, produce TSV for dispatch loop
TASKS_TSV=$(python3 -c "
import yaml, sys, os
with open('$PLAN_FILE') as f:
plan = yaml.safe_load(f)
tasks = plan.get('tasks', [])
if not tasks:
print('ERROR: plan has no tasks', file=sys.stderr)
sys.exit(1)
for t in tasks:
tid = t.get('id', '')
kind = t.get('kind', '')
model = t.get('model', '')
agent = t.get('agent', '')
prompt = t.get('prompt', '')
target = t.get('target', '')
worktree = t.get('worktree', '')
pr_title = t.get('pr_title', '')
if not tid:
print('task missing id', file=sys.stderr)
sys.exit(1)
if not kind:
print(f'task {tid} missing kind', file=sys.stderr)
sys.exit(1)
if not model:
print(f'task {tid} missing model', file=sys.stderr)
sys.exit(1)
if not prompt:
print(f'task {tid} missing prompt', file=sys.stderr)
sys.exit(1)
# Resolve prompt path relative to plan dir
if not os.path.isabs(prompt):
prompt = os.path.join('$PLAN_DIR', prompt)
agent = agent if agent else '-'
target = target if target else '-'
worktree = worktree if worktree else '-'
pr_title = pr_title if pr_title else '-'
print(f'{tid}\t{kind}\t{model}\t{agent}\t{prompt}\t{target}\t{worktree}\t{pr_title}')
" 2>/dev/null) || err "plan parsing failed — check YAML syntax and required fields (id, kind, model, prompt)"
# Allocate plan directory for tracking
PLAN_TS=$(date -u +%Y%m%dT%H%M%SZ)
PLAN_ID="plan-${PLAN_TS}"
PLAN_DIR_OUT="$PLAN_DIR/.subagents/$PLAN_ID"
mkdir -p "$PLAN_DIR_OUT"
TASK_COUNT=$(echo "$TASKS_TSV" | wc -l | tr -d ' ')
log "plan_id=$PLAN_ID tasks=$TASK_COUNT"
# Dispatch each task, collect results
RESULTS_JSON="["
FIRST=1
DISPATCHED=0
SKIPPED=0
while IFS=$'\t' read -r TID TKIND TMODEL TAGENT TPROMPT TTARGET TWORKTREE TPR_TITLE; do
# Resolve root: use plan dir as project root
ROOT="$PLAN_DIR"
# Build dispatch args
DISPATCH_ARGS=(
--root "$ROOT"
--cwd "$ROOT"
--kind "$TKIND"
--model "$TMODEL"
--agent "$TAGENT"
--prompt-file "$TPROMPT"
--target "$TTARGET"
--task-id "$TID"
)
[ -n "$TWORKTREE" ] && [ "$TWORKTREE" != "-" ] && DISPATCH_ARGS+=(--worktree "$TWORKTREE")
[ -n "$TPR_TITLE" ] && [ "$TPR_TITLE" != "-" ] && log "ignoring pr_title for task=$TID (pr-work kind removed)"
# Call dispatch.sh — captures JSON output on stdout
DISPATCH_OUT=$("$DISPATCH" "${DISPATCH_ARGS[@]}" 2>"$PLAN_DIR_OUT/$TID-dispatch-stderr.log") || {
# Dispatch failed — record as skipped
ERRMSG=$(cat "$PLAN_DIR_OUT/$TID-dispatch-stderr.log" 2>/dev/null | tail -1 | sed 's/.*: //' || echo "dispatch failed")
[ "$FIRST" -eq 1 ] && FIRST=0 || RESULTS_JSON+=","
RESULTS_JSON+="{\"id\":\"$TID\",\"status\":\"skipped\",\"reason\":\"$ERRMSG\"}"
SKIPPED=$((SKIPPED + 1))
log "skipped task=$TID: $ERRMSG"
continue
}
# Append dispatch result
[ "$FIRST" -eq 1 ] && FIRST=0 || RESULTS_JSON+=","
RESULTS_JSON+="$DISPATCH_OUT"
DISPATCHED=$((DISPATCHED + 1))
log "dispatched task=$TID"
done < <(echo "$TASKS_TSV")
RESULTS_JSON+="]"
# Write plan output
printf '{"plan_id":"%s","tasks":%s}\n' "$PLAN_ID" "$RESULTS_JSON"
log "done — dispatched=$DISPATCHED skipped=$SKIPPED"
[ "$DISPATCHED" -gt 0 ] || err "no tasks were successfully dispatched"#!/usr/bin/env bash
# subagent-abandon.sh — kill and force-remove a failed or abandoned task.
#
# Terminates the subagent process (TERM then KILL), removes .lock,
# force-removes the worktree and branch, and deletes the task directory.
#
# Usage:
# subagent-abandon.sh --task-id <id> --root <project-root>
#
# Exit codes:
# 0 — abandon complete
# 1 — error (task dir not found)
set -euo pipefail
err() { printf 'subagent-abandon: %s\n' "$*" >&2; exit 1; }
TASK_ID=""
ROOT=""
while [ "$#" -gt 0 ]; do
case "$1" in
--task-id) TASK_ID="$2"; shift 2 ;;
--root) ROOT="$2"; shift 2 ;;
*) err "unknown flag: $1" ;;
esac
done
[ -n "$TASK_ID" ] || err "--task-id is required"
[ -n "$ROOT" ] || err "--root is required"
case "$TASK_ID" in
*[!A-Za-z0-9_.-]*|"") err "unsafe task-id: '$TASK_ID'" ;;
esac
ROOT="$(cd "$ROOT" 2>/dev/null && pwd)" || err "root does not exist: $ROOT"
TASK_DIR="$ROOT/.subagents/$TASK_ID"
[ -d "$TASK_DIR" ] || err "task dir does not exist: $TASK_DIR"
# Kill the subagent process if still alive
LOCK_FILE="$TASK_DIR/.lock"
if [ -f "$LOCK_FILE" ]; then
read -r LOCK_LINE < "$LOCK_FILE" 2>/dev/null || true
LOCK_PID="${LOCK_LINE#PID=}"
LOCK_PID="${LOCK_PID%%[!0-9]*}"
if [ -n "$LOCK_PID" ] && kill -0 "$LOCK_PID" 2>/dev/null; then
kill "$LOCK_PID" 2>/dev/null || true
sleep 2
if kill -0 "$LOCK_PID" 2>/dev/null; then
kill -9 "$LOCK_PID" 2>/dev/null || true
sleep 1
fi
fi
rm -f "$LOCK_FILE"
fi
# Remove symlink in .worktrees/
[ -L "$ROOT/.worktrees/$TASK_ID" ] && rm -f "$ROOT/.worktrees/$TASK_ID"
# Force-remove git worktree and branch if present
WT_DIR="$TASK_DIR/worktree"
if [ -d "$WT_DIR" ]; then
BRANCH=$(git -C "$WT_DIR" branch --show-current 2>/dev/null || echo "")
git -C "$ROOT" worktree remove --force "$WT_DIR" 2>/dev/null || true
if [ -n "$BRANCH" ]; then
CURRENT_MAIN=$(git -C "$ROOT" branch --show-current 2>/dev/null || echo "")
if [ "$BRANCH" != "$CURRENT_MAIN" ]; then
git -C "$ROOT" branch -D "$BRANCH" 2>/dev/null || true
fi
fi
fi
# Remove task directory
rm -rf "$TASK_DIR"
printf 'subagent-abandon: removed task=%s branch=%s\n' "$TASK_ID" "${BRANCH:-none}"#!/usr/bin/env bash
# subagent-cleanup.sh — remove a completed task's artifacts and worktree.
#
# Called after the agent reads FINAL_OUTPUT.md and merges the work.
# Removes .lock, worktree symlink, git worktree, and task directory.
# Tries clean removal first; if the worktree has uncommitted changes,
# forces removal with a warning.
#
# Usage:
# subagent-cleanup.sh --task-id <id> --root <project-root>
#
# Exit codes:
# 0 — cleanup complete
# 1 — error
set -euo pipefail
err() { printf 'subagent-cleanup: %s\n' "$*" >&2; exit 1; }
TASK_ID=""
ROOT=""
while [ "$#" -gt 0 ]; do
case "$1" in
--task-id) TASK_ID="$2"; shift 2 ;;
--root) ROOT="$2"; shift 2 ;;
*) err "unknown flag: $1" ;;
esac
done
[ -n "$TASK_ID" ] || err "--task-id is required"
[ -n "$ROOT" ] || err "--root is required"
case "$TASK_ID" in
*[!A-Za-z0-9_.-]*|"") err "unsafe task-id: '$TASK_ID'" ;;
esac
ROOT="$(cd "$ROOT" 2>/dev/null && pwd)" || err "root does not exist: $ROOT"
TASK_DIR="$ROOT/.subagents/$TASK_ID"
[ -d "$TASK_DIR" ] || err "task dir does not exist: $TASK_DIR"
# Remove .lock if still present
rm -f "$TASK_DIR/.lock"
# Remove symlink in .worktrees/
[ -L "$ROOT/.worktrees/$TASK_ID" ] && rm -f "$ROOT/.worktrees/$TASK_ID"
# Remove git worktree if present
WT_DIR="$TASK_DIR/worktree"
if [ -d "$WT_DIR" ]; then
if git -C "$ROOT" worktree remove "$WT_DIR" 2>/dev/null; then
: # clean removal succeeded
else
printf 'subagent-cleanup: worktree has uncommitted changes, force-removing task=%s\n' "$TASK_ID" >&2
git -C "$ROOT" worktree remove --force "$WT_DIR" 2>/dev/null \
|| err "git worktree remove --force failed for task=$TASK_ID"
fi
fi
# Remove task directory
rm -rf "$TASK_DIR"
printf 'subagent-cleanup: removed task=%s\n' "$TASK_ID"#!/usr/bin/env bash
# validate-run.sh — post-run validation of a dispatch task directory.
# Exits 0 on healthy completion, non-zero with a diagnosis otherwise.
#
# Expects CLI mode events (opencode run --format json):
# step_start / step_finish — agent step boundaries
# text — assistant text deltas
# The completion signal is the last step_finish with part.reason == "stop".
#
# Also strips response blocks from stdout.log and events.jsonl
# (reasoning-model leakage).
#
# Usage: validate-run.sh <task-dir>
# Requires: jq, python3.
set -euo pipefail
err() { printf 'validate-run: %s\n' "$*" >&2; exit 1; }
warn() { printf 'validate-run: warn %s\n' "$*" >&2; }
[ "$#" -ge 1 ] || err "missing task-dir"
TASK_DIR="$1"
case "$TASK_DIR" in
/*) ;;
*) err "task-dir must be absolute: $TASK_DIR" ;;
esac
case "$TASK_DIR" in
*[!A-Za-z0-9_./:-]*) err "task-dir contains unsafe characters: $TASK_DIR" ;;
esac
[ -d "$TASK_DIR" ] || err "no such task-dir: $TASK_DIR"
command -v jq >/dev/null 2>&1 || err "jq is required but not installed"
command -v python3 >/dev/null 2>&1 || err "python3 is required but not installed"
EVENTS="$TASK_DIR/events.jsonl"
STDOUT="$TASK_DIR/stdout.log"
[ -s "$EVENTS" ] || err "events.jsonl missing or empty — likely silent stall"
# CLI mode: opencode run --format json emits step_start / step_finish.
# The completion signal is the last step_finish with part.reason == "stop".
LAST_REASON=$(jq -r 'select(.type=="step_finish") | .part.reason // empty' "$EVENTS" | tail -1)
case "$LAST_REASON" in
stop) ;;
"") err "no step_finish event in stream — likely silent stall" ;;
tool-calls) err "stream ended on a tool-call step (no final stop)" ;;
*) warn "stream ended with step_finish.reason='$LAST_REASON' (not stop)" ;;
esac
if jq -e 'select(.type=="error" or .type=="session.error")' "$EVENTS" >/dev/null 2>&1; then
warn "error event(s) present — see $EVENTS"
fi
# Strip thinking… response blocks from captured logs.
strip_think() {
local target="$1"
[ -f "$target" ] || return 0
grep -q ' response' "$target" || return 0
warn "stripping response blocks from $target"
python3 - "$target" <<'PY'
import os, re, sys, tempfile
p = sys.argv[1]
if os.path.islink(p):
sys.exit(f"refusing to rewrite symlink: {p}")
fd = os.open(p, os.O_RDONLY | os.O_NOFOLLOW)
with os.fdopen(fd, "r", encoding="utf-8", errors="replace") as fh:
s = fh.read()
s = re.sub(r" <thinking>.*?</thinking>", "", s, flags=re.DOTALL)
s = re.sub(r" thinking.*? response\s*", "", s, flags=re.DOTALL)
s = s.replace(" response", "")
d = os.path.dirname(p) or "."
with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False, dir=d) as tf:
tf.write(s)
tmp = tf.name
os.replace(tmp, p)
PY
}
strip_think "$STDOUT"
strip_think "$EVENTS"
printf 'validate-run: ok task-dir=%s\n' "$TASK_DIR"
#!/usr/bin/env bash
# verify-cwd.sh — fail-closed CWD verification for dispatch-opencode.
# Exits 0 only if PATH_ARG is an absolute, existing git work tree that
# matches the expected branch and/or worktree label, and (when worktree
# verification is requested) is rooted under the configured worktree-root.
#
# Usage:
# verify-cwd.sh <absolute-path> \
# [--branch <name>] \
# [--worktree <label> --worktree-root <absolute-root>]
set -euo pipefail
err() { printf 'verify-cwd: %s\n' "$*" >&2; exit 1; }
[ "$#" -ge 1 ] || err "missing path argument"
PATH_ARG="$1"; shift
EXPECT_BRANCH=""
EXPECT_WORKTREE=""
EXPECT_WORKTREE_ROOT=""
while [ "$#" -gt 0 ]; do
case "$1" in
--branch) EXPECT_BRANCH="$2"; shift 2 ;;
--worktree) EXPECT_WORKTREE="$2"; shift 2 ;;
--worktree-root) EXPECT_WORKTREE_ROOT="$2"; shift 2 ;;
*) err "unknown flag: $1" ;;
esac
done
# Strip a trailing slash so the suffix check below is deterministic.
PATH_ARG="${PATH_ARG%/}"
case "$PATH_ARG" in
/*) ;;
*) err "path must be absolute: $PATH_ARG" ;;
esac
[ -d "$PATH_ARG" ] || err "path does not exist or is not a directory: $PATH_ARG"
cd "$PATH_ARG"
git rev-parse --is-inside-work-tree >/dev/null 2>&1 \
|| err "not a git work tree: $PATH_ARG"
ACTUAL_BRANCH="$(git branch --show-current)"
[ -n "$ACTUAL_BRANCH" ] || ACTUAL_BRANCH="(detached)"
if [ -n "$EXPECT_BRANCH" ] && [ "$ACTUAL_BRANCH" != "$EXPECT_BRANCH" ]; then
err "branch mismatch: expected '$EXPECT_BRANCH', got '$ACTUAL_BRANCH'"
fi
if [ -n "$EXPECT_WORKTREE" ]; then
# Reject labels containing slashes, glob characters, or anything outside
# a safe identifier set. Without this, a label of '*' would match every
# path and a label of '..' would let the suffix check accept paths
# outside the intended worktree.
case "$EXPECT_WORKTREE" in
*[!A-Za-z0-9_./-]*|"") err "unsafe worktree label: '$EXPECT_WORKTREE'" ;;
esac
# Worktree mode requires an explicit absolute root so the suffix check
# is anchored. Without the root, '/tmp/attacker/<label>' would pass a
# suffix-only match even though it sits outside the project's
# worktree tree.
[ -n "$EXPECT_WORKTREE_ROOT" ] \
|| err "--worktree requires --worktree-root"
EXPECT_WORKTREE_ROOT="${EXPECT_WORKTREE_ROOT%/}"
case "$EXPECT_WORKTREE_ROOT" in
/*) ;;
*) err "worktree-root must be absolute: $EXPECT_WORKTREE_ROOT" ;;
esac
case "$PATH_ARG" in
"$EXPECT_WORKTREE_ROOT"/*) ;;
*) err "path is not under worktree-root '$EXPECT_WORKTREE_ROOT': $PATH_ARG" ;;
esac
case "$PATH_ARG" in
*"/$EXPECT_WORKTREE") ;;
*) err "worktree label mismatch: expected suffix '/$EXPECT_WORKTREE', got '$PATH_ARG'" ;;
esac
fi
printf 'verify-cwd: ok path=%s branch=%s\n' "$PATH_ARG" "$ACTUAL_BRANCH"
#!/usr/bin/env bash
# Rendered dispatch script — headless-spike
#
# Read-only investigation. Writes .lock on start, FINAL_OUTPUT.md on
# completion, deletes .lock on exit.
set -uo pipefail
TASK_DIR={{ task_dir | shellquote }}
TASK_ID={{ task_id | shellquote }}
GENERATED_AT={{ generated_at | shellquote }}
CWD={{ cwd | shellquote }}
MODEL={{ model | shellquote }}
AGENT={{ agent | shellquote }}
REPORT_PATH={{ report_path | shellquote }}
echo "PID=$$" > "$TASK_DIR/.lock"
export OPENCODE_DISABLE_AUTOCOMPACT=true
export OPENCODE_DISABLE_AUTOUPDATE=true
unset OPENCODE_SERVER_PASSWORD
echo "[dispatch-opencode] task_id=$TASK_ID generated=$GENERATED_AT"
echo "[dispatch-opencode] cwd=$CWD"
echo "[dispatch-opencode] model=$MODEL agent=$AGENT"
echo "[dispatch-opencode] report=$REPORT_PATH"
TIMEOUT_BIN="$(command -v gtimeout 2>/dev/null || command -v timeout 2>/dev/null || true)"
if [ -n "$TIMEOUT_BIN" ]; then
TIMEOUT_PREFIX=("$TIMEOUT_BIN" "600")
else
echo "[dispatch-opencode] warn: no timeout(1)/gtimeout on PATH; running without timeout" >&2
TIMEOUT_PREFIX=()
fi
"${TIMEOUT_PREFIX[@]+"${TIMEOUT_PREFIX[@]}"}" opencode run \
--dir "$CWD" \
--model "$MODEL" \
--agent "$AGENT" \
--format json \
--dangerously-skip-permissions \
--file "$REPORT_PATH" \
< "$TASK_DIR/prompt.md" \
2> "$TASK_DIR/stderr.log" \
| tee "$TASK_DIR/events.jsonl" \
> "$TASK_DIR/stdout.log"
EXIT=$?
SESSION_ID=$(head -1 "$TASK_DIR/events.jsonl" 2>/dev/null | sed 's/.*"sessionID":"\([^"]*\)".*/\1/' || echo "")
cat > "$TASK_DIR/FINAL_OUTPUT.md" <<OUT
# dispatch-opencode result
task_id: $TASK_ID
generated: $GENERATED_AT
exit_code: $EXIT
session_id: ${SESSION_ID:-unknown}
model: $MODEL
agent: $AGENT
report: $REPORT_PATH
OUT
echo "" >> "$TASK_DIR/FINAL_OUTPUT.md"
echo "## Output" >> "$TASK_DIR/FINAL_OUTPUT.md"
python3 -c "
import json, sys
with open('$TASK_DIR/events.jsonl') as f:
for line in f:
try:
ev = json.loads(line)
if ev.get('type') == 'text' and ev.get('part', {}).get('type') == 'text':
print(ev['part']['text'])
except: pass
" 2>/dev/null >> "$TASK_DIR/FINAL_OUTPUT.md" || true
rm -f "$TASK_DIR/.lock"
echo "[dispatch-opencode] exit=$EXIT"
exit "$EXIT"#!/usr/bin/env bash
# Rendered dispatch script — multi-file-fix
#
# Works on the entire CWD. Writes .lock on start, FINAL_OUTPUT.md on
# completion, deletes .lock on exit.
set -euo pipefail
TASK_DIR={{ task_dir | shellquote }}
TASK_ID={{ task_id | shellquote }}
GENERATED_AT={{ generated_at | shellquote }}
CWD={{ cwd | shellquote }}
MODEL={{ model | shellquote }}
AGENT={{ agent | shellquote }}
# Write lock file with PID for stall detection
echo "PID=$$" > "$TASK_DIR/.lock"
if [ -n "${OPENCODE_SERVER_URL:-}" ]; then
ATTACH_ARGS=(--attach "$OPENCODE_SERVER_URL" --password "$OPENCODE_SERVER_PASSWORD")
else
unset OPENCODE_SERVER_PASSWORD
ATTACH_ARGS=(--dir "$CWD")
fi
export OPENCODE_DISABLE_AUTOCOMPACT=true
export OPENCODE_DISABLE_AUTOUPDATE=true
echo "[dispatch-opencode] task_id=$TASK_ID generated=$GENERATED_AT"
echo "[dispatch-opencode] cwd=$CWD"
echo "[dispatch-opencode] model=$MODEL agent=$AGENT"
TIMEOUT_BIN="$(command -v gtimeout 2>/dev/null || command -v timeout 2>/dev/null || true)"
if [ -n "$TIMEOUT_BIN" ]; then
TIMEOUT_PREFIX=("$TIMEOUT_BIN" "600")
else
echo "[dispatch-opencode] warn: no timeout(1)/gtimeout on PATH; running without timeout" >&2
TIMEOUT_PREFIX=()
fi
"${TIMEOUT_PREFIX[@]+"${TIMEOUT_PREFIX[@]}"}" opencode run \
"${ATTACH_ARGS[@]}" \
--model "$MODEL" \
--agent "$AGENT" \
--format json \
--dangerously-skip-permissions \
< "$TASK_DIR/prompt.md" \
2> "$TASK_DIR/stderr.log" \
| tee "$TASK_DIR/events.jsonl" \
> "$TASK_DIR/stdout.log"
EXIT=${PIPESTATUS[0]}
SESSION_ID=$(head -1 "$TASK_DIR/events.jsonl" 2>/dev/null | sed 's/.*"sessionID":"\([^"]*\)".*/\1/' || echo "")
cat > "$TASK_DIR/FINAL_OUTPUT.md" <<OUT
# dispatch-opencode result
task_id: $TASK_ID
generated: $GENERATED_AT
exit_code: $EXIT
session_id: ${SESSION_ID:-unknown}
model: $MODEL
agent: $AGENT
OUT
echo "" >> "$TASK_DIR/FINAL_OUTPUT.md"
echo "## Output" >> "$TASK_DIR/FINAL_OUTPUT.md"
if [ -s "$TASK_DIR/events.jsonl" ]; then
python3 -c "
import json, sys
with open('$TASK_DIR/events.jsonl') as f:
for line in f:
try:
ev = json.loads(line)
if ev.get('type') == 'text' and ev.get('part', {}).get('type') == 'text':
print(ev['part']['text'])
except: pass
" 2>/dev/null >> "$TASK_DIR/FINAL_OUTPUT.md" || true
fi
rm -f "$TASK_DIR/.lock"
echo "[dispatch-opencode] exit=$EXIT"
exit "$EXIT"
#!/usr/bin/env bash
# Rendered dispatch script — single-file-fix
#
# Writes .lock on start, FINAL_OUTPUT.md on completion, deletes .lock on exit.
set -euo pipefail
TASK_DIR={{ task_dir | shellquote }}
TASK_ID={{ task_id | shellquote }}
GENERATED_AT={{ generated_at | shellquote }}
CWD={{ cwd | shellquote }}
MODEL={{ model | shellquote }}
AGENT={{ agent | shellquote }}
TARGET_FILE={{ target_file | shellquote }}
# Write lock file with PID for stall detection
echo "PID=$$" > "$TASK_DIR/.lock"
# When a central server URL is available, use --attach (SPIKE-001). The
# child is an HTTP client, never spawning its own in-process server —
# avoids the session-in-session env-var leak (issue #24747). When no server
# is available (e.g., Claude Code as parent), fall back to local --dir mode.
if [ -n "${OPENCODE_SERVER_URL:-}" ]; then
ATTACH_ARGS=(--attach "$OPENCODE_SERVER_URL" --password "$OPENCODE_SERVER_PASSWORD")
else
unset OPENCODE_SERVER_PASSWORD
ATTACH_ARGS=(--dir "$CWD")
fi
export OPENCODE_DISABLE_AUTOCOMPACT=true
export OPENCODE_DISABLE_AUTOUPDATE=true
echo "[dispatch-opencode] task_id=$TASK_ID generated=$GENERATED_AT"
echo "[dispatch-opencode] cwd=$CWD"
echo "[dispatch-opencode] model=$MODEL agent=$AGENT"
echo "[dispatch-opencode] target=$TARGET_FILE"
TIMEOUT_BIN="$(command -v gtimeout 2>/dev/null || command -v timeout 2>/dev/null || true)"
if [ -n "$TIMEOUT_BIN" ]; then
TIMEOUT_PREFIX=("$TIMEOUT_BIN" "600")
else
echo "[dispatch-opencode] warn: no timeout(1)/gtimeout on PATH; running without timeout" >&2
TIMEOUT_PREFIX=()
fi
"${TIMEOUT_PREFIX[@]+"${TIMEOUT_PREFIX[@]}"}" opencode run \
"${ATTACH_ARGS[@]}" \
--model "$MODEL" \
--agent "$AGENT" \
--format json \
--dangerously-skip-permissions \
--file "$TARGET_FILE" \
< "$TASK_DIR/prompt.md" \
2> "$TASK_DIR/stderr.log" \
| tee "$TASK_DIR/events.jsonl" \
> "$TASK_DIR/stdout.log"
EXIT=${PIPESTATUS[0]}
SESSION_ID=$(head -1 "$TASK_DIR/events.jsonl" 2>/dev/null | sed 's/.*"sessionID":"\([^"]*\)".*/\1/' || echo "")
cat > "$TASK_DIR/FINAL_OUTPUT.md" <<OUT
# dispatch-opencode result
task_id: $TASK_ID
generated: $GENERATED_AT
exit_code: $EXIT
session_id: ${SESSION_ID:-unknown}
model: $MODEL
agent: $AGENT
target: $TARGET_FILE
OUT
echo "" >> "$TASK_DIR/FINAL_OUTPUT.md"
echo "## Output" >> "$TASK_DIR/FINAL_OUTPUT.md"
if [ -s "$TASK_DIR/events.jsonl" ]; then
python3 -c "
import json, sys
with open('$TASK_DIR/events.jsonl') as f:
for line in f:
try:
ev = json.loads(line)
if ev.get('type') == 'text' and ev.get('part', {}).get('type') == 'text':
print(ev['part']['text'])
except: pass
" 2>/dev/null >> "$TASK_DIR/FINAL_OUTPUT.md" || true
fi
rm -f "$TASK_DIR/.lock"
echo "[dispatch-opencode] exit=$EXIT"
exit "$EXIT"
Templates
Per-kind dispatch templates. Only files in this directory are referenced by the skill; empty dirs are not tracked by git.
cli/
Shell templates rendered to .subagents/<task-id>/start-subagent.sh. Each file uses Jinja2 variables from the skill's render step. One .sh.j2 per dispatch kind:
| File | Kind | Use |
|---|---|---|
single-file-fix.sh.j2 | single-file-fix | Focused edit on one target file. |
multi-file-fix.sh.j2 | multi-file-fix | Full-directory fix/refactor, no single-file target. |
headless-spike.sh.j2 | headless-spike | Read-only investigation, writes report file. |
pr-work.sh.j2 | pr-work | Create draft PR, dispatch agent into worktree with PR as chronicle. |
Adding a kind
1. Write <kind>.sh.j2 in cli/. 2. Add to the table above. 3. Add the kind to SKILL.md's dispatch kinds table. 4. Add an invocation example to references/examples.md.
#!/usr/bin/env bash
# test_agent_sashay_invocation.sh — agent integration test for dispatch-opencode
# invocability in a sashay context.
#
# Spawns a real opencode session as the "calling agent" continuing an existing
# sashay: branch, worktree, and remote PR already exist. Checks whether the
# agent correctly discovers and invokes dispatch-opencode and the subagent
# produces results in the worktree.
#
# Checklist (invocation criteria only):
# C1: Agent read SKILL.md (referenced it in session output)
# C2: Agent invoked the skill (plan YAML written or dispatch.sh called)
# C3: Agent used worktree dispatch pattern (start-subagent.sh in task dir)
# C4: Subagent CWD points to the sashay worktree
# C5: prompt.md in task dir
#
# Usage:
# bash tests/test_agent_sashay_invocation.sh # single run
# bash tests/test_agent_sashay_invocation.sh -n 5 # 5 runs, measure compliance
# bash tests/test_agent_sashay_invocation.sh --keep # keep temp dirs on failure
#
# Requires: opencode, git, python3, PyYAML
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
ABANDON="$SKILL_DIR/scripts/subagent-abandon.sh"
SASHAY_BRANCH="fix-add-sashay"
SASHAY_PR_URL="https://github.com/test/repo/pull/42"
N=1; KEEP=0
while [ $# -gt 0 ]; do
case "$1" in
-n) shift; N="${1:-1}"; shift ;;
--keep) KEEP=1; shift ;;
*) shift ;;
esac
done
PASS=0; FAIL=0; CHECKS=0; RUNS=0
C1P=0; C1F=0; C2P=0; C2F=0; C3P=0; C3F=0; C4P=0; C4F=0; C5P=0; C5F=0
check() { local l="$1" r="$2"; CHECKS=$((CHECKS+1)); if [ "$r" = "0" ]; then PASS=$((PASS+1)); eval "${l}P=$((${l}P+1))"; else FAIL=$((FAIL+1)); eval "${l}F=$((${l}F+1))"; fi; }
run_once() {
local run_num="$1" WORK
WORK=$(mktemp -d "/tmp/oc-sashay-agent.${run_num}.XXXXXX")
echo ""
echo "=== Run $run_num: $WORK ==="
# Git repo with remote
mkdir -p "$WORK/remote" "$WORK/shim" "$WORK/.opencode/skills"
cp -r "$SKILL_DIR" "$WORK/.opencode/skills/dispatch-opencode"
git init -q --bare "$WORK/remote"
cd "$WORK"
git init -q -b main
git config --local commit.gpgsign false
git config user.email "sashay-agent@test"
git config user.name "Sashay Agent Test"
git remote add origin "$WORK/remote"
mkdir -p src prompts
printf 'def add(a, b):\n return a - b\n' > src/foo.py
printf '.subagents/\n.worktrees/\n' >> .gitignore
printf '.opencode/\n' >> .gitignore
git add -A && git commit -q -m "fixture: initial commit"
git push -q origin main
# Sashay setup: existing branch + worktree + PR
git checkout -q -b "$SASHAY_BRANCH"
git push -q origin "$SASHAY_BRANCH"
git checkout -q main
git worktree add "$WORK/.worktrees/$SASHAY_BRANCH" "$SASHAY_BRANCH"
# gh shim
cat > "$WORK/shim/gh" <<'SHIM'
#!/usr/bin/env bash
echo "https://github.com/test/repo/pull/42"
SHIM
chmod +x "$WORK/shim/gh"
# Prompt: task-level, references sashay context
cat > "$WORK/calling-agent-prompt.md" <<PROMPT
Fix the bug in src/foo.py: the add() function returns a - b instead of a + b.
A sashay has been started for this fix:
- Branch: ${SASHAY_BRANCH} (pushed to remote)
- Draft PR: ${SASHAY_PR_URL}
- Worktree: .worktrees/${SASHAY_BRANCH}
Continue the sashay. The branch, worktree, and draft PR already exist.
Dispatch a subagent into the worktree to fix the bug.
Do NOT edit any source files yourself.
PROMPT
# Artifact watcher
SNAP="$WORK/.snapshot"; mkdir -p "$SNAP"
while true; do
for d in "$WORK"/.subagents/*/; do
[ -d "$d" ] || continue; tid=$(basename "$d")
[ "$tid" = "plan-"* ] && continue
[ -d "$SNAP/$tid" ] || mkdir -p "$SNAP/$tid"
cp "$d"start-subagent.sh "$SNAP/$tid/" 2>/dev/null || true
cp "$d"prompt.md "$SNAP/$tid/" 2>/dev/null || true
cp "$d"FINAL_OUTPUT.md "$SNAP/$tid/" 2>/dev/null || true
done
sleep 0.2
done &
WATCHER_PID=$!
echo " Dispatching calling agent (opencode run)..."
ATTACH_ARGS=()
if [ -n "${OPENCODE_SERVER_URL:-}" ]; then
ATTACH_ARGS=(--attach "$OPENCODE_SERVER_URL" --password "${OPENCODE_SERVER_PASSWORD:-}")
elif command -v lsof &>/dev/null && lsof -i :4096 -sTCP:LISTEN &>/dev/null; then
ATTACH_ARGS=(--attach http://localhost:4096)
fi
TIMEOUT_BIN="$(command -v gtimeout 2>/dev/null || command -v timeout 2>/dev/null || true)"
if [ -n "$TIMEOUT_BIN" ]; then
$TIMEOUT_BIN 180 opencode run \
--dir "$WORK" --model "ollama-cloud/deepseek-v4-flash:cloud" --agent build \
"${ATTACH_ARGS[@]+"${ATTACH_ARGS[@]}"}" --dangerously-skip-permissions \
< "$WORK/calling-agent-prompt.md" \
>> "$WORK/agent-stdout.log" 2>>"$WORK/agent-stderr.log" || true
else
opencode run \
--dir "$WORK" --model "ollama-cloud/deepseek-v4-flash:cloud" --agent build \
"${ATTACH_ARGS[@]+"${ATTACH_ARGS[@]}"}" --dangerously-skip-permissions \
< "$WORK/calling-agent-prompt.md" \
>> "$WORK/agent-stdout.log" 2>>"$WORK/agent-stderr.log" || true
fi
kill "$WATCHER_PID" 2>/dev/null || true; wait "$WATCHER_PID" 2>/dev/null || true
# Final snapshot pass
for d in "$WORK"/.subagents/*/; do
[ -d "$d" ] || continue; tid=$(basename "$d")
[ "$tid" = "plan-"* ] && continue
[ -d "$SNAP/$tid" ] || mkdir -p "$SNAP/$tid"
cp "$d"start-subagent.sh "$SNAP/$tid/" 2>/dev/null || true
cp "$d"prompt.md "$SNAP/$tid/" 2>/dev/null || true
cp "$d"FINAL_OUTPUT.md "$SNAP/$tid/" 2>/dev/null || true
done
echo " Agent session complete. Checking artifacts..."
local AGENT_LOG="$WORK/agent-stdout.log"
# C1: Agent discovered the skill — use llm to classify session transcript
local SKILL_REF=0
if [ -f "$AGENT_LOG" ] && [ -s "$AGENT_LOG" ] && [ "$(wc -l < "$AGENT_LOG")" -gt 3 ]; then
local LLM_OUT
LLM_OUT=$(llm -s "Answer only YES or NO. Did the agent discover and use the dispatch-opencode skill (read SKILL.md, called run-plan.sh/dispatch.sh, created start-subagent.sh/plan.yaml)?" < "$AGENT_LOG" 2>/dev/null || echo "NO")
echo "$LLM_OUT" | grep -qi "^YES" && SKILL_REF=1
fi
# Secondary: dispatch artifacts confirm skill was used even if llm misclassifies
if [ "$SKILL_REF" -eq 0 ]; then
for d in "$SNAP"/*/ "$WORK"/.subagents/*/; do
[ -d "$d" ] || continue
tid=$(basename "$d"); [ "$tid" = "plan-"* ] && continue
[ -f "$d/start-subagent.sh" ] && SKILL_REF=1 && break
done
fi
if [ "$SKILL_REF" -eq 1 ]; then
check C1 0; echo " C1 PASS: agent discovered dispatch-opencode"
else
check C1 1; echo " C1 FAIL: agent did not discover dispatch-opencode"
fi
# C2: Agent invoked the skill (plan YAML, or dispatch.sh, or run-plan.sh called)
local INVOKED=0
for pf in "$SNAP"/plan*.yaml "$WORK"/plan*.yaml; do
[ -f "$pf" ] && INVOKED=1 && break
done
for d in "$SNAP"/*/; do
[ -f "$d/start-subagent.sh" ] && INVOKED=1 && break
done
for d in "$WORK"/.subagents/*/; do
tid=$(basename "$d"); [ "$tid" = "plan-"* ] && continue
[ -f "$d/start-subagent.sh" ] && INVOKED=1 && break
done
if [ "$INVOKED" -eq 1 ]; then
check C2 0; echo " C2 PASS: agent invoked dispatch-opencode"
else
check C2 1; echo " C2 FAIL: no invocation artifacts found"
[ "$KEEP" -eq 0 ] && rm -rf "$WORK"; return
fi
# C3: Agent used worktree dispatch pattern (start-subagent.sh exists)
local START_SCRIPT="" TASK_ID=""
for d in "$SNAP"/*/; do
[ -d "$d" ] || continue; tid=$(basename "$d")
[ "$tid" = "plan-"* ] && continue
if [ -f "$d/start-subagent.sh" ]; then
START_SCRIPT="$d/start-subagent.sh"; TASK_ID="$tid"; break
fi
done
if [ -z "$START_SCRIPT" ]; then
for d in "$WORK"/.subagents/*/; do
[ -d "$d" ] || continue; tid=$(basename "$d")
[ "$tid" = "plan-"* ] && continue
if [ -f "$d/start-subagent.sh" ]; then
START_SCRIPT="$d/start-subagent.sh"; TASK_ID="$tid"; break
fi
done
fi
if [ -n "$START_SCRIPT" ]; then
check C3 0; echo " C3 PASS: start-subagent.sh in task dir (task $TASK_ID)"
else
check C3 1; echo " C3 FAIL: no start-subagent.sh found"
fi
# C4: Subagent CWD points to the sashay worktree
if [ -n "$START_SCRIPT" ] && grep -q "CWD=.*$SASHAY_BRANCH" "$START_SCRIPT" 2>/dev/null; then
check C4 0; echo " C4 PASS: subagent CWD points to worktree"
else
check C4 1; echo " C4 FAIL: subagent CWD not in worktree"
fi
# C5: prompt.md in task dir
local PM=""
[ -n "$TASK_ID" ] && [ -f "$SNAP/$TASK_ID/prompt.md" ] && PM="$SNAP/$TASK_ID/prompt.md"
[ -z "$PM" ] && [ -n "$TASK_ID" ] && [ -f "$WORK/.subagents/$TASK_ID/prompt.md" ] && PM="$WORK/.subagents/$TASK_ID/prompt.md"
if [ -n "$PM" ]; then
check C5 0; echo " C5 PASS: prompt.md in task dir"
else
check C5 1; echo " C5 FAIL: prompt.md not found"
fi
# Cleanup
if [ -n "$TASK_ID" ] && [ -d "$WORK/.subagents/$TASK_ID" ]; then
"$ABANDON" --task-id "$TASK_ID" --root "$WORK" 2>/dev/null || true
fi
[ "$KEEP" -eq 0 ] && rm -rf "$WORK" || echo " kept $WORK"
}
echo "============================================================"
echo " Agent Integration Test: Sashay Invocation Compliance"
echo " N=$N runs"
echo "============================================================"
for ((i=1; i<=N; i++)); do run_once "$i"; RUNS=$((RUNS+1)); done
echo ""
echo "============================================================"
echo " Compliance Summary: $N runs, $CHECKS checks"
echo "============================================================"
echo ""
printf " %-35s %4s %4s %5s%%\n" "Criterion" "Pass" "Fail" "Rate"
echo " --------------------------------------------------------"
for c in C1 C2 C3 C4 C5; do
p_var="${c}P"; f_var="${c}F"
p="${!p_var}"; f="${!f_var}"
total=$((p+f)); rate=0
[ "$total" -gt 0 ] && rate=$(( p * 100 / total ))
case "$c" in
C1) label="Skill discovered" ;; C2) label="Skill invoked" ;;
C3) label="Worktree dispatch" ;; C4) label="CWD in worktree" ;;
C5) label="prompt.md in task dir" ;;
esac
printf " %-35s %4d %4d %5d%%\n" "$label" "$p" "$f" "$rate"
done
echo ""
rate=0; [ "$CHECKS" -gt 0 ] && rate=$(( PASS * 100 / CHECKS ))
echo " Overall: $PASS/$CHECKS passed (${rate}% compliance)"
[ "$FAIL" -eq 0 ] && exit 0 || exit 1#!/usr/bin/env bash
# test_attach_session_visibility.sh — smoke test for --attach session visibility.
#
# Spawns a background opencode session via --attach, verifies the session
# appears on the serve daemon's /session listing, then checks it completes.
#
# Prerequisites:
# - opencode serve running on port 4096 (or $OPENCODE_SERVER_URL)
# - OPENCODE_SERVER_PASSWORD set in environment
#
# Usage: bash tests/test_attach_session_visibility.sh [--keep]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
KEEP=0; [ "${1:-}" = "--keep" ] && KEEP=1
SERVER_URL="${OPENCODE_SERVER_URL:-http://localhost:4096}"
SESSION_TITLE="PBJ-test-$$"
WORK=$(mktemp -d /tmp/oc-attach-smoke.XXXXXX)
LOG=$(mktemp)
trap '[ "$KEEP" -eq 1 ] && echo "kept $WORK, $LOG" || rm -rf "$WORK" "$LOG"' EXIT
cd "$WORK"
git init -q -b main
git config --local commit.gpgsign false
git config user.email "attach@test"
git config user.name "Attach Test"
mkdir -p bread peanut-butter jelly
echo "test: spawning background opencode run via --attach..."
# Spawn a background task with a distinct title — no env unset,
# the child passes --password explicitly as CLI argument.
opencode run \
--attach "$SERVER_URL" \
--password "$OPENCODE_SERVER_PASSWORD" \
--model "ollama-cloud/deepseek-v4-flash:cloud" \
--agent build \
--title "$SESSION_TITLE" \
--format json \
"Make a PB&J sandwich. Use bash to echo 'spreading peanut butter' and 'spreading jelly' and 'sandwich complete!' in order." \
2> "$WORK/stderr.log" > "$WORK/stdout.log" &
CHILD_PID=$!
# Poll for the session to appear on the server
echo "test: polling for session title='$SESSION_TITLE' on $SERVER_URL..."
FOUND=
for i in $(seq 1 30); do
FOUND=$(curl -s -u "opencode:$OPENCODE_SERVER_PASSWORD" \
"$SERVER_URL/session" 2>/dev/null \
| python3 -c "
import json, sys
try:
sessions = json.load(sys.stdin)
for s in sessions:
title = s.get('title', '')
if '$SESSION_TITLE' in title:
print(s['id'])
sys.exit(0)
except: pass
" 2>/dev/null || true)
if [ -n "$FOUND" ]; then
echo "test: session $FOUND visible on server after ${i}s"
break
fi
sleep 1
done
if [ -z "$FOUND" ]; then
echo "test: FAIL — session '$SESSION_TITLE' never appeared on server" >&2
echo " stderr from child:" >&2
cat "$WORK/stderr.log" >&2
kill "$CHILD_PID" 2>/dev/null || true
exit 1
fi
# Wait for completion
echo "test: waiting for child process to finish..."
wait "$CHILD_PID" 2>/dev/null || true
# Check it completed successfully
EXIT=$?
if [ "$EXIT" -ne 0 ]; then
# Timeout exit code 124, but we also accept 0
echo "test: child exit code=$EXIT (non-zero may mean timeout — still OK for visibility test)"
fi
# Verify FINAL_OUTPUT.md (not yet part of --attach mode, but check stdout for PBJ content)
if grep -qi "sandwich\|peanut butter\|jelly\|complete" "$WORK/stdout.log" 2>/dev/null; then
echo "test: PB&J content confirmed in output"
else
echo "test: NOTE — PB&J content not found in stdout (model variability)"
fi
echo "test: PASS — session visibility confirmed"
echo " session ID: $FOUND"
echo " title: $SESSION_TITLE"
echo " server: $SERVER_URL"
echo " child exit: $EXIT"
echo ""
echo " To re-test manually:"
echo " opencode attach $SERVER_URL --session $FOUND"
#!/usr/bin/env bash
# test_dispatch_refactor.sh — test the refactored dispatch.sh + lifecycle scripts.
#
# Tests:
# 1. dispatch.sh creates task dir, spawns, confirms .lock, returns JSON
# 2. subagent-cleanup.sh removes task artifacts
# 3. subagent-abandon.sh kills process and force-removes
# 4. run-plan.sh validates and dispatches from a plan YAML
#
# Usage: bash tests/test_dispatch_refactor.sh [--keep]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
DISPATCH="$SKILL_DIR/scripts/dispatch.sh"
CLEANUP="$SKILL_DIR/scripts/subagent-cleanup.sh"
ABANDON="$SKILL_DIR/scripts/subagent-abandon.sh"
RUN_PLAN="$SKILL_DIR/scripts/run-plan.sh"
KEEP=0; [ "${1:-}" = "--keep" ] && KEEP=1
err() { printf 'test: FAIL %s\n' "$*" >&2; exit 1; }
ok() { printf 'test: PASS %s\n' "$*"; }
WORK=$(mktemp -d /tmp/oc-dispatch-test.XXXXXX)
trap '[ "$KEEP" -eq 1 ] && echo "kept $WORK" || rm -rf "$WORK"' EXIT
cd "$WORK"
git init -q -b main
git config --local commit.gpgsign false
git config user.email "dispatch@test"
git config user.name "Dispatch Test"
mkdir -p src
printf 'def add(a, b):\n return a - b\n' > src/foo.py
printf 'fix the bug\n' > prompt.md
git add -A && git commit -q -m fixture
ROOT="$WORK"
# --- Test 1: dispatch.sh creates task dir and returns JSON ---
echo "test: dispatch.sh with --root/--task-id flags..."
OUT=$("$DISPATCH" \
--root "$ROOT" \
--cwd "$ROOT" \
--kind single-file-fix \
--model ollama-cloud/deepseek-v4-flash:cloud \
--agent build \
--prompt-file "$ROOT/prompt.md" \
--target src/foo.py \
--task-id test-dispatch-1 \
2>/dev/null) || err "dispatch.sh failed"
# Parse JSON output
echo "$OUT" | python3 -c "import json,sys; d=json.load(sys.stdin); assert d['id']=='test-dispatch-1'; assert d['status']=='dispatched'; assert 'lockfile' in d; assert 'pid' in d" 2>/dev/null \
|| err "dispatch.sh JSON output invalid: $OUT"
ok "dispatch.sh returned valid JSON"
TASK_DIR="$ROOT/.subagents/test-dispatch-1"
[ -d "$TASK_DIR" ] || err "task dir not created: $TASK_DIR"
ok "task dir exists"
[ -f "$TASK_DIR/.lock" ] || err ".lock not created"
ok ".lock exists"
[ -f "$TASK_DIR/prompt.md" ] || err "prompt.md not copied"
ok "prompt.md copied"
# Wait for subagent to finish (or timeout)
echo "test: waiting for subagent to complete..."
for i in $(seq 1 60); do
[ ! -f "$TASK_DIR/.lock" ] && break
sleep 2
done
if [ -f "$TASK_DIR/.lock" ]; then
# Subagent still running — abandon it
"$ABANDON" --task-id test-dispatch-1 --root "$ROOT" 2>/dev/null
echo "test: NOTE subagent did not complete within 120s (model variability)"
else
ok "subagent completed (.lock removed)"
[ -f "$TASK_DIR/FINAL_OUTPUT.md" ] || err "FINAL_OUTPUT.md not written"
ok "FINAL_OUTPUT.md written"
# Cleanup
"$CLEANUP" --task-id test-dispatch-1 --root "$ROOT" 2>/dev/null \
|| err "subagent-cleanup.sh failed"
ok "subagent-cleanup.sh succeeded"
[ ! -d "$TASK_DIR" ] || err "task dir still exists after cleanup"
ok "task dir removed after cleanup"
fi
# --- Test 2: dispatch.sh with worktree ---
echo "test: dispatch.sh with --worktree flag..."
OUT2=$("$DISPATCH" \
--root "$ROOT" \
--cwd "$ROOT" \
--kind single-file-fix \
--model ollama-cloud/deepseek-v4-flash:cloud \
--agent build \
--prompt-file "$ROOT/prompt.md" \
--target src/foo.py \
--task-id test-wt-1 \
--worktree test-wt-1-branch \
2>/dev/null) || err "dispatch.sh with worktree failed"
echo "$OUT2" | python3 -c "import json,sys; d=json.load(sys.stdin); assert d['worktree'] is not None, 'worktree should be set'" 2>/dev/null \
|| err "dispatch.sh worktree JSON invalid: $OUT2"
ok "dispatch.sh with worktree returned valid JSON"
[ -d "$ROOT/.subagents/test-wt-1/worktree" ] || err "worktree dir not created"
ok "worktree directory exists"
[ -L "$ROOT/.worktrees/test-wt-1" ] || err "worktree symlink not created"
ok "worktree symlink exists"
# Abandon the worktree task
"$ABANDON" --task-id test-wt-1 --root "$ROOT" 2>/dev/null \
|| err "subagent-abandon.sh failed"
ok "subagent-abandon.sh succeeded"
[ ! -d "$ROOT/.subagents/test-wt-1" ] || err "task dir still exists after abandon"
ok "task dir removed after abandon"
[ ! -L "$ROOT/.worktrees/test-wt-1" ] || err "worktree symlink still exists after abandon"
ok "worktree symlink removed after abandon"
# --- Test 3: run-plan.sh validates and dispatches ---
echo "test: run-plan.sh with 1-task plan..."
cat > "$ROOT/plan1.yaml" <<'YAML'
tasks:
- id: plan-task-1
kind: single-file-fix
model: ollama-cloud/deepseek-v4-flash:cloud
agent: build
prompt: prompt.md
target: src/foo.py
YAML
PLAN_OUT=$("$RUN_PLAN" --plan "$ROOT/plan1.yaml" 2>/dev/null) || err "run-plan.sh failed"
echo "$PLAN_OUT" | python3 -c "import json,sys; d=json.load(sys.stdin); assert 'plan_id' in d; tasks=d['tasks']; assert len(tasks)==1; assert tasks[0]['status']=='dispatched'" 2>/dev/null \
|| err "run-plan.sh JSON invalid: $PLAN_OUT"
ok "run-plan.sh returned valid JSON with dispatched task"
# Clean up plan task
"$ABANDON" --task-id plan-task-1 --root "$ROOT" 2>/dev/null || true
# --- Test 4: run-plan.sh skips task with bad worktree ---
echo "test: run-plan.sh skips task when worktree creation fails..."
# Create a branch that conflicts
git -C "$ROOT" checkout -b conflict-branch -q 2>/dev/null
git -C "$ROOT" checkout main -q 2>/dev/null
cat > "$ROOT/plan2.yaml" <<YAML
tasks:
- id: conflict-task
kind: single-file-fix
model: ollama-cloud/deepseek-v4-flash:cloud
agent: build
prompt: prompt.md
target: src/foo.py
worktree: conflict-branch
YAML
PLAN_OUT2=$("$RUN_PLAN" --plan "$ROOT/plan2.yaml" 2>/dev/null) || true
echo "$PLAN_OUT2" | python3 -c "import json,sys; d=json.load(sys.stdin); tasks=d['tasks']; assert tasks[0]['status']=='skipped', f'expected skipped, got {tasks[0][\"status\"]}'" 2>/dev/null \
|| err "run-plan.sh did not skip conflicting worktree task"
ok "run-plan.sh skips task when worktree creation fails"
echo "test: all dispatch refactor checks passed"#!/usr/bin/env bash
# test_e2e_params.sh — verify each dispatch.sh parameter is passed correctly.
#
# The subagent receives the parameters and reports what it observed.
# The harness cross-checks against the values it passed.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
DISPATCH="$SKILL_DIR/scripts/dispatch.sh"
CLEANUP="$SKILL_DIR/scripts/subagent-cleanup.sh"
KEEP=0; [ "${1:-}" = "--keep" ] && KEEP=1
err() { printf 'test: FAIL %s\n' "$*" >&2; exit 1; }
ok() { printf 'test: PASS %s\n' "$*"; }
TEST_MODEL="ollama-cloud/deepseek-v4-flash:cloud"
TEST_AGENT="explore"
TEST_TARGET="reports/param-report.md"
WORK=$(mktemp -d /tmp/oc-params.XXXXXX)
trap '[ "$KEEP" -eq 1 ] && echo "kept $WORK" || rm -rf "$WORK"' EXIT
cd "$WORK"
git init -q -b main
git config --local commit.gpgsign false
git config user.email "params@test"
git config user.name "Params Test"
mkdir -p reports
cat > prompt.md <<MD
Check your environment and report: what is your CWD, what model are you
using, what agent, and what file was passed to --file? Write this to
$TEST_TARGET
MD
touch "$WORK/$TEST_TARGET"
git add -A && git commit -q -m fixture
echo "test: dispatching with parameters..."
echo " kind=headless-spike"
echo " cwd=$WORK"
echo " model=$TEST_MODEL"
echo " agent=$TEST_AGENT"
echo " target=$TEST_TARGET"
OUT=$("$DISPATCH" \
--root "$WORK" \
--cwd "$WORK" \
--kind headless-spike \
--model "$TEST_MODEL" \
--agent "$TEST_AGENT" \
--prompt-file "$WORK/prompt.md" \
--target "$WORK/$TEST_TARGET" \
--task-id params-1 \
2>/dev/null) || err "dispatch.sh failed"
TASK_DIR=$(echo "$OUT" | python3 -c "import json,sys; print(json.load(sys.stdin)['task_dir'])" 2>/dev/null) \
|| err "JSON output invalid: $OUT"
ok "dispatch returned valid JSON"
LOCKFILE=$(echo "$OUT" | python3 -c "import json,sys; print(json.load(sys.stdin)['lockfile'])" 2>/dev/null)
# Poll for completion
for i in $(seq 1 60); do
[ ! -f "$LOCKFILE" ] && break
sleep 2
done
[ -f "$TASK_DIR/FINAL_OUTPUT.md" ] || err "FINAL_OUTPUT.md not found"
ok "FINAL_OUTPUT.md present"
REPORT="$WORK/$TEST_TARGET"
if [ -f "$REPORT" ]; then
echo ""
echo "--- subagent param report ---"
sed 's/^/ /' "$REPORT"
echo ""
REPORT_LOWER=$(tr '[:upper:]' '[:lower:]' < "$REPORT")
MODEL_SHORT="${TEST_MODEL#ollama-cloud/}"
if echo "$REPORT_LOWER" | grep -q "${MODEL_SHORT%:*}"; then
ok "subagent reported correct model"
else
echo "test: NOTE model not clearly reported"
fi
if echo "$REPORT_LOWER" | grep -q "$TEST_AGENT"; then
ok "subagent reported correct agent ($TEST_AGENT)"
else
echo "test: NOTE agent not clearly reported"
fi
else
echo "test: NOTE subagent did not write param report"
fi
[ -s "$TASK_DIR/events.jsonl" ] || err "events.jsonl empty"
ok "events.jsonl has session data"
"$CLEANUP" --task-id params-1 --root "$WORK" 2>/dev/null || true
echo ""
echo "test: all parameter tests completed"#!/usr/bin/env bash
# test_empty_agent_field.sh — verify issue #3: TSV parsing with empty agent field.
#
# When a plan YAML omits the agent field, run-plan.sh must still produce
# correct TSV (with placeholder) and dispatch.sh must accept it.
#
# Tests:
# 1. run-plan.sh dispatches a task with no agent field
# 2. dispatch.sh accepts empty agent (defaults to "default")
# 3. run-plan.sh with multi-task plan mixing empty and non-empty agents
#
# Usage: bash tests/test_empty_agent_field.sh [--keep]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
DISPATCH="$SKILL_DIR/scripts/dispatch.sh"
RUN_PLAN="$SKILL_DIR/scripts/run-plan.sh"
ABANDON="$SKILL_DIR/scripts/subagent-abandon.sh"
KEEP=0; [ "${1:-}" = "--keep" ] && KEEP=1
err() { printf 'test: FAIL %s\n' "$*" >&2; exit 1; }
ok() { printf 'test: PASS %s\n' "$*"; }
WORK=$(mktemp -d /tmp/oc-empty-agent.XXXXXX)
trap '[ "$KEEP" -eq 1 ] && echo "kept $WORK" || rm -rf "$WORK"' EXIT
cd "$WORK"
git init -q -b main
git config --local commit.gpgsign false
git config user.email "emptyagent@test"
git config user.name "Empty Agent Test"
mkdir -p src
printf 'def add(a, b):\n return a - b\n' > src/foo.py
printf 'fix the bug\n' > prompt.md
git add -A && git commit -q -m fixture
ROOT="$WORK"
# --- Test 1: run-plan.sh dispatches task with no agent field ---
echo "test: run-plan.sh with no agent field..."
cat > "$ROOT/plan-no-agent.yaml" <<'YAML'
tasks:
- id: no-agent-1
kind: single-file-fix
model: ollama-cloud/deepseek-v4-flash:cloud
prompt: prompt.md
target: src/foo.py
YAML
PLAN_OUT=$("$RUN_PLAN" --plan "$ROOT/plan-no-agent.yaml" 2>/dev/null) || err "run-plan.sh failed with no agent field"
echo "$PLAN_OUT" | python3 -c "
import json, sys
d = json.load(sys.stdin)
tasks = d['tasks']
assert len(tasks) == 1, f'expected 1 task, got {len(tasks)}'
assert tasks[0]['status'] == 'dispatched', f'expected dispatched, got {tasks[0][\"status\"]}'
" 2>/dev/null || err "run-plan.sh did not dispatch task with missing agent: $PLAN_OUT"
ok "run-plan.sh dispatches task with no agent field"
# Clean up
"$ABANDON" --task-id no-agent-1 --root "$ROOT" 2>/dev/null || true
# --- Test 2: dispatch.sh accepts empty agent (placeholder "-") ---
echo "test: dispatch.sh with --agent '-' placeholder..."
OUT=$("$DISPATCH" \
--root "$ROOT" \
--cwd "$ROOT" \
--kind single-file-fix \
--model ollama-cloud/deepseek-v4-flash:cloud \
--agent "-" \
--prompt-file "$ROOT/prompt.md" \
--target src/foo.py \
--task-id test-empty-agent \
2>/dev/null) || err "dispatch.sh failed with --agent '-'"
echo "$OUT" | python3 -c "import json,sys; d=json.load(sys.stdin); assert d['status']=='dispatched'" 2>/dev/null \
|| err "dispatch.sh JSON invalid with empty agent: $OUT"
ok "dispatch.sh accepts --agent '-' placeholder"
# Clean up
"$ABANDON" --task-id test-empty-agent --root "$ROOT" 2>/dev/null || true
# --- Test 3: multi-task plan mixing empty and non-empty agents ---
echo "test: run-plan.sh with mixed agent fields..."
cat > "$ROOT/plan-mixed.yaml" <<'YAML'
tasks:
- id: has-agent
kind: single-file-fix
model: ollama-cloud/deepseek-v4-flash:cloud
agent: explore
prompt: prompt.md
target: src/foo.py
- id: no-agent-2
kind: single-file-fix
model: ollama-cloud/deepseek-v4-flash:cloud
prompt: prompt.md
target: src/foo.py
YAML
PLAN_OUT2=$("$RUN_PLAN" --plan "$ROOT/plan-mixed.yaml" 2>/dev/null) || err "run-plan.sh failed with mixed agent fields"
echo "$PLAN_OUT2" | python3 -c "
import json, sys
d = json.load(sys.stdin)
tasks = d['tasks']
assert len(tasks) == 2, f'expected 2 tasks, got {len(tasks)}'
for t in tasks:
assert t['status'] == 'dispatched', f'task {t[\"id\"]} not dispatched: {t[\"status\"]}'
" 2>/dev/null || err "run-plan.sh did not dispatch all tasks in mixed plan: $PLAN_OUT2"
ok "run-plan.sh dispatches all tasks in mixed plan"
# Clean up
"$ABANDON" --task-id has-agent --root "$ROOT" 2>/dev/null || true
"$ABANDON" --task-id no-agent-2 --root "$ROOT" 2>/dev/null || true
echo ""
echo "test: all empty-agent-field tests passed"#!/usr/bin/env bash
# test_hello_world.sh — minimal end-to-end smoke test.
#
# Verifies: dispatch.sh spawns subagent, subagent runs, produces output.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
DISPATCH="$SKILL_DIR/scripts/dispatch.sh"
CLEANUP="$SKILL_DIR/scripts/subagent-cleanup.sh"
KEEP=0; [ "${1:-}" = "--keep" ] && KEEP=1
err() { printf 'test: FAIL %s\n' "$*" >&2; exit 1; }
ok() { printf 'test: PASS %s\n' "$*"; }
WORK=$(mktemp -d /tmp/oc-hello.XXXXXX)
trap '[ "$KEEP" -eq 1 ] && echo "kept $WORK" || rm -rf "$WORK"' EXIT
cd "$WORK"
git init -q -b main
git config --local commit.gpgsign false
git config user.email "hello@test"
git config user.name "Hello Test"
cat > prompt.md <<'MD'
Say exactly "hello world" in your response. No other text.
MD
touch "$WORK/report.md"
git add -A && git commit -q -m fixture
echo "test: running hello-world dispatch..."
OUT=$("$DISPATCH" \
--root "$WORK" \
--cwd "$WORK" \
--kind headless-spike \
--model "ollama-cloud/deepseek-v4-flash:cloud" \
--agent explore \
--prompt-file "$WORK/prompt.md" \
--target "$WORK/report.md" \
--task-id hello-1 \
2>/dev/null) || err "dispatch.sh failed"
TASK_DIR=$(echo "$OUT" | python3 -c "import json,sys; print(json.load(sys.stdin)['task_dir'])" 2>/dev/null) \
|| err "JSON output invalid: $OUT"
ok "dispatch returned valid JSON"
LOCKFILE=$(echo "$OUT" | python3 -c "import json,sys; print(json.load(sys.stdin)['lockfile'])" 2>/dev/null)
# Poll for completion
for i in $(seq 1 60); do
[ ! -f "$LOCKFILE" ] && break
sleep 2
done
[ -f "$TASK_DIR/FINAL_OUTPUT.md" ] || err "FINAL_OUTPUT.md not found"
ok "FINAL_OUTPUT.md present"
if grep -qi "hello world" "$TASK_DIR/FINAL_OUTPUT.md"; then
ok "subagent said 'hello world'"
else
echo "test: NOTE 'hello world' not found (model variability)"
fi
"$CLEANUP" --task-id hello-1 --root "$WORK" 2>/dev/null || true
echo "test: hello-world passed"#!/usr/bin/env bash
# test_lock_watch_cycle.sh — smoke test for the full async lock-watch cycle.
#
# Uses dispatch.sh to dispatch a single-file-fix task, verifies:
# 1. .lock file created while running
# 2. FINAL_OUTPUT.md written on completion
# 3. JSON output valid
# 4. events.jsonl has content
#
# Usage: bash tests/test_lock_watch_cycle.sh [--keep]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
DISPATCH="$SKILL_DIR/scripts/dispatch.sh"
CLEANUP="$SKILL_DIR/scripts/subagent-cleanup.sh"
KEEP=0; [ "${1:-}" = "--keep" ] && KEEP=1
err() { printf 'test: FAIL %s\n' "$*" >&2; exit 1; }
ok() { printf 'test: PASS %s\n' "$*"; }
WORK=$(mktemp -d /tmp/oc-lockwatch.XXXXXX)
trap '[ "$KEEP" -eq 1 ] && echo "kept $WORK" || rm -rf "$WORK"' EXIT
cd "$WORK"
git init -q -b main
git config --local commit.gpgsign false
git config user.email "lockwatch@test"
git config user.name "Lock Watch Test"
mkdir -p src
cat > src/foo.py <<'PY'
def add(a, b):
return a - b
PY
cat > prompt.md <<'MD'
Fix the bug in src/foo.py. The `add` function uses subtraction instead of
addition. Change `a - b` to `a + b`. Reply with "DONE" when fixed.
MD
git add -A && git commit -q -m fixture
echo "test: dispatching single-file-fix via dispatch.sh..."
OUT=$("$DISPATCH" \
--root "$WORK" \
--cwd "$WORK" \
--kind single-file-fix \
--model "ollama-cloud/deepseek-v4-flash:cloud" \
--agent build \
--prompt-file "$WORK/prompt.md" \
--target src/foo.py \
--task-id lockwatch-1 \
2>/dev/null) || err "dispatch.sh failed"
# Parse JSON output
TASK_DIR=$(echo "$OUT" | python3 -c "import json,sys; print(json.load(sys.stdin)['task_dir'])" 2>/dev/null) \
|| err "JSON output invalid: $OUT"
LOCKFILE=$(echo "$OUT" | python3 -c "import json,sys; print(json.load(sys.stdin)['lockfile'])" 2>/dev/null)
ok "dispatch returned valid JSON"
[ -d "$TASK_DIR" ] || err "task dir not created"
ok "task dir exists: $(basename "$TASK_DIR")"
# Poll for completion
for i in $(seq 1 60); do
[ ! -f "$LOCKFILE" ] && break
sleep 2
done
if [ -f "$LOCKFILE" ]; then
err ".lock still exists after 120s"
fi
ok ".lock cleaned up"
[ -f "$TASK_DIR/FINAL_OUTPUT.md" ] || err "FINAL_OUTPUT.md not found"
ok "FINAL_OUTPUT.md present"
grep -q 'exit_code: 0' "$TASK_DIR/FINAL_OUTPUT.md" || err "exit_code not 0 in FINAL_OUTPUT.md"
ok "FINAL_OUTPUT.md has exit_code: 0"
[ -s "$TASK_DIR/events.jsonl" ] || err "events.jsonl missing or empty"
ok "events.jsonl has content"
"$CLEANUP" --task-id lockwatch-1 --root "$WORK" 2>/dev/null || err "cleanup failed"
[ ! -d "$TASK_DIR" ] || err "task dir still exists after cleanup"
ok "cleanup removed task dir"
echo "test: all checks passed"#!/usr/bin/env bash
# test_poll_subagent.sh — unit + behavioral tests for poll-subagent.sh.
#
# Tests completion, stuck detection, timeout, and edge cases using
# mock task directories (no real dispatch needed).
#
# Usage: bash tests/test_poll_subagent.sh [--keep]
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
POLL="$SKILL_DIR/scripts/poll-subagent.sh"
KEEP=0; [ "${1:-}" = "--keep" ] && KEEP=1
PASS=0; FAIL=0
ok() { printf ' PASS %s\n' "$*"; PASS=$((PASS + 1)); }
err() { printf ' FAIL %s\n' "$*" >&2; FAIL=$((FAIL + 1)); }
WORK=$(mktemp -d /tmp/oc-poll-test.XXXXXX)
trap '[ "$KEEP" -eq 1 ] && echo "kept $WORK" || rm -rf "$WORK"' EXIT
cd "$WORK"
git init -q -b main
git config --local commit.gpgsign false
git config user.email "poll-test@test"
git config user.name "Poll Test"
setup_task() {
local tid="$1"
mkdir -p ".subagents/$tid"
touch ".subagents/$tid/.lock"
touch ".subagents/$tid/events.jsonl"
}
teardown_task() {
local tid="$1"
rm -rf ".subagents/$tid"
}
run_poll() {
# Run poll-subagent.sh, capture exit code without triggering set -e.
# Prints stderr to file $STDERR_FILE if set, otherwise /dev/null.
local rc=0
STDERR_TARGET="${STDERR_FILE:-/dev/null}"
"$POLL" "$@" 2>"$STDERR_TARGET" || rc=$?
echo "$rc"
}
# --- Test 1: task completes before timeout (lockfile removed) ---
echo "test: task completes before timeout..."
TASK_ID="poll-complete-1"
setup_task "$TASK_ID"
echo '{"type":"start"}' > ".subagents/$TASK_ID/events.jsonl"
# Schedule lockfile removal after brief delay
(
sleep 2
rm -f ".subagents/$TASK_ID/.lock"
echo '# Output' > ".subagents/$TASK_ID/FINAL_OUTPUT.md"
) &
EXIT_CODE=$(run_poll --task-id "$TASK_ID" --root "$WORK" --interval 1 --max-polls 20 --stale-threshold 30)
if [ "$EXIT_CODE" -eq 0 ]; then ok "completed task exits 0"; else err "expected exit 0, got $EXIT_CODE"; fi
teardown_task "$TASK_ID"
# --- Test 2: task stuck (events stall past threshold) ---
echo "test: task stuck detection..."
TASK_ID="poll-stuck-1"
setup_task "$TASK_ID"
echo '{"type":"start"}' > ".subagents/$TASK_ID/events.jsonl"
# Force mtime 120s in the past so stale check triggers on second poll
python3 -c "
import os, time
path = '$WORK/.subagents/$TASK_ID/events.jsonl'
age = time.time() - 120
os.utime(path, (age, age))
"
EXIT_CODE=$(run_poll --task-id "$TASK_ID" --root "$WORK" --interval 1 --max-polls 20 --stale-threshold 5)
if [ "$EXIT_CODE" -eq 2 ]; then ok "stuck task exits 2"; else err "expected exit 2 (stuck), got $EXIT_CODE"; fi
teardown_task "$TASK_ID"
# --- Test 3: timeout (max polls reached, lockfile still present) ---
echo "test: timeout when max polls reached..."
TASK_ID="poll-timeout-1"
setup_task "$TASK_ID"
# Keep appending events so it doesn't look stuck, just never remove lockfile
(
for i in $(seq 1 10); do
echo "{\"type\":\"tick\",\"i\":$i}" >> ".subagents/$TASK_ID/events.jsonl"
sleep 1
done
) &
BG_PID=$!
EXIT_CODE=$(run_poll --task-id "$TASK_ID" --root "$WORK" --interval 1 --max-polls 3 --stale-threshold 60)
if [ "$EXIT_CODE" -eq 3 ]; then ok "timeout exits 3"; else err "expected exit 3 (timeout), got $EXIT_CODE"; fi
kill "$BG_PID" 2>/dev/null || true
wait "$BG_PID" 2>/dev/null || true
teardown_task "$TASK_ID"
# --- Test 4: error on missing task dir ---
echo "test: error on missing task dir..."
EXIT_CODE=$(run_poll --task-id nonexistent-task --root "$WORK" --interval 1 --max-polls 3)
if [ "$EXIT_CODE" -eq 1 ]; then ok "missing task dir exits 1"; else err "expected exit 1, got $EXIT_CODE"; fi
# --- Test 5: error on missing --task-id ---
echo "test: error on missing --task-id..."
EXIT_CODE=$(run_poll --root "$WORK")
if [ "$EXIT_CODE" -eq 1 ]; then ok "missing --task-id exits 1"; else err "expected exit 1, got $EXIT_CODE"; fi
# --- Test 6: error on unsafe task-id ---
echo "test: error on unsafe task-id..."
for BAD_ID in "../etc" "bad task"; do
EXIT_CODE=$(run_poll --task-id "$BAD_ID" --root "$WORK")
if [ "$EXIT_CODE" -eq 1 ]; then ok "unsafe task-id '$BAD_ID' rejected"; else err "unsafe task-id '$BAD_ID' should have been rejected, got $EXIT_CODE"; fi
done
# --- Test 7: progress logging to stderr ---
echo "test: progress lines logged to stderr..."
TASK_ID="poll-log-1"
setup_task "$TASK_ID"
echo '{"type":"start"}' > ".subagents/$TASK_ID/events.jsonl"
# Schedule completion
(
sleep 2
rm -f ".subagents/$TASK_ID/.lock"
echo '# Done' > ".subagents/$TASK_ID/FINAL_OUTPUT.md"
) &
STDERR_FILE=$(mktemp)
EXIT_CODE=$(STDERR_FILE="$STDERR_FILE" run_poll --task-id "$TASK_ID" --root "$WORK" --interval 1 --max-polls 10 --stale-threshold 30)
if [ "$EXIT_CODE" -eq 0 ]; then ok "poll completed for logging test"; else err "poll failed for logging test: exit $EXIT_CODE"; fi
if grep -q 'poll [0-9]/10 task=poll-log-1 lines=' "$STDERR_FILE"; then
ok "progress lines with line count appear"
else
err "no progress lines with line count found in stderr"
fi
if grep -q 'COMPLETED' "$STDERR_FILE"; then
ok "COMPLETED message appears"
else
err "no COMPLETED message in stderr"
fi
rm -f "$STDERR_FILE"
teardown_task "$TASK_ID"
# --- Test 8: already completed (lockfile absent at first poll) ---
echo "test: already completed task (lockfile absent)..."
TASK_ID="poll-already-1"
setup_task "$TASK_ID"
echo '{"type":"start"}' > ".subagents/$TASK_ID/events.jsonl"
# Remove lockfile before polling starts
rm -f ".subagents/$TASK_ID/.lock"
EXIT_CODE=$(run_poll --task-id "$TASK_ID" --root "$WORK")
if [ "$EXIT_CODE" -eq 0 ]; then ok "already-completed exits 0"; else err "expected exit 0 for already-complete, got $EXIT_CODE"; fi
teardown_task "$TASK_ID"
# --- Summary ---
echo ""
echo "--- Test Summary: $PASS passed, $FAIL failed ---"
[ "$FAIL" -eq 0 ] || exit 1#!/usr/bin/env bash
# test_pr_work_flow.sh — end-to-end test for pr-work dispatch kind.
#
# Verifies:
# 1. pr-work dispatch creates branch + worktree
# 2. Template renders with PR_URL, gh pr create, branch, pr_title
# 3. subagent runs in worktree and produces output
# 4. Cleanup removes worktree but preserves remote branch + PR
#
# Usage: bash tests/test_pr_work_flow.sh [--keep]
#
# Requires: opencode, git, python3, PyYAML
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
DISPATCH="$SKILL_DIR/scripts/dispatch.sh"
RUN_PLAN="$SKILL_DIR/scripts/run-plan.sh"
CLEANUP="$SKILL_DIR/scripts/subagent-cleanup.sh"
KEEP=0; [ "${1:-}" = "--keep" ] && KEEP=1
err() { printf 'test: FAIL %s\n' "$*" >&2; exit 1; }
ok() { printf 'test: PASS %s\n' "$*"; }
WORK=$(mktemp -d /tmp/oc-pr-work.XXXXXX)
trap '[ "$KEEP" -eq 1 ] && echo "kept $WORK" || rm -rf "$WORK"' EXIT
# Setup: create a test repo with a local bare remote
mkdir -p "$WORK/remote" "$WORK/shim"
cd "$WORK/remote"
git init -q --bare
cd "$WORK"
git init -q -b main
git config --local commit.gpgsign false
git config user.email "pr-work@test"
git config user.name "PR Work Test"
git remote add origin "$WORK/remote"
mkdir -p src
printf 'def add(a, b):\n return a - b\n' > src/foo.py
printf '.subagents/\n.worktrees/\n' >> .gitignore
git add -A && git commit -q -m fixture
git push -q origin main
# Install a gh shim that creates a fake PR URL
cat > "$WORK/shim/gh" <<'SHIM'
#!/usr/bin/env bash
# gh shim — validates flags and returns a fake PR URL for testing
if [[ "$*" != *--draft* || "$*" != *--title* || "$*" != *--body-file* ]]; then
echo "unexpected gh args: $*" >&2
exit 1
fi
echo "https://github.com/test/repo/pull/42"
SHIM
chmod +x "$WORK/shim/gh"
# Also shim git push to always succeed
export PATH="$WORK/shim:$PATH"
# Write prompt
cat > "$WORK/prompt-work.md" <<'MD'
# Test implementation
Fix the add function in src/foo.py. Change a - b to a + b.
## Working guidelines
- Commit and push your changes
- Add a PR comment for each checkpoint
- When done, ensure tests pass
MD
# Write plan
cat > "$WORK/plan.yaml" <<'YAML'
tasks:
- id: pr-work-test
kind: pr-work
model: ollama-cloud/deepseek-v4-flash:cloud
agent: build
prompt: prompt-work.md
worktree: pr-work-test-branch
pr_title: "Test: PR-work dispatch kind"
YAML
echo "test: running pr-work dispatch via run-plan.sh..."
OUT=$("$RUN_PLAN" --plan "$WORK/plan.yaml" 2>/dev/null) || { err "run-plan.sh failed"; }
STATUS=$(echo "$OUT" | python3 -c "import json,sys; print(json.load(sys.stdin)['tasks'][0]['status'])" 2>/dev/null)
[ "$STATUS" = "dispatched" ] && ok "pr-work task dispatched" || err "pr-work task not dispatched (status=$STATUS)"
TASK_DIR=$(echo "$OUT" | python3 -c "import json,sys; print(json.load(sys.stdin)['tasks'][0]['task_dir'])" 2>/dev/null)
LOCKFILE=$(echo "$OUT" | python3 -c "import json,sys; print(json.load(sys.stdin)['tasks'][0]['lockfile'])" 2>/dev/null)
# Check worktree exists
[ -d "$TASK_DIR/worktree" ] && ok "worktree directory created" || err "worktree directory missing"
[ -L "$WORK/.worktrees/pr-work-test" ] && ok "worktree symlink created" || err "worktree symlink missing"
# Verify branch exists
git -C "$WORK" branch --list pr-work-test-branch | grep -q pr-work-test-branch \
&& ok "worktree branch created" || err "worktree branch missing"
# Check start script renders with pr-work variables
START_SCRIPT="$TASK_DIR/start-subagent.sh"
[ -f "$START_SCRIPT" ] && ok "start-subagent.sh rendered" || err "start-subagent.sh missing"
grep -q 'gh pr create' "$START_SCRIPT" && ok "start script contains gh pr create" || err "missing gh pr create in start script"
grep -q 'BRANCH=' "$START_SCRIPT" && ok "start script contains BRANCH var" || err "missing BRANCH var"
grep -q 'PR_TITLE=' "$START_SCRIPT" && ok "start script contains PR_TITLE var" || err "missing PR_TITLE var"
grep -q 'PR_URL' "$START_SCRIPT" && ok "start script contains PR_URL" || err "missing PR_URL in start script"
# Check prompt was copied
[ -f "$TASK_DIR/prompt.md" ] && ok "prompt.md copied" || err "prompt.md missing"
# Poll for completion (generous timeout since this is a real opencode run)
for ((i=1; i<=90; i++)); do
[ ! -f "$LOCKFILE" ] && break
sleep 2
done
if [ ! -f "$LOCKFILE" ]; then
ok "subagent completed (.lock removed)"
else
err "subagent did not complete within 180s"
fi
# Check FINAL_OUTPUT.md
[ -f "$TASK_DIR/FINAL_OUTPUT.md" ] && ok "FINAL_OUTPUT.md written" || err "FINAL_OUTPUT.md missing"
grep -q 'pr_url' "$TASK_DIR/FINAL_OUTPUT.md" && ok "pr_url in FINAL_OUTPUT.md" || err "pr_url missing from FINAL_OUTPUT.md"
grep -q 'pr_title' "$TASK_DIR/FINAL_OUTPUT.md" && ok "pr_title in FINAL_OUTPUT.md" || err "pr_title missing from FINAL_OUTPUT.md"
grep -q 'branch' "$TASK_DIR/FINAL_OUTPUT.md" && ok "branch in FINAL_OUTPUT.md" || err "branch missing from FINAL_OUTPUT.md"
[ -s "$TASK_DIR/events.jsonl" ] && ok "events.jsonl has content" || err "events.jsonl empty"
# Cleanup
"$CLEANUP" --task-id pr-work-test --root "$WORK" 2>/dev/null && ok "cleanup succeeded" || err "cleanup failed"
[ ! -d "$TASK_DIR" ] && ok "task dir removed" || err "task dir persists after cleanup"
[ ! -L "$WORK/.worktrees/pr-work-test" ] && ok "worktree symlink removed" || err "symlink persists"
# Verify remote branch survives cleanup (PR is still open)
if git -C "$WORK" branch -r | grep -q "origin/pr-work-test-branch"; then
ok "remote branch survives cleanup (PR stays open)"
else
echo "test: NOTE remote branch not found (expected if remote is bare)"
fi
rm -f "$WORK/shim/gh"
echo "test: pr-work flow completed"
#!/usr/bin/env bash
# test_prompt_path_guard.sh — test that dispatch.sh rejects prompt files
# inside .subagents/ (which would cause BSD cp "identical file" failure).
#
# Usage: bash tests/test_prompt_path_guard.sh [--keep]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
DISPATCH="$SKILL_DIR/scripts/dispatch.sh"
KEEP=0; [ "${1:-}" = "--keep" ] && KEEP=1
err() { printf 'test: FAIL %s\n' "$*" >&2; exit 1; }
ok() { printf 'test: PASS %s\n' "$*"; }
WORK=$(mktemp -d /tmp/oc-dispatch-test.XXXXXX)
trap '[ "$KEEP" -eq 1 ] && echo "kept $WORK" || rm -rf "$WORK"' EXIT
cd "$WORK"
git init -q -b main
git config --local commit.gpgsign false
git config user.email "guard@test"
git config user.name "Guard Test"
mkdir -p src
printf 'def add(a, b): return a + b\n' > src/foo.py
ROOT="$WORK"
# --- Test 1: prompt file inside .subagents/<task-id>/ is rejected ---
echo "test: prompt inside .subagents/ is rejected..."
TASK_ID="guard-test-1"
mkdir -p ".subagents/$TASK_ID"
printf 'fix the bug' > ".subagents/$TASK_ID/prompt.md"
OUT=$("$DISPATCH" \
--root "$ROOT" \
--cwd "$ROOT" \
--kind single-file-fix \
--model ollama-cloud/deepseek-v4-flash:cloud \
--agent build \
--prompt-file "$ROOT/.subagents/$TASK_ID/prompt.md" \
--target src/foo.py \
--task-id "$TASK_ID" \
2>&1 || true)
if echo "$OUT" | grep -q "resolves to the same path"; then
ok "dispatch.sh rejected prompt inside .subagents/$TASK_ID/"
else
err "expected rejection for prompt inside .subagents/, got: $OUT"
fi
rm -rf ".subagents/$TASK_ID"
# --- Test 2: prompt file outside .subagents/ still works ---
echo "test: prompt outside .subagents/ succeeds..."
printf 'fix the bug' > "$ROOT/prompt.md"
"$DISPATCH" \
--root "$ROOT" \
--cwd "$ROOT" \
--kind single-file-fix \
--model ollama-cloud/deepseek-v4-flash:cloud \
--agent build \
--prompt-file "$ROOT/prompt.md" \
--target src/foo.py \
--task-id guard-test-2 \
2>/dev/null || err "dispatch.sh should succeed with prompt outside .subagents/"
ok "dispatch.sh succeeded with prompt outside .subagents/"
# Cleanup
ABANDON="$SKILL_DIR/scripts/subagent-abandon.sh"
"$ABANDON" --task-id guard-test-2 --root "$ROOT" 2>/dev/null || true
echo "test: all prompt path guard checks passed"#!/usr/bin/env bash
# test_tsv_parsing.sh — unit + adversarial tests for TSV field parsing (issue #3).
#
# Tests TSV generation from run-plan.sh's Python parser and the dispatch.sh
# agent defaulting logic. Does NOT require a running model.
#
# Test levels: unit, adversarial
#
# Usage: bash tests/test_tsv_parsing.sh [--keep]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
DISPATCH="$SKILL_DIR/scripts/dispatch.sh"
RUN_PLAN="$SKILL_DIR/scripts/run-plan.sh"
KEEP=0; [ "${1:-}" = "--keep" ] && KEEP=1
PASS=0
FAIL=0
ok() { PASS=$((PASS + 1)); printf ' PASS %s\n' "$*"; }
err() { FAIL=$((FAIL + 1)); printf ' FAIL %s\n' "$*" >&2; }
WORK=$(mktemp -d /tmp/oc-tsv-test.XXXXXX)
trap '[ "$KEEP" -eq 1 ] && echo "kept $WORK" || rm -rf "$WORK"' EXIT
cd "$WORK"
git init -q -b main
git config --local commit.gpgsign false
git config user.email "tsv@test"
git config user.name "TSV Test"
mkdir -p src
printf 'fix\n' > prompt.md
printf 'x\n' > src/foo.py
git add -A && git commit -q -m fixture
ROOT="$WORK"
echo "=== Unit + Adversarial: TSV Parsing & Agent Defaulting ==="
echo ""
# ── Unit: TSV generation produces no consecutive tabs ──
echo "--- Unit: TSV generation ---"
echo " Testing: TSV with empty agent produces '-' placeholder..."
TSV_OUT=$(python3 -c "
import yaml
tasks = [{'id': 't1', 'kind': 'single-file-fix', 'model': 'm', 'prompt': 'p.md', 'target': 's/f.py'}]
for t in tasks:
tid = t.get('id', '')
kind = t.get('kind', '')
model = t.get('model', '')
agent = t.get('agent', '')
prompt = t.get('prompt', '')
target = t.get('target', '')
worktree = t.get('worktree', '')
pr_title = t.get('pr_title', '')
agent = agent if agent else '-'
worktree = worktree if worktree else '-'
pr_title = pr_title if pr_title else '-'
print(f'{tid}\t{kind}\t{model}\t{agent}\t{prompt}\t{target}\t{worktree}\t{pr_title}')
")
# Verify no consecutive tabs (the original bug)
if echo "$TSV_OUT" | grep $'\t\t'; then
err "TSV contains consecutive tabs (field shift bug)"
else
ok "TSV has no consecutive tabs"
fi
# Verify 8 fields
FIELD_COUNT=$(echo "$TSV_OUT" | awk -F'\t' '{print NF}')
[ "$FIELD_COUNT" -eq 8 ] && ok "TSV has exactly 8 fields" || err "TSV has $FIELD_COUNT fields (expected 8)"
# Verify agent field is '-'
AGENT_FIELD=$(echo "$TSV_OUT" | awk -F'\t' '{print $4}')
[ "$AGENT_FIELD" = "-" ] && ok "empty agent becomes '-'" || err "agent field is '$AGENT_FIELD' (expected '-')"
# Verify worktree field is '-'
WT_FIELD=$(echo "$TSV_OUT" | awk -F'\t' '{print $7}')
[ "$WT_FIELD" = "-" ] && ok "empty worktree becomes '-'" || err "worktree field is '$WT_FIELD' (expected '-')"
# Verify pr_title field is '-'
PT_FIELD=$(echo "$TSV_OUT" | awk -F'\t' '{print $8}')
[ "$PT_FIELD" = "-" ] && ok "empty pr_title becomes '-'" || err "pr_title field is '$PT_FIELD' (expected '-')"
echo " Testing: TSV with all fields populated..."
TSV_FULL=$(python3 -c "
import yaml
tasks = [{'id': 't2', 'kind': 'single-file-fix', 'model': 'mymodel', 'agent': 'explore', 'prompt': 'p.md', 'target': 's/f.py', 'worktree': 'my-branch', 'pr_title': 'My PR'}]
for t in tasks:
tid = t.get('id', '')
kind = t.get('kind', '')
model = t.get('model', '')
agent = t.get('agent', '')
prompt = t.get('prompt', '')
target = t.get('target', '')
worktree = t.get('worktree', '')
pr_title = t.get('pr_title', '')
agent = agent if agent else '-'
worktree = worktree if worktree else '-'
pr_title = pr_title if pr_title else '-'
print(f'{tid}\t{kind}\t{model}\t{agent}\t{prompt}\t{target}\t{worktree}\t{pr_title}')
")
FIELD_COUNT2=$(echo "$TSV_FULL" | awk -F'\t' '{print NF}')
[ "$FIELD_COUNT2" -eq 8 ] && ok "full TSV has 8 fields" || err "full TSV has $FIELD_COUNT2 fields"
AGENT2=$(echo "$TSV_FULL" | awk -F'\t' '{print $4}')
[ "$AGENT2" = "explore" ] && ok "populated agent passes through" || err "agent is '$AGENT2' (expected 'explore')"
WT2=$(echo "$TSV_FULL" | awk -F'\t' '{print $7}')
[ "$WT2" = "my-branch" ] && ok "populated worktree passes through" || err "worktree is '$WT2' (expected 'my-branch')"
PT2=$(echo "$TSV_FULL" | awk -F'\t' '{print $8}')
[ "$PT2" = "My PR" ] && ok "populated pr_title passes through" || err "pr_title is '$PT2' (expected 'My PR')"
# Full TSV round-trip through bash IFS read
while IFS=$'\t' read -r TID2 TKIND2 TMODEL2 TAGENT2 TPROMPT2 TTARGET2 TWORKTREE2 TPR_TITLE2; do
[ "$TPR_TITLE2" = "My PR" ] && ok "full TSV pr_title round-trips through bash IFS" || err "TPR_TITLE2='$TPR_TITLE2' (expected 'My PR')"
done <<< "$TSV_FULL"
# ── Unit: bash IFS=$'\t' read does not shift fields ──
echo "--- Unit: bash IFS tab-read field alignment ---"
echo " Testing: placeholder TSV round-trips through bash correctly..."
while IFS=$'\t' read -r TID TKIND TMODEL TAGENT TPROMPT TTARGET TWORKTREE TPR_TITLE; do
[ "$TID" = "t1" ] && ok "TID=t1" || err "TID='$TID' (expected t1)"
[ "$TKIND" = "single-file-fix" ] && ok "TKIND=single-file-fix" || err "TKIND='$TKIND'"
[ "$TMODEL" = "m" ] && ok "TMODEL=m" || err "TMODEL='$TMODEL'"
[ "$TAGENT" = "-" ] && ok "TAGENT=-" || err "TAGENT='$TAGENT'"
[ "$TPROMPT" = "p.md" ] && ok "TPROMPT=p.md" || err "TPROMPT='$TPROMPT'"
[ "$TTARGET" = "s/f.py" ] && ok "TTARGET=s/f.py" || err "TTARGET='$TTARGET'"
[ "$TWORKTREE" = "-" ] && ok "TWORKTREE=-" || err "TWORKTREE='$TWORKTREE'"
[ "$TPR_TITLE" = "-" ] && ok "TPR_TITLE=-" || err "TPR_TITLE='$TPR_TITLE'"
done <<< "$TSV_OUT"
# ── Unit: dispatch.sh agent defaulting ──
echo "--- Unit: dispatch.sh agent defaulting ---"
echo " Testing: --agent '-' resolves to 'default' in start-subagent.sh..."
OUT=$("$DISPATCH" \
--root "$ROOT" --cwd "$ROOT" \
--kind single-file-fix --model "ollama-cloud/deepseek-v4-flash:cloud" \
--agent "-" \
--prompt-file "$ROOT/prompt.md" --target src/foo.py \
--task-id test-unit-agent-dash \
2>/dev/null) || { err "dispatch.sh failed with --agent '-'"; }
TASK_DIR="$ROOT/.subagents/test-unit-agent-dash"
# Verify AGENT=default appears in the rendered start-subagent.sh
if [ -f "$TASK_DIR/start-subagent.sh" ]; then
if grep -q "default" "$TASK_DIR/start-subagent.sh" 2>/dev/null; then
ok "start-subagent.sh uses 'default' agent (not '-')"
else
err "start-subagent.sh does not contain 'default' — placeholder '-' may have leaked through"
fi
else
err "start-subagent.sh not found"
fi
# Kill the subagent immediately (we only needed to verify the rendered script)
PID=$(echo "$OUT" | python3 -c "import json,sys; print(json.load(sys.stdin)['pid'])" 2>/dev/null || true)
[ -n "$PID" ] && kill "$PID" 2>/dev/null || true
rm -rf "$TASK_DIR" 2>/dev/null || true
# ── Adversarial: edge cases ──
echo "--- Adversarial: edge cases ---"
echo " Testing: plan YAML with explicit agent='-' literal..."
cat > "$ROOT/adv-literal-dash.yaml" <<'YAML'
tasks:
- id: adv-dash
kind: single-file-fix
model: ollama-cloud/deepseek-v4-flash:cloud
agent: "-"
prompt: prompt.md
target: src/foo.py
YAML
OUT=$("$RUN_PLAN" --plan "$ROOT/adv-literal-dash.yaml" 2>/dev/null) || true
STATUS=$(echo "$OUT" | python3 -c "import json,sys; print(json.load(sys.stdin)['tasks'][0]['status'])" 2>/dev/null || echo "")
[ "$STATUS" = "dispatched" ] && ok "plan with agent='-' literal dispatches" || err "plan with agent='-' literal failed (status=$STATUS)"
# Kill the subagent
TASK_DIR_ADV="$ROOT/.subagents/adv-dash"
[ -d "$TASK_DIR_ADV" ] && rm -rf "$TASK_DIR_ADV" 2>/dev/null || true
echo " Testing: plan with only agent omitted (no key at all)..."
cat > "$ROOT/adv-no-agent.yaml" <<'YAML'
tasks:
- id: adv-no-agent
kind: single-file-fix
model: ollama-cloud/deepseek-v4-flash:cloud
prompt: prompt.md
target: src/foo.py
YAML
OUT2=$("$RUN_PLAN" --plan "$ROOT/adv-no-agent.yaml" 2>/dev/null) || true
STATUS2=$(echo "$OUT2" | python3 -c "import json,sys; print(json.load(sys.stdin)['tasks'][0]['status'])" 2>/dev/null || echo "")
[ "$STATUS2" = "dispatched" ] && ok "plan with agent omitted dispatches" || err "plan with agent omitted failed (status=$STATUS2)"
TASK_DIR2="$ROOT/.subagents/adv-no-agent"
[ -d "$TASK_DIR2" ] && rm -rf "$TASK_DIR2" 2>/dev/null || true
echo " Testing: plan with only worktree omitted (no key at all)..."
cat > "$ROOT/adv-no-worktree.yaml" <<'YAML'
tasks:
- id: adv-no-wt
kind: single-file-fix
model: ollama-cloud/deepseek-v4-flash:cloud
agent: build
prompt: prompt.md
target: src/foo.py
YAML
OUT3=$("$RUN_PLAN" --plan "$ROOT/adv-no-worktree.yaml" 2>/dev/null) || true
STATUS3=$(echo "$OUT3" | python3 -c "import json,sys; print(json.load(sys.stdin)['tasks'][0]['status'])" 2>/dev/null || echo "")
[ "$STATUS3" = "dispatched" ] && ok "plan with worktree omitted dispatches" || err "plan with worktree omitted failed (status=$STATUS3)"
TASK_DIR3="$ROOT/.subagents/adv-no-wt"
[ -d "$TASK_DIR3" ] && rm -rf "$TASK_DIR3" 2>/dev/null || true
echo " Testing: plan with both agent and worktree omitted..."
cat > "$ROOT/adv-no-both.yaml" <<'YAML'
tasks:
- id: adv-neither
kind: single-file-fix
model: ollama-cloud/deepseek-v4-flash:cloud
prompt: prompt.md
target: src/foo.py
YAML
OUT4=$("$RUN_PLAN" --plan "$ROOT/adv-no-both.yaml" 2>/dev/null) || true
STATUS4=$(echo "$OUT4" | python3 -c "import json,sys; print(json.load(sys.stdin)['tasks'][0]['status'])" 2>/dev/null || echo "")
[ "$STATUS4" = "dispatched" ] && ok "plan with both agent+worktree omitted dispatches" || err "plan with both omitted failed (status=$STATUS4)"
TASK_DIR4="$ROOT/.subagents/adv-neither"
[ -d "$TASK_DIR4" ] && rm -rf "$TASK_DIR4" 2>/dev/null || true
echo " Testing: dispatch.sh with no --agent flag defaults to 'default'..."
OUT5=$("$DISPATCH" \
--root "$ROOT" --cwd "$ROOT" \
--kind single-file-fix --model "ollama-cloud/deepseek-v4-flash:cloud" \
--prompt-file "$ROOT/prompt.md" --target src/foo.py \
--task-id test-unit-no-agent-flag \
2>/dev/null) || { err "dispatch.sh failed without --agent flag"; }
TASK_DIR5="$ROOT/.subagents/test-unit-no-agent-flag"
if [ -f "$TASK_DIR5/start-subagent.sh" ]; then
if grep -q "default" "$TASK_DIR5/start-subagent.sh" 2>/dev/null; then
ok "no --agent flag resolves to 'default' in start-subagent.sh"
else
err "no --agent flag: 'default' not found in start-subagent.sh"
fi
else
err "start-subagent.sh not found for no-agent-flag test"
fi
PID5=$(echo "$OUT5" | python3 -c "import json,sys; print(json.load(sys.stdin)['pid'])" 2>/dev/null || true)
[ -n "$PID5" ] && kill "$PID5" 2>/dev/null || true
rm -rf "$TASK_DIR5" 2>/dev/null || true
# ── Summary ──
echo ""
echo "=== Test Summary: $PASS passed, $FAIL failed ==="
[ "$FAIL" -eq 0 ] && exit 0 || exit 1