ADR-0071: Shared workflow board + the team workflow¶
- Status: Accepted
- Date: 2026-10-21
- Issues: #197 (design), #297 (fan-out races, Phase 0)
- Related: ADR-0039 (the engine), ADR-0043 (jobs)
Context¶
swarm maximizes parallelism on independent items. Issue #197 asked for the other shape: a
coordinated swarm for loosely coupled work — a shared kanban-style board as the single source
of truth, claims to prevent duplicate work, findings to unblock teammates mid-flight, and a
coordinator reconciling between passes. Design questions the issue listed: board as tools vs
prompt-injected state, coordinator role, atomic claiming, size budgets, persistence, naming.
A design pass grounded in the engine (posted on the issue) settled them before any code, and surfaced two hard facts that shaped the result:
- The engine has no conditional stage and no shared writable state inside a fan-out.
run_unless_marker_inis static and reads one parent var; inside aparallel_groupit reads the item's private vars copy, so sibling writes are invisible. Faking claims withagent_turn+save_ascannot short-circuit an already-claimed item — rejected outright. - Fan-out items shared one runner adapter — session ID, nonce, one-shot flags and the
shared
*local.Agent's pending-image field raced across item goroutines. Phase 0 (#297, merged first) closed that with per-item clones (workflow.CloneableTurnRunner) and per-call ctx-carried image parts, establishing the discipline everything below follows: concurrent items share nothing mutable except the board, which is mutex-protected by construction.
Decisions¶
1. The board is a generic engine capability, not a workflow feature¶
internal/workflow/board knows nothing about which workflow uses it. The definition flag
board: true opts any YAML workflow in; board_seed is an ordinary stage kind; the tools and
{{.board}} injection key off "is a board on this turn", never off a name. No workflow name is
branchable anywhere in the interpreter — team is a YAML file (definitions/team.yaml) using
those primitives, exactly like swarm uses parallel_group.
2. Read path: injection + tools, layered¶
{{.board}}renders into every stage prompt at stage start (rides the engine's existing 12K-per-var / 30K-total budgets;missingkey=zero+ an explicit empty render keep board-less definitions clean).board_get/board_claim/board_update/board_postare registered per call, only when the turn's ctx carries a board (same dynamic-backing-store treatment as task/otel tools, appended afterfilterToolsand pre-marked observed so ADR-0058 pruning keeps them). Handlers read the board and the acting identity (worker·Item-2) back off the same ctx — nothing is written to any shared agent instance (#297's rule).- CLI-provider roles get the snapshot only — they cannot receive milk's tools. Honest v1
limitation, documented in
team's worker prompt with an explicit fallback line.
3. Claims: in-process CAS, attributed by ctx identity¶
Claim is compare-and-set under one mutex — first claimer wins; the holder is returned to the
loser, idempotent for the same identity, done never claimable. This is race-free because
fan-out bodies are goroutines in one process and tool handlers are direct calls (no fs race
window, unlike bash-claimed files). Identity is threaded ctx-plain by the interpreter
(role·Item-N); no shared-runner mutation.
4. Persistence: write-through sidecar, not checkpoint payload¶
<checkpoint-path>.board.json (sibling of *.workflow[.id].interp.json), atomic tmp+rename on
every mutation. Parallel groups checkpoint only leaf-atomically — a mid-fan-out crash would
lose live claims if the board rode the checkpoint. Resume loads the sidecar; if it is gone but
the checkpoint still holds the board_seed leaf, the item skeleton is rebuilt from that value
(findings in a deleted sidecar are unrecoverable by construction). Caps: 200 findings, 500-rune
finding text, 400-rune notes, compact render (~8K) — long runs degrade, never blow the prompt.
5. Verdicts: declared keys are real (ParseVerdictKey)¶
Stage.Verdict looked like a free-form map but ParseVerdict only ever recognized three
hard-coded keywords, so a stage could not prompt for its own words — team's stand-up needs
all_done / more_work. The stage path now matches the stage's own declared keys (end-scan,
longest-key-first, unknown never scraped from prose) and falls back to the historical
keywords only when no declared key matched — full backward compatibility for dev/pair/swarm.
6. team (the YAML)¶
Designer (same NO_QUESTIONS flow) → board_seed → bounded pass loop {
parallel_group over items [worker+evaluator retry loop] , coordinator stand-up
} → final evaluation → optional implementer pass. The stand-up verdict breaks the loop
(all_done) or re-queues with context (more_work), which is how blocked items get un-blocked
without any engine-level dynamic re-waving. Claims stay advisory on contention: a loser spends
one cheap turn to skip (no per-item conditional-skip yet — see below).
Rejected / deferred¶
- Fake
board_seed/board_claimwithagent_turn+save_as— impossible (fact 1 above). - Peer-to-peer messaging, cross-process claiming — single process is the execution model; revisit only if items ever become subprocesses.
- Engine-side per-item conditional skip, board-aware
from:collections, dynamic re-waving — the prompt-convention skip costs one cheap turn on rare contention; premature otherwise. - Board panel view (Phase 3) — live board renders fine via
board_get/{{.board}}; a dedicated TUI row surface is an on-demand follow-up.
Consequences¶
swarm-class runs unchanged unless they opt in; every existing definition stays valid (Validaterejectsboard_seedwithoutboard: true, not the reverse).go test -raceover./internal/workflow/...and./cmd/milkis part of the contract (#297's acceptance) — the board is the one deliberately shared mutable thing, so it must stay mutex-first.- Custom verdict keys are now available to any definition, not just
team.