FROZEN — immutable history. Superseding/current decisions live in
docs/decisions/. Do not edit.
ADR 0021: Assignment Leases and Stale-Claim Takeover
Date: 2026-07-02 Status: Draft (shaped with Brian Anderson, 2026-07-02). Completes the coordination loop designed in ADR 0012; gated on the same Surface ≥1.2.x bump.
Context
ADR 0012 designed the primitive — an advisory liveness claim stream (claim / heartbeat /
release, TTL-reaped, recomputed via the Surface status function) — and the implementation
exists (src/cli/workflow-sidecar.ts liveness command, scripts/hooks/lib/liveness-read.js,
the workflow-steering.js SessionStart warning). But the loop is unwired in practice:
- Nothing emits. Lifecycle auto-emit is gated behind
FLOW_AGENTS_LIVENESS(default off); no skill callsliveness claim; the live runtime has noliveness/stream at all. pull-worknever reads. Selection writes no claim, label, or assignee (provider mutation is forbidden by the skill), so N concurrent sessions independently classify the same issue “ready” and all take it (#166, under epic #151).- Actor identity collapses.
FLOW_AGENTS_ACTORdefaults to the shared literal"local", so co-located sessions are indistinguishable — held/conflict detection is structurally defeated on exactly the machine where it matters most. - The sidecar directory is a collision point.
workItemSlug()is deterministic per work item, so two sessions on one issue race last-writer-wins on the samestate.jsonand the single globalcurrent.jsonpointer. - The merge race is unguarded. No branch convention (templates default
branch: main), no hold check before push/PR/merge — a lapsed-and-superseded session can still publish over its successor’s work. - Durable assignment is missing entirely. The liveness stream is local and ephemeral by design; there is no cross-machine, human-visible, crash-surviving record of who owns what.
The user-level requirement: run X concurrent sessions unattended, with no duplicate pickup, no clobbered merges, visibility into who holds what — and no lost locks: a claim must never outlive its holder’s usefulness just because a terminal window was forgotten.
Decision
1. Effective claim state is a join: assignment ⋈ liveness. Neither layer alone is truth.
Two layers with distinct jobs, never conflated:
| Layer | Records | Lifetime | Medium |
|---|---|---|---|
| Assignment | intent / ownership | durable, survives crashes | provider (issue assignee/label/comment) |
| Liveness | presence / freshness | ephemeral, TTL-reaped | ADR 0012 claim stream |
Readers always compute the join; provider state is never trusted alone:
| Assignment | Liveness | Effective state | pull-work treatment |
|---|---|---|---|
| assigned | fresh heartbeat | held | excluded |
| assigned | stale / absent | reclaimable | offered, via takeover protocol (§5) |
| assigned (human) | n/a (humans don’t heartbeat) | human-held | surfaced, never auto-taken (§6) |
| unassigned | fresh (claim only) | held (assignment lagging) | excluded |
| unassigned | absent | free | offered |
This join rule is what makes lost locks structurally impossible: staleness — not assignment — is what excludes, so an orphaned assignee/label from a dead session can never gate work.
2. Assignment is a provider abstraction — GitHub is an implementation, not the model.
A third provider leg beside the existing backlog and change providers
(backlog-provider-settings.schema.json, ChangeProvider):
AssignmentProvider:
claim(subjectId, actor, meta) # assign + attach machine-readable claim record
release(subjectId, actor, meta) # unassign + handoff note
supersede(subjectId, from, to, meta) # reassign with audit trail
status(subjectId) -> {assignee?, actor?, claimedAt?, meta?}
list(actor?) -> [subjectId]
The GitHub implementation: assignee (notification/board hook) + a single agent:claimed
label (board filter) + a machine-readable claim comment carrying the full actor struct,
claimed_at, TTL, branch, and artifact-dir pointer. The comment carries identity because the
assignee field cannot: N agent sessions typically share one GitHub account, so per-session
identity lives in the attached record, and each provider decides what it can natively
represent versus what goes in the record. Other implementations (Linear, Jira, GitLab,
local-file for tracker-less repos) map the same operations.
Actor identity is runtime-agnostic: {runtime, session_id, host, human?} — Claude Code,
codex, opencode, and pi sessions (all of which already have telemetry hook adapters) and
humans are all actors. The SessionStart hook derives and exports FLOW_AGENTS_ACTOR from the
runtime session id automatically; the "local" default is retired as an error, not a fallback.
3. Liveness is cross-cutting: ambient reads, lifecycle writes, one hard gate.
Five touchpoints, one policy:
| Touchpoint | Direction | Behavior |
|---|---|---|
workflow-steering.js, every turn |
read | one-line liveness digest; “superseded” interrupt for the held subject |
pull-work |
read + write | exclude held; claim (liveness + provider) on selection |
ensure-session / pickup |
read + write | refuse entry to a session dir under a fresh other-actor claim; supersede-then-enter if stale (implemented by #291) |
advance-state + tool activity |
write | heartbeat rides existing writes; no bespoke timer |
| publish (push / PR / merge) | read, blocking | verify-hold: hard-stop if not the fresh, non-superseded holder |
Once an actor holds a fresh claim it is “heads down”: the ambient check narrows from “what is everyone doing” to “have I been superseded”. The verify-hold gate is the fencing token — the advisory layer’s single mutex moment, placed at the integration layer exactly where ADR 0012 §4 assigns real serialization. It is what defeats the zombie: a session that sleeps past TTL, gets superseded, and wakes is caught deterministically before it can publish over its successor.
FLOW_AGENTS_LIVENESS flips to default-on. Branch becomes a first-class routing field in
state.json with the convention agent/<actor>/<slug>; current.json becomes per-actor
rather than one global last-writer-wins pointer.
4. Provider state is corrected lazily at claim transitions — plus a janitor for humans.
Nothing updates the provider on a timer; every mutation has a responsible actor:
| Transition | Mutator | When |
|---|---|---|
| claim | the claiming session | at selection |
| clean release | the incumbent (Stop hook / terminal advance-state) |
session end — unassign + handoff comment |
| supersede | the successor, inside the takeover protocol | after the grace beat |
| crash, no successor | nobody, initially | corrected by the next actor to want the subject, or the janitor |
Lazy correction is safe by §1 (stale assignment excludes nothing) but leaves the human board
lying in the crash case. The supplement is a janitor: a sweep that joins agent:claimed
subjects against the liveness stream and relabels/unassigns past-TTL claims with an explanatory
comment. Locally this is a cron sweep; for fleets it is the natural first coordination duty of
the hosted Console relay (§7), which is the one vantage with global heartbeat visibility.
Heartbeats are never written to the provider (rate-limit abuse for no benefit); at most the
claim comment refreshes on phase transitions, doubling as board-level progress.
5. Takeover is resumption, not restart — continue-work triggered by staleness.
ADR 0012 §3 established that durable evidence outlives the ephemeral claim. This ADR makes the
resume path concrete. On seeing assigned + stale:
- Emit a supersede claim on the subject.
- Grace beat: wait one heartbeat interval; if the incumbent revives, back off (the “laptop just woke” race resolves in the incumbent’s favor).
supersede()via the provider — reassign with the audit-trail comment (“superseded actor X, last seen T, resuming from trust bundle”).- Resume through the existing handoff/trust-bundle machinery — the deterministic work-item slug means the successor inherits the same artifact dir by construction (the prior collision hazard becomes the resume feature once claims gate entry).
- Continue the incumbent’s branch (fetched from
state.json’s branch field), never a parallel one — otherwise takeover recreates the duplicate work it exists to prevent.
The woken zombie is then caught twice: its next turn’s steering digest says “superseded”, and the verify-hold gate blocks it at publish regardless.
6. Human assignees are never auto-superseded.
Humans do not heartbeat, so assigned-to-human + no-liveness is normal, not stale. Agents
surface idle human assignments (“assigned to brian 3 days ago, no linked activity —
reclaim?”) and act only on explicit confirmation. The threshold and behavior are a policy knob
in the assignment-provider settings; the default is ask-first.
7. Local-first stands; the Console is the fleet tier, never required.
Per ADR 0012 §5 the sink ladder is local file → git ref → optional hosted relay. Everything in §§1–6 works on a single machine with the local JSONL stream and the GitHub provider. The hosted Console (console.kontourai.io) adds, strictly as a relay/projection of the advisory stream — consistent with its “never becomes the authority” stance:
- liveness relay: sessions mirror claim/heartbeat/release events (its existing idempotent ingest + tenant auth + SSE substrate fits without new authority semantics);
- fleet view: active actors, held/reclaimable subjects, per-session cost it already has;
- the janitor (§4), the first genuinely cross-machine duty.
This is the deliberate product boundary: single-machine parallelism is fully unlocked locally; multi-machine fleets, human oversight, and history are what the hosted tier sells.
Consequences
- Running X unattended concurrent sessions becomes safe by construction: duplicate pickup is excluded by the join rule, clobbered merges by verify-hold, lost locks by TTL + Stop-hook release, and abandoned work is resumed — not restarted — via takeover.
- The Stop hook gains a duty: emit
release-with-handoff on every non-terminal session end, so the common case (closed terminal) frees work instantly and TTL only covers real crashes. This keeps TTLs generous, which ADR 0012 §4 identifies as the operational risk. pull-workloses its blanket no-provider-mutation rule in favor of one narrow, audited mutation: the claim at selection.- New surface to maintain: the
AssignmentProvidercontract + settings schema, the actor struct, a branch-naming convention, per-actorcurrent.json, and the janitor. - Cross-repo dependency: the Console relay/fleet-view/janitor land in the console repo; this repo only gains the optional relay sink.
Alternatives Considered
- Liveness-only (no durable assignment). Fails cross-machine and human visibility; a crash leaves no record of ownership anywhere a human looks. Rejected — it is the presence layer, not the ownership layer.
- Provider-only (assignee/label, no liveness). ADR 0012 already rejected as insufficient; additionally it has no freshness, so it creates the lost-lock problem this ADR exists to prevent. TTL logic bolted onto provider comments is the same design with worse atomicity.
- Hard leases (provider-side CAS, lock server). Re-rejected per ADR 0012 — stale leases orphan work; the verify-hold gate gets mutex safety at the single point that needs it.
- Heartbeating to the provider. Rejected: rate limits, comment spam, and it duplicates the stream that already exists.
- Auto-superseding humans symmetrically with agents. Rejected: humans lack heartbeats, so symmetry manufactures false reclaims against exactly the actors least able to defend a claim.
References
- ADR 0012: Agent Coordination as Hachure Liveness Claims — the primitive this ADR wires and completes. (Its reference to #137 for pull-work claim wiring is stale; the live tracking is #166 under epic #151.)
- #151 — the ADR 0012 coordination epic; this ADR shapes its remaining slices. #166 (pull-work claim emit/exclude) is extended by §1’s join rule; #167 (advisory surface, warn-never-block) is deliberately upgraded by §3 at exactly one point — the verify-hold publish gate; #153 (resume from the durable record) is the substrate §5’s takeover builds on; #161 (deterministic work-item slug) is the landed foundation.
src/cli/workflow-sidecar.ts(liveness,withLock,writeCurrent,workItemSlug);scripts/hooks/workflow-steering.js(ambient read side, already live);scripts/hooks/stop-goal-fit.js(release-with-handoff injection point).docs/kontour-resource-contract.mdScopeOverlap— the declared-file-scope seam for work-area (vs work-item) conflict detection; claims should carry declared scope sopull-work’sglobal_conflictscan consult live claims, closing the gap ADR 0012 names.- Console:
docs/integrations/flow-agents-console.md, console repoPOST /records+/ingest/flow+ SSE (the relay substrate).