Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down
105 changes: 105 additions & 0 deletions docs/SEQUENTIAL_DISPATCH.md
Original file line number Diff line number Diff line change
@@ -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 <id> --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.
103 changes: 103 additions & 0 deletions examples/holdback-wave.py
Original file line number Diff line number Diff line change
@@ -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()
Loading