CLI, workflow helpers, Worker code, tests, and simulation fixtures for coordinating concurrent agent work.
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.
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.
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 demoThe 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.
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.gemspecReplace 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.gemGenerated .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.
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 demoThe 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.
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.jsonFor a trusted single-operator deployment that intentionally needs unrestricted
access, pass --all-state instead of prefix flags:
worker/bin/provision-token <machine-name> --all-stateAfter 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-stateUse 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 claimsbin/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-integrationThe 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 doctorEach 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-codexSession identity resolves in this order and is never reused from a stale persistent value:
AGENT_COORD_SESSION_ID— explicit caller-supplied session or run idCODEX_THREAD_ID— supplied automatically by Codex sessions- 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.
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" --jsonThe 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:
- explicit CLI backend flags (
--state-root,--api-url, or--backend) - backend selectors already present in the process environment
- backend selectors in the canonical user file
- 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"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.
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.acquiredon a genuine claim acquisition or takeover — carries the agent, target, branch, and anyphase/generation/instance-idmetadata 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.releasedon every non-terminalrelease— carries the final claimstatus, and for handoffs therelease_mode: "handoff",handoff_to, andhandoff_notefields. Terminal releases emit the richerlane_closedevent instead and do not double-emitclaim.released.phase.changedon aheartbeatwhose--phasediffers from the phase on the record it overwrote — carriesprevious_phaseand the newphase. It fires only on an actual transition between two phases, never on every beat; the first phase assignment is captured byclaim.acquiredinstead.
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.
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 query2026-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 |
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.
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.
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.tsvThe 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 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.
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 |
reason ∈ blocked-user-input, question, permission |
escalation_requested |
--from-route, --to-route, --evidence |
free-form (all three required and non-empty) |
error |
--severity, --category, --message |
severity ∈ P0, P1, P2, P3; category/message free-form |
human_intervention |
--kind |
kind ∈ takeover, 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_PRreads only the claim paths for that work item's spellings, plus one heartbeat per distinct claim holder.claimwrites the raw target, so one item can be held under any spelling of itself, and the candidate set is exactly the spellingslogfolds onto the same work item:<n>,issue:<n>, andpr:<n>for a number;<slug>andadhoc:<slug>for a slug. A prefix that survives folding is part of the identity, soadhoc:319andissue:<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 holding9832andpr:9832both appear. Records are reported once per lease, where a lease is therepo,target, andagent_idthe 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 319does not report a claim on319:qa, and--target 319:qaresolvesissue:319:qaandpr:319:qa, not319.- 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
claimssection note names each such path and says a claim there would not be reported; the notes also appear indegraded. Read them:claims: nonewith such a note is "could not check everything", which is not the same answer as a bareclaims: 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 aheartbeatsnote naming that holder. Anything broader — a 500, aroute_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:319finds a claim stored canonically asissue:319, but a claim stored asIssue:319is found only by that same spelling —--target 319will 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:statusreports both holders, whilelogfolds them and reports one.
- 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
status --batch-id IDreads onlybatches/<id>.json,events/<id>/, lane-owner heartbeats, and dependency batch files plus referenced lane-owner heartbeats needed to computeblocked_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 --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.acquiredevent, and - a terminal signal — either a
claim.releasedevent or alane_closedevent.
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. |
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.
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.
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.
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.
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_pass → auto), 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.
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.
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 requiredThe 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 requiredconfig 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.
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 is derived from timestamps:
now < expires_at->liveexpires_at <= now < updated_at + 4 * ttl->stalenow >= 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.
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.
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.
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-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.
{
"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.
{
"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 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 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.
{
"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.
{
"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.
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.
- Coordinator registers a batch manifest describing lanes and dependencies.
- Worker acquires a claim for its issue or PR target.
- Worker refreshes a heartbeat during active work and records phase events for milestones that should remain visible after later heartbeats overwrite state.
- Coordinator uses targeted
status --repo ... --target ...orstatus --batch-id ...for lane decisions, and fullstatusonly for broad audits where an all-state scan is acceptable. - 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.
