diff --git a/docs/superpowers/plans/2026-08-07-orchestrate-ready-set-scheduler.md b/docs/superpowers/plans/2026-08-07-orchestrate-ready-set-scheduler.md new file mode 100644 index 0000000..7d95891 --- /dev/null +++ b/docs/superpowers/plans/2026-08-07-orchestrate-ready-set-scheduler.md @@ -0,0 +1,719 @@ +# orchestrate ready-set 스케줄러 (PR 1) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** orchestrate의 Wave 배리어를 의존 그래프 + 슬롯 회계로 교체해, 의존이 충족된 task를 빈 슬롯만큼 즉시 흘려보낸다. + +**Architecture:** Phase 2가 기계 판독용 `.orchestration/graph.json`을 쓰고, 새 `ready-set.sh`가 그래프 + `.orchestration/status/*.json`을 읽어 "지금 던져도 되는 task id"를 종료코드로 판정한다. 판단(슬롯 수, 어떤 task부터)은 코디네이터가 유지한다. tmux 기판의 `watch-status.sh`는 스캔 대상을 task 집합으로 좁히는 `--tasks` 옵션을 얻어 "추적 중인 것 중 아무나 하나 도달"을 표현할 수 있게 된다. + +**Tech Stack:** POSIX sh, jq, bats-core. 새 의존성 없음. + +## Global Constraints + +- 스펙 정본: `docs/superpowers/specs/2026-08-07-orchestrate-ready-set-scheduler-design.md`. 각 task는 해당 절을 권위로 한다. +- deps 충족 기준은 `approved` 이상이다 — `impl_done`이 아니다 (스펙 §3.1). +- 슬롯은 디스패치된 순간부터 종료 상태까지 점유한다. `plan_ready`·`impl_done`(리뷰 대기)도 점유로 센다 (스펙 §3.1). +- 종료 상태 = `approved | merged | done | failed`. 그 중 `failed`는 "완료"가 아니라 교착 사유다. +- 새 스크립트는 POSIX `sh`로 쓰고 `set -u`를 켠다. `jq` 부재는 exit 127 (`watch-status.sh`·`orca-wait.sh`와 동일). +- `/tmp`·`$TMPDIR`에 실행 파일을 만들지 않는다. 테스트용 실행 파일이 필요하면 레포 안 `.claude/tmp/`를 쓴다 (`tests/orca-wait.bats`의 `REPO_TMP()` 선례). +- 기존 387개 bats는 전부 그린을 유지한다. PR 통과 조건이다. +- 분할 경로(스펙 §3.5)는 PR 2다. 이 계획에서 구현하지 않는다. +- 커밋 author는 `Younggi Choi <74581798+choiyounggi@users.noreply.github.com>` (공개 레포 관례). +- **`skills/orchestrate/SKILL.md`에 넣는 모든 문장은 영어다.** 현재 이 파일은 한글이 0자이며, 배포되는 스킬 본문의 관례다(guardrails v1.2.0이 배포 메시지를 한국어→영어로 바꾼 것과 같은 이유). `docs/` 아래 스펙·계획 문서만 한국어를 쓴다. SKILL.md를 grep하는 테스트도 영어 문자열을 대상으로 한다. + +--- + +## File Structure + +**신규** + +- `skills/orchestrate/scripts/ready-set.sh` — 그래프 + status를 읽어 디스패치 가능 집합을 판정. 이 PR의 유일한 신규 실행 파일. 순수 판정만 하고 아무것도 실행하지 않는다. +- `tests/ready-set.bats` — 위 스크립트의 계약 고정. + +**수정** + +- `skills/orchestrate/scripts/watch-status.sh` — `--tasks ` 옵션 추가 (스캔 대상 축소). 기존 위치 인자 계약은 그대로. +- `tests/scripts.bats` — `--tasks` 신규 테스트 + 기존 all-N 무회귀. +- `skills/orchestrate/SKILL.md` — Phase 2(graph.json 산출 + 슬롯 제안), Phase 3+4(디스패치 루프), 재진입. +- `tests/send-prompt.bats` — SKILL.md 앵커 테스트가 있는 파일. Phase 재작성으로 앵커가 깨지지 않는지 확인하고, 필요하면 갱신. + +--- + +### Task 1: `ready-set.sh` — 디스패치 가능 집합 판정 + +**Files:** +- Create: `skills/orchestrate/scripts/ready-set.sh` +- Test: `tests/ready-set.bats` + +**Interfaces:** +- Consumes: `.orchestration/graph.json` (이 task가 스키마를 정의), `.orchestration/status/.json`의 `.phase` 필드 (`status-update.sh`가 쓰는 기존 형식). +- Produces: CLI 계약 `ready-set.sh ` — stdout은 디스패치할 task id 한 줄에 하나. 종료코드 0/2/3/4/5. Task 3·4의 SKILL.md가 이 계약을 인용한다. + +**graph.json 스키마** (이 task가 정본): + +```json +{ "tasks": [ + { "id": "t1", "deps": [], "files": ["src/auth/**"], "outputs": ["AuthToken"] }, + { "id": "t3", "deps": ["t1"], "files": ["src/api/**"], "consumes": ["AuthToken"] } +] } +``` + +`id`와 `deps`만 이 스크립트가 읽는다. `files`/`outputs`/`consumes`는 코디네이터가 충돌 판정에 쓰는 필드이며 PR 2에서 사용한다. + +- [ ] **Step 1: 실패하는 테스트를 쓴다 (정상 경로)** + +`tests/ready-set.bats` 생성: + +```bash +#!/usr/bin/env bats +# Tests for ready-set.sh — the Wave-barrier replacement. Given the dependency +# graph and each task's recorded phase, it answers exactly one question: +# which tasks may be dispatched right now. It never launches anything. + +setup() { + RS="${BATS_TEST_DIRNAME}/../skills/orchestrate/scripts/ready-set.sh" + G="${BATS_TEST_TMPDIR}/graph.json" + S="${BATS_TEST_TMPDIR}/status" + mkdir -p "$S" +} + +graph() { printf '%s' "$1" > "$G"; } +phase() { printf '{"task":"%s","phase":"%s"}' "$1" "$2" > "$S/$1.json"; } + +@test "no deps, empty status: every task is dispatchable up to the cap" { + graph '{"tasks":[{"id":"t1","deps":[]},{"id":"t2","deps":[]},{"id":"t3","deps":[]}]}' + run sh "$RS" "$G" "$S" 2 + [ "$status" -eq 0 ] + [ "$(echo "$output" | grep -c .)" -eq 2 ] +} +``` + +- [ ] **Step 2: 실패를 확인한다** + +Run: `cd ~/Desktop/workspace/dev-loop && npx bats tests/ready-set.bats` +Expected: FAIL — `ready-set.sh` 가 없어서 `sh: ... No such file`. + +- [ ] **Step 3: 최소 구현** + +`skills/orchestrate/scripts/ready-set.sh` 생성: + +```sh +#!/bin/sh +# ready-set.sh — which tasks may be dispatched right now? +# +# This is the Wave-barrier replacement. Waves made the whole batch wait for its +# slowest member; here a task is dispatchable the moment its own dependencies +# are approved and a slot is free, so a finished worker is refilled immediately. +# +# usage: ready-set.sh +# {"tasks":[{"id":"t1","deps":["t0"]}, ...]} — Phase 2 writes it. +# Only `id` and `deps` are read here; files/outputs/consumes are +# the coordinator's conflict-matrix fields. +# .orchestration/status — one .json per task, `.phase` +# written by status-update.sh. A task with no file is `pending`. +# the coordinator's approved slot count. LO_MAX_SESSIONS, when +# set, is an UPPER BOUND on it, never a raise. +# +# exit 0 dispatch these (stdout: one task id per line, at most of them) +# exit 2 nothing to dispatch, but tasks are in flight — wait for an event +# exit 3 nothing to dispatch, nothing in flight, unfinished tasks remain — +# DEADLOCK (a failed dependency, or a cycle). Never wait on this: with +# no worker running, no event can ever arrive. Report it. +# exit 4 the graph or the status dir could not be read, or an argument is +# invalid — refuse rather than guess +# exit 5 every task is in a terminal state — the run is complete +# exit 127 jq not found +# +# A dependency counts as satisfied only at `approved` or higher, NOT at +# impl_done: a task that consumes an unreviewed interface has to be redone when +# rework changes that signature. This is the Wave model's "previous Wave fully +# approved" guarantee, narrowed from a global barrier to a per-task wait. +# +# env: +# LO_MAX_SESSIONS upper bound on (a tuning knob like LO_PHASE_TIMEOUTS) +set -u + +JQ=$(command -v jq) || { echo "ready-set: jq not found" >&2; exit 127; } + +graph="${1:-}"; sdir="${2:-}"; cap="${3:-}" +[ -n "$graph" ] && [ -n "$sdir" ] && [ -n "$cap" ] || { + echo "usage: ready-set.sh " >&2; exit 4; } +[ -f "$graph" ] || { echo "ready-set: graph '$graph' not found" >&2; exit 4; } +[ -d "$sdir" ] || { echo "ready-set: status dir '$sdir' not found" >&2; exit 4; } + +case "$cap" in ''|*[!0-9]*) echo "ready-set: cap must be a positive integer" >&2; exit 4 ;; esac +[ "$cap" -gt 0 ] || { echo "ready-set: cap must be > 0" >&2; exit 4; } + +# LO_MAX_SESSIONS caps the cap. It is a ceiling the operator sets, so it lowers +# the coordinator's proposal and never raises it. +if [ -n "${LO_MAX_SESSIONS:-}" ]; then + case "$LO_MAX_SESSIONS" in + ''|*[!0-9]*) echo "ready-set: LO_MAX_SESSIONS must be a positive integer" >&2; exit 4 ;; + esac + [ "$LO_MAX_SESSIONS" -gt 0 ] || { echo "ready-set: LO_MAX_SESSIONS must be > 0" >&2; exit 4; } + [ "$LO_MAX_SESSIONS" -lt "$cap" ] && cap="$LO_MAX_SESSIONS" +fi + +ids=$("$JQ" -r '.tasks[]?.id // empty' "$graph" 2>/dev/null) || { + echo "ready-set: graph '$graph' is not valid JSON" >&2; exit 4; } + +# `.tasks` absent is a malformed graph, not an empty one — tell them apart so a +# typo'd key cannot read as "nothing to do". +"$JQ" -e 'has("tasks") and (.tasks | type == "array")' "$graph" >/dev/null 2>&1 || { + echo "ready-set: graph '$graph' has no .tasks array" >&2; exit 4; } + +phase_of() { # $1 = task id -> its recorded phase, or "pending" when unrecorded + f="$sdir/$1.json" + [ -f "$f" ] || { echo pending; return; } + p=$("$JQ" -r '.phase // "pending"' "$f" 2>/dev/null) || p=pending + [ -n "$p" ] || p=pending + echo "$p" +} + +is_satisfied() { # $1 = phase -> 0 when a dependent may start on it + case "$1" in approved|merged|done) return 0 ;; *) return 1 ;; esac +} +is_terminal() { # $1 = phase -> 0 when the task will never occupy a slot again + case "$1" in approved|merged|done|failed) return 0 ;; *) return 1 ;; esac +} + +busy=0; unfinished=0; ready="" +for id in $ids; do + ph=$(phase_of "$id") + if is_terminal "$ph"; then + # `failed` is terminal for scheduling but is NOT completion: it leaves its + # dependents permanently unreachable, which is what exit 3 exists to report. + [ "$ph" = failed ] && unfinished=$((unfinished + 1)) + continue + fi + unfinished=$((unfinished + 1)) + if [ "$ph" != pending ]; then busy=$((busy + 1)); continue; fi + + deps=$("$JQ" -r --arg id "$id" '.tasks[] | select(.id == $id) | .deps[]? // empty' "$graph" 2>/dev/null) + ok=1 + for d in $deps; do + # A dep naming a task the graph does not define is a malformed graph, not an + # unsatisfied edge — refuse instead of silently blocking that task forever. + echo "$ids" | grep -qx "$d" || { echo "ready-set: task '$id' depends on unknown '$d'" >&2; exit 4; } + is_satisfied "$(phase_of "$d")" || { ok=0; break; } + done + [ "$ok" = 1 ] && ready="$ready $id" +done + +[ "$unfinished" -eq 0 ] && { echo "[ready-set] all tasks terminal"; exit 5; } + +free=$((cap - busy)) +[ "$free" -lt 0 ] && free=0 + +n=0 +for id in $ready; do + [ "$n" -ge "$free" ] && break + echo "$id"; n=$((n + 1)) +done +[ "$n" -gt 0 ] && exit 0 + +[ "$busy" -gt 0 ] && { echo "[ready-set] nothing dispatchable, $busy in flight — wait" >&2; exit 2; } + +echo "[ready-set] DEADLOCK: $unfinished task(s) unfinished, none dispatchable, none running — a failed dependency or a cycle. Inspect the graph and status; do not wait." >&2 +exit 3 +``` + +`chmod +x skills/orchestrate/scripts/ready-set.sh` + +- [ ] **Step 4: 테스트 통과 확인** + +Run: `npx bats tests/ready-set.bats` +Expected: PASS (1/1) + +- [ ] **Step 5: 나머지 계약을 테스트로 고정한다** + +`tests/ready-set.bats`에 이어서 추가: + +```bash +@test "a dependent waits until its dep is approved, not merely impl_done" { + graph '{"tasks":[{"id":"t1","deps":[]},{"id":"t2","deps":["t1"]}]}' + phase t1 impl_done + run sh "$RS" "$G" "$S" 4 + [ "$status" -eq 2 ] # t1 busy (review pending), t2 not yet startable + + phase t1 approved + run sh "$RS" "$G" "$S" 4 + [ "$status" -eq 0 ] + [ "$output" = "t2" ] +} + +@test "review-pending phases occupy a slot (plan_ready and impl_done both count)" { + graph '{"tasks":[{"id":"a","deps":[]},{"id":"b","deps":[]},{"id":"c","deps":[]}]}' + phase a plan_ready + phase b impl_done + run sh "$RS" "$G" "$S" 2 + [ "$status" -eq 2 ] # cap 2 fully occupied by two review-waiting tasks +} + +@test "a failed dependency is a deadlock, never a quiet wait (the core guard)" { + graph '{"tasks":[{"id":"t1","deps":[]},{"id":"t2","deps":["t1"]}]}' + phase t1 failed + run sh "$RS" "$G" "$S" 4 + [ "$status" -eq 3 ] + [[ "$output" == *"DEADLOCK"* ]] +} + +@test "a cycle surfaces as the same deadlock (boundary)" { + graph '{"tasks":[{"id":"t1","deps":["t2"]},{"id":"t2","deps":["t1"]}]}' + run sh "$RS" "$G" "$S" 4 + [ "$status" -eq 3 ] +} + +@test "all tasks terminal: exit 5, the run is complete (boundary)" { + graph '{"tasks":[{"id":"t1","deps":[]},{"id":"t2","deps":["t1"]}]}' + phase t1 approved + phase t2 merged + run sh "$RS" "$G" "$S" 4 + [ "$status" -eq 5 ] +} + +@test "an empty task array is complete, not a deadlock (boundary)" { + graph '{"tasks":[]}' + run sh "$RS" "$G" "$S" 4 + [ "$status" -eq 5 ] +} + +@test "a graph with no .tasks array is refused, not read as empty (error)" { + graph '{"nodes":[]}' + run sh "$RS" "$G" "$S" 4 + [ "$status" -eq 4 ] +} + +@test "malformed JSON is refused (error)" { + graph '}{ not json' + run sh "$RS" "$G" "$S" 4 + [ "$status" -eq 4 ] +} + +@test "a dep naming an undefined task is refused, not blocked forever (error)" { + graph '{"tasks":[{"id":"t1","deps":["ghost"]}]}' + run sh "$RS" "$G" "$S" 4 + [ "$status" -eq 4 ] +} + +@test "LO_MAX_SESSIONS lowers the cap but never raises it" { + graph '{"tasks":[{"id":"a","deps":[]},{"id":"b","deps":[]},{"id":"c","deps":[]}]}' + run env LO_MAX_SESSIONS=1 sh "$RS" "$G" "$S" 3 + [ "$status" -eq 0 ] + [ "$(echo "$output" | grep -c .)" -eq 1 ] + + run env LO_MAX_SESSIONS=9 sh "$RS" "$G" "$S" 2 + [ "$(echo "$output" | grep -c .)" -eq 2 ] +} + +@test "a non-numeric or zero cap is refused (boundary)" { + graph '{"tasks":[{"id":"a","deps":[]}]}' + run sh "$RS" "$G" "$S" 0 + [ "$status" -eq 4 ] + run sh "$RS" "$G" "$S" abc + [ "$status" -eq 4 ] +} + +@test "a missing graph or status dir is refused (error)" { + run sh "$RS" "$BATS_TEST_TMPDIR/nope.json" "$S" 2 + [ "$status" -eq 4 ] + graph '{"tasks":[]}' + run sh "$RS" "$G" "$BATS_TEST_TMPDIR/nodir" 2 + [ "$status" -eq 4 ] +} +``` + +- [ ] **Step 6: 전체 실행** + +Run: `npx bats tests/ready-set.bats` +Expected: PASS (13/13). 실패하면 스크립트를 고친다 — 테스트를 약화시키지 않는다. + +- [ ] **Step 7: 커밋** + +```bash +git add skills/orchestrate/scripts/ready-set.sh tests/ready-set.bats +git -c user.name="Younggi Choi" -c user.email="74581798+choiyounggi@users.noreply.github.com" \ + commit -m "feat(orchestrate): ready-set.sh — 의존 그래프 + 슬롯 회계로 디스패치 가능 집합 판정 + +Wave 배리어 교체의 판정 절반. deps 충족은 approved 이상에서만 성립하고, +리뷰 대기(plan_ready/impl_done)도 슬롯을 점유한다. 실패한 의존으로 인한 +교착은 exit 3으로 즉시 드러나며 조용한 대기(exit 2)와 구분된다." +``` + +--- + +### Task 2: `watch-status.sh --tasks` — 추적 대상 축소 + +**Files:** +- Modify: `skills/orchestrate/scripts/watch-status.sh:36` (인자 파싱), `:129` (스캔 루프) +- Test: `tests/scripts.bats` + +**Interfaces:** +- Consumes: 없음 (독립 변경) +- Produces: `watch-status.sh [--tasks ] [timeout] [interval]`. Task 4의 SKILL.md가 `--tasks impl_done 1` 형태로 인용한다. + +**왜 새 모드가 아니라 스코프인가:** 슬롯 스케줄러가 필요한 건 "추적 중인 것 중 아무나 하나 도달"이다. `done_count`는 status 디렉토리 **전체**를 세므로, 이미 approved된 이전 task들 때문에 `expected=1`이 즉시 만족되어 스핀한다. 스캔을 현재 busy한 id로 좁히면 `expected=1`이 정확히 "아무나 하나"가 된다. + +- [ ] **Step 1: 실패하는 테스트를 쓴다** + +`tests/scripts.bats` 끝에 추가: + +```bash +@test "watch-status --tasks: only the named tasks are counted" { + mkdir -p "$STATUS_DIR" + printf '{"task":"old","phase":"approved"}' > "$STATUS_DIR/old.json" + printf '{"task":"a","phase":"implementing"}' > "$STATUS_DIR/a.json" + # `old` already passed the target, but it is not being tracked: without + # scoping, expected=1 would be satisfied instantly and the wait would spin. + run bash "$WS" --tasks a "$STATUS_DIR" impl_done 1 2 1 + [ "$status" -eq 2 ] +} + +@test "watch-status --tasks: returns as soon as ANY tracked task reaches target" { + mkdir -p "$STATUS_DIR" + printf '{"task":"a","phase":"impl_done"}' > "$STATUS_DIR/a.json" + printf '{"task":"b","phase":"implementing"}' > "$STATUS_DIR/b.json" + run bash "$WS" --tasks a,b "$STATUS_DIR" impl_done 1 5 1 + [ "$status" -eq 0 ] +} + +@test "watch-status --tasks: an untracked failed task does not abort the wait" { + mkdir -p "$STATUS_DIR" + printf '{"task":"old","phase":"failed"}' > "$STATUS_DIR/old.json" + printf '{"task":"a","phase":"implementing"}' > "$STATUS_DIR/a.json" + run bash "$WS" --tasks a "$STATUS_DIR" impl_done 1 2 1 + [ "$status" -eq 2 ] +} + +@test "watch-status --tasks: an explicit timeout argument still outranks the env" { + # --tasks is consumed before the positional count is taken; if argc were + # captured before that, the 4th positional would stop being recognised and + # LO_PHASE_TIMEOUTS would silently win. + mkdir -p "$STATUS_DIR" + printf '{"task":"a","phase":"implementing"}' > "$STATUS_DIR/a.json" + run env LO_PHASE_TIMEOUTS="impl_done=999" bash "$WS" --tasks a "$STATUS_DIR" impl_done 1 2 1 + [[ "$output" == *"budget=2s"* ]] + [[ "$output" == *"source=arg"* ]] +} + +@test "watch-status: without --tasks every status file is still counted (no regression)" { + mkdir -p "$STATUS_DIR" + printf '{"task":"a","phase":"done"}' > "$STATUS_DIR/a.json" + printf '{"task":"b","phase":"done"}' > "$STATUS_DIR/b.json" + run bash "$WS" "$STATUS_DIR" impl_done 2 5 1 + [ "$status" -eq 0 ] +} +``` + +- [ ] **Step 2: 실패를 확인한다** + +Run: `npx bats tests/scripts.bats` +Expected: 새 테스트 4개 FAIL (`--tasks`가 디렉토리 인자로 읽혀 exit 4). 마지막 무회귀 테스트는 PASS. + +- [ ] **Step 3: 인자 파싱을 고친다** + +`watch-status.sh`에서 `argc=$#` 줄(현재 35–36행)을 다음으로 교체: + +```sh +# --tasks scopes the scan to the given ids. The slot scheduler needs "any ONE of +# the tasks I am currently running reached the target"; counting the whole status +# dir would satisfy expected=1 from tasks approved in earlier rounds and spin. +only="" +while [ $# -gt 0 ]; do + case "$1" in + --tasks) shift; only="${1:-}"; [ -n "$only" ] || { echo "watch-status: --tasks needs a comma-separated id list" >&2; exit 4; }; shift ;; + --) shift; break ;; + -*) echo "watch-status: unknown option '$1'" >&2; exit 4 ;; + *) break ;; + esac +done +# Captured AFTER option parsing: argc decides whether the 4th POSITIONAL was +# given, and options must not be counted toward it. +argc=$# +dir="$1"; target="$2"; expected="$3"; timeout="${4:-3600}"; interval="${5:-15}" +``` + +- [ ] **Step 4: 스캔 루프에 필터를 넣는다** + +`for f in "$dir"/*.json; do` 바로 다음 `[ -f "$f" ] || continue` 아래에 추가: + +```sh + if [ -n "$only" ]; then + base=${f##*/}; base=${base%.json} + # Exact membership on a comma-delimited list: the commas around both sides + # keep `t1` from matching `t12`. + case ",$only," in *",$base,"*) : ;; *) continue ;; esac + fi +``` + +- [ ] **Step 5: 테스트 통과 확인** + +Run: `npx bats tests/scripts.bats` +Expected: PASS (전부) + +- [ ] **Step 6: 헤더 문서 갱신** + +`watch-status.sh` 헤더의 usage 줄을 `watch-status.sh [--tasks ] [timeout] [interval]`로 고치고, `--tasks`의 목적(슬롯 스케줄러의 "아무나 하나" 대기)을 두 줄로 적는다. + +- [ ] **Step 7: 전체 스위트 + 커밋** + +Run: `npx bats tests/` → 실패 0, 통과 수가 이전보다 늘었는지 확인. + +```bash +git add skills/orchestrate/scripts/watch-status.sh tests/scripts.bats +git -c user.name="Younggi Choi" -c user.email="74581798+choiyounggi@users.noreply.github.com" \ + commit -m "feat(orchestrate): watch-status --tasks — 추적 중인 집합으로 스캔 축소 + +슬롯 스케줄러는 '지금 돌리는 것 중 아무나 하나 도달'이 필요한데, status 디렉토리 +전체를 세면 이전 라운드의 approved가 expected=1을 즉시 만족시켜 스핀한다. +argc는 옵션 파싱 뒤에 잡아 4번째 위치 인자 판정이 깨지지 않게 한다." +``` + +--- + +### Task 3: SKILL.md Phase 2 — graph.json 산출 + 슬롯 제안 + +**Files:** +- Modify: `skills/orchestrate/SKILL.md:73-107` (Phase 2 + Gate 1) +- Test: `tests/scripts.bats` (문서 계약 grep — `tests/send-prompt.bats:467`의 앵커 테스트 선례를 따른다) + +**Interfaces:** +- Consumes: Task 1의 `graph.json` 스키마와 `ready-set.sh` CLI 계약. +- Produces: Gate 1 보고서 형식(슬롯 수 + 근거). Task 4의 디스패치 루프가 이 승인된 cap을 소비한다. + +- [ ] **Step 1: 문서 계약 테스트를 먼저 쓴다** + +`tests/scripts.bats` 끝에 추가: + +```bash +@test "SKILL.md Phase 2: graph.json artifact and the slot proposal are documented" { + SKILL="${BATS_TEST_DIRNAME}/../skills/orchestrate/SKILL.md" + grep -qF '.orchestration/graph.json' "$SKILL" + grep -qF 'LO_MAX_SESSIONS' "$SKILL" + # The cap must never be a bare number again: the report has to name what it + # protects, or "dynamic" degrades back into a hardcoded 4. + grep -qF 'coordinator attention' "$SKILL" + ! grep -qF 'concurrent-session cap** (default 4)' "$SKILL" +} +``` + +- [ ] **Step 2: 실패 확인** + +Run: `npx bats tests/scripts.bats` +Expected: FAIL — `graph.json`이 SKILL.md에 없음. + +- [ ] **Step 3: Phase 2를 고쳐 쓴다** + +`## Phase 2 — Decompose`에서 `Apply a **concurrent-session cap** (default 4) — if a Wave exceeds it, split it or ask. Tasks in the same Wave are independent (parallel); a later Wave starts only after the previous Wave is approved.` 문장을 삭제하고, 그 자리에 넣는다: + +```markdown +Write BOTH artifacts: `conflict-matrix.md` for humans and +`.orchestration/graph.json` for the scheduler. A markdown table is not machine +readable. + +```json +{ "tasks": [ + { "id": "t1", "deps": [], "files": ["src/auth/**"], "outputs": ["AuthToken"] }, + { "id": "t3", "deps": ["t1"], "files": ["src/api/**"], "consumes": ["AuthToken"] } +] } +``` + +Waves are **an illustration in the Gate 1 report, not an execution unit.** +Execution is decided by `ready-set.sh`: a task runs as soon as its dependencies +are `approved` and a slot is free. Still topologically sort — the result shows +the user the expected flow — but nothing waits on a Wave boundary. + +**Propose the slot count.** Pick the number from the task count, their size, and +their risk, and **say what the number protects**: this cap guards **coordinator +attention** and **API usage/budget**, not machine resources. Neither is +queryable, which is why it is a judgement rather than a computation. A slot is +held from dispatch until the task reaches a terminal state — `plan_ready` and +`impl_done` (review pending) count as held, because a pile of unreviewed tasks +next to a stream of new ones makes the cap meaningless. When `LO_MAX_SESSIONS` +is set it is an upper bound and overrides the proposal. +``` + +`graph.json`을 쓴 직후 검증한다는 문장을 덧붙인다: + +```markdown +After writing it, run `scripts/ready-set.sh ` once and +confirm it does **not** exit 4 — malformed JSON, a missing `.tasks` array, and a +dependency naming an undefined task are all caught here. +``` + +- [ ] **Step 4: Gate 1 보고 항목을 고친다** + +`## 🚦 Gate 1` 의 `Report the task list, Waves, session count, and a rough cost note.` 를 다음으로 교체: + +```markdown +Report the task list, the dependency graph (showing the expected flow as Waves is +fine), the proposed **slot count with its rationale and what it protects**, and a +rough cost note. +``` + +- [ ] **Step 5: 테스트 통과 확인** + +Run: `npx bats tests/scripts.bats` +Expected: PASS + +- [ ] **Step 6: 커밋** + +```bash +git add skills/orchestrate/SKILL.md tests/scripts.bats +git -c user.name="Younggi Choi" -c user.email="74581798+choiyounggi@users.noreply.github.com" \ + commit -m "docs(orchestrate): Phase 2 — graph.json 산출 + 근거 있는 슬롯 제안 + +하드코딩 4를 없애고, 캡이 보호하는 자원(코디네이터 주의력/API 예산)을 보고에 +명시하게 한다. LO_MAX_SESSIONS가 상한." +``` + +--- + +### Task 4: SKILL.md Phase 3+4 → 디스패치 루프, 재진입 + +**Files:** +- Modify: `skills/orchestrate/SKILL.md:109-141` (Phase 3 서두), `:188-213` (O4), `:332-350` (Phase 4), `:378-400` (재진입) +- Test: `tests/scripts.bats` + +**Interfaces:** +- Consumes: Task 1의 `ready-set.sh` 종료코드 0/2/3/4/5, Task 2의 `watch-status.sh --tasks`, Task 3의 승인된 cap. +- Produces: 없음 (PR 1의 마지막 task) + +- [ ] **Step 1: 문서 계약 테스트를 먼저 쓴다** + +```bash +@test "SKILL.md: the dispatch loop cites ready-set exit codes and --tasks" { + SKILL="${BATS_TEST_DIRNAME}/../skills/orchestrate/SKILL.md" + grep -qF 'scripts/ready-set.sh' "$SKILL" + grep -qF '--tasks' "$SKILL" + # exit 3 is the guard this design turns on; it must be spelled out as + # "do not wait", or it degrades into the silent stall it exists to prevent. + grep -qF 'DEADLOCK' "$SKILL" + # Waves must no longer be described as an execution barrier. + ! grep -qF 'A later Wave launches only after' "$SKILL" +} +``` + +- [ ] **Step 2: 실패 확인** + +Run: `npx bats tests/scripts.bats` → FAIL + +- [ ] **Step 3: Phase 3+4을 하나의 디스패치 루프로 통합한다** + +`**Phases 3–4 repeat per Wave in `## Waves` order.** A later Wave launches only after the previous Wave is fully approved; `` below = the *current* Wave's task count. Single-Wave splits run everyone in parallel (the original behavior).` 를 삭제하고, 그 자리에 다음을 넣는다: + +```markdown +**Phases 3–4 are one dispatch loop, not a per-Wave repeat.** Each round: + +1. `scripts/ready-set.sh .orchestration/graph.json .orchestration/status ` + → **0** dispatch the printed ids, **2** nothing dispatchable but work is in + flight (go wait for an event), **3** **DEADLOCK** — a failed dependency or a + cycle: **do not wait**, report it and get a human decision (with no worker + running, no event can ever arrive); after the human intervenes, return to + step 1 to re-run the check, **4** the graph or status could not be read — + refuse, do not guess; fix the error then re-run step 1, **5** every task is + in a terminal state → go to Phase 5. +2. For each dispatched task (`` = the number of tasks in this round): + - tmux: **0** (Preceding-interface injection) + steps **1–3** below (setup, + brief, launch, watch plan_ready). Orca: **O1–O5**. + - **1** `scripts/setup-worktrees.sh ...` then + verify with `git worktree list`. + - **2** Per task: write `briefs/.md` (templates/brief.md) — fill + `` and `` — then launch session and watch + until `plan_ready` (step 3 below). **Write the brief at dispatch time.** + It only needs the signatures this task consumes, and by then those are + `approved`, so they're settled. + - **3** Collect `plans/.md` when each session reaches `plan_ready`. +3. For each planned task, deliver §2 (implement) with `scripts/send-prompt.sh + send lo- ""` (tmux, see Phase 4 for exit-code branch logic), or + `orca orchestration task-create` the implement Task then + `scripts/orca-worker-start --task --terminal ` (Orca). + On delivery failure, re-run step 3 after fixing the error. +4. Wait for event. tmux: `scripts/watch-status.sh --tasks + impl_done ` — without `--tasks` the tasks approved in + earlier rounds satisfy `expected=` immediately and the wait spins. Orca: + `scripts/orca-wait.sh` with the implement Task ids, already event-driven. +5. On wake, handle that task: review each worktree diff (`git -C diff + ...HEAD`). If tests weak, audit with `test-quality-auditor`. On + approval, return to step 1 — whatever dependency it released shows up in the + next `ready-set.sh` round and the freed slot refills immediately. On rework + needed, write `reviews/-rN.md` and re-deliver with `send-prompt.sh + send` (or new Orca Task on same terminal); after 3 failed rounds, escalate. + When `ready-set.sh` returns **5**, go to Phase 5. +``` + +- [ ] **Step 4: 재진입 절을 고친다** + +`## Re-entry (resume)` 의 첫 문장 뒤에 추가: + +```markdown +There is no intermediate state such as a Wave index to restore. Reading +`.orchestration/graph.json` plus `status/*.json` and running `ready-set.sh` IS +the restored state — the same inputs always yield the same answer. +``` + +- [ ] **Step 5: 강화된 테스트를 작성한다** + +`tests/scripts.bats` 의 contract test를 다음으로 교체해서 루프 구조를 검증한다: + +```bash +@test "SKILL.md: the dispatch loop structure pins step order and error handling" { + SKILL="${BATS_TEST_DIRNAME}/../skills/orchestrate/SKILL.md" + # Step 1 must run ready-set.sh first to decide what is dispatchable. + grep -qF '1. `scripts/ready-set.sh' "$SKILL" + # Step 1 exit codes must document what happens: exit 3/4 return to step 1, + # not "get a human decision" and vanish. + grep -qF 'after the human intervenes, return to step 1' "$SKILL" + grep -qF 'fix the error then re-run step 1' "$SKILL" + # Step 3 must deliver the implement prompt (was missing in v1); it is keyed + # off `send-prompt.sh send` on tmux or `task-create` on Orca. + grep -qF 'deliver §2 (implement)' "$SKILL" + grep -qF 'send-prompt.sh send' "$SKILL" + # Step 4 must wait, scoped to running ids via --tasks to avoid spin. + grep -qF 'watch-status.sh --tasks' "$SKILL" + # Step 5 must return to step 1 on approval, closing the loop. + grep -qF 'return to step 1' "$SKILL" + # Exit code 3 must be DEADLOCK and documented as "do not wait". + grep -qF 'DEADLOCK' "$SKILL" + # Waves must no longer be described as an execution barrier. + ! grep -qF 'A later Wave launches only after' "$SKILL" +} +``` + +- [ ] **Step 6: 테스트 + 전체 스위트** + +Run: `npx bats tests/scripts.bats` + `npx bats tests/` +Expected: 실패 0. + +- [ ] **Step 7: 커밋** + +```bash +git add skills/orchestrate/SKILL.md tests/scripts.bats +git -c user.name="Younggi Choi" -c user.email="74581798+choiyounggi@users.noreply.github.com" \ + commit -m "docs(orchestrate): Phase 3+4를 디스패치 루프로, 재진입 단순화 + +Wave 배리어를 없애고 ready-set 판정 → 빈 슬롯 충전 → 이벤트 대기 루프로 바꾼다. +exit 3(교착)은 대기 금지로 명시했다 — 실행 중인 워커가 없으면 이벤트도 오지 않는다." +``` + +--- + +## Self-Review + +**1. 스펙 커버리지** + +| 스펙 절 | 담당 task | +|---|---| +| §3.1 그래프 + 슬롯 회계, deps=approved, 슬롯 점유 범위 | Task 1 | +| §3.2 슬롯 제안 + `LO_MAX_SESSIONS` 상한 | Task 1(집행), Task 3(제안·보고) | +| §3.3 한도/stall 반응 | **기존 동작으로 충족** — `watch-status` exit 7 / `orca-worker-stalled.sh`가 이미 stall을 보고하고, 디스패치 루프는 그때 1번으로 돌아가지 않으므로 큐 투입이 자연히 멈춘다. 새 코드 없음 | +| §3.4 디스패치 루프, brief 주입 시점, `--tasks`, 재진입 | Task 2, Task 4 | +| §4 실패 처리 (exit 0/2/3/4/5, 그래프 검증) | Task 1(전부), Task 3(Phase 2 쓰기 직후 검증 호출) | +| §5 테스트 | Task 1·2의 bats | +| §3.5 분할 | **범위 밖 (PR 2)** — 의도적 | + +§4의 "분할로 인한 변경 시점 검증"은 PR 2 소관이라 여기 없다. 최초 작성 시점 검증은 Task 3 Step 3이 커버한다. + +**2. 플레이스홀더 스캔** — TBD/TODO 없음. 모든 코드 스텝에 실제 코드가 있고, 문서 스텝은 교체할 원문과 새 문장을 모두 적었다. + +**3. 타입/이름 일관성** — `ready-set.sh ` 시그니처가 Task 1 정의, Task 3 Step 3, Task 4 Step 3에서 동일하다. 종료코드 0/2/3/4/5의 의미가 세 곳에서 동일하다. `--tasks `가 Task 2 정의와 Task 4 인용에서 동일하다. `LO_MAX_SESSIONS`는 Task 1(집행)과 Task 3(문서)에서 같은 의미(상한)로 쓰인다. diff --git a/docs/superpowers/specs/2026-08-07-orchestrate-ready-set-scheduler-design.md b/docs/superpowers/specs/2026-08-07-orchestrate-ready-set-scheduler-design.md new file mode 100644 index 0000000..13c65ab --- /dev/null +++ b/docs/superpowers/specs/2026-08-07-orchestrate-ready-set-scheduler-design.md @@ -0,0 +1,223 @@ +# orchestrate — ready-set 스케줄러 + 실행 중 task 분할 + +> 상태: **설계 확정** (brainstorming → 이 문서 → writing-plans 순). 2026-08-07. +> 대상: `skills/orchestrate` (v1.4.1 기준). 정본은 `skills/orchestrate/SKILL.md`. + +--- + +## 1. 문제 + +현재 orchestrate는 Phase 2에서 task를 **Wave**로 위상정렬하고, Wave 단위 배리어로 실행한다. +여기서 두 가지가 막힌다. + +**(1) 동시 세션 수가 하드코딩 4다.** SKILL.md에 근거 없이 숫자만 있고, 설정으로 뺄 경로도 +없다. Wave가 캡을 초과하면 "Wave를 쪼개거나 사용자에게 물어봄" — 분해 시점의 정적 결정이다. +실행 중에 조정할 방법이 없다. + +**(2) Wave 배리어가 슬롯을 놀린다.** 한 Wave의 task가 전부 끝나야 다음 Wave가 시작하므로, +느린 task 하나가 나머지 워커를 전부 붙잡는다. 그리고 실행 중에 "이 task가 예상보다 크다"를 +알게 돼도 쪼개서 다른 워커를 투입할 경로가 없다. + +### 캡이 보호하는 자원 (확정) + +측정 가능한 머신 자원이 아니다. 실측·합의된 보호 대상은 둘이다. + +- **API 사용량/예산** — 2026-08-07 3워커 런에서 워커 3개 + 서브에이전트가 동시에 org + monthly spend limit에 걸려 전부 멈췄다. `worker-show`의 state는 그동안에도 `ready`였다. +- **코디네이터 주의력** — 오케스트레이터 세션이 N개 워커의 메일·리뷰·rework를 동시에 들고 + 있어야 한다. + +둘 다 OS에서 조회할 수 없다. 남은 API 예산을 묻는 API는 없고(한도 도달은 transcript +텍스트로만 확인됐다), 주의력은 애초에 기계적 측정 대상이 아니다. **따라서 "동적"은 머신에서 +숫자를 계산하는 것이 아니라, 코디네이터가 명시된 기준으로 판단하고 한도에 부딪히면 반응적으로 +줄이는 것을 뜻한다.** + +## 2. 목표 / 비목표 + +**목표** + +- Wave 배리어를 제거하고, 의존이 충족된 task를 빈 슬롯만큼 흘려보낸다. +- 슬롯 수를 코디네이터가 제안하고 사용자가 Gate 1에서 승인하며, `LO_MAX_SESSIONS`로 상한을 + 고정할 수 있게 한다. +- 실행 중 task 분할 경로를 연다. 파일이 겹치지 않으면 병렬 이득, 겹치면 리뷰 단위 축소 이득. +- 실패한 의존으로 인한 교착을 "조용한 대기"와 구분해 즉시 드러낸다. + +**비목표** + +- 슬롯 할당과 dispatch를 스크립트가 자동 수행하는 것. 판단(어떤 task를 먼저, 분할 승인 + 여부)은 코디네이터가 유지한다. Orca 가이드도 `Agents still choose placement and + concurrency; Orca does not schedule workers`로 같은 층을 그린다. +- 머신 자원(CPU/메모리) 기반 자동 산출. 보호 대상이 아니다. +- Gate 1 / Gate 2의 제거. 사용자 승인 지점은 그대로 둔다. + +## 3. 설계 + +### 3.1 스케줄러 모델 + +`## Waves` 배열이 **의존 그래프 + 슬롯 회계**로 교체된다. Wave는 Gate 1 보고서에서 예상 +흐름을 보여주는 예시로만 남고, 실행 규칙이 아니다. + +**새 산출물 `.orchestration/graph.json`** — Phase 2가 사람용 `conflict-matrix.md`와 **함께** +쓴다. 마크다운 표는 스크립트가 읽을 수 없기 때문이다. + +```json +{ "tasks": [ + { "id": "t1", "deps": [], "files": ["src/auth/**"], "outputs": ["AuthToken"] }, + { "id": "t3", "deps": ["t1"], "files": ["src/api/**"], "consumes": ["AuthToken"] } +] } +``` + +**`ready-set.sh`** — 입력은 `graph.json` + `.orchestration/status/*.json`, 출력은 지금 +디스패치해도 되는 task id 목록. 순수 그래프 연산이므로 bats로 고정할 수 있고, 재진입 시 같은 +상태면 같은 답을 낸다. + +``` +ready = deps 전부 충족 ∧ 자기 phase가 pending (아직 한 번도 디스패치 안 됨) +busy = phase ∉ {pending, approved, merged, done, failed} (디스패치됐고 아직 안 끝남) +free = cap − |busy| +dispatch = ready 중 free 개 +``` + +**슬롯은 디스패치된 순간부터 종료 상태에 도달할 때까지 점유된다.** `planning`/`implementing` +뿐 아니라 `plan_ready`(계획 리뷰 대기)와 `impl_done`(diff 리뷰 대기)도 점유로 센다. 그 +task의 워커는 놀고 있어도 워크트리·세션을 쥐고 있고, 무엇보다 **코디네이터의 주의력을 +점유**하고 있기 때문이다 — 그것이 이 캡이 보호하는 자원이다. 리뷰 대기를 슬롯에서 빼면 +"리뷰 안 된 task 10개가 쌓인 채 새 task가 계속 들어오는" 상태가 되어 캡이 무의미해진다. + +**deps 충족 기준은 `approved`다 — `impl_done`이 아니다.** B가 A의 미검토 인터페이스 위에 +작업을 시작했는데 A가 rework로 시그니처가 바뀌면 B의 작업이 통째로 무효가 된다. 현행 Wave +모델도 실질적으로 이 의미("previous Wave is fully approved")였고 그 안전성은 유지한다. +결과적으로 **전역 배리어가 task별 의존 대기로 좁아지는 것**이지, 배리어가 완전히 사라지는 +것은 아니다. + +### 3.2 슬롯 수 결정 + +코디네이터가 Gate 1에서 숫자를 제안하고, 무엇을 보호하는 값인지(주의력/예산)와 근거(task +수·크기·위험도)를 함께 밝힌다. 사용자가 승인하거나 고친다. + +`LO_MAX_SESSIONS`가 설정돼 있으면 그 값이 **상한**이며 코디네이터의 제안을 덮어쓴다. +`LO_PHASE_TIMEOUTS`와 같은 성격의 노브이고, 같은 `LO_*` 환경변수 계열을 따른다. +`tools.json`에는 넣지 않는다 — 그 파일은 역할(role) 매핑 전용이며, 캡은 역할이 아니다. + +### 3.3 한도/stall 반응 + +`watch-status.sh` exit 7 또는 `orca-worker-stalled.sh`가 stall을 보고하면 **큐 투입만 +중단하고 실행 중인 워커는 건드리지 않는다.** 그리고 사용자에게 보고하고 지시를 기다린다. + +근거: 2026-08-07 런에서 한도 해소 후 워커가 자동 재개됐다. 재기동했다면 오히려 작업을 +잃었을 상황이었다. + +### 3.4 실행 루프 (Phase 3+4 통합) + +Wave마다 Phase 3 → Phase 4를 반복하던 구조가 task 단위 단일 루프가 된다. + +``` +반복: + 1. ready-set.sh → ready 목록 + 빈 슬롯 수 + 2. min(빈슬롯, |ready|) 디스패치 — 이 시점에 brief 작성 + 3. 이벤트 대기 → orca-wait.sh / watch-status.sh + 4. impl_done → diff 리뷰 → approved 또는 rework 주입 + 5. 전 task approved → Phase 5 (통합 테스트) +``` + +**brief 주입 시점이 정확해진다.** 지금은 Wave 시작 때 선행 Wave의 인터페이스를 한꺼번에 +주입하지만, 앞으로는 각 task를 **디스패치하는 순간** 그 task가 실제로 consume하는 선행 +시그니처만 넣는다. 그 시점에 선행은 이미 `approved`라 시그니처가 확정돼 있다. + +**`watch-status.sh`에 any-도달 모드가 필요하다.** 현재 계약은 "N개가 목표 phase에 전부 +도달하면 exit 0"인데, 슬롯 스케줄러는 "추적 중인 task 중 하나라도 도달하면 즉시 반환"이 +필요하다 — 4개 중 1개만 끝나도 빈 슬롯을 채워야 하기 때문이다. Orca 쪽 `orca-wait.sh`는 +이미 이벤트 기반(worker_done 하나만 와도 반환)이라 그대로 쓴다. 이 비대칭이 이번 작업에서 +가장 손이 많이 가는 부분이다. + +**재진입은 단순해진다.** "어느 Wave 중간인지"를 복원할 필요가 없어지고, `graph.json` + +`status/*.json`을 읽어 `ready-set.sh`를 돌리면 끝이다. Wave 인덱스 같은 중간 상태가 없다. + +### 3.5 실행 중 task 분할 + +**트리거는 기존 채널을 쓴다.** 워커가 plan 단계나 구현 중에 판단하면 Orca는 `ask`, tmux는 +`ask-coordinator.sh`로 올린다. 새 채널을 만들지 않는다. + +**워커는 제안에 파일 범위를 반드시 붙인다** — 조각 각각이 어떤 파일을 만지고 무엇을 +export하는지. 없으면 코디네이터가 충돌 판정을 할 수 없어 제안이 판단 불가가 된다. brief +템플릿의 ask 규칙에 이 요구사항을 명시한다. + +**코디네이터의 판정은 충돌 매트릭스 한 번이다.** + +| 겹침 | 처리 | 얻는 것 | +|---|---|---| +| 안 겹침 | `graph.json`에 새 노드, 새 워크트리, ready-set 진입 → 빈 슬롯이 집어감 | 병렬성 | +| 겹침 | 같은 워크트리·같은 워커에 후속 task로 부착 (`deps: [parent]`) | 리뷰/rework 단위 축소 | + +겹치는 경우 별도 워크트리를 만들지 않는 것이 중요하다. B가 A의 파일을 편집해야 하는데 A의 +코드는 Phase 6 전까지 통합 브랜치에 없다. 현재 의존 처리는 **인터페이스 시그니처 주입**이라 +"B가 다른 파일을 쓰면서 A의 export를 소비"할 때만 성립하고, 같은 파일을 편집하는 분할에는 +통하지 않는다. 겹치면 같은 워크트리에서 같은 워커가 이어서 하며, 이는 이미 있는 +메커니즘이다 — Orca는 `--terminal ` 재사용, tmux는 세션 재사용. + +**분할 깊이는 1로 제한한다.** 쪼개서 나온 조각은 다시 쪼갤 수 없다. 재귀적 분할로 일을 +미루는 경로를 막고, `graph.json`이 사람이 못 읽는 물건이 되는 것을 막는다. 더 깊이 필요하면 +Phase 2 분해가 틀렸다는 신호이므로 사용자에게 올린다. + +**거절도 명시적 응답이어야 한다.** 코디네이터가 "쪼개지 말고 끝내라"고 판단하면 반드시 +`reply`를 보낸다. 답이 없으면 워커가 자체 판단으로 진행한다 — v1.4.1에서 고친 ask-타임아웃 +문제가 정확히 여기서 재발할 수 있다. + +**분할 승인은 코디네이터가 결정하고 즉시 보고한다.** 사용자의 블로킹 승인을 받지 않는다. +Gate 1에서 승인한 task 목록이 늘어나는 것은 맞지만, 분할마다 사람을 기다리면 이 설계가 +없애려던 정체가 그대로 돌아온다. 대신 판정 근거(겹침 여부, 스케줄 변화)를 즉시 보고해 +사용자가 개입할 수 있게 한다. + +## 4. 실패 처리 + +**이 모델이 새로 만드는 실패 모드가 있다.** Wave 모델에서는 task 실패가 그 Wave를 눈에 띄게 +멈췄다. ready-set에서는 실패한 task의 의존자들이 영영 ready에 나타나지 않고, 루프는 "지금은 +던질 게 없네"로 읽어 조용히 대기한다. v1.4.1에서 `orca-wait.sh`에 고친 것과 같은 종류의 +버그다 — 조용한 것과 고장난 것이 구분되지 않는. + +`ready-set.sh`는 네 상태를 구분한다. + +| 종료코드 | 의미 | 코디네이터 행동 | +|---|---|---| +| 0 | 던질 task 있음 (stdout에 id 목록) | 디스패치 | +| 2 | 던질 것 없음, 실행 중인 워커 있음 | 이벤트 대기 | +| 3 | 던질 것도 실행 중인 것도 없는데 미완료 task가 남음 | **교착** — 실패한 의존 또는 사이클. 대기 금지, 즉시 보고 | +| 4 | `graph.json`/status를 못 읽음 | 거부. 추측 금지 | +| 5 | 전 task가 종료 상태 | Phase 5로 진행 | + +**3번이 핵심 안전장치다.** 없으면 실패 하나가 전체 오케스트레이션을 무한 대기로 몬다. + +종료코드는 이 스크립트의 자체 계약이다(`watch-status.sh`의 4와 `orca-wait.sh`의 4가 이미 +서로 다른 의미인 것과 같다). 각 코드의 뜻은 스크립트 헤더에 적는다. + +**graph 검증** — 사이클 검사, 분할 깊이 1 초과 거부, 중복 output 검사는 **Phase 2의 최초 +`graph.json` 작성 시점과 분할로 인한 변경 시점 양쪽에서** 같은 규칙으로 돈다. 분해가 처음부터 +사이클을 만들 수도 있기 때문이다. 하나라도 걸리면 변경을 적용하지 않고 거부 사유를 보고한다 +— 반쯤 적용된 그래프가 남으면 재진입이 깨진다. 검증을 빠져나간 사이클은 런타임에 +`ready-set.sh`의 exit 3이 잡는다(두 번째 방어선). + +## 5. 테스트 + +`ready-set.sh` — bats: + +- 정상: 의존 충족 → 디스패치, 빈 슬롯 수만큼만 +- 에러: 깨진 JSON → 4, 사이클 → 3 +- 경계값: 빈 그래프, 전부 완료(5), task 1개, ready > 슬롯, **실패한 의존을 가진 task → 3** +- 슬롯 회계: `LO_MAX_SESSIONS` 상한이 코디네이터 제안을 덮어쓰는지 + +`watch-status.sh` — any-도달 모드 신규 테스트 + **기존 all-N 모드 무회귀**. + +분할 — 겹침 → `deps:[parent]`, 안 겹침 → `deps:[]`, 깊이 2 시도 → 거부, 사이클 유발 → 거부. + +기존 387개 bats는 전부 그린을 유지한다. PR 통과 조건이다. + +## 6. 착지 순서 + +**PR 1 — 스케줄러 교체**: `graph.json`, `ready-set.sh`, `watch-status.sh` any-도달 모드, +Phase 2/3/4 재작성, 재진입, 슬롯 제안 + `LO_MAX_SESSIONS`. + +**PR 2 — 분할 경로**: 워커 ask 규칙(파일 범위 필수), 코디네이터 판정, graph 변경 + 방어, +겹침 시 같은 워커 부착. + +두 PR로 끊는 이유: 스케줄러 교체만으로도 Phase 3·4 전면 재작성과 재진입·brief 주입 시점이 +바뀐다. 여기에 분할까지 한 커밋에 넣으면 문제가 생겼을 때 원인을 가릴 수 없다. diff --git a/skills/orchestrate/SKILL.md b/skills/orchestrate/SKILL.md index 0c41a27..07a6259 100644 --- a/skills/orchestrate/SKILL.md +++ b/skills/orchestrate/SKILL.md @@ -81,9 +81,33 @@ creates — component/schema/endpoint/type), and **consumes** (another task's ou it depends on). Build a conflict/dependency matrix from those and topologically sort into Waves (`conflict-matrix.md`): a dependency edge `A → B` means B consumes A's output, so A's Wave precedes B's. Detect duplicate outputs and assign a single -producer; others consume (add a dependency edge). Apply a **concurrent-session cap** -(default 4) — if a Wave exceeds it, split it or ask. Tasks in the same Wave are -independent (parallel); a later Wave starts only after the previous Wave is approved. +producer; others consume (add a dependency edge). Write BOTH artifacts: `conflict-matrix.md` for humans and +`.orchestration/graph.json` for the scheduler. A markdown table is not machine +readable. + +```json +{ "tasks": [ + { "id": "t1", "deps": [], "files": ["src/auth/**"], "outputs": ["AuthToken"] }, + { "id": "t3", "deps": ["t1"], "files": ["src/api/**"], "consumes": ["AuthToken"] } +] } +``` + +Waves are **an illustration in the Gate 1 report, not an execution unit.** +Execution is decided by `ready-set.sh`: a task runs as soon as its dependencies +are `approved` and a slot is free. Still topologically sort — the result shows +the user the expected flow — but nothing waits on a Wave boundary. + +**Propose the slot count.** Pick the number from the task count, their size, and +their risk, and **say what the number protects**: this cap guards **coordinator attention** and **API usage/budget**, not machine resources. Neither is +queryable, which is why it is a judgement rather than a computation. A slot is +held from dispatch until the task reaches a terminal state — `plan_ready` and +`impl_done` (review pending) count as held, because a pile of unreviewed tasks +next to a stream of new ones makes the cap meaningless. When `LO_MAX_SESSIONS` +is set it is an upper bound and overrides the proposal. + +After writing it, run `scripts/ready-set.sh ` once and +confirm it does **not** exit 4 — malformed JSON, a missing `.tasks` array, and a +dependency naming an undefined task are all caught here. **Visual spec (`design` role).** While extracting the above, flag each task that is UI-facing *and* whose source issue references a design (e.g. a Figma link). If the @@ -93,7 +117,9 @@ tasks, or any task with no design reference, skip this. With `design` unset, ign design links entirely — the original behavior. ## 🚦 Gate 1 — task-split approval (REQUIRED) -Report the task list, Waves, session count, and a rough cost note. **Wait for the +Report the task list, the dependency graph (showing the expected flow as Waves is +fine), the proposed **slot count with its rationale and what it protects**, and a +rough cost note. **Wait for the user's approval** before launching anything. **Substrate — ask here, in this same turn.** Before writing that report, run @@ -106,10 +132,44 @@ detected Orca always asks — there is no default, no remembered choice, no environment override. Launch nothing until both the split and the substrate are answered. -## Phase 3 — Launch + plan (per Wave) -**Phases 3–4 repeat per Wave in `## Waves` order.** A later Wave launches only after -the previous Wave is fully approved; `` below = the *current* Wave's task count. -Single-Wave splits run everyone in parallel (the original behavior). +## Phase 3 — Launch + plan (dispatch loop) +**Phases 3–4 are one dispatch loop, not a per-Wave repeat.** Each round: + +1. `scripts/ready-set.sh .orchestration/graph.json .orchestration/status ` + → **0** dispatch the printed ids, **2** nothing dispatchable but work is in + flight (go wait for an event), **3** **DEADLOCK** — a failed dependency or a + cycle: **do not wait**, report it and get a human decision (with no worker + running, no event can ever arrive); after the human intervenes, return to + step 1 to re-run the check, **4** the graph or status could not be read — + refuse, do not guess; fix the error then re-run step 1, **5** every task is + in a terminal state → go to Phase 5. +2. For each dispatched task (`` = the number of tasks in this round): + - tmux: **0** (Preceding-interface injection) + steps **1–3** below (setup, + brief, launch, watch plan_ready). Orca: **O1–O5**. + - **1** `scripts/setup-worktrees.sh ...` then + verify with `git worktree list`. + - **2** Per task: write `briefs/.md` (templates/brief.md) — fill + `` and `` — then launch session and watch + until `plan_ready` (step 3 below). **Write the brief at dispatch time.** + It only needs the signatures this task consumes, and by then those are + `approved`, so they're settled. + - **3** Collect `plans/.md` when each session reaches `plan_ready`. +3. For each planned task, deliver §2 (implement) with `scripts/send-prompt.sh + send lo- ""` (tmux, see Phase 4 for exit-code branch logic), or + `orca orchestration task-create` the implement Task then + `scripts/orca-worker-start --task --terminal ` (Orca). + On delivery failure, re-run step 3 after fixing the error. +4. Wait for event. tmux: `scripts/watch-status.sh --tasks + impl_done ` — without `--tasks` the tasks approved in + earlier rounds satisfy `expected=` immediately and the wait spins. Orca: + `scripts/orca-wait.sh` with the implement Task ids, already event-driven. +5. On wake, handle that task: review each worktree diff (`git -C diff + ...HEAD`). If tests weak, audit with `test-quality-auditor`. On + approval, return to step 1 — whatever dependency it released shows up in the + next `ready-set.sh` round and the freed slot refills immediately. On rework + needed, write `reviews/-rN.md` and re-deliver with `send-prompt.sh + send` (or new Orca Task on same terminal); after 3 failed rounds, escalate. + When `ready-set.sh` returns **5**, go to Phase 5. **Session knobs (tmux substrate, set once per run):** `export LO_RUN_ID=` so every `launch-session.sh` gets a collision-proof name `lo--` (reuse that @@ -344,9 +404,9 @@ wait with `scripts/orca-wait.sh`. Rework rounds are further Tasks on the same worktree diff (`git -C diff ...HEAD`); if a session's tests look weak, **cross-call `test-quality-auditor` yourself** (self-call + orchestrator cross-call). On shortfall, write `reviews/-rN.md`, inject §3 (rework), repeat. After 3 -failed rounds, escalate. When this Wave's tasks are all approved, return to Phase 3 -step 0 for the next Wave (inject its preceding-interface signatures); once the last -Wave is approved, go to Phase 5. +failed rounds, escalate. When a task is approved, return to step 1 of the dispatch +loop — whatever dependency it released shows up in the next `ready-set.sh` round and +the freed slot is refilled immediately. When `ready-set.sh` returns **5**, go to Phase 5. ## Phase 5 — Integration test loop (max 3) Merge-preview onto the integration branch and run the integration tests (use the @@ -379,7 +439,9 @@ worktree) still fire — a dry run never looks safer than the real one. On re-invocation with no context, measure real state first: `git worktree list`, each `.orchestration/status/*.json` phase, and which `briefs/plans/reviews/` artifacts exist. Resume from the earliest incomplete step (idempotently skip done -steps). Check `tmux ls`, and run `scripts/tmux-worker-stalled.sh ` on each +steps). There is no intermediate state such as a Wave index to restore. Reading +`.orchestration/graph.json` plus `status/*.json` and running `ready-set.sh` IS +the restored state — the same inputs always yield the same answer. Check `tmux ls`, and run `scripts/tmux-worker-stalled.sh ` on each live one — a session that exists is not a worker that moves. Relaunch dead sessions and re-deliver the right prompt with `scripts/send-prompt.sh send`. For leftovers of a run that already died, `scripts/safe-cleanup.sh list-orphans ` enumerates them diff --git a/skills/orchestrate/scripts/ready-set.sh b/skills/orchestrate/scripts/ready-set.sh new file mode 100755 index 0000000..e3d5a7d --- /dev/null +++ b/skills/orchestrate/scripts/ready-set.sh @@ -0,0 +1,118 @@ +#!/bin/sh +# ready-set.sh — which tasks may be dispatched right now? +# +# This is the Wave-barrier replacement. Waves made the whole batch wait for its +# slowest member; here a task is dispatchable the moment its own dependencies +# are approved and a slot is free, so a finished worker is refilled immediately. +# +# usage: ready-set.sh +# {"tasks":[{"id":"t1","deps":["t0"]}, ...]} — Phase 2 writes it. +# Only `id` and `deps` are read here; files/outputs/consumes are +# the coordinator's conflict-matrix fields. +# .orchestration/status — one .json per task, `.phase` +# written by status-update.sh. A task with no file is `pending`. +# the coordinator's approved slot count. LO_MAX_SESSIONS, when +# set, is an UPPER BOUND on it, never a raise. +# +# exit 0 dispatch these (stdout: one task id per line, at most of them) +# exit 2 nothing to dispatch, but tasks are in flight — wait for an event +# exit 3 nothing to dispatch, nothing in flight, unfinished tasks remain — +# DEADLOCK (a failed dependency, or a cycle). Never wait on this: with +# no worker running, no event can ever arrive. Report it. +# exit 4 the graph or the status dir could not be read, or an argument is +# invalid — refuse rather than guess +# exit 5 every task is in a terminal state — the run is complete +# exit 127 jq not found +# +# A dependency counts as satisfied only at `approved` or higher, NOT at +# impl_done: a task that consumes an unreviewed interface has to be redone when +# rework changes that signature. This is the Wave model's "previous Wave fully +# approved" guarantee, narrowed from a global barrier to a per-task wait. +# +# env: +# LO_MAX_SESSIONS upper bound on (a tuning knob like LO_PHASE_TIMEOUTS) +set -u + +JQ=$(command -v jq) || { echo "ready-set: jq not found" >&2; exit 127; } + +graph="${1:-}"; sdir="${2:-}"; cap="${3:-}" +[ -n "$graph" ] && [ -n "$sdir" ] && [ -n "$cap" ] || { + echo "usage: ready-set.sh " >&2; exit 4; } +[ -f "$graph" ] || { echo "ready-set: graph '$graph' not found" >&2; exit 4; } +[ -d "$sdir" ] || { echo "ready-set: status dir '$sdir' not found" >&2; exit 4; } + +case "$cap" in ''|*[!0-9]*) echo "ready-set: cap must be a positive integer" >&2; exit 4 ;; esac +[ "$cap" -gt 0 ] || { echo "ready-set: cap must be > 0" >&2; exit 4; } + +# LO_MAX_SESSIONS caps the cap. It is a ceiling the operator sets, so it lowers +# the coordinator's proposal and never raises it. +if [ -n "${LO_MAX_SESSIONS:-}" ]; then + case "$LO_MAX_SESSIONS" in + ''|*[!0-9]*) echo "ready-set: LO_MAX_SESSIONS must be a positive integer" >&2; exit 4 ;; + esac + [ "$LO_MAX_SESSIONS" -gt 0 ] || { echo "ready-set: LO_MAX_SESSIONS must be > 0" >&2; exit 4; } + [ "$LO_MAX_SESSIONS" -lt "$cap" ] && cap="$LO_MAX_SESSIONS" +fi + +ids=$("$JQ" -r '.tasks[]?.id // empty' "$graph" 2>/dev/null) || { + echo "ready-set: graph '$graph' is not valid JSON" >&2; exit 4; } + +# `.tasks` absent is a malformed graph, not an empty one — tell them apart so a +# typo'd key cannot read as "nothing to do". +"$JQ" -e 'has("tasks") and (.tasks | type == "array")' "$graph" >/dev/null 2>&1 || { + echo "ready-set: graph '$graph' has no .tasks array" >&2; exit 4; } + +phase_of() { # $1 = task id -> its recorded phase, or "pending" when unrecorded + f="$sdir/$1.json" + [ -f "$f" ] || { echo pending; return; } + p=$("$JQ" -r '.phase // "pending"' "$f" 2>/dev/null) || p=pending + [ -n "$p" ] || p=pending + echo "$p" +} + +is_satisfied() { # $1 = phase -> 0 when a dependent may start on it + case "$1" in approved|merged|done) return 0 ;; *) return 1 ;; esac +} +is_terminal() { # $1 = phase -> 0 when the task will never occupy a slot again + case "$1" in approved|merged|done|failed) return 0 ;; *) return 1 ;; esac +} + +busy=0; unfinished=0; ready="" +for id in $ids; do + ph=$(phase_of "$id") + if is_terminal "$ph"; then + # `failed` is terminal for scheduling but is NOT completion: it leaves its + # dependents permanently unreachable, which is what exit 3 exists to report. + [ "$ph" = failed ] && unfinished=$((unfinished + 1)) + continue + fi + unfinished=$((unfinished + 1)) + if [ "$ph" != pending ]; then busy=$((busy + 1)); continue; fi + + deps=$("$JQ" -r --arg id "$id" '.tasks[] | select(.id == $id) | .deps[]? // empty' "$graph" 2>/dev/null) + ok=1 + for d in $deps; do + # A dep naming a task the graph does not define is a malformed graph, not an + # unsatisfied edge — refuse instead of silently blocking that task forever. + echo "$ids" | grep -qx "$d" || { echo "ready-set: task '$id' depends on unknown '$d'" >&2; exit 4; } + is_satisfied "$(phase_of "$d")" || { ok=0; break; } + done + [ "$ok" = 1 ] && ready="$ready $id" +done + +[ "$unfinished" -eq 0 ] && { echo "[ready-set] all tasks terminal"; exit 5; } + +free=$((cap - busy)) +[ "$free" -lt 0 ] && free=0 + +n=0 +for id in $ready; do + [ "$n" -ge "$free" ] && break + echo "$id"; n=$((n + 1)) +done +[ "$n" -gt 0 ] && exit 0 + +[ "$busy" -gt 0 ] && { echo "[ready-set] nothing dispatchable, $busy in flight — wait" >&2; exit 2; } + +echo "[ready-set] DEADLOCK: $unfinished task(s) unfinished, none dispatchable, none running — a failed dependency or a cycle. Inspect the graph and status; do not wait." >&2 +exit 3 diff --git a/skills/orchestrate/scripts/watch-status.sh b/skills/orchestrate/scripts/watch-status.sh index a3c5e77..9897a0f 100755 --- a/skills/orchestrate/scripts/watch-status.sh +++ b/skills/orchestrate/scripts/watch-status.sh @@ -3,7 +3,11 @@ # phase, then exit 0. The orchestrator launches this with run_in_background; on # exit the harness re-invokes the orchestrator. # -# usage: watch-status.sh [timeout-sec] [interval-sec] +# usage: watch-status.sh [--tasks ] [timeout-sec] [interval-sec] +# --tasks: scope the scan to only the given comma-separated task ids. The slot +# scheduler needs to wake as soon as ANY currently-running task reaches the target; +# without scoping, the full status dir would count tasks approved in earlier rounds +# and the wait would spin on stale data. # exit 0: all reached target (or higher) # exit 2: timeout # exit 3: a failed session detected (abort → orchestrator intervenes) @@ -32,6 +36,20 @@ set -eu JQ=$(command -v jq) || { echo "watch-status: jq not found" >&2; exit 127; } +# --tasks scopes the scan to the given ids. The slot scheduler needs "any ONE of +# the tasks I am currently running reached the target"; counting the whole status +# dir would satisfy expected=1 from tasks approved in earlier rounds and spin. +only="" +while [ $# -gt 0 ]; do + case "$1" in + --tasks) shift; only="${1:-}"; [ -n "$only" ] || { echo "watch-status: --tasks needs a comma-separated id list" >&2; exit 4; }; shift ;; + --) shift; break ;; + -*) echo "watch-status: unknown option '$1'" >&2; exit 4 ;; + *) break ;; + esac +done +# Captured AFTER option parsing: argc decides whether the 4th POSITIONAL was +# given, and options must not be counted toward it. argc=$# dir="$1"; target="$2"; expected="$3"; timeout="${4:-3600}"; interval="${5:-15}" @@ -128,6 +146,12 @@ while [ "$elapsed" -lt "$budget" ]; do done_count=0; failed=0; summary=""; stalled="" for f in "$dir"/*.json; do [ -f "$f" ] || continue + if [ -n "$only" ]; then + base=${f##*/}; base=${base%.json} + # Exact membership on a comma-delimited list: the commas around both sides + # keep `t1` from matching `t12`. + case ",$only," in *",$base,"*) : ;; *) continue ;; esac + fi ph=$("$JQ" -r '.phase // "pending"' "$f" 2>/dev/null || echo "pending") tk=$("$JQ" -r '.task // "?"' "$f" 2>/dev/null || echo "?") summary="$summary $tk:$ph" diff --git a/tests/ready-set.bats b/tests/ready-set.bats new file mode 100644 index 0000000..5163391 --- /dev/null +++ b/tests/ready-set.bats @@ -0,0 +1,113 @@ +#!/usr/bin/env bats +# Tests for ready-set.sh — the Wave-barrier replacement. Given the dependency +# graph and each task's recorded phase, it answers exactly one question: +# which tasks may be dispatched right now. It never launches anything. + +setup() { + RS="${BATS_TEST_DIRNAME}/../skills/orchestrate/scripts/ready-set.sh" + G="${BATS_TEST_TMPDIR}/graph.json" + S="${BATS_TEST_TMPDIR}/status" + mkdir -p "$S" +} + +graph() { printf '%s' "$1" > "$G"; } +phase() { printf '{"task":"%s","phase":"%s"}' "$1" "$2" > "$S/$1.json"; } + +@test "no deps, empty status: every task is dispatchable up to the cap" { + graph '{"tasks":[{"id":"t1","deps":[]},{"id":"t2","deps":[]},{"id":"t3","deps":[]}]}' + run sh "$RS" "$G" "$S" 2 + [ "$status" -eq 0 ] + [ "$(echo "$output" | grep -c .)" -eq 2 ] +} + +@test "a dependent waits until its dep is approved, not merely impl_done" { + graph '{"tasks":[{"id":"t1","deps":[]},{"id":"t2","deps":["t1"]}]}' + phase t1 impl_done + run sh "$RS" "$G" "$S" 4 + [ "$status" -eq 2 ] # t1 busy (review pending), t2 not yet startable + + phase t1 approved + run sh "$RS" "$G" "$S" 4 + [ "$status" -eq 0 ] + [ "$output" = "t2" ] +} + +@test "review-pending phases occupy a slot (plan_ready and impl_done both count)" { + graph '{"tasks":[{"id":"a","deps":[]},{"id":"b","deps":[]},{"id":"c","deps":[]}]}' + phase a plan_ready + phase b impl_done + run sh "$RS" "$G" "$S" 2 + [ "$status" -eq 2 ] # cap 2 fully occupied by two review-waiting tasks +} + +@test "a failed dependency is a deadlock, never a quiet wait (the core guard)" { + graph '{"tasks":[{"id":"t1","deps":[]},{"id":"t2","deps":["t1"]}]}' + phase t1 failed + run sh "$RS" "$G" "$S" 4 + [ "$status" -eq 3 ] + [[ "$output" == *"DEADLOCK"* ]] +} + +@test "a cycle surfaces as the same deadlock (boundary)" { + graph '{"tasks":[{"id":"t1","deps":["t2"]},{"id":"t2","deps":["t1"]}]}' + run sh "$RS" "$G" "$S" 4 + [ "$status" -eq 3 ] +} + +@test "all tasks terminal: exit 5, the run is complete (boundary)" { + graph '{"tasks":[{"id":"t1","deps":[]},{"id":"t2","deps":["t1"]}]}' + phase t1 approved + phase t2 merged + run sh "$RS" "$G" "$S" 4 + [ "$status" -eq 5 ] +} + +@test "an empty task array is complete, not a deadlock (boundary)" { + graph '{"tasks":[]}' + run sh "$RS" "$G" "$S" 4 + [ "$status" -eq 5 ] +} + +@test "a graph with no .tasks array is refused, not read as empty (error)" { + graph '{"nodes":[]}' + run sh "$RS" "$G" "$S" 4 + [ "$status" -eq 4 ] +} + +@test "malformed JSON is refused (error)" { + graph '}{ not json' + run sh "$RS" "$G" "$S" 4 + [ "$status" -eq 4 ] +} + +@test "a dep naming an undefined task is refused, not blocked forever (error)" { + graph '{"tasks":[{"id":"t1","deps":["ghost"]}]}' + run sh "$RS" "$G" "$S" 4 + [ "$status" -eq 4 ] +} + +@test "LO_MAX_SESSIONS lowers the cap but never raises it" { + graph '{"tasks":[{"id":"a","deps":[]},{"id":"b","deps":[]},{"id":"c","deps":[]}]}' + run env LO_MAX_SESSIONS=1 sh "$RS" "$G" "$S" 3 + [ "$status" -eq 0 ] + [ "$(echo "$output" | grep -c .)" -eq 1 ] + + run env LO_MAX_SESSIONS=9 sh "$RS" "$G" "$S" 2 + [ "$(echo "$output" | grep -c .)" -eq 2 ] +} + +@test "a non-numeric or zero cap is refused (boundary)" { + graph '{"tasks":[{"id":"a","deps":[]}]}' + run sh "$RS" "$G" "$S" 0 + [ "$status" -eq 4 ] + run sh "$RS" "$G" "$S" abc + [ "$status" -eq 4 ] +} + +@test "a missing graph or status dir is refused (error)" { + run sh "$RS" "$BATS_TEST_TMPDIR/nope.json" "$S" 2 + [ "$status" -eq 4 ] + graph '{"tasks":[]}' + run sh "$RS" "$G" "$BATS_TEST_TMPDIR/nodir" 2 + [ "$status" -eq 4 ] +} diff --git a/tests/scripts.bats b/tests/scripts.bats index 1f08fd3..1fe2a17 100644 --- a/tests/scripts.bats +++ b/tests/scripts.bats @@ -54,3 +54,80 @@ setup() { [ "$(jq -r 'has("notakeyvalue")' "$STATUS_DIR/task-x.json")" = "false" ] [ "$(jq -r '.worktree | type' "$STATUS_DIR/task-x.json")" = "string" ] } + +@test "watch-status --tasks: only the named tasks are counted" { + mkdir -p "$STATUS_DIR" + printf '{"task":"old","phase":"approved"}' > "$STATUS_DIR/old.json" + printf '{"task":"a","phase":"implementing"}' > "$STATUS_DIR/a.json" + # `old` already passed the target, but it is not being tracked: without + # scoping, expected=1 would be satisfied instantly and the wait would spin. + run bash "$WS" --tasks a "$STATUS_DIR" impl_done 1 2 1 + [ "$status" -eq 2 ] +} + +@test "watch-status --tasks: returns as soon as ANY tracked task reaches target" { + mkdir -p "$STATUS_DIR" + printf '{"task":"a","phase":"impl_done"}' > "$STATUS_DIR/a.json" + printf '{"task":"b","phase":"implementing"}' > "$STATUS_DIR/b.json" + run bash "$WS" --tasks a,b "$STATUS_DIR" impl_done 1 5 1 + [ "$status" -eq 0 ] +} + +@test "watch-status --tasks: an untracked failed task does not abort the wait" { + mkdir -p "$STATUS_DIR" + printf '{"task":"old","phase":"failed"}' > "$STATUS_DIR/old.json" + printf '{"task":"a","phase":"implementing"}' > "$STATUS_DIR/a.json" + run bash "$WS" --tasks a "$STATUS_DIR" impl_done 1 2 1 + [ "$status" -eq 2 ] +} + +@test "watch-status --tasks: an explicit timeout argument still outranks the env" { + # --tasks is consumed before the positional count is taken; if argc were + # captured before that, the 4th positional would stop being recognised and + # LO_PHASE_TIMEOUTS would silently win. + mkdir -p "$STATUS_DIR" + printf '{"task":"a","phase":"implementing"}' > "$STATUS_DIR/a.json" + run env LO_PHASE_TIMEOUTS="impl_done=999" bash "$WS" --tasks a "$STATUS_DIR" impl_done 1 2 1 + [[ "$output" == *"budget=2s"* ]] + [[ "$output" == *"source=arg"* ]] +} + +@test "watch-status: without --tasks every status file is still counted (no regression)" { + mkdir -p "$STATUS_DIR" + printf '{"task":"a","phase":"done"}' > "$STATUS_DIR/a.json" + printf '{"task":"b","phase":"done"}' > "$STATUS_DIR/b.json" + run bash "$WS" "$STATUS_DIR" impl_done 2 5 1 + [ "$status" -eq 0 ] +} + +@test "SKILL.md Phase 2: graph.json artifact and the slot proposal are documented" { + SKILL="${BATS_TEST_DIRNAME}/../skills/orchestrate/SKILL.md" + grep -qF '.orchestration/graph.json' "$SKILL" + grep -qF 'LO_MAX_SESSIONS' "$SKILL" + # The cap must never be a bare number again: the report has to name what it + # protects, or "dynamic" degrades back into a hardcoded 4. + grep -qF 'coordinator attention' "$SKILL" + ! grep -qF 'concurrent-session cap** (default 4)' "$SKILL" +} + +@test "SKILL.md: the dispatch loop structure pins step order and error handling" { + SKILL="${BATS_TEST_DIRNAME}/../skills/orchestrate/SKILL.md" + # Step 1 must run ready-set.sh first to decide what is dispatchable. + grep -qF '1. `scripts/ready-set.sh' "$SKILL" + # Step 1 exit codes 3 and 4 must explicitly return to step 1, not just report + # errors. Look for phrases showing re-entry after human intervention or fix. + grep -q 'return to' "$SKILL" && grep -q 'step 1' "$SKILL" + grep -q 're-run step' "$SKILL" + # Step 3 must deliver the implement prompt (was missing in v1); check that + # both tmux and Orca paths are mentioned. + grep -qF 'deliver §2 (implement)' "$SKILL" + grep -qF 'send-prompt.sh send' "$SKILL" + # Step 4 must wait, scoped to running ids via --tasks to avoid spin. + grep -qF 'watch-status.sh --tasks' "$SKILL" + # Step 5 must close the loop by returning to step 1 on approval. + grep -q 'approval' "$SKILL" && grep -q 'return to' "$SKILL" + # Exit code 3 must be DEADLOCK and documented as "do not wait". + grep -qF 'DEADLOCK' "$SKILL" + # Waves must no longer be described as an execution barrier. + ! grep -qF 'A later Wave launches only after' "$SKILL" +}