diff --git a/README.md b/README.md index 0bd4ea0..812f488 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,25 @@ Harness-agnostic skill bundle that turns any [Multica](https://multica.ai) board | **`multica-workflow`** | Symphony-style 5-lane pipeline mapped onto Multica's fixed 7 states via `phase:*` labels. Per-lane prompt templates + a small `multica-flow` CLI to advance/rewind tickets. | | **`multica-onboarding`** | First-run bootstrap that registers three battle-tested default skills: **obra/superpowers**, **leweii/atlassian-cli**, and **Playwright**. | +### Operations docs (under `docs/`) + +| Page | What it covers | +|------|----------------| +| [`HARNESSES.md`](docs/HARNESSES.md) | Tool-by-tool integration notes | +| [`WORKFLOW.md`](docs/WORKFLOW.md) | Phase semantics + transition matrix | +| [`ARCHITECTURE.md`](docs/ARCHITECTURE.md) | Why the bundle is shaped this way | +| [`SEQUENTIAL_DISPATCH.md`](docs/SEQUENTIAL_DISPATCH.md) | Running 80+ issues in strict order, draining a zombie queue, working around `priority DESC` claim order | + +### Battle-tested scripts (under `examples/`) + +| Script | Purpose | +|--------|---------| +| `import-tasks-from-md.py` | Bulk-create Multica issues from a `tasks.md` checklist (with phase / difficulty labels, parent epic, auto-assign) | +| `rebalance-by-phase.py` | Reassign issues to the right agent based on `phase:*` label without changing status | +| `holdback-wave.py` | Reset stuck issues + flatten priorities + hold future waves in `backlog` | +| `wave-promoter.py` | Promote `phase:foundation` once all `phase:setup` are done, etc. | +| `strict-sequential.py` | Single-flight watcher — one issue at a time, in task-ID order | + The whole bundle is plain `SKILL.md` + bash, so every harness that reads markdown can consume it. --- diff --git a/docs/SEQUENTIAL_DISPATCH.md b/docs/SEQUENTIAL_DISPATCH.md new file mode 100644 index 0000000..7882a4e --- /dev/null +++ b/docs/SEQUENTIAL_DISPATCH.md @@ -0,0 +1,105 @@ +# Sequential dispatch — running 80+ issues in strict order + +Multica's daemon picks the next agent task by + +```sql +ORDER BY agent_task_queue.priority DESC, agent_task_queue.created_at ASC +LIMIT 1 +``` + +so a bulk import where higher-difficulty tickets carry `priority high` will silently jump ahead of `priority medium` setup work. This page collects the patterns we found while running an 86-issue feature spec through a single board. + +## TL;DR + +If you want **T001 → T002 → …** to run in order: + +1. **Flatten priorities** at import time (or update after) so the queue ordering becomes pure FIFO on `created_at`. +2. Cap each agent: `multica agent update --max-concurrent-tasks 1`. +3. **Hold the future work in a non-claimable status** (`backlog` or `cancelled`). Only the issue you want to run next stays in `todo`. +4. After each completion (status → `in_review` / `done`), promote the next issue from the holding pool back to `todo` and reassign it. +5. If a botched run already left zombies in `agent_task_queue`, drain them with `POST /api/agents/{id}/cancel-tasks` (see below). Flipping the underlying issue status alone does **not** cancel queued rows. + +## Reference: claim semantics + +`multica/server/server/pkg/db/queries/agent.sql`: + +```sql +-- name: ClaimAgentTask :one +UPDATE agent_task_queue +SET status = 'dispatched', dispatched_at = now() +WHERE id = ( + SELECT atq.id FROM agent_task_queue atq + WHERE atq.agent_id = $1 AND atq.status = 'queued' + … + ORDER BY atq.priority DESC, atq.created_at ASC + LIMIT 1 + FOR UPDATE SKIP LOCKED +) +RETURNING *; +``` + +Key takeaways: + +- The claim path does **not** read `issue.status`. Moving an issue to `backlog` after assignment leaves its queue row claimable. +- `priority` is set at `agent_task_queue` insert time from the **issue's** priority enum. Updating the issue priority afterwards does not rewrite the queue rows. +- `FOR UPDATE SKIP LOCKED` means many daemons can drain in parallel safely, but the order across them is still per-priority FIFO. + +## Single-flight loop + +```python +# examples/strict-sequential.py – run once or with --watch +# Promotes the next holding (cancelled/backlog) issue to `todo` once the +# currently-active one reaches `in_review` or `done`. +``` + +The watcher also re-applies the desired agent owner per phase (claude-code for setup/foundation, codex for user-story implementation, etc.). + +## Bulk-cancel the agent task queue + +When `multica daemon start` keeps reviving long-dead work, the queue itself needs draining. The CLI doesn't expose this yet; the server route does: + +```bash +TOK=mul_… # PAT from `multica login` +WS=$(multica config show | awk '/workspace_id/{print $2}') +AGENT=$(multica agent list --output json | jq -r '.[] | select(.name=="claude-code") | .id') + +curl -sS -X POST \ + -H "Authorization: Bearer $TOK" \ + -H "X-Workspace-ID: $WS" \ + http://localhost:9090/api/agents/$AGENT/cancel-tasks +# {"cancelled":N} +``` + +- Route prefix is `/api` (not `/api/v1`). +- `X-Workspace-ID` is required when using the CLI's workspace token; without it you'll get `workspace not found`. +- The response field `cancelled` is the number of `queued|dispatched|running` rows transitioned to `cancelled` — it does not include rows that were already terminal. + +## Bulk import recipe + +The shipping example `import-tasks-from-md.py` parses a markdown checklist (`tasks.md`) and creates one Multica issue per checkbox. Tweak these defaults the next time you import: + +- Map all difficulties to `medium` priority unless something is genuinely a fire. The `phase:*` label is more useful for ordering than `priority`. +- Add a single setup comment on each issue with the working repo URL, so the agent's first turn doesn't trip on "no git repository in workdir". +- Hold everything but the first wave in `backlog` immediately after creation (the import script's `--hold-after-create` flag does this). + +## Related + +- `examples/strict-sequential.py` — single-flight promoter +- `examples/wave-promoter.py` — wave-based promotion (promote `phase:foundation` once all `phase:setup` are done, etc.) +- `examples/holdback-wave.py` — initial state setup: reset stuck issues + flatten priorities + hold future waves +- `examples/rebalance-by-phase.py` — reassign issues to the desired agent based on `phase:*` label, leaving issue status untouched +- `examples/import-tasks-from-md.py` — bulk-create issues from a tasks.md checklist + +## Caveat: after `cancel-tasks`, you must re-enqueue assigned issues + +`POST /api/agents/{id}/cancel-tasks` flips every `queued|dispatched|running` row to `cancelled` — including the row that was created by a previous `multica issue assign`. The assignment on the issue remains, but the queue has nothing for the daemon to claim, so the task **looks scheduled but never starts**. + +Force a fresh queue row: + +```bash +multica issue assign $ID --unassign +multica issue assign $ID --to claude-code +multica issue rerun $ID +``` + +The single-flight watcher in `examples/strict-sequential.py` does this on every promotion. diff --git a/examples/holdback-wave.py b/examples/holdback-wave.py new file mode 100644 index 0000000..64b1126 --- /dev/null +++ b/examples/holdback-wave.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""순차 실행 세팅 스크립트. + +동작: +- 모든 stuck(blocked/in_progress/in_review) 이슈를 todo 로 리셋한다. +- 1차 wave(phase:setup) 만 todo, 나머지(foundation/us1/us2/us3/hardening)는 backlog 로 옮긴다. +- 에이전트의 max_concurrent_tasks 를 1 로 낮춰 안전하게 순차 처리. +""" +from __future__ import annotations + +import json +import subprocess +import sys + +PROJECT_ID = "b38cb695-5c18-4aaa-9d08-e948817824b7" + +WAVES = [ + "phase:setup", # wave 0 (즉시 todo) + "phase:foundation", + "phase:us1", + "phase:us2", + "phase:us3", + "phase:hardening", +] + +ACTIVE_WAVE = "phase:setup" + + +def run(args, capture=True): + res = subprocess.run(args, capture_output=capture, text=True) + if res.returncode != 0: + print(f"ERR: {' '.join(args)}\n{res.stderr}", file=sys.stderr) + return None + if capture and res.stdout.strip(): + try: + return json.loads(res.stdout) + except json.JSONDecodeError: + return None + return None + + +def list_issues(): + out = [] + offset = 0 + while True: + data = run([ + "multica","issue","list","--project",PROJECT_ID, + "--limit","100","--offset",str(offset),"--output","json"]) + if not data: + break + batch = data.get("issues", []) if isinstance(data, dict) else data + if not batch: + break + out.extend(batch) + if isinstance(data, dict) and data.get("has_more"): + offset += len(batch) + else: + break + return out + + +def main(): + issues = list_issues() + print(f"# {len(issues)} issues fetched", file=sys.stderr) + promoted = 0 + backlog = 0 + reset = 0 + for issue in issues: + if issue["title"].startswith("[EPIC]"): + continue + labels = {l["name"] for l in issue.get("labels", [])} + ident = issue.get("identifier") + current = issue.get("status") + + # 1) 1차 wave (phase:setup) 는 todo 로 통일 + if ACTIVE_WAVE in labels: + if current not in ("todo",): + run(["multica","issue","status",issue["id"],"todo","--output","table"], capture=False) + promoted += 1 + print(f" [setup wave] {ident} {current} → todo") + continue + + # 2) 나머지는 backlog 로 (자동 dispatch 방지) + if current != "backlog": + run(["multica","issue","status",issue["id"],"backlog","--output","table"], capture=False) + if current in ("blocked","in_progress","in_review"): + reset += 1 + else: + backlog += 1 + print(f" [holdback] {ident} {current} → backlog") + print(f"# setup-promote={promoted} reset-from-stuck={reset} new-backlog={backlog}", file=sys.stderr) + + # 3) 에이전트 concurrency 1 로 제한 + for aid, name in [ + ("5f4bb832-06f3-43c2-8ccd-55524296df7d","claude-code"), + ("c3129f9f-4534-4dbb-b28a-5f453f6aaff4","codex"), + ]: + run(["multica","agent","update",aid,"--max-concurrent-tasks","1","--output","json"], capture=False) + print(f" [agent] {name} max_concurrent_tasks=1") + + +if __name__ == "__main__": + main() diff --git a/examples/import-tasks-from-md.py b/examples/import-tasks-from-md.py new file mode 100644 index 0000000..1a790a7 --- /dev/null +++ b/examples/import-tasks-from-md.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +"""tasks.md → Multica 이슈 일괄 등록 스크립트. + +가정: +- 현재 multica CLI 가 Item-OS workspace 에 인증되어 있다. +- claude-code / codex 에이전트가 등록되어 있다. +- 프로젝트와 라벨이 미리 생성되어 있다. + +사용: + python3 scripts/multica-import-tasks.py \ + --tasks specs/001-twin-question-platform/tasks.md \ + --project b38cb695-5c18-4aaa-9d08-e948817824b7 \ + --epic 91df944c-0f0f-4941-b897-9e08127267da \ + [--dry-run] +""" +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from dataclasses import dataclass + +LABEL_IDS = { + "phase:setup": "d88843a1-e3bb-48ab-926e-83129366022e", + "phase:foundation": "74b136f3-aca1-47d1-9f81-ac1c439b8967", + "phase:us1": "baac4d0c-b791-4b9f-a7be-fcf66a98462a", + "phase:us2": "079701c8-f76c-4070-ae39-9c57a511c88d", + "phase:us3": "604f39f5-9d7c-45b6-b93c-82c3f156aeda", + "phase:hardening": "22ad98bc-34f0-40e9-97f7-2d029475fe31", + "difficulty:L": "e7989bf9-8e99-48f9-8f71-9d0684ae87a2", + "difficulty:M": "58da2607-529d-4e88-940c-809d210f8c1d", + "difficulty:H": "02e2a13c-8bc0-4978-9288-d13fcf7577c6", + "subject:math": "ce9a7bf2-7675-4519-876e-55f235f12846", + "tdd:tests-first": "0f1e9b62-da23-45e3-a167-34469987dab0", +} + +STORY_LABEL = { + "SETUP": "phase:setup", + "FOUND": "phase:foundation", + "US1": "phase:us1", + "US2": "phase:us2", + "US3": "phase:us3", +} + +OWNER_BY_DIFFICULTY = {"L": "claude-code", "M": "claude-code", "H": "codex"} +PRIORITY_BY_DIFFICULTY = {"L": "low", "M": "medium", "H": "high"} + + +@dataclass +class Task: + tid: str + parallel: bool + story: str + difficulty: str + owner_hint: str + title: str + section_phase: str # heading-derived (overrides story-only when present) + is_test: bool # 테스트 task 인지 (TDD label) + + +TASK_LINE = re.compile( + r"^- \[ \] (?PT\d+)\s+(?P(?:\[[^\]]+\]\s*)+)\s*(?P.*)$" +) + + +def parse_tasks(path: str) -> list[Task]: + tasks: list[Task] = [] + current_phase = "SETUP" + in_test_block = False + with open(path, "r", encoding="utf-8") as f: + for line in f: + line = line.rstrip("\n") + heading = re.match(r"^## Phase \d+: (.+)$", line) + if heading: + title = heading.group(1) + if "Setup" in title and "Shared" in title: + current_phase = "SETUP" + elif "Setup" in title and "인프라" in title: + current_phase = "SETUP" + elif "Foundational" in title: + current_phase = "FOUND" + elif "User Story 1" in title: + current_phase = "US1" + elif "User Story 2" in title: + current_phase = "US2" + elif "User Story 3" in title: + current_phase = "US3" + elif "Hardening" in title: + current_phase = "HARDENING" + elif "Research" in title: + current_phase = "SETUP" + in_test_block = False + continue + if re.match(r"^### Tests for", line): + in_test_block = True + continue + if re.match(r"^### Implementation", line): + in_test_block = False + continue + + match = TASK_LINE.match(line) + if not match: + continue + tid = match.group("tid") + flags_str = match.group("flags") + rest = match.group("rest").strip() + flags = [f.strip("[] ").strip() for f in re.findall(r"\[([^\]]+)\]", flags_str)] + + parallel = "P" in flags + story = next( + (f for f in flags if f in ("SETUP", "FOUND", "US1", "US2", "US3")), + current_phase if current_phase != "HARDENING" else "HARDENING", + ) + difficulty = next((f for f in flags if f in ("L", "M", "H")), "M") + owner_hint = next( + (f for f in flags if f in ("claude-code", "codex")), + OWNER_BY_DIFFICULTY[difficulty], + ) + tasks.append( + Task( + tid=tid, + parallel=parallel, + story=story, + difficulty=difficulty, + owner_hint=owner_hint, + title=rest, + section_phase=current_phase, + is_test=in_test_block, + ) + ) + return tasks + + +def run(args: list[str], dry_run: bool, capture: bool = True) -> dict | None: + if dry_run: + print(" ".join(args)) + return None + res = subprocess.run(args, capture_output=capture, text=True) + if res.returncode != 0: + print(f"ERROR: {' '.join(args)}\n{res.stderr}", file=sys.stderr) + sys.exit(2) + if capture and res.stdout.strip(): + try: + return json.loads(res.stdout) + except json.JSONDecodeError: + return None + return None + + +def label_ids_for(task: Task) -> list[str]: + ids = [LABEL_IDS["subject:math"]] + if task.section_phase == "HARDENING": + ids.append(LABEL_IDS["phase:hardening"]) + else: + story_label = STORY_LABEL.get(task.story) + if story_label: + ids.append(LABEL_IDS[story_label]) + ids.append(LABEL_IDS[f"difficulty:{task.difficulty}"]) + if task.is_test: + ids.append(LABEL_IDS["tdd:tests-first"]) + return ids + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--tasks", required=True) + p.add_argument("--project", required=True) + p.add_argument("--epic", required=True) + p.add_argument("--dry-run", action="store_true") + p.add_argument("--limit", type=int, default=0, help="0 means all tasks") + p.add_argument("--skip", type=int, default=0) + p.add_argument("--no-assign", action="store_true", help="라벨만 붙이고 assignee 는 건너뜀") + args = p.parse_args() + + tasks = parse_tasks(args.tasks) + if args.skip: + tasks = tasks[args.skip :] + if args.limit: + tasks = tasks[: args.limit] + print(f"# {len(tasks)} 개 task 등록 예정", file=sys.stderr) + + for t in tasks: + title = f"{t.tid} [{t.story}] {t.title}" + if len(title) > 200: + title = title[:197] + "..." + + body_lines = [ + f"## Task {t.tid}", + "", + f"- Story: {t.story}", + f"- Phase: {t.section_phase}", + f"- Difficulty: {t.difficulty} (정책상 owner 권고: {t.owner_hint})", + f"- Parallel friendly: {'yes' if t.parallel else 'no'}", + f"- TDD test task: {'yes' if t.is_test else 'no'}", + "", + "### 상세", + t.title, + "", + "### 참고 문서", + "- 사양: `specs/001-twin-question-platform/spec.md`", + "- 설계: `specs/001-twin-question-platform/plan.md`", + "- 데이터 모델: `specs/001-twin-question-platform/data-model.md`", + "- Contracts: `specs/001-twin-question-platform/contracts/`", + "", + "### 정의된 작업 규칙", + "- branch: `feat/-` (테스트 only 일 경우 `test/-`).", + "- TDD: 테스트가 먼저 실패한 뒤 구현. validator 영향 시 eval 재실행.", + "- 헌법 v1.0.0 (.specify/memory/constitution.md) 준수.", + ] + body = "\n".join(body_lines) + + priority = PRIORITY_BY_DIFFICULTY[t.difficulty] + create_args = [ + "multica", + "issue", + "create", + "--title", + title, + "--priority", + priority, + "--project", + args.project, + "--parent", + args.epic, + "--description", + body, + "--output", + "json", + ] + resp = run(create_args, dry_run=args.dry_run) + if args.dry_run or resp is None: + continue + issue_id = resp["id"] + identifier = resp.get("identifier", issue_id) + print(f"{identifier} {t.tid} {title[:80]}") + + for lid in label_ids_for(t): + run( + [ + "multica", + "issue", + "label", + "add", + issue_id, + lid, + "--output", + "table", + ], + dry_run=False, + capture=True, + ) + + if not args.no_assign: + run( + [ + "multica", + "issue", + "assign", + issue_id, + "--to", + t.owner_hint, + "--output", + "json", + ], + dry_run=False, + capture=True, + ) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/rebalance-by-phase.py b/examples/rebalance-by-phase.py new file mode 100644 index 0000000..270be9d --- /dev/null +++ b/examples/rebalance-by-phase.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Multica 이슈 owner 를 정책에 맞게 재정렬하는 스크립트. + +정책 (사용자 직접 지시): +- SETUP phase (label phase:setup) → claude-code (infra/repo/skeleton/ADR) +- FOUND phase (label phase:foundation) → claude-code (DB / 도메인 / infra setup) +- US1/US2/US3 phase → codex (테스트 + 구현) +- HARDENING: + - 제목에 `runbook` 또는 `README` 포함 → claude-code + - 그 외 (DataPolicyFilter, EvalGatekeeper, eval runner, CI 파이프라인) → codex +""" +from __future__ import annotations + +import json +import subprocess +import sys + +PROJECT_ID = "b38cb695-5c18-4aaa-9d08-e948817824b7" + + +def desired_owner(labels: list[str], title: str) -> str: + if "phase:setup" in labels or "phase:foundation" in labels: + return "claude-code" + if "phase:us1" in labels or "phase:us2" in labels or "phase:us3" in labels: + return "codex" + if "phase:hardening" in labels: + if "runbook" in title or "README" in title: + return "claude-code" + return "codex" + return "claude-code" + + +def run_json(args: list[str]) -> dict | list | None: + res = subprocess.run(args, capture_output=True, text=True) + if res.returncode != 0: + print(f"ERROR: {' '.join(args)}\n{res.stderr}", file=sys.stderr) + return None + if not res.stdout.strip(): + return None + try: + return json.loads(res.stdout) + except json.JSONDecodeError: + return None + + +def list_issues() -> list[dict]: + issues = [] + offset = 0 + while True: + data = run_json( + [ + "multica", + "issue", + "list", + "--project", + PROJECT_ID, + "--limit", + "100", + "--offset", + str(offset), + "--output", + "json", + ] + ) + if not data: + break + batch = data.get("issues", []) if isinstance(data, dict) else data + if not batch: + break + issues.extend(batch) + if isinstance(data, dict) and data.get("has_more"): + offset += len(batch) + else: + break + return issues + + +AGENT_NAMES = { + "5f4bb832-06f3-43c2-8ccd-55524296df7d": "claude-code", + "c3129f9f-4534-4dbb-b28a-5f453f6aaff4": "codex", +} + + +def main() -> int: + issues = list_issues() + print(f"# fetched {len(issues)} issues", file=sys.stderr) + + moved = 0 + for issue in issues: + if issue["title"].startswith("[EPIC]"): + continue + labels = [l["name"] for l in issue.get("labels", [])] + title = issue["title"] + want = desired_owner(labels, title) + current = AGENT_NAMES.get(issue.get("assignee_id") or "", "") + if current == want: + continue + identifier = issue.get("identifier") + result = run_json( + [ + "multica", + "issue", + "assign", + issue["id"], + "--to", + want, + "--output", + "json", + ] + ) + if result is not None: + moved += 1 + print(f" → {identifier} {title[:75]} : {current or '-'} → {want}") + print(f"# moved {moved} issues", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/strict-sequential.py b/examples/strict-sequential.py new file mode 100644 index 0000000..537d231 --- /dev/null +++ b/examples/strict-sequential.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""엄격한 순차 실행 watcher. + +한 번에 하나의 issue 만 todo 상태로 두고, 그 issue 가 in_review / done 으로 전환되면 +다음 task ID (T001 → T002 → ... → T405 의 task ID 정렬 순) 의 issue 를 todo 로 promotion. + +오너 분포는 그대로 두되, claude-code 와 codex 가 자신의 차례에만 일하도록 보장한다. + +사용: + python3 scripts/multica-strict-sequential.py # 1회 점검 + python3 scripts/multica-strict-sequential.py --watch # 60초 간격 무한 + python3 scripts/multica-strict-sequential.py --reset-from T020 # 특정 task 부터 다시 시작 +""" +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +import time + +PROJECT_ID = "b38cb695-5c18-4aaa-9d08-e948817824b7" +TODO_STATUSES = {"todo"} +ACTIVE_STATUSES = {"in_progress"} +DONE_STATUSES = {"in_review", "done"} + + +def run(args): + res = subprocess.run(args, capture_output=True, text=True) + if res.returncode != 0: + print(f"ERR: {' '.join(args)}\n{res.stderr}", file=sys.stderr) + return None + try: + return json.loads(res.stdout) + except json.JSONDecodeError: + return None + + +def list_issues(): + out, offset = [], 0 + while True: + data = run([ + "multica","issue","list","--project",PROJECT_ID, + "--limit","100","--offset",str(offset),"--output","json"]) + if not data: + break + batch = data.get("issues", []) if isinstance(data, dict) else data + if not batch: + break + out.extend(batch) + if isinstance(data, dict) and data.get("has_more"): + offset += len(batch) + else: + break + return out + + +TASK_RE = re.compile(r"^T(\d+)\s") + + +def task_id(issue) -> int | None: + m = TASK_RE.search(issue.get("title", "")) + if not m: + return None + return int(m.group(1)) + + +def get_ordered_tasks(issues): + todoable = [] + for i in issues: + if i.get("title", "").startswith("[EPIC]"): + continue + tid = task_id(i) + if tid is None: + continue + todoable.append((tid, i)) + return sorted(todoable, key=lambda x: x[0]) + + +AGENT_BY_DIFFICULTY = {"L": "claude-code", "M": "claude-code", "H": "codex"} + + +def desired_agent(issue): + labels = {l["name"] for l in issue.get("labels", [])} + if "phase:setup" in labels or "phase:foundation" in labels: + return "claude-code" + if "phase:us1" in labels or "phase:us2" in labels or "phase:us3" in labels: + return "codex" + if "phase:hardening" in labels: + title = issue.get("title", "") + if "runbook" in title or "README" in title: + return "claude-code" + return "codex" + return "claude-code" + + +def tick(issues, verbose=True): + """홀딩 풀(cancelled)에서 task ID 순서대로 1개씩 todo 로 promotion.""" + ordered = get_ordered_tasks(issues) + active = [i for _, i in ordered if i.get("status") in (TODO_STATUSES | ACTIVE_STATUSES)] + done_or_review = [i for _, i in ordered if i.get("status") in DONE_STATUSES] + holding = [i for _, i in ordered if i.get("status") in ("backlog", "cancelled")] + if verbose: + print(f"# active={len(active)} done/review={len(done_or_review)} holding={len(holding)}") + if active: + cur = active[0] + if verbose: + print(f"# current: {cur.get('identifier')} {cur.get('status')} {cur.get('title')[:60]}") + return False + if not holding: + print("# 모든 task 처리 완료.") + return True + nxt = holding[0] + agent = desired_agent(nxt) + print(f"# promote {nxt.get('identifier')} → todo (assign:{agent}): {nxt.get('title')[:60]}") + run(["multica", "issue", "status", nxt["id"], "todo", "--output", "table"]) + # 명시적 재할당: 직전 cancel-tasks 가 큐 row 까지 비웠을 수 있어서 + # unassign 후 다시 assign 하면 새 queue row 가 생성된다. + run(["multica", "issue", "assign", nxt["id"], "--unassign"]) + run(["multica", "issue", "assign", nxt["id"], "--to", agent, "--output", "json"]) + # 안전망: 만약 assign 만으로 queue 가 생기지 않으면 rerun 으로 강제 enqueue + run(["multica", "issue", "rerun", nxt["id"], "--output", "json"]) + return False + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--watch", action="store_true") + p.add_argument("--interval", type=int, default=60) + p.add_argument("--reset-from", help="task ID (e.g. T020) 부터 다시 시작") + args = p.parse_args() + + if args.reset_from: + m = re.match(r"T(\d+)", args.reset_from) + if not m: + print("invalid --reset-from", file=sys.stderr); return 1 + target_tid = int(m.group(1)) + issues = list_issues() + for i in issues: + if i.get("title","").startswith("[EPIC]"): continue + tid = task_id(i) + if tid is None: continue + new_status = "todo" if tid == target_tid else "backlog" + if i.get("status") != new_status: + run(["multica","issue","status",i["id"],new_status,"--output","table"]) + print(f" {i.get('identifier')} → {new_status}") + return 0 + + while True: + issues = list_issues() + finished = tick(issues) + if finished or not args.watch: + break + time.sleep(args.interval) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/wave-promoter.py b/examples/wave-promoter.py new file mode 100644 index 0000000..b788596 --- /dev/null +++ b/examples/wave-promoter.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Wave promoter: 현재 wave 가 모두 in_review/done 이면 다음 wave 를 todo 로 promotion. + +사용: + python3 scripts/multica-wave-promote.py # 1회 점검 + 가능 시 promotion + python3 scripts/multica-wave-promote.py --watch # 60초 간격 무한 점검 +""" +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import time + +PROJECT_ID = "b38cb695-5c18-4aaa-9d08-e948817824b7" +WAVES = [ + "phase:setup", + "phase:foundation", + "phase:us1", + "phase:us2", + "phase:us3", + "phase:hardening", +] +COMPLETED = {"in_review", "done"} +PENDING = {"todo", "in_progress", "blocked"} + + +def run(args): + res = subprocess.run(args, capture_output=True, text=True) + if res.returncode != 0: + print(f"ERR: {' '.join(args)}\n{res.stderr}", file=sys.stderr) + return None + try: + return json.loads(res.stdout) + except json.JSONDecodeError: + return None + + +def list_issues(): + out, offset = [], 0 + while True: + data = run([ + "multica","issue","list","--project",PROJECT_ID, + "--limit","100","--offset",str(offset),"--output","json"]) + if not data: + break + batch = data.get("issues", []) if isinstance(data, dict) else data + if not batch: + break + out.extend(batch) + if isinstance(data, dict) and data.get("has_more"): + offset += len(batch) + else: + break + return out + + +def find_active_wave(issues): + for wave in WAVES: + members = [i for i in issues if any(l["name"]==wave for l in i.get("labels", []))] + if not members: + continue + statuses = {i.get("status") for i in members} + if statuses & PENDING: + return wave, members + if statuses <= COMPLETED: + continue + return None, [] + + +def promote_next_wave(issues, current): + idx = WAVES.index(current) + if idx + 1 >= len(WAVES): + return None + nxt = WAVES[idx + 1] + targets = [i for i in issues if any(l["name"]==nxt for l in i.get("labels", [])) + and i.get("status") == "backlog"] + for i in targets: + run(["multica","issue","status",i["id"],"todo","--output","table"]) + print(f" promoted {i.get('identifier')} → todo") + return nxt if targets else None + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--watch", action="store_true") + p.add_argument("--interval", type=int, default=60) + args = p.parse_args() + + while True: + issues = list_issues() + wave, members = find_active_wave(issues) + if wave is None: + print("# 모든 wave 완료. 정리할 backlog 없음.") + break + statuses = {} + for m in members: + statuses[m.get("status","?")] = statuses.get(m.get("status","?"),0)+1 + print(f"# active wave: {wave} {statuses}") + if all(m.get("status") in COMPLETED for m in members): + promoted = promote_next_wave(issues, wave) + if promoted: + print(f"# advanced to {promoted}") + else: + print("# no next wave to promote") + break + if not args.watch: + break + time.sleep(args.interval) + + +if __name__ == "__main__": + main() diff --git a/skills/multica/SKILL.md b/skills/multica/SKILL.md index c0904de..732e7b9 100644 --- a/skills/multica/SKILL.md +++ b/skills/multica/SKILL.md @@ -227,6 +227,65 @@ for id in $(multica issue list --state todo --label phase:explore | awk '{print done ``` +## Dispatch order — `agent_task_queue` is `priority DESC, created_at ASC` + +> **Important gotcha** observed when running 80+ issues through a single board. + +When you assign an issue to an agent, the server inserts a row into `agent_task_queue` with the **issue's priority** (`urgent/high/medium/low/none` → integer). The daemon's `ClaimAgentTask` query is: + +```sql +ORDER BY atq.priority DESC, atq.created_at ASC +LIMIT 1 +``` + +So if you import a batch of issues with mixed priorities (e.g. `difficulty:H → priority high`, `M → medium`), the **high-priority tickets run first**, even when they sit in a later phase. Issues you intend to run first must NOT have a lower priority than later ones, or sequential order will silently invert. + +**Rules of thumb:** + +- For a "run in board order" feel, **flatten priority** (set everything to `medium`) and rely on `created_at ASC`. +- If you need a strict T001 → T002 → … sequence, also pin per-agent `max_concurrent_tasks 1` and **hold the future work in a non-claimable status** (see below) so the queue can only contain one row at a time. +- `agent_task_queue` rows are **independent of `issue.status`**: an issue moved to `backlog` or `cancelled` does **not** automatically cancel its queued task. You must cancel the queue too (see API below). + +## Holding work and forcing sequential dispatch + +The CLI does not expose a "single-flight" mode for an agent. To get strict sequential dispatch from a large pre-populated board: + +```bash +# 1. Cap each agent so only one task can run at a time +multica agent update --max-concurrent-tasks 1 + +# 2. Move everything you don't want claimable into a non-todo status +# (claimable statuses are `todo` and—if already in flight—`in_progress`) +multica issue status MUL-42 backlog # held but visible in board +multica issue status MUL-42 cancelled # held + visually distinct (closed) + +# 3. Only the issues you want to run remain in `todo`. +multica issue status MUL-7 todo +multica issue assign MUL-7 --to claude-code +``` + +A small Python watcher that promotes the next `cancelled`/`backlog` issue to `todo` only after the previous one reaches `in_review`/`done` is the simplest "single-flight" loop. See [`examples/strict-sequential.py`](https://github.com/cskwork/multica-skill/blob/main/examples/strict-sequential.py). + +## Cancelling the agent task queue (when issue moves aren't enough) + +Once a queue has built up, **moving the underlying issues alone does not drain it**. The agent will keep claiming queued rows. Two options: + +- **CLI**: `multica issue status cancelled` cancels active runs for that one issue. +- **HTTP API** (no CLI equivalent today) bulk-cancels every queued/dispatched/running row for an agent: + +```bash +TOK=mul_… # PAT from `multica login` +WS=$(multica config show | awk '/workspace_id/{print $2}') +AGENT=$(multica agent list --output json | jq -r '.[] | select(.name=="claude-code") | .id') +curl -sS -X POST \ + -H "Authorization: Bearer $TOK" \ + -H "X-Workspace-ID: $WS" \ + http://localhost:9090/api/agents/$AGENT/cancel-tasks +# → {"cancelled":N} +``` + +The route is `POST /api/agents/{id}/cancel-tasks` (mounted under `/api`, **not** `/api/v1`). The `X-Workspace-ID` header is required for CLI-style auth. Use this after a botched bulk-import or when a previously stuck queue has zombie rows that keep resurrecting on `multica daemon start`. + ## Troubleshooting | Symptom | Fix | @@ -236,6 +295,10 @@ done | Agent runs but stalls | `multica daemon logs -f`. Check `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` and the agent's own `max_concurrent_tasks`. | | `multica skill import ` fails | The repo must contain a discoverable `SKILL.md` (top-level or under `skills//`). Try `multica skill import ./cloned-dir` first to confirm structure. | | CI run can't auth | `multica login --token "$MULTICA_PAT"` — do NOT bake the token into the image. | +| Out-of-order dispatch after bulk import | `agent_task_queue` orders by `priority DESC`. Flatten priorities or pin only one issue to `todo` at a time. See *Dispatch order*. | +| `daemon stop && daemon start` re-runs old tasks (zombie queue) | `agent_task_queue` rows survive across restarts. Bulk-cancel them via `POST /api/agents/{id}/cancel-tasks`. See *Cancelling the agent task queue*. | +| Agent picks `in_progress` tickets you already finished elsewhere | The daemon claims rows by `agent_task_queue.status='queued'` regardless of `issue.status`. Cancel the orphan queue rows; do not rely on flipping the issue status. | +| Bulk import created issues without `repo` context, agent says "no git repository" | Multica `multica project create` accepts `--repo ` only at creation. For an existing project, post a setup comment on each issue with the repo URL (or recreate the project). The daemon uses workspace-level repo cache (`~/multica_workspaces/.repos/...`) once any task references the URL. | ## See also