coord/DESIGN.md

266 lines
15 KiB
Markdown
Raw Permalink Normal View History

# Agent coordination design
Status: implemented. Packaging and installation: [`README.md`](README.md). Maintainer invariants:
[`CLAUDE.md`](CLAUDE.md).
## Boundary that actually isolates work
The shared Jujutsu working copy is also `@`. Every `jj` command snapshots all peer filesystem
edits into whichever change currently owns that checkout. File leases cannot prevent this because
the absorbing session did not write those files. This caused duplicate work, foreign features
inside unrelated commits, and one temporary loss/recovery incident.
Writable main agents therefore use independent colocated `jj git clone` instances. They have
independent operation logs, working-copy commits and Git backends, and ordinary Git-aware Nix
flake discovery works in them. The canonical checkout is for read-only inspection and one
explicit integration owner.
Every managed checkout receives a unique `agent-<full-instance-id>` jj workspace name, a
namespaced `agent/<task>/<full-instance-id>` bookmark, and a working-copy description carrying
the full coordinator instance ID. These make parallel heads legible and handoffs explicit; they
remain presentation/navigation metadata, while stored full change and commit IDs are authority.
Agents do not move shared bookmarks. The integration owner alone does so under an exact resource
lease.
Native `jj workspace add` remains available behind `--native-workspace`. It is useful when sharing
one repository is intentional, but it shares the operation log/object store and is not the
default isolation boundary. In this repository its secondary directory also lacks an ordinary
`.git`, so `nix flake metadata .` does not discover the colocated Git flake normally.
The compatibility default for managed clones is `/var/tmp/fleet-audit`, the large
non-snapshotted scratch dataset in the original deployment. Other installations should select a
safe local dataset with `--scratch-root`; avoid any location whose capacity, snapshots, backup,
or mount semantics make disposable clones expensive.
## Identity
- A conversation ID is durable across resume.
- A runtime ID is a fresh UUID for one live attachment to that conversation.
- Human/LLM-facing runtime and workspace identities are exact five-word handles derived from the
full opaque ID with a domain-separated SHA-256 mapping over 128 common words (35 display bits).
Exact handle matches carry authority; prefixes never do, and an unlikely collision is refused
rather than guessed.
- Full UUIDs remain canonical in SQLite, tmux metadata, launcher environments and deterministic
audit exports. Handles are aliases, not secrets and not replacements for internal entropy.
- Managed launches export the exact workspace record as `COORD_WORK_ID`. It may default only
low-blast-radius workspace-local operations after proving that the live runtime owns the record
and `cwd` is inside its registered path. Resource scopes and destructive targets stay explicit.
- The launcher stores full IDs in tmux user options. Window names are presentation only:
`<short-task>-<sid4>`.
The distinction prevents a stale pre-resume process from renewing or releasing the resumed
process's resources.
## Transactional state
Runtime state is a private SQLite WAL database at
`$XDG_STATE_HOME/fleet-agent-coord/<project-id>.sqlite3` (override with `COORD_DB`). It is outside
every agent-writable clone, directory mode 0700 and database/WAL mode 0600.
This database is deliberately host-local. It coordinates Claude and Codex processes sharing one
machine; it does not serialize work performed from different fleet nodes. Cross-host deployment
still needs an explicit human/integration owner (or a future authenticated service).
Filesystem isolation also does not isolate external mutable state. Concurrent agents must use
distinct service ports, database/schema names, containers, and temporary/output directories when
their tools would otherwise share them. Dependency download caches may be shared only when their
cache protocol is concurrency-safe; mutable build trees must remain workspace-local.
All conflict-check-plus-claim operations use `BEGIN IMMEDIATE`. Resource names are exact,
repository-relative hierarchical names. Equal names and ancestor/descendant names overlap.
The compatibility matrix is:
| Existing | Requested append | Requested exclusive |
|---|---:|---:|
| append | allowed | refused |
| exclusive | refused | refused |
Leases cover files and global operations alike, for example:
- `tests/checks.nix`
- `integration/master`
- `push/origin/master`
- `activate/home/tpp15s`
- `deploy/system/nix-control`
- `external/fleet-dotfiles`
Heartbeats renew both runtime presence and held leases. Expired/dead runtime leases are reaped
transactionally. Partial release removes only the named resources.
Messages have monotonic integer IDs. A broadcast snapshots its recipient set at send time.
Delivery transitions `queued → delivered → acked`. Delivered context repeats at hook boundaries
until the receiving agent explicitly runs `coord ack <message-id>`. The first delivery carries the
body; later boundaries carry the message ID, sender handle, kind and body digest plus an explicit
`coord inbox --peek` recovery command. A rejected/lost harness response therefore cannot silently
consume it, while a long body is not reinjected on every tool call.
Audit events remain append-like in SQLite and can be exported deterministically as JSONL with
`coord audit`.
## Action composition
`coord batch ACTION : ACTION...` reduces agent/tool round trips without inventing a second command
language: every action is parsed by the ordinary CLI parser and executed by its ordinary handler.
It runs sequentially, stops on the first nonzero result, and can return compact human output or
one JSON envelope. The separator is a standalone token, so quoted message punctuation is not
special.
A batch is intentionally not atomic and performs no rollback. Each state-changing action retains
its own transaction, validation and audit event. The allowlist excludes clone creation/removal,
tmux process replacement and integration queueing. Generic composition must not become a way to
hide destructive scope or ambiguous external side effects.
Every nested action inherits the outer batch's already-resolved exact runtime. Identity flags are
rejected inside action token streams and the resolved nested runtime is asserted equal before
dispatch, preventing one batch from mixing or impersonating audit/lease authority.
Remote publication, integration, activation and deployment need purpose-built workflows with
explicit resources and expected object IDs. In particular, a future publish workflow must acquire
the exact push lease, authoritatively read the network remote, refuse an unexpected base, avoid
force, reread the new remote head, and retain the lease with recovery instructions after an
ambiguous mutation result.
## Enforcement and degradation
The shared hook recognizes Claude `Edit`/`Write` paths and every Codex `apply_patch` source and
`Move to:` destination. It canonicalizes against the hook payload's working directory and blocks
a peer's exclusive overlapping lease.
This is scoped enforcement, not a shell parser. Shell writers, formatters, generators and VCS
operations remain advisory. Isolation is what makes that honest limitation safe. A private clone
can continue working if the coordinator is unavailable.
The hook and lifecycle bridge fail open on load, parse or database failure. A positively
recognized conflicting write fails closed. Denial and `additionalContext` are separate branches
because Codex rejects a response containing both.
Claude subagents receive their own deterministic runtime UUID, derived from the full parent
conversation ID and hook `agent_id`. They therefore conflict, message, and lease independently
instead of inheriting the parent's authority. Writable subagents should still use their own
managed clone; the distinct identity makes accidental writes in the integration checkout fail
closed unless that subagent explicitly owns the resource.
## Workspace lifecycle and cleanup
Each record contains the full task/conversation/runtime identity, source and actual path, exact
base/current Jujutsu IDs, dependencies, planned/actual resources, gate/integration/lifecycle
states, immutable tmux IDs and an ownership token.
The default CLI view deliberately omits those machine fields. It prints handles, task/state, and
only the path needed to enter a newly created clone. `--verbose`, `--json`, and audit export are
explicit diagnostic/machine surfaces. Cleanup tokens remain private by default; normal removal
requires repeating the exact work handle and then compares the registry token with the on-disk
marker.
Lifecycle states support active, parked, resumed, archived, queued/integrating/lock-released/
integrated and removed work. Lock release is not integration evidence: a separate proof verifies
that the exact source commit is an ancestor of the exact target commit. Cleanup never guesses:
The current “queue” is an integration lock plus an auditable state machine, not a fair scheduler:
enqueue order and recorded dependencies are visible but do not automatically grant the next turn.
The integration owner adjudicates readiness and stale bases.
On acquisition, coord snapshots the isolated checkout and freezes its exact change and commit IDs
as the integration candidate. Release records the same full runtime and resource that acquired the
lock. Integration proof is rejected without matching owner/resource evidence and proves the frozen
candidate—not a stale create-time record—is an ancestor of the exact target. Cleanup also refuses
a workspace changed after that candidate was integrated.
- path must remain below the recorded scratch root;
- private registry token and on-disk `.jj` marker must agree;
- working copy must be clean;
- an advanced change must be released/integrated;
- automatic cleanup requires a passed or explicitly waived gate.
Missing, dirty, ungated or identity-mismatched directories remain recovery candidates.
Known independent clones can be recovered after database loss with explicit `work adopt`. Adoption
requires both `.jj/` and `.git/`, a path beneath the configured scratch root, and a source distinct
from the candidate. It validates exact Jujutsu IDs before rotating the ownership marker. There is
deliberately no directory-name scan or automatic adoption.
If older shared-checkout work was lost, recover read-only with
`jj --at-op=<op> --ignore-working-copy file show <path>`, then merge the recovered content into a
fresh isolated clone. Never restore a whole stale file over newer work.
## Tmux rules
Managed sessions discover project ownership through tmux user options and mutate objects only by
immutable `$session`, `@window` and `%pane` IDs returned by tmux. Names are never lookup targets.
Automatic window renaming is disabled. Codex resumes with `codex-direct resume <conversation>`;
Claude resumes with `claude-direct --resume <conversation>`.
`work close` revalidates those IDs and kills the exact window. It refuses when called from any
pane in the target window, because killing that window would terminate the coordinator before its
registry update. If the operator is attached elsewhere in the target session's last window, it
first switches to another managed session and otherwise refuses, preventing the surprising drop
to a plain outer shell.
The ordinary `codex`/`claude` helper also persists interactive resume invocations and forwards
their arguments to the session runner. Automation (`codex exec`, `claude -p`, pipes and non-TTY
calls) remains direct and status-faithful.
## Proved failure modes
Behavioral checks cover:
- eight-way exclusive race: exactly one winner;
- six simultaneous messages: no loss and monotonic IDs;
- exclusive/append matrix, hierarchical conflicts, renewal/expiry and partial release;
- truncated identity refusal;
- `apply_patch` move-destination blocking;
- repeated delivery until explicit acknowledgement;
- independent clone metadata and explicit native-workspace limitations;
- dirty, ungated, advanced and ownership-mismatched cleanup refusal;
- tmux name collision handling and immutable-ID-only mutations;
- distinct Claude subagent authority, explicit orphan adoption and exact-window close;
- real disposable `jj git clone --colocate` create/inspect/remove.
## Implementation language and versioning
Each standalone release carries a semantic `VERSION`, vendored into `.coord/VERSION` and exposed
by `coord --version`. The installer doctor reports both source and installed versions but still
compares every managed byte and executable mode; equal version strings are not integrity evidence.
Legacy installations without the marker are explicitly `unversioned` and can be repaired through
the same preflighted atomic plan as any upgrade. This adds observability without coupling runtime
SQLite schema state to package release numbering.
Python plus the standard-library SQLite driver remains the current best fit: hooks can run before
a Nix activation or build bootstrap, deployment has no compiled-artifact handoff, and the
transaction boundary lives in SQLite rather than process memory. A Go rewrite would improve
single-binary distribution, startup predictability and static typing, but would not improve the
isolation or locking model by itself. Those benefits do not currently justify a second
implementation and migration surface.
## Interface and context budget
Coordinator output crosses an unusually expensive boundary: lifecycle context and command output
are repeatedly injected into model context. The interface therefore follows these rules:
- managed environments infer caller identity; ordinary commands do not repeat it;
- required operands are positional (`claim integration/master`, `work create cache-review`);
- readable long options remain for genuinely optional semantics;
- default listings use exact word handles and suppress full IDs, conversations, paths and
workspace rows not requested by the caller;
- JSON is compact and explicit, and ownership secrets are redacted unless a private recovery
export opts in;
- full Jujutsu/Git IDs are stored as evidence but presentation output uses a sufficient display
prefix only where no later command consumes it as authority.
This is a larger saving than removing the two dash characters from every option. Cryptic flags
can increase correction turns and negate their tiny lexical saving.
The executable hook source is cooperative infrastructure, not a security boundary: a writable
clone contains a writable copy, and hook trust is hash-pinned by each harness. Before deploying
incompatible database schemas, add explicit versioned migrations with backup/rollback and package
one immutable coordinator runtime through Home Manager. That packaging can use Python or Go; it
does not require a language rewrite.
## Third-party adjudication
The current system is intentionally small and local. MCP Agent Mail may later add a searchable
mail UI, and `tmux-agent-status` may add an operator sidebar, but neither is authoritative for
leases. Beads adds a useful task DAG at the cost of Dolt; Gas Town and Agent of Empires are
Git-worktree-oriented and too invasive as the coordination substrate. Any pilot must compose with,
not replace, the transactional resource and isolated-clone boundary.