Skip to content

Repository files navigation

Agent Coordination

CLI, workflow helpers, Worker code, tests, and simulation fixtures for coordinating concurrent agent work.

ShakaCode Agent Workflows — Run AI coding agents in fleets, safely

Documentation →

A zero-config first run uses a clearly labeled local store so one person can try the CLI immediately. The team and multi-machine runtime path is the HTTP backend: AGENT_COORD_API_URL points the CLI at the Cloudflare Worker backed by D1, and AGENT_COORD_API_TOKEN authenticates this machine to that Worker. The legacy GitHub backend is available only when explicitly requested by maintainers.

Keep this public repository code-only. Do not commit live claims/, heartbeats/, batches/, events/, *.json.lock, secrets, environment files, customer data, credentials, or source-code patches here.

License

This repository is the MIT License protocol plane for Agent Coordination. That plane includes the CLI, Cloudflare Worker API, Worker-served read-only dashboard, simulation harness, tests, documentation, ADRs, and examples.

The runtime state is private data, not product source. Live claims, heartbeats, batches, events, lock files, tokens, credentials, customer data, and source-code patches must stay outside this repository.

A future hosted or monetized ShakaStack product plane can use a different license and repository boundary. Product-plane dashboards and batch-planning features should consume the protocol-plane API rather than relicense the protocol primitives in this repository. The standalone shakacode/agent-coordination-dashboard repository should carry the same MIT protocol-plane stance while it remains the local/protocol dashboard. See ADR 0002.

Setup

gh auth status
gh repo clone shakacode/agent-coordination
cd agent-coordination
bundle install
git config core.hooksPath .githooks
bundle exec rubocop
ruby -Itest test/agent_coordination_cli_test.rb
bin/agent-coord --help
bin/agent-coord bootstrap
export PATH="$HOME/.local/bin:$PATH"
agent-coord --help
agent-coord status
agent-coord demo

The versioned pre-commit hook in .githooks/pre-commit runs RuboCop on staged Ruby files before each commit after core.hooksPath is configured. CI runs the full RuboCop check on every pull request.

CLI package preparation

The CLI supports Ruby 3.2 or newer. This repository pins a current Ruby version for development and CI also runs the Ruby suite on the supported floor.

The agent-coordination RubyGem installs the agent-coord coordination CLI, the local aggregate-only agent-coord-harvest telemetry CLI, and their public documentation; the Worker deployment remains source-only. The gem has not been published. Build and install it locally to verify the distribution without changing a registry, tag, or release:

gem build agent-coordination.gemspec

Replace VERSION with the version in the filename printed by gem build.

gem install --local ./agent-coordination-VERSION.gem
agent-coord version --json
agent-coord-harvest harvest --help
rm ./agent-coordination-VERSION.gem

Generated .gem files are local artifacts and should not be committed. See the Changelog for release-facing changes and the Worker state protocol with curl for placeholder-only HTTP examples. For observability, see the operational feedback loop, the local telemetry ledger, and the observability kaizen ledger.

Zero-config local first run

Run agent-coord status without configuring a backend. The CLI uses $XDG_STATE_HOME/agent-coordination when XDG_STATE_HOME is an absolute path. Relative, empty, or unset values use ~/.local/state/agent-coordination. Every command that selects this implicit default prints its path with a local mode — single-machine only notice. JSON commands keep machine-readable output on stdout; the notice goes to stderr.

This default is for one machine only. Configure the HTTP backend before sharing coordination state across machines or operators. If a consumer env file already configures AGENT_COORD_API_URL, this implicit default is refused for write commands; see the split-brain hard stop under backend selection, and set AGENT_COORD_LOCAL=1 when single-machine local state is what you want.

Run the deterministic walkthrough to see the claim and heartbeat model without configuring or changing any persistent backend:

agent-coord demo

The demo uses an isolated temporary local store, shows a live-holder claim refusal followed by stale and dead heartbeat states and a successful takeover, then removes its temporary state. It ignores configured HTTP and legacy GitHub backends, so it never writes demo data remotely. It also ignores the canonical user configuration file entirely — including one that is missing, insecure, or malformed — by reading a config home inside its own temporary root, so the walkthrough works before any configuration exists and while a broken one is being repaired.

HTTP backend

Deploy the Worker/D1 backend

Run this once for each Cloudflare environment before provisioning machine tokens:

cd worker
npm install
npx wrangler login
npx wrangler d1 create agent-coord
# In wrangler.toml, replace the all-zero database_id with the ID printed above.
# Keep that deployment-specific substitution out of commits.
npx wrangler d1 migrations apply agent-coord --remote
npx wrangler deploy
export AGENT_COORD_API_URL=<worker-url>
curl -fsS "$AGENT_COORD_API_URL/v1/health"
cd ..

Keep deployment credentials and generated tokens out of git. The CLI only needs the deployed Worker URL and a machine token at runtime.

Provision one token per machine from the repository root. The command prints the token once and stores only its SHA-256 hash in D1, so run it in a private terminal:

worker/bin/provision-token <machine-name> \
  --read-prefix <read-prefix> \
  --write-prefix <write-prefix>

Token provisioning requires at least one read or write prefix. An omitted scope dimension receives no access ([]). Use repeatable flags when a machine needs multiple path scopes:

worker/bin/provision-token m5 \
  --read-prefix claims/shakacode/react_on_rails \
  --read-prefix heartbeats \
  --write-prefix claims/shakacode/react_on_rails \
  --write-prefix heartbeats/m5-codex.json

For a trusted single-operator deployment that intentionally needs unrestricted access, pass --all-state instead of prefix flags:

worker/bin/provision-token <machine-name> --all-state

After a D1 rotation, target the replacement database explicitly and use --rotate so an existing machine row is updated or a missing row is inserted:

worker/bin/provision-token <machine-name> \
  --database <database-name> \
  --rotate \
  --all-state

Use explicit --read-prefix and --write-prefix flags instead of --all-state when the consumer needs narrower access. The token is printed once; persist it immediately in the consumer's private environment file. See the backend rotation runbook for the complete database, token, restart, and verification sequence.

The stored empty scope ("") grants all state, but the provisioning command does not accept an empty prefix as a shortcut; all-state access must use the explicit flag. A directory scope such as claims/shakacode/react_on_rails covers descendant paths. A valid record-path scope such as heartbeats/m5-codex.json covers exactly that flat record. The Worker enforces read scopes for GET /v1/state/<path> and GET /v1/state?prefix=..., write scopes for PUT /v1/state/<path>, and records the authenticated machine as updated_by on each state write. Active-path DELETE requires write coverage for both the active path and its archive/<path> mirror; archive-path DELETE requires archive write coverage. Ordinary active-only writer tokens therefore cannot delete, while GC tokens use explicit active-plus-archive mirrors or the trusted all-state scope. Claim takeover checks may need read access to the current holder's heartbeat; use an exact heartbeat write scope only when the machine's agent id is stable. Active state paths are limited to 512 UTF-8 bytes. Mirrored archive paths allow 520 bytes total for the archive/ prefix plus that same at-most-512-byte active suffix; an archive path cannot carry a longer original suffix. When listing a parent prefix above a scoped token's read scope, the Worker returns only covered descendants. Claims-scoped tokens can pass the default agent-coord doctor read probe; tokens scoped only to other prefixes should use agent-coord doctor --doctor-prefix <read-prefix>. Directory prefixes are checked with the list endpoint; exact record-path prefixes such as heartbeats/m5-codex.json are checked with a record read. The provisioning script rejects a command with no scope flags and rejects combining prefix flags with --all-state. Read-only tokens support status and doctor workflows. Write-only tokens support append-only callers such as record-event; claim, release, heartbeat, and batch mutation commands read existing state before writing and therefore need matching read prefixes.

For local Wrangler/D1 development, pass --local:

worker/bin/provision-token dev --local \
  --read-prefix claims \
  --write-prefix claims

bin/test-http-integration and CI's standalone worker-smoke job select a free port by default. Set AGENT_COORD_TEST_HTTP_PORT to pin the port while reproducing a run:

AGENT_COORD_TEST_HTTP_PORT=8799 bin/test-http-integration

The pinned value is used unchanged, so it must be numeric and free. A value of 0 keeps Wrangler-owned allocation and discovers the ready address. Pinning a busy port makes Wrangler report EADDRINUSE.

Machine names may contain letters, numbers, dots, underscores, colons, and hyphens. Database names may contain letters, numbers, dots, underscores, and hyphens. If wrangler d1 execute fails, the script preserves Wrangler's output. Use --rotate when re-keying an existing machine.

After the Worker is deployed and this machine has a token, set both HTTP backend env vars and verify the backend:

export AGENT_COORD_API_URL=<worker-url>
export AGENT_COORD_API_TOKEN=<machine-token>
agent-coord doctor

Machine and session identity

Each machine also exports one static, non-secret machine identifier so coordination writes carry machine/session attribution:

# M5
export AGENT_COORD_MACHINE_ID=m5

# M1
export AGENT_COORD_MACHINE_ID=m1-codex

Session identity resolves in this order and is never reused from a stale persistent value:

  1. AGENT_COORD_SESSION_ID — explicit caller-supplied session or run id
  2. CODEX_THREAD_ID — supplied automatically by Codex sessions
  3. unset — writes omit session attribution rather than guessing

Every claim, release, heartbeat, and record-event write stamps the resolved tuple into the record as machine_id, session_id, and session_source (agent_coord_session_id or codex_thread_id), and status --json projects the same fields back to consumers. Terminal closeouts use AGENT_COORD_MACHINE_ID for closed_by.machine, falling back to --host when the variable is unset. Blank values are treated as unset. The tuple is atomic per write, in both directions: when a write declares a machine id that differs from the record's last recorded machine and resolves no session, the stale session fields are cleared rather than paired with the new machine, and when a write resolves a session that differs from the record's last recorded session without declaring a machine, the stale machine id is cleared rather than paired with the new session.

These variables are attribution metadata only: they must never contain token or secret values, and machine-token authentication remains authoritative for access control. Machine provisioning happens outside this repository — add AGENT_COORD_MACHINE_ID to each machine's shell profile and to the private launchd/systemd env file described below so launch templates propagate the tuple to background heartbeats.

agent-coord doctor --deep (any backend) reports the resolved tuple in text output and under environment_identity in doctor --deep --json, including the session resolution source, the authenticated token_machine when the deep HTTP doctor ran, and a machine_match status of match, mismatch, or unverified. The lightweight doctor output is unchanged. When agent-coord doctor --deep runs against the HTTP backend and AGENT_COORD_MACHINE_ID does not match the /v1/whoami machine, doctor still emits its report, then fails with exit code 2, because a mismatch usually means the wrong machine token or machine id is installed. The stack doctor carries the same tuple as an identity.machine component check: failed on a mismatch (driving exit 2), healthy on a verified match, and skipped when no authenticated token machine is available to compare.

Backend selection is global and shell-independent. The CLI safely reads the canonical user file from AGENT_COORD_ENV_FILE when explicitly set, otherwise from $XDG_CONFIG_HOME/agent-coord/env (falling back to $HOME/.config/agent-coord/env). It parses assignments without evaluating the file as shell code, accepts only the documented AGENT_COORD_* allowlist, and opens it with no-follow semantics, then validates and reads that same descriptor. The file must be regular, current-user-owned, and have no group or world permissions. Its parent chain may contain only root- or current-user-owned directories and symlinks — the standard dotfiles layout links ~/.config into a user-owned repository — and every directory in the chain, before and after symlink resolution, must have no group or world write permission. The conventional agent-coord leaf directory must be current-user-owned with mode 0700; an explicit AGENT_COORD_ENV_FILE parent is validated but never automatically chmodded because it may contain unrelated files. For compatibility with older documented setup commands, a safe current-user-owned conventional leaf that is not group/world-writable is automatically hardened to 0700 when read. State-root values saved in this file must be absolute paths. An explicitly selected missing file, an insecure file, duplicate keys, or ambiguous syntax is an operational failure rather than a fallback to another backend. The single exception is config set, whose job is to bring that file into existence: it tolerates an AGENT_COORD_ENV_FILE that does not exist yet and creates exactly the named path. A file that does exist but is insecure, unreadable, or malformed still fails closed, for config set as for everything else, so the command can never discard one. Every command loads this file except version, bootstrap, and demo, which read no configuration at all: they render compiled-in constants, install the command, and replay a walkthrough inside a temporary root, so none of them resolves a backend, ref, policy, or machine identity. Those three therefore keep working while a broken canonical file is exactly what you are trying to repair. config show does load it, because reporting the coordination configuration is its job; if the file cannot be read safely, config show fails with the reason. This hardened config path requires a POSIX-compatible Ruby/filesystem with no-follow opens, ownership/mode checks, and flock; native Windows Ruby is not a supported runtime for this CLI.

Recovering from a broken canonical user config

Default behavior remains fail closed: without an explicit recovery option, an insecure, unreadable, or malformed canonical user file blocks every command that loads it even when the command also names a backend. Automation that must keep inspecting or safely coordinating work while that file is repaired can use --ignore-user-config, for example:

agent-coord status --ignore-user-config --state-root "$RECOVERY_STATE_ROOT" --json

The option is accepted only when the same invocation supplies exactly one explicit backend selector: --state-root, --api-url, or --backend. Different selectors and duplicate occurrences of the same selector are both refused before any coordination mutation. Process- and user-file backend selectors, saved refs, and status-only local roots do not participate; an explicit --ref may accompany --backend. The recovery path therefore never selects an implicit local backend. Process-scoped credentials and machine or session identity remain available to authenticate and attribute the explicitly named backend.

Because config show --json is the workflow policy-enforcement seam, its recovery form also requires a valid process AGENT_COORD_POLICY (required, optional, or disabled). The command reports that explicit process policy instead of silently downgrading a bypassed canonical required or disabled policy to the default optional. Other recovery commands keep their existing policy behavior.

Every accepted invocation writes a warning to stderr that says the canonical file was bypassed and names the selector flag, never its value or a secret. Claim refusal, ambiguous-result handling, and ownership and dependency checks are unchanged. The option does not repair or rewrite the file and is not valid for config set or config-independent commands; repair the canonical file separately, then resume normal fail-closed operation without the flag.

Backend selection follows this precedence:

  1. explicit CLI backend flags (--state-root, --api-url, or --backend)
  2. backend selectors already present in the process environment
  3. backend selectors in the canonical user file
  4. otherwise, the labeled local store at the zero-config path above

Passing one of those flags is an explicit selection, so an empty or whitespace-only value is refused with exit 1 and --state-root requires a non-empty value rather than treated as a selection of nothing. This matters for wrappers: agent-coord claim --state-root "$STATE_ROOT" with STATE_ROOT unset used to resolve to the working directory and silently read and write coordination state there. An environment selector that is empty or whitespace-only is treated as unset rather than as a selection, so an exported-but-blank variable cannot shadow a configured backend.

Within an environment tier, AGENT_COORD_API_URL selects HttpStore, AGENT_COORD_STATE_ROOT selects LocalStore, and AGENT_COORD_BACKEND selects the legacy GitHubStore. Existing process values also override same-named user-file values for authentication and identity.

When both AGENT_COORD_API_URL and AGENT_COORD_STATE_ROOT are set, the CLI uses the HTTP backend and warns once. Pass --state-root only for an explicit local smoke check. When both AGENT_COORD_STATE_ROOT and AGENT_COORD_BACKEND are set, the CLI uses the local backend and warns once; pass --backend to force the legacy GitHub backend. When both AGENT_COORD_API_URL and AGENT_COORD_BACKEND are set, the CLI uses the HTTP backend and warns once; pass --backend to force the legacy GitHub backend. With all three set the warning names the state root only; remove it and the next run reports the remaining backend conflict. Each CLI flag that selects a different backend than the configured selectors warns once the same way — --state-root, --api-url, and --backend alike, and including when the configured selector is AGENT_COORD_BACKEND. Replacing a configured selector with the same kind of backend (for example --api-url over a configured AGENT_COORD_API_URL) is not a conflict and stays quiet.

The last selection is implicit local. The canonical user file is loaded automatically. When the older compatibility file $XDG_CONFIG_HOME/agent-coord/http-env.sh configures AGENT_COORD_API_URL but the canonical file does not, an implicit local run is a split-brain configuration: writes would land on a local state root the fleet never reads. claim, release, heartbeat, record-event, and register-batch therefore hard stop with exit 2 and name the offending env file. Read commands (status, batch-audit) keep the advisory warning and still succeed. Choose a backend explicitly to proceed: source the env file for fleet writes, pass --state-root PATH (or set AGENT_COORD_STATE_ROOT) for an explicit local root, or set AGENT_COORD_LOCAL=1 to opt into implicit local mode. AGENT_COORD_LOCAL accepts 1, true, or yes (case-insensitive); any other value, including empty and 0, is not an opt-in. The opt-in also silences the advisory warning for read commands.

React on Rails workflow docs assume agent-coord is available on PATH. bin/agent-coord bootstrap installs agent-coord into $HOME/.local/bin by default and appends that directory to the current shell profile. Use --install-dir PATH to choose another directory or --no-profile to skip profile edits. If the shell has not reloaded the profile yet, export the path in the active terminal:

export PATH="$HOME/.local/bin:$PATH"

When the install path contains non-ASCII characters, an entry written by a version of bootstrap older than locale-stable PATH-line generation may not match the now-stable generated line. ASCII-only paths produce the same bytes and do not hit this case. The first post-upgrade bootstrap can therefore append exactly one new, cosmetic duplicate PATH entry. The next run matches the new line and converges without appending another entry.

The CLI intentionally does not guess which legacy encoding produced an older entry or rewrite the user's shell profile. A false match could make bootstrap omit the required PATH entry, which is worse than one duplicate.

Run agent-coord doctor after setup. The default doctor is intentionally lightweight: it verifies backend access and the expected state layout without downloading and parsing every JSON record. On an unconfigured first run it initializes and verifies the zero-config local root. For an explicitly selected legacy GitHub backend, it also rejects an archived repository because archived repositories remain readable but cannot accept coordination writes. Run agent-coord doctor --deep for a full audit. On the HTTP backend it reports a separate result for claims, heartbeats, batches, and events plus the authenticated machine and its scopes. A stale or unknown token names the failing resource and prints the token-rotation command. If a consumer env file configures an API URL while status or doctor resolves to local storage, the CLI emits a split-brain warning. When doctor itself resolved to the implicit local backend under that configuration, it emits its full report with status: split_brain plus a split_brain_env_file field naming the env file, then exits 2; the same explicit opt-ins that unblock writes (--state-root, AGENT_COORD_STATE_ROOT, AGENT_COORD_LOCAL=1) return it to ok and exit 0. If an explicitly configured backend fails, agents should report coordination state as UNKNOWN and use the public claim-comment fallback until the operator fixes backend access. For HTTP tokens scoped outside claims, pass a readable scope: agent-coord doctor --doctor-prefix events/<batch-id>.

Stack aggregators should invoke agent-coord doctor --stack-json --deep with exactly one direct backend selector: --state-root PATH, --api-url URL, or --backend OWNER/REPO. Environment defaults still participate in normal backend resolution, but do not satisfy this machine-contract selector requirement. The explicit stack output is the component contract v1: it reports agent-coordination as healthy, degraded, or failed, with normalized checks for CLI version readiness, backend readability, deep resource evidence, and machine identity (identity.machine, which fails on an environment-versus-token machine mismatch and is skipped when unverifiable). Exit codes are 0 for healthy, 1 for degraded, 2 for failed, and 64 for invalid usage. Usage errors emit no JSON. --stack-json is strictly read-only: it never creates a missing explicit local state root, and reports that missing root as a failed component rather than falling back. If the saved user configuration cannot be loaded, it likewise emits a failed component report with the configuration diagnostic instead of breaking the JSON boundary; configuration permissions, parsing, and validation still fail closed. Omit --deep only when a shallow report with skipped resource evidence is intentional. Legacy text and doctor --json output remain unchanged.

For LocalStore, the explicitly selected top-level state root is an operator-owned trust boundary and may itself be a symlink. Deep reads fail closed when a top-level state prefix such as claims/, or any directory or record below it, is a symlink. These are check-then-use guards for cooperative local state, not atomic filesystem traversal: another process able to rewrite the tree concurrently under the same local owner is inside that trust boundary.

To override the default for a local smoke check, set AGENT_COORD_STATE_ROOT or pass --state-root to use a temporary filesystem state directory. Both are explicit local selections, so neither trips the split-brain hard stop (AGENT_COORD_LOCAL=1 keeps the implicit default instead):

STATE_ROOT=$(mktemp -d)
AGENT_COORD_STATE_ROOT="$STATE_ROOT" agent-coord heartbeat \
  --agent-id worker-3969 \
  --repo shakacode/react_on_rails \
  --target 3969 \
  --batch-id batch-2026-06-13 \
  --branch jg-codex/3969-agent-coord-backend
AGENT_COORD_STATE_ROOT="$STATE_ROOT" agent-coord status
rm -rf "$STATE_ROOT"

CLI

bin/agent-coord claim     --agent-id ID --repo OWNER/REPO --target ISSUE_OR_PR [--batch-id ID] [--branch BRANCH] [--metadata options] [--ttl SECONDS]
bin/agent-coord release   --agent-id ID --repo OWNER/REPO --target ISSUE_OR_PR [--metadata options] [--handoff-to ID] [--handoff-note TEXT] [--terminal done|abandoned|superseded] [--pr-state STATE] [--evidence-url URL] [--workspace WORKSPACE]
bin/agent-coord heartbeat --agent-id ID [--repo OWNER/REPO] [--target ISSUE_OR_PR] [--batch-id ID] [--branch BRANCH] [--metadata options] [--status STATUS]
bin/agent-coord register-batch --file PATH [--launch-prompt PATH|-]
bin/agent-coord record-event --batch-id ID --type TYPE [--lane NAME] [--agent-id ID] [--repo OWNER/REPO] [--target ISSUE_OR_PR] [--branch BRANCH] [--status STATUS] [--metadata options] [--message TEXT]
bin/agent-coord status [--json] [--include-archived]
bin/agent-coord status --repo OWNER/REPO --target ISSUE_OR_PR [--json]
bin/agent-coord status --batch-id ID [--json]
bin/agent-coord batch-audit --batch-id ID [--json]
bin/agent-coord attention-upsert --record-json PATH|- [--json]
bin/agent-coord attention-resolve --workspace WORKSPACE --repo OWNER/REPO --attention-id ID --source-generation N [--json]
bin/agent-coord attention-get --workspace WORKSPACE --repo OWNER/REPO --attention-id ID [--json]
bin/agent-coord attention-list --workspace WORKSPACE --repo OWNER/REPO [--include-resolved] [--limit N] [--json]
bin/agent-coord log [OWNER/REPO#TARGET] [--since VALUE] [--machine ID] [--host codex|claude] [--type TYPE] [--limit N] [--format text|tsv] [--json] [--sync] [--include-synthetic]
bin/agent-coord version [--json]
bin/agent-coord config [show] [--json]
bin/agent-coord doctor [--json|--stack-json] [--deep] [--doctor-prefix PREFIX] [--state-root PATH|--api-url URL|--backend OWNER/REPO]
bin/agent-coord gc (--dry-run|--execute) [--json] [--hot-days DAYS] [--archive-days DAYS] [--synthetic-hot-days DAYS]
bin/agent-coord bootstrap [--install-dir PATH] [--profile PATH] [--no-profile]
bin/agent-coord demo

demo is a deterministic, isolated local walkthrough. It does not use backend environment variables, read the canonical user configuration file, make remote requests, or preserve its temporary state.

claim acquires or renews a lease. If an active claim exists for another agent, the holder's heartbeat is the normal liveness source: live or stale heartbeats refuse takeover, while a dead heartbeat allows takeover. If the holder heartbeat is missing or invalid, expires_at is the safe fallback and the claim can be taken over only after that fallback has passed. Existing claim updates use the active store's compare-and-swap token, so competing updates fail instead of silently overwriting each other.

Metadata options available on claim, heartbeat, and release are --thread-handle, --chat-handle, --host, --pr-url, --dashboard-url, --operator, --phase, --generation, --instance-id, --synthetic, and --synthetic-kind. These fields are additive, optional, and included in JSON status output when present. Workers use them to connect a lane, chat, host app, branch, PR, operator, and dashboard deep link without parsing handoff prose.

release --handoff-to ID --handoff-note TEXT is the structured handoff path for moving work between agents, hosts, machines, or operators. The released claim is stamped with release_mode: "handoff" plus handoff_to and handoff_note, so the next claimant can recover the branch, PR, phase, and resume note from the target-scoped record. The handoff fields ride along on the auto-emitted claim.released event described under "Auto-emitted lifecycle events" below.

release --terminal done|abandoned|superseded records a version-2 lane_closed event before releasing the held claim, then stamps the matching registered lane. The last terminal lane changes its batch manifest to status: "completed". Terminal release is mutually exclusive with handoff and requires a claim with batch_id. --pr-state records the final pull-request state; --evidence-url can point at replayable closeout evidence.

register-batch --file PATH validates and writes a JSON batch manifest to batches/<batch-id>.json using the active store. Pass --launch-prompt PATH to read the exact coordination prompt from a file, or --launch-prompt - to read it from stdin; the explicit option overrides any launch_prompt already present in the manifest. Registration stamps schema_version, registered_at, and updated_at, preserves optional operator/dashboard/thread metadata, and rejects malformed lane names or owner/target fields before workers claim lanes. --synthetic --synthetic-kind KIND stamps batch-level simulation provenance; re-registration preserves those fields when a later manifest and command omit them, so completed synthetic batches retain the one-day GC window.

record-event appends immutable batch or lane events under events/<batch-id>/<event-id>.json. Use it for phase changes and noteworthy operator-visible milestones that should remain visible even when a heartbeat is overwritten by the next phase. Event records accept the same optional metadata fields as claims and heartbeats, plus --type, --lane, and --message. Most claim-lifecycle events are now emitted automatically (see below); use direct record-event for additional operator breadcrumbs.

Auto-emitted lifecycle events

claim, release, and heartbeat auto-emit lifecycle events so a claim's acquire, phase transitions, and release leave a queryable trail under events/<batch-id>/ without any explicit record-event calls. Each emit is best-effort (a failed event write warns on stderr and never fails the underlying operation) and only happens when a batch_id is known for the operation:

  • claim.acquired on a genuine claim acquisition or takeover — carries the agent, target, branch, and any phase/generation/instance-id metadata on the claim. A same-holder TTL renewal with an unchanged lane and unchanged generation/instance is treated as a routine renewal and emits nothing.
  • claim.released on every non-terminal release — carries the final claim status, and for handoffs the release_mode: "handoff", handoff_to, and handoff_note fields. Terminal releases emit the richer lane_closed event instead and do not double-emit claim.released.
  • phase.changed on a heartbeat whose --phase differs from the phase on the record it overwrote — carries previous_phase and the new phase. It fires only on an actual transition between two phases, never on every beat; the first phase assignment is captured by claim.acquired instead.

Ordinary phase, lifecycle, and milestone events use timestamp-plus-random IDs and remain append-only. lane_closed is the deliberate exception: its event ID is a deterministic reservation derived from the lane name, stable within the batch. A create-only write to that path makes concurrent or retried closeout idempotent; the first event is authoritative and a conflicting closeout cannot append a second terminal record for the lane.

Hosts that separate event production from claim release can write the same terminal record with record-event --type lane_closed --terminal STATE, plus --batch-id, --agent-id, --repo, and --target. --lane is optional when the target uniquely identifies one registered lane. Terminal events default workspace to default and identify the closer in closed_by using the agent id and --host machine value. The public producer/consumer contract is contracts/state-schema-v2.json.

Reading the trail: where is the work on an issue or PR?

agent-coord log answers the operator questions the lifecycle events above were recorded to answer, without reading the full coordination dump:

agent-coord log ShakaCode/hichee#9765
agent-coord log --repo ShakaCode/hichee --target 9765   # the same query
2026-07-24T14:21:00Z  m1   codex   ShakaCode/hichee#9765   claim.acquired  verification-complete  codex-hichee-9765-r3-worker
2026-07-24T14:41:28Z  m1   codex   ShakaCode/hichee#9765   phase.changed   qa-fix-handoff         codex-hichee-9765-r3-worker  handoff -> qa-fix-handoff

One line per event, oldest first. Nothing is inferred beyond ordering by time:

Question Where the answer is
Where is it now? The last line
Codex or Claude? Which machine? The host and machine columns
Was it moved? Any line where the machine, host, or agent column changes, plus handoff and claim.released rows
When was it last worked on? The timestamp on the last line

Filters and output

Filter a broader feed with --since (a 3d/12h/30m duration or an ISO8601 timestamp), --machine, --host, --type, and --limit. --host takes either a family (codex, claude) or any recorded spelling (claude-code, codex-subagent), since both normalize to the same family.

--format tsv emits a tab-separated record that also carries the unnormalized host and the event id, and --json emits the same fields as structured output.

Archive completeness

Events that gc has compacted into archive/events are part of the trail, not a separate view of it, so there is no --include-archived to pass: a completed lane is exactly the work most likely to be asked about after the fact, and answering no events for one said the same thing log says about work that never happened. Archived and live events for a work item interleave by timestamp, and an event readable from both prefixes — a compaction interrupted between writing its envelope and deleting its sources — is reported once.

Their rows are unchanged, so text, tsv, --json, and the --sync mirror hold exactly what they held before gc ran. The provenance goes to stderr instead:

note: 4 of 6 events read from the archive; 1 source event was dropped by compaction; archive deleted after 2026-09-27T02:27:30Z; mirror it with log --sync

Both numbers matter. Compaction retains only the first event, the last event, every terminal event, and actual phase transitions, so the source events it drops leave the backend the moment gc --execute runs; what it does retain then sits on the delete_after clock (30 days by default). If the archive cannot be listed — a token without archive/events read scope, an unreadable directory, a record that will not parse — log says so on stderr and reports the live events anyway. Reading the archive can add rows or a warning, never turn a trail that reads today into a failure; --sync then refuses to mirror a trail it already knows is short.

A backend too old to have an archive/events prefix is the one case that is not a short trail. The Worker change that made that prefix listable is the same one that made it writable, so a backend that cannot list it never stored anything there and nothing is missing. log says so on stderr and --sync still writes the mirror — refusing there would break the very step this page asks you to run before every gc --execute.

Simulation and smoke records are excluded unless --include-synthetic is passed. When included they are marked [synthetic] in text output and carry synthetic and synthetic_kind columns in tsv and JSON, so a simulation row that has been merged into the mirror cannot later be read as real work.

Durable mirror

Run agent-coord log --sync before agent-coord gc --execute. The sync merges the complete trail into <state-root>/log.tsv and rejects every narrowing option, including a work item. It preserves rows already in the mirror, deduplicates the result, and keeps it in timestamp order, so the last line remains the current state even when a later sync discovers an older event. Ordering uses the parsed instant rather than the rendered string; timestamps with offsets sort correctly, and an undated legacy event sorts first and is excluded from every --since window.

An exclusive lock on the log.tsv.lock sidecar prevents concurrent syncs from publishing duplicate rows. The mirror is written to a temporary file, flushed, and atomically renamed, then its parent directory is synced. Once gc prunes the backend, this file can be the only remaining copy, so it must never be left partial. Sync every state root whose history you would want to read back.

Plain grep can then answer the same questions offline and instantly. Match the complete repository-qualified work-item column rather than a bare number: the mirror spans repositories, and event ids or detail text can contain the same digits. Include the accepted target aliases and lane suffixes so the offline query covers the same event trail as agent-coord log ShakaCode/hichee#9765.

agent-coord log --sync
grep -Ei $'^([^\t]*\t){3}ShakaCode/hichee#(issue:|pr:)?9765(:[^\t]*)?\t' ~/.local/state/agent-coordination/log.tsv

The mirror records each event's target as it was written, so grep does not get the work-item matching the command does. The expression above explicitly unions the bare, issue:, and pr: spellings, including their lanes.

The $'...' quoting matters: GNU grep does not define \t inside an ERE, so the plain-quoted form matches a literal t rather than a tab. ANSI-C quoting puts real tab boundaries around the TSV work-item column before grep sees the pattern.

-i matters for the same reason: grep is case-sensitive by default, while the command folds case (below). Without it the offline union silently drops a record written as Issue:9765 or PR:9765 — a partial trail, which is what anchoring on the spellings is here to prevent.

Matching and claim fallback

Matching is case-insensitive for the work item, machine, host, and type. That matters for the work item in particular, because the event store has recorded the same repository under more than one casing, and an exact match would return only half of a work item's history.

For the same reason, the target is matched on the work item it names rather than on its literal spelling. Issues and pull requests share one number sequence per repository, so 9765, issue:9765, and pr:9765 are one work item and one trail; the store holds all three spellings, and matching literally answers with whichever share of the history happens to use the queried one. A trailing segment is a lane within the item (issue:9765:qa): asking for the item covers its lanes, and asking for a lane stays narrow and does not widen to the parent. This is a read-path identity only — claims keep their exact key, because a lane holds its own lease and folding two keys together would break exclusion. --json reports the spellings that actually matched under work_item.matched_targets, and a trail of complete or incomplete alongside them, so an empty events array from a scoped token is never mistaken for a work item that was never touched.

A work item can also hold a claim while having no event trail, since claims written before lifecycle auto-emit were overwritten in place rather than appended. Rather than reporting a bare "no events" and hiding live custody, log reports the claim record, labelled as one, whenever it is the latest thing known about the work item. That covers an empty trail and also a trail whose events are all older than the claim — claim permits omitting --batch-id, and no lifecycle event is emitted without a batch, so stale events and a live claim can coexist. The claim is never reported when a filter emptied the trail, since it is not evaluated against --since, --machine, --host, or --type:

no events for ShakaCode/hichee#issue:10112
claim active m5 codex codex-whimstay-queue-20260801 issue:10112 phase implementing updated 2026-08-01T01:13:03Z

Each claim line names the exact target it holds, because aliases such as 9832 and pr:9832 are independently claimable and the fleet holds such pairs live under different agents. One line is printed per holder, oldest first, so the last one read is the current one. --json mirrors that as a claims array; the singular claim remains the newest holder for consumers that read one value. claims is omitted entirely when no claim matched, so read it with a default rather than assuming the key is present.

A claim whose lease has run out is reported as such rather than presented as current custody — recency alone does not make a claim live, and the fleet holds many left active with a lease long past:

claim released m5 codex codex-whimstay-queue-20260801 issue:10112 phase implementation updated 2026-08-01T06:05:27Z lease elapsed 2026-08-01T06:05:27Z

The elapsed lease is reported as a fact, not as a verdict that custody ended: a holder whose heartbeat is still live can hold a claim past its lease. Deciding that here would be the state inference this command exists to avoid, so you get the lease time and your own judgement.

Two reporting rules worth knowing. Hosts are recorded with many spellings (codex, codex-subagent, codex-desktop, codex-collaboration@its, claude-code); log normalizes them onto the codex and claude families used by lib/agent_coordination/host_adapters.rb and keeps the raw value in the tsv column beside it. Events recorded before machine stamping report ? rather than an inferred machine, so an unknown origin stays visibly unknown.

log never writes coordination state. It is not a split-brain write command, so it keeps the advisory that warns when a consumer env file configures a fleet API URL but the CLI is reading local state — the trail you are reading is then the local one, not the fleet's.

Typed operational-signal events

record-event recognizes four typed --type names for the operational signals most worth counting. Each validates its required fields at write time and rejects a missing field or out-of-set value with a clear error and a non-zero exit; the values are stored as additive payload fields on the event record and projected into status --batch-id --json events (present-only). Typing is strict: a typed event also rejects any typed field that belongs to a different typed type (for example --type help_requested --severity P1 is an error), so a typed record carries only its own fields. Any other --type value stays allowed and unvalidated, exactly as before, so free-form breadcrumbs are unaffected.

--type Required fields Allowed values
help_requested --reason reasonblocked-user-input, question, permission
escalation_requested --from-route, --to-route, --evidence free-form (all three required and non-empty)
error --severity, --category, --message severityP0, P1, P2, P3; category/message free-form
human_intervention --kind kindtakeover, supersede, manual-fix, drain

The typed fields (reason, from_route, to_route, evidence, severity, category, kind) are payload-only and never become path segments. Events stay append-only and reuse the same write path as every other record-event call. These four types plus their fields are the schema downstream consumers (the event harvester and the agent-workflows companion) read; treat this table as the reference.

heartbeat upserts heartbeats/<agent-id>.json. status renders coordination state in text or JSON. Full status renders compact claims, heartbeats, batch lanes, lane dependencies, blocked-on refs, and recent events for broad audits. Scoped status is the preferred batch-workflow path:

  • status --repo OWNER/REPO --target ISSUE_OR_PR reads only the claim paths for that work item's spellings, plus one heartbeat per distinct claim holder. claim writes the raw target, so one item can be held under any spelling of itself, and the candidate set is exactly the spellings log folds onto the same work item: <n>, issue:<n>, and pr:<n> for a number; <slug> and adhoc:<slug> for a slug. A prefix that survives folding is part of the identity, so adhoc:319 and issue:<slug> each name only themselves and cost the single literal read they always did. A query spelled in non-canonical case also probes its canonical form. That is at most four claim reads — three for a canonically spelled number, two for a slug, one where the prefix is part of the base — plus one heartbeat per distinct holder, independent of fleet size: point reads only, never a listing or a prefix scan. Every claim found is reported, so two agents holding 9832 and pr:9832 both appear. Records are reported once per lease, where a lease is the repo, target, and agent_id the record names: on a case-insensitive checkout two candidate paths reach one file, and reporting it twice would invent a second holder. Because the candidates are separate point reads rather than one snapshot, a renew landing between them changes only the timestamps and still reports one lease — but a takeover landing between them returns two different holders for that one record, and both are reported, which is the safer reading of genuinely ambiguous state. Lane suffixes are never folded away, because a lane holds its own lease: --target 319 does not report a claim on 319:qa, and --target 319:qa resolves issue:319:qa and pr:319:qa, not 319.
    • The queried spelling and the alias spellings fail differently on purpose. A corrupt or unreadable record at the queried path is still a hard error with exit 2, unchanged. An alias candidate that cannot answer does not abort the query: malformed JSON, a payload that is not a claim object, a path outside a scoped token's read prefixes, an unreadable file, and a symlink where a record belongs are each reported instead of raised. The healthy records still answer, the exit stays 0, and a claims section note names each such path and says a claim there would not be reported; the notes also appear in degraded. Read them: claims: none with such a note is "could not check everything", which is not the same answer as a bare claims: none. The same asymmetry applies one level down: the queried claim holder's heartbeat still fails the query if it cannot be read, while a holder reached only through an alias claim degrades to a heartbeats note naming that holder. Anything broader — a 500, a route_not_found, an unreachable backend — is still a failed query, not an absent claim.
    • Casing is folded on the query side only. Claim paths are literal, so covering every casing a claim could have been written under would cost a read per casing rather than a closed set. --target Issue:319 finds a claim stored canonically as issue:319, but a claim stored as Issue:319 is found only by that same spelling — --target 319 will not find it. log, which lists and folds, still will. Where two case-differing keys are both live, they are two files and therefore two leases: status reports both holders, while log folds them and reports one.
  • status --batch-id ID reads only batches/<id>.json, events/<id>/, lane-owner heartbeats, and dependency batch files plus referenced lane-owner heartbeats needed to compute blocked_on.

A heartbeat record that is valid JSON but is not an object is unreadable state, not a heartbeat. All three status scopes degrade consistently for that record: target and batch scopes report their existing holder/lane-owner notes, while the broad audit omits the invalid heartbeat row, preserves healthy rows, and reports heartbeat records unreadable. The command still exits 0 because the degraded note makes the incomplete section explicit; this does not make the record valid.

Scoped JSON payloads include scope and degraded fields. A scoped command can show degraded notes for intentionally omitted unrelated state, such as claims not checked in batch scope; that is different from exit 2. Exit 2 means the coordination backend result is UNKNOWN for that command. Text status renders the same degraded notes as a footer when rows are present. In large backends, prefer target or batch scoped status for React on Rails batch lanes and treat a timed out full coordination read as degraded/UNKNOWN rather than guessing. Unscoped status excludes archive/ by default. Pass --include-archived for an explicit archive inventory; scoped status remains hot-state-only so target and batch dependency checks never turn into an all-archive scan.

batch-audit telemetry-completeness gate

batch-audit --batch-id ID reports, per registered lane, which expected lifecycle events are missing, so a batch-closeout workflow can fail-closed on an incomplete event trail. It is read-only and, like status, defaults to the local status state root. It reads the registered batch manifest for its lanes and the batch's events under events/<batch-id>/, attributing each event to a lane by this rule: an explicit event lane matching the lane name is always trusted (lane names are unique); a target match attributes only when the target is unique among the batch's lanes (maps to exactly one lane); and the owner (agent_id) fallback attributes only when the owner is unique among the batch's lanes and the event's target belongs to that lane (or the event carries no target). This is what links the auto-emitted claim.acquired/claim.released events (which carry agent_id and target but no lane field) to their lane — while a unique owner doing unrelated work on a different target under the same batch-id does not complete the lane. register-batch enforces unique lane names but not unique targets or owners, so when two lanes share a target or an owner that shared key is ambiguous and is not used on its own for attribution; a lane whose only keys are ambiguous and was never touched by a lane-name-tagged event correctly stays incomplete rather than false-completing. Empty-string targets are ignored (never an attribution key). When the batch manifest declares a repo, only events whose repo matches it are considered at all (lanes carry no per-lane repo); a batch id reused across repos with colliding target numbers therefore cannot let an event recorded under a different --repo complete a lane. A batch that declares no repo applies no repo gate.

A lane is telemetry-complete when it has both:

  • at least one claim.acquired event, and
  • a terminal signal — either a claim.released event or a lane_closed event.

A lane missing either signal is reported incomplete with the specific missing signals (claim.acquired, terminal); a lane with no events at all is incomplete with both missing. A malformed (non-object) lane entry in the manifest is reported incomplete (never assumed complete). The batch verdict is complete only when every registered lane is complete. A batch that registers no lanes (an empty or missing lanes array, only reachable via hand-edited/legacy state since register-batch rejects it) is reported UNKNOWN (exit 2), never a vacuous complete. Text output lists each lane; --json enumerates per-lane { name, owner, targets, event_count, missing, complete, malformed } (every lane entry carries the same keys — malformed is false for a normal lane and true for a non-object manifest entry) plus the overall verdict.

Exit codes let closeout gate on the result:

Exit Verdict Meaning
0 complete Every registered lane has an acquire event and a terminal signal.
1 incomplete At least one lane is missing a signal; closeout should fail-closed.
2 unknown Coordination state is UNKNOWN — the batch id is unregistered, invalid/unsafe, a non-object batch record, a batch registering no lanes, an event trail only partially visible to a scoped token (filtered listing), or the batch/events are unreadable. A malformed id returns unknown (exit 2), never incomplete (exit 1). Never reported as a false complete.

Host-limit contract foundation

The published schema/state/v1/host-limit.schema.json defines a shared usage-limit record keyed by (workspace, machine, quota_host, scope). quota_host is a canonical quota-pool identifier deliberately distinct from existing lane host app/wrapper metadata; runtime mapping between them remains UNKNOWN. The contract includes active and explicitly cleared states, known or unknown reset times, and an optional host_limits status projection from which consumers may derive blocked-on-limit for lanes carrying a matching explicit quota_host. Positive, negative, procedural, and two-lane replay fixtures live under schema/state/v1/fixtures/.

This is a schema-only foundation. The CLI and Worker do not yet report, persist, clear, or project these records, and provider message/probe facts remain UNKNOWN. See ADR 0007 for canonical quota-host, reset, clear, workspace-key, composite uniqueness, and non-goal semantics.

Capacity reservation contract foundation

The schema-first reservation contract lives under schema/state/v1/capacity-reservation/. It makes four protocol-plane inputs authoritative: numeric capacity profiles, enabled inboxes bound to those profiles, persisted lane occupancy (including blocked lanes without live heartbeats), and short-lived per-lane reservation holds. Product-plane planning, ranking, scheduling, and approval UI remain separate consumers of this protocol state.

Capacity is the unique union of occupied/blocked lane refs and active reserved lane refs, so reservation-to-launch overlap counts once. Creation is all-or-nothing and fails closed when any capacity, inbox, occupancy, or reservation input is missing, malformed, disabled, cross-workspace, or mismatched. Host-limit records remain a separate eligibility gate. Reservation holds use the authenticated machine plus planner owner/instance tuple, expire on server time with expires_at derived exactly from created_at + ttl_seconds, and move monotonically from active to consumed, released, or expired.

The replay fixtures cover final-slot contention, idempotent retry, payload conflict, workspace/profile matching, TTL boundaries, owner enforcement, and partial consume/release. Runtime CLI/Worker operations are intentionally not implemented here; a later additive CLI uses RESERVATION_REFUSED exit code 4 rather than overloading CLAIM_REFUSED. See ADR 0008.

Usage record contract foundation

The published schema/state/v1/usage/usage-record.schema.json defines a per-model token and estimated-cost record keyed by (workspace, repo, batch_id, lane_name, agent_id, target, model). One record per model lets consumers aggregate tokens-by-model and per-batch token/cost tiles. input_tokens, output_tokens, and cost are optional metrics: an unknown value is sent as null or the em dash "—" and is never omitted or emitted as a fabricated zero, so the schema rejects both a dropped metric key and any other string. v1 cost is USD only, so aggregation never combines currencies. An optional usage status projection embeds records into existing status documents; consumers exclude unknown metrics from sums and keep an all-unknown model or batch aggregate unknown rather than zero. Positive, negative, procedural, and aggregation replay fixtures live under schema/state/v1/usage/fixtures/.

This is a schema-only foundation. The CLI and Worker do not yet report, persist, or project these records, and provider token accounting and pricing remain UNKNOWN. Absent usage keeps the dashboard's degrade. See ADR 0009 for logical-key, optional-metric discipline, storage-key encoding, and non-goal semantics.

Lane route contract foundation

The published schema/state/v1/route/route.schema.json defines a lane's bound model + reasoning effort (the "route"), emitted additively on a claim, heartbeat, or lane-manifest record. A route is either the compact model/effort string (for example gpt-5.6-sol/xhigh) or the equivalent { model, effort } object; both canonicalize to the same chip. Route is optional and the only way to signal no route is to omit the property — it is never null — so the dashboard degrades an absent route to a hidden/ chip. Positive, negative, and chip-rendering fixtures live under schema/state/v1/route/fixtures/.

This is a schema-only foundation. The CLI and Worker do not yet emit, detect, or project routes. Route rides on the identity of its host record and declares no key of its own. See ADR 0010.

Batch merge-authority contract foundation

The published schema/state/v1/merge-authority/merge-authority.schema.json persists a batch's declared merge authority additively on the batch manifest as the canonical short enum none | ask | auto. The pr-batch launch vocabulary maps to these before persistence (auto_merge_when_gates_passauto), so the dashboard reads one short value. It is optional and signaled absent by omission only — never null — so a legacy batch degrades to an em-dash Merge auth field, distinct from an explicit none. Positive, negative, and drawer fixtures live under schema/state/v1/merge-authority/fixtures/.

This is a schema-only foundation. The CLI and Worker do not yet capture the launch declaration into batch state. See ADR 0011.

Batch completion-report contract foundation

The published schema/state/v1/batch-completion/batch-completion.schema.json persists a completed batch's audit (verdict + free-form author that folds version and timestamp), completion report (state, receipts, baseline, per-lane outcomes, and optional usage/tokensTotal/cost/duration), and finalReport, keyed by (workspace, batch_id), so the dashboard drawer renders the audit chip, completion table, and final report instead of a degrade note. The dashboard-rendered payload follows the dashboard report contract verbatim (camelCase field names such as tokensTotal), while the record envelope keeps snake_case. Optional metrics send null/"—" and are never omitted or fabricated; archive-ready requires state.live, audit, and receipts. Positive, negative, and drawer-render fixtures live under schema/state/v1/batch-completion/fixtures/.

This is a schema-only foundation. The CLI and Worker do not yet capture batch handoffs into this record. See ADR 0012.

Batch blocker contract foundation

The published schema/state/v1/batch-blocker/batch-blocker.schema.json persists a structured blocker on a batch, keyed by (workspace, batch_id), when a supervisor blocks on operator authority: a message, a non-empty decisions list, and an optional recommendedReply. recommendedReply is signaled absent by omission only — never null — so the dashboard renders the Blocker panel instead of reconstructing decisions from lane blockedOn dependencies. Positive, negative, and panel-render fixtures live under schema/state/v1/batch-blocker/fixtures/.

This is a schema-only foundation. The CLI and Worker do not yet persist a structured blocker. A batch with no blocker keeps the blockedOn-derived fallback. See ADR 0013.

gc applies one retention plan to local, GitHub, and HTTP stores. Exactly one mode is required: --dry-run prints proposed actions without writing, while --execute copies eligible records into archive/ with compare-and-swap protection and only then removes their hot source. Terminal lane/target events are compacted into an immutable archive envelope before their source events are removed. Events are grouped by batch, lane, repository, and target. A lane-less event joins the sole valid terminal lane for the same batch/repository/target; when zero or multiple terminal lanes exist it remains in the explicit legacy group, so one lane's marker cannot sweep a sibling lane. A generation is deferred until every current source event has independently passed its hot window. Each envelope path includes a deterministic digest of lane/provenance identity, source paths, and recursively key-sorted JSON content, so an identical retry reuses the same destination while changed content at a stable path creates a new generation without rewriting the first. The envelope lists every consumed source path but retains only the first event, last event, every valid terminal event, and actual phase transitions; repeated same-phase renewals are intentionally dropped. If a multi-source delete stops after some hot events are removed, retry can leave the immutable archive envelope as a safe expiring duplicate; copy-before-delete still guarantees retained history is not lost. Likewise, ordinary source mutation after the archive write but before the CAS delete can leave a stale expiring envelope, but CAS prevents deletion of the new live payload. Expired archive envelopes are deleted with the same compare-and-swap guard.

Run agent-coord log --sync before gc --execute. Compaction is not lossless: the source events an envelope does not retain leave the backend as soon as it runs, and the envelope holding the rest expires with delete_after. log reads compacted envelopes back, so a completed lane's custody trail does not vanish the moment gc runs — see Reading the trail — but the mirror --sync writes is the only copy that outlives both.

Record state Hot retention Archive retention Result
Released/terminal claim 7 days 30 days Archive, then delete
Dead or terminal heartbeat 7 days 30 days Archive, then delete
Completed batch 7 days 30 days Archive, then delete
Events for a terminal target 7 days 30 days Compact, then delete
Eligible claim/heartbeat/batch with synthetic: true 1 day 30 days Aggressive archive, then delete
Fully synthetic orphan event generation 1 day per event 30 days Compact, then delete

--hot-days, --archive-days, and --synthetic-hot-days override those defaults. Archive retention starts at archived_at, so the default lifecycle is 7 hot days followed by 30 archive days. Producers mark non-production state with --synthetic --synthetic-kind simulation|smoke; batch manifests may carry the same fields. The marker shortens retention only after normal family eligibility: active claims, live heartbeats, and incomplete batches remain hot. This protects scripted workers that claim once and refresh only their heartbeat. Synthetic events without a valid terminal marker compact as an orphan generation only after every event independently passes the synthetic window; missing repository or target metadata uses the batch/lane/available-provenance identity rather than blocking cleanup. Metadata-less legacy events remain in their own absent-lane group, and non-synthetic orphan events remain untouched. Run ruby sim/bin/graveyard for a deterministic dry-run, execute, compaction, and idempotent replay check. Repeat --prefix claims|heartbeats|batches|events to restrict hot-family scans; without it GC scans all four families. Archive expiry is always scanned. For example, agent-coord gc --execute --prefix claims works with a least-privileged token that can read the selected claims subtree plus its archive mirror and can write/delete both. Forbidden selected prefixes remain an operational error; GC never silently widens or skips requested scope. Scoped HTTP tokens used for GC need read and write coverage for each selected hot prefix and archive; use --all-state only for a trusted operator machine. release marks a claim released while preserving the record for auditability. Only the recorded holder can release or restamp metadata on an existing claim; another agent should claim the target after release instead of re-releasing the old holder's record. For planned ownership moves, include --handoff-to and --handoff-note on the original release, then have the next worker claim the same repo/target and continue on the recorded branch/PR. version prints the CLI contract version. config show --json prints runtime defaults, machine-readable exit codes, and a coordination object containing the effective policy, selected backend, configured state, source provenance, and available: null. Configuration inspection never performs a network probe, so consumers must run doctor before treating the backend as available.

The durable user policy is required, optional, or disabled; it defaults to optional. New writes persist AGENT_COORD_POLICY in the same canonical env file as backend and identity settings:

agent-coord config set --policy required

The CLI validates and reports this policy but does not make repository workflow decisions from it. Workflow entrypoints consume config show --json and enforce required or disabled according to their repository coordination seam.

To install or repair the endpoint, token, and machine identity without putting the token in command history, pipe only the token to stdin:

printf '%s\n' "$AGENT_COORD_API_TOKEN" |
  agent-coord config set \
    --api-url https://coordination.example \
    --token-stdin \
    --machine-id m1 \
    --policy required

config set validates or securely creates the full parent chain, stages one canonical mode-0600 file, syncs it, atomically renames it under an exclusive config lock, and syncs the containing directory. Endpoint, token, identity, and policy therefore become visible together through one rename. This is also how you create the file for the first time: point AGENT_COORD_ENV_FILE at the path you want and run config set, and the command writes exactly that path. Until it exists, every other command still fails with configured user env file does not exist. Each setter re-reads under the lock, so concurrent updates preserve unspecified supported keys; readers use a shared lock once the lock file exists. The adjacent lock is namespaced to the selected config file, so an explicit file in a shared parent does not reuse or modify an unrelated .config.lock. A saved URL cannot be changed while preserving its old token: --token-stdin is required in the same transaction. The command never prints token values. A token-only update also fails if another writer changes the saved URL after the command reads it; retry with --api-url in the same transaction to bind the credential explicitly. config set rewrites the file as a canonical list of supported AGENT_COORD_* assignments. Comments, blank lines, and non-AGENT_COORD_* assignments in that dedicated file are intentionally not preserved. An unrecognized AGENT_COORD_* key fails closed instead of being dropped. If the existing file fails ownership, permission, encoding, or syntax validation, config set refuses to overwrite it. Repair or remove that file out of band, then rerun config set; the command will not discard an unreadable configuration while preserving unspecified keys. A process AGENT_COORD_POLICY overrides the persisted policy for one invocation. The old sibling agent-coord/policy file remains a read-only legacy fallback only when neither the process nor canonical env file supplies a policy; config set never updates or deletes that legacy file. That fallback is only consulted beside the derived <config home>/agent-coord/env path, so an AGENT_COORD_ENV_FILE override never reads an unrelated sibling named policy — including when the override's own directories mirror the conventional names.

A token read from the user file is credential-bound to that file's saved API URL. A differing --api-url or process AGENT_COORD_API_URL requires a process-scoped AGENT_COORD_API_TOKEN; the CLI never forwards the persisted token to an override endpoint. A process-scoped token, by contrast, still wins for every selected URL, so the CLI warns on stderr when one is used with a URL it was not set alongside — for example a stale exported token reaching an API URL saved by config set. The warning never prints a token value.

Default doctor verifies the current backend without writing state or parsing every record; doctor --deep adds full JSON validation. For HTTP tokens whose read scope does not overlap claims, use doctor --doctor-prefix <read-prefix> to verify that scoped read path. bootstrap installs the agent-coord command used by public workflow docs.

Legacy / Non-Stack CLI Contract And Exit Codes

Use agent-coord version --json and agent-coord config show --json as the stable contract for public workflow docs. Public repos should avoid copying private implementation defaults when they can point agents at these commands.

The following exit code contract applies to legacy and non-stack commands. The doctor --stack-json component contract and its exit codes are documented in the doctor section above.

Exit Meaning Agent behavior
0 Command succeeded Use the returned state.
1 Usage error Fix the command invocation before proceeding.
2 Operational failure Report coordination state as UNKNOWN; use advisory fallback when safe.
3 CLAIM_REFUSED by live/stale/active hold Hard stop for machine agents; report holder/liveness instead of competing.
4 Reserved future RESERVATION_REFUSED Stop admission without treating capacity contention as an operational failure.

A refused claim is intentionally different from a bootstrap/auth/network failure. A machine agent may not override exit 3 on its own. Exit 2 means the backend could not be trusted for that command, including storage-level compare and-swap contention and a refused split-brain write (a consumer env file configures AGENT_COORD_API_URL while the CLI fell back to the implicit local root; doctor reports status: split_brain for the same condition); dependency-sensitive lanes should stop with UNKNOWN until the coordinator restores backend access. Exit 4 is reserved by ADR 0008 but is not emitted or reported by config show --json until the separately sequenced capacity-reservation runtime commands exist. This additive reservation supersedes the 0-3 freeze only for that future boundary; the archived Backend v2 Phase 1 plan remains historical guidance for its completed phase.

Heartbeat Liveness

Heartbeat liveness is derived from timestamps:

  • now < expires_at -> live
  • expires_at <= now < updated_at + 4 * ttl -> stale
  • now >= updated_at + 4 * ttl -> dead

ttl is the interval between updated_at and expires_at. Use short heartbeat TTLs, normally 15 minutes. A stale heartbeat is a warning that the agent may be thinking, offline, or between tool calls. A dead heartbeat means claims held by that agent are recoverable.

Workers should refresh heartbeats at every phase transition: item start, branch or PR update, review pass, blocked state, and done state. Long-running desktop sessions should also use the platform scheduler templates so liveness does not depend on the agent being between tool calls.

Scheduler Renewal

macOS launchd

The launchd/com.shakacode.agent-coord-heartbeat.plist.example template refreshes one heartbeat every 5 minutes. Install one heartbeat job per live batch lane:

export AGENT_ID=m5-codex-batch2
export TARGET_REPO=shakacode/react_on_rails
export TARGET=3970
export BATCH_ID=agent-coord-2026-06-13
export BRANCH=jg-codex/3969-agent-coord-backend
export AGENT_COORD_REPO="$(pwd)"
export AGENT_COORD_ENV_FILE="$HOME/.config/agent-coord/env"
mkdir -p "$(dirname "$AGENT_COORD_ENV_FILE")"
chmod 700 "$(dirname "$AGENT_COORD_ENV_FILE")"
install -m 600 /dev/null "$AGENT_COORD_ENV_FILE"
cat > "$AGENT_COORD_ENV_FILE" <<'EOF'
AGENT_COORD_API_URL=<worker-url>
AGENT_COORD_API_TOKEN=<machine-token>
AGENT_COORD_MACHINE_ID=<machine-id, e.g. m5>
EOF
perl -pe 's#__AGENT_ID__#$ENV{AGENT_ID}#g;
          s#__TARGET_REPO__#$ENV{TARGET_REPO}#g;
          s#__TARGET__#$ENV{TARGET}#g;
          s#__BATCH_ID__#$ENV{BATCH_ID}#g;
          s#__BRANCH__#$ENV{BRANCH}#g;
          s#__AGENT_COORD_ENV_FILE__#$ENV{AGENT_COORD_ENV_FILE}#g;
          s#__AGENT_COORD_REPO__#$ENV{AGENT_COORD_REPO}#g' \
  launchd/com.shakacode.agent-coord-heartbeat.plist.example \
  > "$HOME/Library/LaunchAgents/com.shakacode.agent-coord-heartbeat.${AGENT_ID}.plist"
launchctl bootstrap "gui/$(id -u)" \
  "$HOME/Library/LaunchAgents/com.shakacode.agent-coord-heartbeat.${AGENT_ID}.plist"

You can also replace the __PLACEHOLDER__ values manually. Keep the env file private (chmod 600) and never commit it. The checked-in template loads AGENT_COORD_API_URL, AGENT_COORD_API_TOKEN, and AGENT_COORD_MACHINE_ID from that local file instead of storing values in the repository. Background services read only this env file — a machine id exported solely in a shell profile does not reach them, so keep it in the env file for heartbeat attribution.

Linux systemd --user

The systemd/agent-coord-heartbeat.service.example template runs the same heartbeat loop under systemd --user. Install one service per live batch lane, substituting the same placeholders used by the launchd template:

mkdir -p "$HOME/.config/systemd/user"
sed -e "s#__AGENT_ID__#${AGENT_ID}#g" \
    -e "s#__TARGET_REPO__#${TARGET_REPO}#g" \
    -e "s#__TARGET__#${TARGET}#g" \
    -e "s#__BATCH_ID__#${BATCH_ID}#g" \
    -e "s#__BRANCH__#${BRANCH}#g" \
    -e "s#__AGENT_COORD_ENV_FILE__#${AGENT_COORD_ENV_FILE}#g" \
    -e "s#__AGENT_COORD_REPO__#${AGENT_COORD_REPO}#g" \
    systemd/agent-coord-heartbeat.service.example \
    > "$HOME/.config/systemd/user/agent-coord-heartbeat.${AGENT_ID}.service"
systemctl --user daemon-reload
systemctl --user enable --now "agent-coord-heartbeat.${AGENT_ID}.service"

The systemd template loads the same private env file for AGENT_COORD_API_URL, AGENT_COORD_API_TOKEN, and AGENT_COORD_MACHINE_ID.

State Layout

Runtime state lives in these directories:

claims/<owner>/<repo>/<issue-or-pr>.json
heartbeats/<agent-id>.json
batches/<batch-id>.json
events/<batch-id>/<event-id>.json
attention/<workspace>/<owner>/<repo>/<attention-id>.json
archive/claims/<owner>/<repo>/<issue-or-pr>.json
archive/heartbeats/<agent-id>.json
archive/batches/<batch-id>.json
archive/events/<batch-id>/<event-or-compaction-id>.json

The checked-in .gitkeep files only preserve the directories. Schema examples are documented below rather than committed as live JSON records, so status does not show fake work.

Attention records

attention-upsert persists one schema-valid open record from --record-json using the same LocalStore or HTTP CAS path as other coordination state. The logical key is (workspace, repository, id). A lower source_generation is rejected; an equal generation may refresh an open record; reopening a resolved record requires a greater generation. Refreshes preserve the original created_at. Each key component is limited to 160 ASCII characters, making the longest attention storage path 497 bytes—below the Worker's 512-byte active-path limit.

attention-resolve changes the record to resolved and adds resolved_at without deleting its question, source task identity, capability truth, or creation time. The Worker does not permit DELETE for the attention family. Attention timestamps require an explicit RFC 3339 offset and seconds from 00 through 59; leap-second spellings are rejected so ordering remains unambiguous.

attention-get performs an exact read. Both read commands prefer AGENT_COORD_STATUS_STATE_ROOT, like status. attention-list returns open records by default, accepts --include-resolved, and is bounded to 100 records. Its JSON payload includes records, limit, and truncated; records are ordered by the documented priority classes and stable creation/id ties. Consumers can rerank from priority_class and priority_reason without parsing Markdown.

The v1 schema is schema/state/v1/attention/attention-record.schema.json. It stores provider, host, task, optional native URI, last-seen time, and explicit available / unavailable / unknown states for native open and prompt forwarding. It stores bounded decision context and safe-resume text, never transcripts or prompts. Capability state is descriptive only and cannot block unrelated coordination work.

Claim Schema

{
  "schema_version": 1,
  "repo": "shakacode/react_on_rails",
  "target": "3969",
  "agent_id": "worker-3969",
  "batch_id": "batch-2026-06-13",
  "branch": "jg-codex/3969-agent-coord-backend",
  "thread_handle": "batch13-backend-quokka",
  "host": "codex",
  "machine_id": "m5",
  "session_id": "019a2f6c-codex-thread",
  "session_source": "codex_thread_id",
  "operator": "justin",
  "phase": "claimed",
  "generation": 3,
  "instance_id": "m5-codex-20260708T180000Z",
  "status": "active",
  "claimed_at": "2026-06-13T00:30:00Z",
  "updated_at": "2026-06-13T00:30:00Z",
  "expires_at": "2026-06-13T04:30:00Z"
}

Required fields: schema_version, repo, target, agent_id, status, claimed_at, updated_at, expires_at.

Allowed claim status values are active and released. A released claim may also carry terminal done, abandoned, or superseded semantics. For lane status, protocol-declared terminal state wins over heartbeat or GitHub-derived state; consumers derive from GitHub only when terminal protocol state is absent. Coordinators should treat a claim holder with a dead heartbeat as recoverable even if the claim expires_at timestamp is still in the future. expires_at remains useful for audit and as the fallback when the heartbeat is missing or invalid.

Optional lane metadata fields on claims are thread_handle, chat_handle, host, machine_id, session_id, session_source, pr_url, dashboard_url, operator, phase, generation, and instance_id. release preserves the existing claim record and the recorded holder may update the same metadata fields for terminal states, such as adding a final pr_url or phase. machine_id, session_id, and session_source come from the machine/session identity environment described in Machine and session identity; a write without that environment preserves the last recorded attribution, and a write that declares a different machine without a session clears the stale session fields.

Heartbeat Schema

{
  "schema_version": 1,
  "agent_id": "worker-3969",
  "repo": "shakacode/react_on_rails",
  "target": "3969",
  "batch_id": "batch-2026-06-13",
  "branch": "jg-codex/3969-agent-coord-backend",
  "thread_handle": "batch13-backend-quokka",
  "host": "codex",
  "machine_id": "m5",
  "session_id": "019a2f6c-codex-thread",
  "session_source": "codex_thread_id",
  "pr_url": "https://github.com/shakacode/react_on_rails/pull/3969",
  "dashboard_url": "https://coord.example.test/batches/batch-2026-06-13/backend",
  "operator": "justin",
  "phase": "validating",
  "generation": 3,
  "instance_id": "m5-codex-20260708T180000Z",
  "status": "in_progress",
  "updated_at": "2026-06-13T00:40:00Z",
  "expires_at": "2026-06-13T00:55:00Z"
}

Required fields: schema_version, agent_id, status, updated_at, expires_at.

Optional lane metadata fields on heartbeats are thread_handle, chat_handle, host, machine_id, session_id, session_source, pr_url, dashboard_url, operator, phase, generation, instance_id, and status_raw. Status readers should treat missing metadata as UNKNOWN rather than inferring it from branch names or handoff text. machine_id, session_id, and session_source come from the machine/session identity environment; renewals without that environment preserve the last recorded attribution, and renewals declaring a different machine without a session clear the stale session fields.

Heartbeat status vocabulary

Heartbeat and ordinary-event status values are a canonical enum, normalized by the CLI at write time. Multi-word values are snake_case. The machine- readable vocabulary, including the alias map, is published by agent-coord config show --json under heartbeat_status_vocabulary.

Working statuses (the lane owner is active or the lane needs attention):

Status Meaning
in_progress Owner is actively working the lane
blocked Blocked on an unmet lane dependency
blocked_user_input Blocked on an operator or user decision
waiting_on_checks_or_review PR up; waiting on external checks or review
external_gate_failing An external gate (CI, review bot) is failing
no_pr_evidence Closeout audit found no PR evidence; needs triage
failed Work attempt failed; lane needs attention

Terminal statuses (the owner is finished and will not renew the heartbeat):

Status Meaning
done Work completed
merged PR merged
ready Work complete and ready for pickup or merge
ready_gates_clean PR ready with gates green, awaiting merge
ready_no_merge_authority Work complete; worker lacks merge authority
abandoned Lane abandoned without completing
superseded Lane superseded by other work

Write-time normalization first folds case and hyphens (Done, waiting-on-checks-or-review, and In-Progress fold to their snake_case forms), then applies the known-alias map:

Alias Canonical
complete, completed done
ready_to_merge, ready_handoff, ready_for_coordinator ready
in_process, claimed, implementing, validating, pushing in_progress

released is deliberately not an alias: a released heartbeat can mean a claim handoff rather than completion, so it takes the unknown-value path below and never satisfies dependencies. When coercion changes the caller's value, the original spelling is preserved in status_raw. Values that resolve to neither the vocabulary nor the alias map are preserved verbatim, copied to status_raw, and reported with a warning: line on stderr; the exit code of an otherwise-successful write does not change. status --json projects status_raw on heartbeats and events. A record whose status equals its status_raw was written with unrecognized vocabulary.

Normalization applies where the CLI writes caller-supplied status values: the heartbeat command and ordinary record-event statuses. Claim status (active/released), lane-closure terminal reasons, and the released claim-status snapshot on claim.released events are separate closed vocabularies written by the CLI itself and pass through unchanged. Rows written by older CLIs are normalized only when rewritten: dependency gating still accepts the legacy complete/completed synonyms, and gc continues to reclaim legacy non-canonical rows through dead-heartbeat classification while classifying canonical terminal statuses as terminal_heartbeat.

Claims and heartbeats may carry synthetic: true and a synthetic_kind such as simulation or smoke. These markers are protocol metadata: they let gc apply the shorter synthetic hot-retention window without guessing from names.

Archive envelopes have a shared 1 MiB serialized-data cap in the CLI and HTTP Worker. Dry-run and execute identically preflight every planned archive/compaction envelope; execute performs no writes if any would exceed the cap. Split or reduce the source history before retrying. A malformed or forward-incompatible record encountered while evaluating an otherwise eligible retention action intentionally fails the whole plan with a path-specific operational error; unknown or non-eligible records remain untouched. Repair the record or upgrade the consumer, then retry. Active HTTP records retain their separate 256 KiB cap.

Archive Schema

Archive paths mirror the hot record grammar below archive/. A single-record envelope retains source_path and the original data; terminal event compaction uses source_paths as the complete set of consumed inputs and records as the compacted first/last/phase-transition history; the arrays are not positional and renewal paths may have no retained record. Both carry archived_at, delete_after, reason, and the synthetic marker. The published contract and fixture are contracts/archive-record-schema-v1.json and contracts/fixtures/v1/. Compaction archive filenames include both a canonical lane/provenance identity digest and a path-plus-content source-generation digest. Multiple immutable envelopes for one identity are valid successive generations, not a conflict or an in-place append protocol.

Event Schema

{
  "schema_version": 1,
  "event_id": "20260708T235500.123456Z-deadbeef",
  "batch_id": "batch-2026-06-13",
  "type": "phase",
  "lane": "docs",
  "agent_id": "worker-3972",
  "repo": "shakacode/react_on_rails",
  "target": "3972",
  "branch": "jg-codex/3972-docs",
  "thread_handle": "thread-docs",
  "host": "codex",
  "operator": "justin",
  "phase": "validating",
  "message": "running tests",
  "at": "2026-06-13T00:42:00Z"
}

Required fields: schema_version, event_id, batch_id, type, and at. Events also carry the optional machine_id, session_id, and session_source attribution fields when the machine/session identity environment is set, and lane_closed events resolve closed_by.machine from AGENT_COORD_MACHINE_ID before falling back to host. Ordinary events retain schema version 1. The explicitly versioned lane_closed event uses schema version 2 and follows the published contract; version --json advertises both schema_version and lane_closed_schema_version so producers do not mislabel unrelated records. Lane events should include lane and agent_id when available. Lane names follow the same rules as registered batch lanes: non-empty and no : characters, because dependency refs split on the last colon. An ordinary event's optional status uses the heartbeat status vocabulary and is normalized the same way at write time, with the caller's original spelling in status_raw when coercion changed it. claim.released events record the released claim's status snapshot (released) verbatim; that value is claim-status vocabulary, not a heartbeat status. Event ids are time-sortable and unique per write for ordinary append-only events. A lane_closed ID is instead stable per batch/lane and begins with lane_closed-; it is not a chronology key. Consumers should order mixed event families by at (using path only as a deterministic tie-breaker), and deduplicate terminal closeout by its batch/lane reservation path rather than by arrival order.

The current HTTP backend stores events in the same JSON state API as claims, heartbeats, and batches, so events/<batch-id> is intended for low-volume phase transitions and audit breadcrumbs, not high-frequency telemetry. Keep event volume bounded per batch until the relational /v1/events endpoint in backend-design.md replaces the interim JSON store. The interim Worker state listing is resumable: GET /v1/state?prefix=... keeps the historical full-snapshot response, while callers may pass limit and then follow next_cursor with the same prefix to read additional pages. Prune or export released claims, expired heartbeats, and old batch/event records before prefix snapshots become expensive.

Batch Schema

{
  "schema_version": 1,
  "batch_id": "batch-2026-06-13",
  "repo": "shakacode/react_on_rails",
  "objective": "Ship backend and docs updates",
  "operator": "justin",
  "dashboard_url": "https://coord.example.test/batches/batch-2026-06-13",
  "lanes": [
    {
      "name": "backend",
      "owner": "worker-3969",
      "targets": ["3969"],
      "thread_handle": "thread-backend",
      "host": "m5",
      "pr_url": "https://github.com/shakacode/react_on_rails/pull/3969",
      "depends_on": []
    },
    {
      "name": "docs",
      "owner": "worker-3972",
      "targets": ["3972"],
      "thread_handle": "thread-docs",
      "host": "m1",
      "depends_on": ["batch-2026-06-13:backend"]
    }
  ],
  "registered_at": "2026-06-13T00:30:00Z",
  "updated_at": "2026-06-13T00:30:00Z"
}

Required manifest fields before registration: batch_id and non-empty lanes. register-batch writes schema_version, registered_at, and updated_at. Use --launch-prompt PATH|- to attach the exact coordination prompt without editing the manifest JSON.

Each lane should include name, owner, and targets. owner is the stable agent id used by heartbeat, so status can attach the lane's latest heartbeat status and liveness. Lane names must not contain :; batch ids may contain :. depends_on is optional and accepts a string or array of lane refs in the form <batch-id>:<lane-name>, split at the last colon.

Top-level batch metadata such as repo, objective, instructions, launch_prompt, operator, dashboard_url, and lane metadata such as thread_handle, chat_handle, host, pr_url, dashboard_url, operator, and phase are preserved and included in JSON status output. A dependency is considered met when the referenced lane owner's heartbeat reports a dependency-satisfying terminal status: done, merged, ready, ready_gates_clean, or ready_no_merge_authority from the heartbeat status vocabulary, plus the legacy complete/completed synonyms still stored in rows written by older CLIs. A stored legacy released row stays excluded: it can mean a handoff release rather than completion, so it does not unblock dependents. New released writes are equally non-dependency-satisfying — they are not aliased, so the CLI warns and preserves them verbatim with a status_raw copy. Terminal abandoned and superseded heartbeats end a lane without completing it and do not unblock dependents. A released claim is preserved for auditability and does not unblock dependent lanes by itself. Unmet dependencies appear in the lane's blocked_on field:

batches
- batch-2026-06-13
  - lane backend owner worker-3969 targets 3969 status in_progress live deps - blocked_on -
  - lane docs owner worker-3972 targets 3972 status blocked live deps batch-2026-06-13:backend blocked_on batch-2026-06-13:backend

Workers with unmet dependencies should set their own heartbeat to blocked, switch to another independent lane, and check agent-coord status again before resuming, rebasing, or pushing dependency-sensitive work.

Issue-targeted lanes and external publication preflights

A lane targets entry names a work item, not the pull request that resolved it. Issues and pull requests share one number sequence per repository, so 130, issue:130, and pr:130 fold to the same work item when a trail is read (see Reading the trail). That folding is read-side only. A claim is keyed by the exact target string, so 130, issue:130, and pr:130 are three separate leases and each stays independently claimable — a prefixed spelling does not take the bare number's lease. Write the bare number in targets. A lane that is assigned an issue therefore records the issue in targets and the pull request that resolved it separately in pr_url — two different facts, not a disagreement.

External closeout tooling does not necessarily share that model. The completed-batch-publication-preflight helper in shakacode/agent-workflows' post-merge-audit skill resolves a lane by parsing each targets entry as a bare integer and by requiring a lane's targets and pr_url to name the same target when both are present. It also derives one expected terminal state per target type — merged for a pull request, closed for an issue — and compares all of them against the lane's single pr_state scalar. Those two assumptions make some correct lane shapes unpublishable there. Measured against that helper for a lane that resolved issue 130 with pull request 156:

Lane shape Helper's expected_targets Preflight verdict
targets: ["156"], pr_url set, pr_state: merged [pr 156] eligible
targets: ["130"], no pr_url, pr_state: closed [issue 130] eligible, with the no-PR evidence below
targets: ["130"], pr_url set, pr_state: merged any of the three blocked: lane target absent or ambiguous
targets: ["130", "156"], no pr_url, pr_state: merged [issue 130, pr 156] blocked: issue target state is not closed
targets: ["130", "156"], no pr_url, pr_state: closed [issue 130, pr 156] blocked: PR target state is not merged

The last two rows are the same terminal: done lane with the only two values pr_state can hold: one scalar cannot satisfy two per-type expectations at once, so no targets spelling reaches them. Each of those rows actually emits two blockers — the state mismatch above plus coordination and target state disagree.

A typed spelling does not help. validate_segment! already permits :, so targets: ["issue:130"] round-trips through register-batch, claim, release, and status today with no schema change — which makes it easy to reach for and worth warning about. The helper parses each entry as an integer, gets nil, and resolves nothing, so a typed spelling leaves every blocked row blocked and turns both eligible rows into blocked ones: issue:130 breaks the second row and pr:156 breaks the first. That is why targets stays a bare work-item id.

The table lists the shapes worth knowing about, not every shape the helper will accept. A few others do reach eligible — recording the issue URL rather than a PR URL in pr_url, splitting the work across a separate issue lane and PR lane, or closing out terminal: abandoned with later authenticated completion — but each one either asserts that no implementation PR exists or that the lane was abandoned, and neither is true of a lane whose PR merged. The constraint that actually matters is that the lane records no PR-pointing url target.

Both eligible shapes in the table carry a cost. Re-registering a lane onto the pull-request number mid-flight needs a second claim on that number, and the operator must remember to release the issue claim first. A plain non-terminal release is always available — it only checks that the caller still holds the claim — but nothing prompts for it, and by then a terminal release of the issue fails, because the re-registered manifest no longer has a lane matching that target. Skip the release and the batch completes while still holding a live lease on the issue. Either way the lane stops recording which issue it worked, and batch-audit reports complete while dropping the pre-PR custody events.

The no-pr_url shape needs more than a closed target: the helper also requires head_sha: not_applicable, a no_pr_evidence record naming the canonical issue target with its exact issue URL and a rationale, and QA evidence replaying to NOT_APPLICABLE with required: no, status: not_applicable, and release_blocking: not_applicable. Together those assert that no implementation PR was created, so the shape is only honest for an issue that genuinely closed without one.

Until this is resolved upstream, an issue-targeted lane that records its pr_url cannot publish autonomously. Its closeout has to go through the accepted-deferral path in agent-workflows' pr-batch skill, which needs a current write-authorized non-bot maintainer to publish a decision comment accepting the exact batch, blocker, owner, predecessor, and preflight digest. An agent coordinator cannot self-authorize it, so an operator hitting this needs a human in the loop. Tracked here in #172 and upstream in shakacode/agent-workflows#522.

Lifecycle

  1. Coordinator registers a batch manifest describing lanes and dependencies.
  2. Worker acquires a claim for its issue or PR target.
  3. Worker refreshes a heartbeat during active work and records phase events for milestones that should remain visible after later heartbeats overwrite state.
  4. Coordinator uses targeted status --repo ... --target ... or status --batch-id ... for lane decisions, and full status only for broad audits where an all-state scan is acceptable.
  5. Worker releases the claim or lets the lease expire if the session is lost.

Keep leases short enough that abandoned work is recoverable, usually 2-4 hours for active batch claims and 15 minutes for heartbeats.

About

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages