Worker protocol
Use this when you are building a worker that runs queue items without
going through spor dispatch — a custom launcher, a different harness
adapter, or a headless polling process — and need the exact contract it must
honor against the graph.
You do not need this if spor dispatch already does this for you; see
Dispatch, capabilities, and profiles instead.
After reading this, you should be able to implement the claim → brief →
work → report → resolve loop against plain REST calls, and know exactly what
makes a run resolved, reported, or failed.
Spor is the durable substrate of a software factory, not the factory
itself. A coding-agent harness — Claude Code, Codex, OpenCode, GitHub
Copilot CLI, or anything else that can read a prompt and write code — is a
fungible worker behind a protocol specified precisely enough that a
third party can implement a conforming worker without the Spor client.
Everything below reduces to plain REST calls plus the sequencing and shapes
those calls compose into. spor dispatch/spor work are one reference
implementation of this protocol, not the protocol itself — new harnesses
are additive adapters, never a fork of the orchestration layer.
What a worker is
Section titled “What a worker is”A worker is any process that, over one unit of work:
- claims a node from the queue (The pool and the claim),
- reads the compiled context for it (The prompt contract),
- does the work — out of this protocol’s scope: write code, run tests, whatever the task requires,
- reports back onto the graph in exactly one of three terminal shapes (Terminal states), and
- releases the lease so the item returns to (or leaves) the pool, in an order that can never lose the work.
Nothing here requires the Spor CLI, the Claude Code plugin, or any particular model. A worker is identified to the graph by an agent node carrying its own bearer token; every write it makes is attributed through that token, independent of which binary is doing the writing.
Agent identity and attribution
Section titled “Agent identity and attribution”A worker writes to the graph as an agent node — a person-owned principal, not a person. Create one (self-serve, no admin needed):
POST /v1/agents {label} → agent-<slug>, owned-by <you>POST /v1/agents/{id}/token {session?} → a bearer token scoped to itEvery write under an agent-scoped token is stamped authored_by_agent: <agent-id> and session: <id>, with authored_via: dispatch; author:
stays the agent’s owning person, so the node reads “agent on behalf of
person.” This is the token a worker process should hold — never a person’s
own account-scoped token or connector session, which would attribute the
work to the human instead of the agent that did it.
No agent identity resolves, or minting one fails? On a real remote
dispatch, this hard-fails, naming the fix (spor agent use <agent-id>) —
never a silent fall back to a person-scoped token, which is exactly the
human step agent attribution exists to keep out of the loop. Unlike the
readiness-gap split in The pool and the claim,
this one isn’t locked to a surface: both spor dispatch and the autonomous
spor work loop refuse by default, and both accept the same explicit
escape hatch — --allow-person-token (the standing dispatch.allowPersonToken
config key, or the SPOR_ALLOW_PERSON_TOKEN env var) — with a loud warning
on every fallback launch it permits. The escape hatch exists for solo/local
use where nobody has bothered to mint a machine identity and that’s a
deliberate choice, not an accident; leaving it unset is what keeps an
unattended worker from ever silently misattributing agent work to the
person that happens to own its token. Local mode is unaffected — there is
no CA to mint an agent token against.
Minting and managing this identity — the two token shapes (per-session and
standing), and late session binding when the harness’s real session id
isn’t known at mint time — is the full subject of Tokens and
agents. A worker only needs to
know: hold an agent-scoped token, and if your launcher can’t know the
harness’s session id up front, bind it once the harness reports it via
POST /v1/agents/session rather than guessing or omitting it forever.
The pool and the claim
Section titled “The pool and the claim”GET /v1/queue?project=<slug> returns the ranked, live queue for a
project. Each item’s readiness field (agent/human/untriaged) says
whether it is meant for an autonomous worker at all — readiness: human
always wins over any stamp, and a worker should never claim it. suggest
on each item (do/dispatch/blocked/triage/close/approve) is a
further hint; blocked means a live blocks edge still gates it — claiming
it is legal but the item cannot resolve until its blocker does. See
Agent-readiness and
The decision queue for what drives both fields.
On top of that floor, spor work’s pickup is configurable via the
acceptance policy (--accept, the work.accept config key, or
SPOR_WORK_ACCEPT). ready — the default — is explicit consent: only
items whose derived readiness is agent (a person’s spor ready <id>
stamp, or an assigned -> agent routing) are dispatched; an untriaged
item is skipped with a visible reason on the worker’s stdout and in
spor work --status. open opts back into the looser pickup: everything
except readiness: human — that floor is not part of the knob. An unknown
value refuses to start the worker, and spor work --print shows the
effective policy.
The page widens rather than starving. Selection reads a fixed-size ranked page, and the policy above filters what comes back — so a page filled entirely by items this worker may not take would hide an eligible one ranked below it on every poll, forever. When nothing on the page is dispatchable by this worker — un-consented, out of scope, already in flight here, or cooling off after a refusal — the read widens (doubling, up to a server-enforced ceiling) until something is dispatchable or the queue is exhausted. A pass that finds a candidate on the first page pays nothing extra, and the width a pass needed carries over to the next poll, so a worker starved behind a page of items it may not take still pays only one queue read per poll rather than re-walking the ladder every time.
Before starting work, take the heartbeat-renewed lease:
POST /v1/nodes/{id}/claim {session?, dispatch?}This writes the durable assigned edge once (attributed to the
authenticated identity — never a body field) and creates an ephemeral
lease. expires_in_ms in the response is the renewal horizon relative to
when this call ran — a worker should renew at roughly half of it, never
hardcode a TTL, since the bound is graph-resident tenant policy and varies
per repo. A live lease held by someone else is 409 conflict naming the
holder and expiry; re-claiming your own live lease is an idempotent renew.
dispatch is an optional opaque nonce a launcher can tag its claim with so
the server can tell a second concurrent launch of the same node by the
same identity apart from an idempotent re-claim/renew — without it, a
same-identity double-launch just renews and silently starts two workers on
one node. Pass a fresh value (e.g. a UUID) per launch attempt; omit it for
a deliberate re-attach to an already-running claim.
The full lease family — renew, extend, release, reserve, and their
bulk working-set twins — is documented on Leases.
The one fact worth repeating here: a crashed worker needs to do nothing.
The lease is read-time self-healing — a lapsed lease demotes the claim
and the node re-enters the pool with zero sweep and zero scheduler.
The prompt contract
Section titled “The prompt contract”A worker’s context is assembled from three parts, in this order — this is
the shape spor dispatch builds, and the one a third-party launcher should
reproduce so a worker sees the same standing context regardless of harness:
> **Spor session project:** `<slug>`. If you file a question with> `ask_question` (or `POST /v1/questions`) that has no clear `mentions:`,> pass `project: "<slug>"` so it is stamped to this project rather than> defaulting to the asker's home project.
# Spor briefing (compiled for this task — your standing context)
<compiled neighborhood — from POST /v1/digest {root: <node-id>} or query>
---
# Task
Work on <node-id> — <title>. The compiled Spor briefing above is yourstanding context. <any additional free-text task instructions>- Session note. One paragraph naming the session’s project slug, so a worker filing a mention-less question stamps it correctly instead of defaulting to the asker’s home project.
- Compiled briefing.
POST /v1/digest {root: <node-id>}(or{query: <text>}for a free-text task with no target node) returns the node’s neighborhood: prior decisions, constraints, dismissed approaches, related work — the same compiler behind Briefings and corrections. This section can be omitted entirely for a bare-bones launch, but every worker benefits from including it. - Task. What to do — the target node’s id and title plus any additional instruction text, or free-text task instructions with no node at all.
- Worker contract (
spor workonly). An unattendedspor workrun appends a standing contract to the task’s instruction text: work only in the launched checkout and branch, never merge to or push the target ref, don’t touch the factory’s protected paths, verify deterministically, commit everything and leave the tree clean before resolving, resolve the item last with a resolver node carrying aresolvesedge, and if the item won’t converge, leave it unresolved with the blocker named in the report. The factory-specific lines (the integration target, the acceptance command, the protected paths and their lane, the cross-model review) appear only when the factory declares them; a bare worker’s contract is the plain commit-then-resolve discipline. A person’s one-offspor dispatchadds nothing — they write their own task text.
There is no wire-level requirement that a worker consume this exact string; what matters is that a conforming worker (a) is capable of reading a compiled briefing before acting non-trivially, and (b) knows which node it is working on, so its terminal report can name it.
Capability declaration (optional)
Section titled “Capability declaration (optional)”A worker box may publish what it can run so a routing layer can pick a
satisfying host instead of a human hardcoding one — harnesses, reachable
MCP servers, skills, and plugins, matched atomically against a profile
node’s runtime fields, with no silent substitution when a box can’t satisfy
one. This is entirely optional: a worker that never publishes capabilities
simply never appears in a host lookup, and nothing above depends on it. Full
detail — the publish/read/heartbeat routes and host matching — is on
Tokens and agents;
the profile side of the match is on Dispatch, capabilities, and
profiles.
Terminal states: the outcome contract
Section titled “Terminal states: the outcome contract”This is the contract every worker must honor, however it is launched. It answers a question the process’s own exit code cannot: what did this run actually do to the graph — an agent can exit 0 having done nothing, and one that crashed after writing its resolver still finished the job.
terminal_state is exactly one of:
| Value | Means | How it’s earned |
|---|---|---|
resolved |
the target is genuinely done | re-reading the graph shows a live inbound resolves/answers edge onto the target node |
reported |
not done, but the work reached the graph | no resolving edge, but the worker’s final report was filed as an artifact relates-to the target |
declined |
the worker declared the ITEM wrong, not the work unfinished | the final report’s first line is exactly DECLINED: <reason> |
failed |
nothing usable reached the graph | no resolving edge and no usable report |
resolved is a graph read, never an exit code, never the worker’s own
claim. Re-fetch the node — GET /v1/nodes/{id} — and check its
resolution enrichment: a live, visible, inbound resolves or answers
edge, carrying the resolver’s id. Its absence is the answer for a worker
that claims success without writing one — that absence reads as
reported or failed, never resolved. This is the single most important
rule in this protocol: a worker’s own “I’m done” is not evidence; a
resolving edge on the graph is.
Report presence — not exit status — discriminates reported from
failed. A run that crashed midway but had already produced a usable
final report is reported, not failed; the crash itself is a separate,
process-level fact, never conflated with the outcome. The invariant a
consumer keys on: whenever a report artifact id is present, terminal_state
is reported — always, whether the verdict was fully verified or not (see
unverifiable targets below).
A decline is a fixed form, not prose. A worker that finds the item
itself wrong — its premise no longer holds, it is already done, the change
belongs in another repo — declines it: commits nothing, writes no resolver,
and makes the first non-blank line of its final message exactly DECLINED: <one-line reason> (the rest of the message explains; a heading or bold
wrapper around the line is tolerated). Only that exact line is read — “I
declined” in a later paragraph is a report, not a declaration. A declined
run:
- carries no claim of completion, so a factory’s gate pipeline never gates it — gates test a claim of completion, and a decline makes none;
- has its reason filed as a
findingnode,relates-tothe target, with the full report in the body, so the item re-briefs with the decline attached the next time it is compiled; - has its
readiness: agentstamp cleared, so a worker running thereadyacceptance policy (see The pool and the claim) does not re-dispatch the item as written; - releases the lease, in the same file-then-release order as a report.
The graph still wins over the words: a target that already reads resolved is
resolved whatever the final line says.
Unverifiable targets. Only node types whose completion is a resolving
edge rather than a status flip — task, issue, question, incident —
can be judged for resolved at all. A decision or finding target
(closed by status, not by edge) is out of scope for the verdict: the filed
report’s wording says “not verified” instead of “ended without resolving
it,” and terminal_enforced reads false even when a report was filed.
This type also changes the ordering below: the lease is never released on
the strength of a verdict this contract cannot make — not on a successful
report filing, and not on a missing one either. It is left to lapse at its
own TTL in both cases.
Ordering is the contract: file the report, then release the lease — never the other way, and never both-or-neither on a failure.
- Re-read the target node; a live resolving edge → done,
resolved, release nothing (the durableassignededge already stands as the record of who did the work). - Target is an unjudgeable type (
decision/finding) → file the report if text exists, worded “not verified”; release nothing either way —terminal_statereadsreportedif a report was filed orfailedif there was none, but always withterminal_enforced: falseand the lease left to lapse at its TTL. - Judgeable type, no resolving edge, a final report text exists → file it
as an artifact (see The report artifact). If
the write lands (or was already there — filing is idempotent), release
the lease →
reported. If the write is refused by the graph, the lease is deliberately left held rather than releasing a signal-free item back into the pool — it lapses at its own TTL instead. - Judgeable type, no resolving edge, no report text at all → release the
lease →
failed, with aterminal_noteexplaining why.
A crash between step 3’s two writes can therefore only ever leave the lease held with the report already filed, or leave both undone — never a released lease with no report to show for it.
What “enforced” means, and where it doesn’t apply yet. terminal_state
is only as trustworthy as terminal_enforced says it is: it is true only
when the graph was actually re-read against a reachable server — and, for a
target with no resolving edge, the report actually filed there too (a
resolved verdict needs only the re-read; nothing is filed once a
resolving edge is already found). Every other case — no team graph
configured (local-mode dispatch), a free-text dispatch with no target node,
an unreachable server, an out-of-scope target type (above), or a
native-background launch whose termination this runner cannot
deterministically observe — stamps terminal_enforced: false and can never
read resolved. A reported or failed value on an unenforced record is
a best-effort classification of the process outcome, not a checked
verdict; terminal_enforced is the field a consumer must gate on before
treating either as ground truth.
What a third-party (non-reference-client) worker must do
Section titled “What a third-party (non-reference-client) worker must do”If your launcher is not spor dispatch/spor work, reproduce the
algorithm above directly against REST once your worker process ends:
1. GET /v1/nodes/{targetId}2. if resolution.by present → terminal_state = resolved; done, no release3. elif final report's first line is `DECLINED: <reason>` → POST /v1/nodes (file a `finding`, if_exists: skip) if the write lands: POST /v1/nodes/{targetId}/readiness {readiness: "clear"} POST /v1/nodes/{leaseNode}/release → terminal_state = declined if the write is refused: leave the lease held → terminal_state = declined (held)4. elif targetId's type is decision/finding (an unjudgeable type) → if final report text exists: POST /v1/nodes (file it, if_exists: skip) → terminal_state = reported, terminal_enforced = false else → terminal_state = failed, terminal_enforced = false NEVER release the lease either way — it lapses at its own TTL5. elif final report text exists → POST /v1/nodes (file the report, if_exists: skip) if the write lands: POST /v1/nodes/{leaseNode}/release → terminal_state = reported if the write is refused: leave the lease held → terminal_state = failed (held)6. else → POST /v1/nodes/{leaseNode}/release → terminal_state = failedA decline is checked before any type-based branching, so it takes priority
over step 4 for every target type — a DECLINED: report is never
misclassified as a filed report just because the target happens to be a
decision or finding.
leaseNode is whichever node your claim actually established the lease
on — normally the same as targetId, but not necessarily (a forced
re-dispatch that renewed someone else’s lease releases nothing, since that
lease isn’t yours to hand back).
Candidate submission under controller completion
Section titled “Candidate submission under controller completion”A factory may declare completion.by: controller (see Factory → Declaring
the stage),
which replaces this contract’s ordinary resolve step with a candidate
submission: commit on the launched branch, leave the tree clean, write
the resolver node carrying a relates-to edge — never resolves — and open
the final report with the fixed line CANDIDATE: <resolver node id> — <why>
instead of resolving the item directly. terminal_state still reads by the
algorithm above exactly as for any other run: with no resolving edge yet on
the graph, an enforced submission reads reported, never resolved. The
factory’s own gate pipeline is what later writes the resolves edge, at the
declared completion boundary, once it accepts the candidate this run pinned
— see Factory → Controller
completion
for the pin, the publication, and the execution hold that keeps a stray
write inert until then.
The integration step: a code-enforced merge queue after gates
Section titled “The integration step: a code-enforced merge queue after gates”spor work --factory <id> can declare an integration: block on the
factory definition — the pipeline’s LAST stage, run only after every
declared gate has passed. It is opt-in, exactly like the gate list itself: a
factory that declares no integration: block resolves work exactly as
described above, with no adoption cliff. Where it applies, a resolved
verdict in the terminal states
sense above is only reachable once this stage lands the change (or, under
propose mode, is deferred to a later pass — see below).
A declared gate earns its own pass the same way this protocol earns
resolved above: never on its own dispatcher’s say-so. An agent-review gate
dispatched at a branch that carries no committed change against the
trusted ref — the implementer landed nothing, or resolved with an empty
diff behind it — fails closed and unretried rather than reading back a
vacuous pass; no reviewer is sent to review nothing.
{ "integration": { "target_ref": "main", "mode": "local", "command": "npm test", "strategy": "merge", "serialize": "repo" }}| Field | Means |
|---|---|
target_ref |
what “landed” means; defaults to the factory’s own trusted_ref |
mode |
local CAS’s a local ref with git update-ref; push pushes to a remote, whose own non-fast-forward rejection is the compare-and-swap; propose opens a pull request instead of mutating target_ref at all — see Propose mode below |
command |
the FULL suite, run on the merged candidate tree — never a fast tier deferred to a service after landing |
strategy |
merge | squash | rebase — how the candidate tree is built |
serialize |
the lease’s scope; repo is the only value today |
reruns |
default 0, at most 3 — the same bounded same-tree rerun a command gate has: the candidate suite reruns on the one candidate worktree (never rebuilt) before a failure becomes a fix cycle |
The candidate build. A throwaway worktree at merge(target_ref, branch) per the declared strategy — merge lands the branch onto the
target, rebase replays the branch’s own commits onto it, squash folds
the branch into one commit on top of it. A merge conflict is a fix-cycle
event, not a terminal error — fed back to the same implementer through
the same cycle-cap-then-escalate machinery a failing gate uses.
Protected paths are forced, again, under the same guarantee and matcher
a command gate’s candidate tree gets — a tree that is itself staged with the
repo’s own dispatch.worktreeSetup hook exactly as a dispatch worktree is
(a node_modules symlink, a pinned sibling checkout, whatever the suite
needs that isn’t tracked in git, with the hook’s .claude/settings.local.json
env reaching the suite too; a hook that fails refuses the tree, so the
suite is never run on a half-staged one): every declared protected_paths
glob is restored to the trusted ref’s own copy before the suite runs. That
restore only rewrites the candidate worktree’s working directory (it
creates no commit), so when the restore changes anything the stage
re-commits the restored tree and lands that sha instead of the
pre-restoration one — otherwise a tampered protected-path edit could ship
behind a suite that only ever ran against the restored tree.
The candidate suite runs full, on the merged tree, every landing — a failure feeds into the same fix-cycle machinery a conflict does.
Landing is compare-and-swap, and losing the race is nobody’s mistake.
Local mode’s git update-ref target_ref new_sha old_sha refuses if the ref
moved since the candidate was built (and brings a checkout with
target_ref already checked out up to date for the landed paths, where its
index and working copy were otherwise untouched); push mode’s rejection of
a non-fast-forward push is the same guarantee over a remote ref, and push
mode fetches the target before every candidate build so a rebuild after a
lost race is always against the live tip. Either way, a lost race
rebuilds the candidate against the ref’s new tip and reruns —
automatically, bounded by a small retry ceiling, and never charged
against the fix-cycle cap: the implementer did nothing wrong, another
landing simply won first. The serialize: repo lease (a server-held claim
in remote mode, a machine-local lockfile with no server) makes this race
rare; the CAS is what makes it harmless regardless — every failure
acquiring the lease is fail-open, logging a note and proceeding without
one.
Every landing or failure is a graph fact, art-merge-… — the
integration stage’s twin of a gate’s art-gate-… fact: the same idempotent
id scheme, the same relates-to (never resolves) edge onto the work
item. A failure that exhausts its fix cycles demotes the item exactly as
a failed gate does: an escalation is filed that blocks the work item,
and the item’s completion status is rolled back if it claimed one — the
run’s resolver already declared every gate passed, so the only thing an
integration failure disputes is whether the change ever reached the target
ref. The same gate_escalation_failed marker and spor work --regate <run-id> door back apply here exactly as they do to a gate refusal.
Cleanup runs on a landing or a proposal. The candidate worktree is
always removed, win or lose (it is throwaway by construction); the
implementer’s own dispatch worktree and branch are removed once the work
has either landed or been proposed — a propose-mode PR is already durable
on the remote once opened, so there is nothing left for the dispatch
worktree to hold. Only an outright failure (a conflict or a suite that
never resolves, a PR that never opens) leaves the dispatch worktree
standing.
Propose mode: PR-landing for review policies
Section titled “Propose mode: PR-landing for review policies”mode: propose runs the identical candidate build, protected-path
restore, and full suite every other mode runs — the point of running it
pre-PR is that the PR is known-green the moment it opens. Only the landing
step differs: instead of a CAS mutating target_ref, it pushes the
implementer’s own branch (tree.head, unmerged — never the throwaway
candidate commit, which only ever proved merging would be green) and opens
a PR against target_ref through the gh CLI, the v1 backend. gh is a
declared capability, checked through the same machine-profile
satisfiability
layer a profile’s harness/MCP/skills/plugins already go through: loading a
factory that declares propose warns loudly, once, at
the same load-time check an unreadable factory already gets, but does not
kill the whole worker — a box that can never land a proposal idles,
visibly (in spor work --status), skipping every candidate rather than
crash-looping, leaving the item for a capable box. No lease is ever
established on a box that can’t finish the job. A re-run (a fix cycle, or a
resumed pipeline) reuses whatever PR is already open for the branch rather
than erroring on a duplicate.
Opening the PR parks the item — it does not resolve it, and it frees the
work-loop slot immediately. This is deliberately not the shape of a
human gate, which polls a graph approval in-process for up to a day
(approval_timeout_ms), holding a concurrency slot the whole time — fine
for an approval a person answers within a shift, wrong for a PR review that
can legitimately take days. Parking instead reuses only the graph-state
half of a failed gate’s demotion: a tracking item is filed carrying a
blocks edge onto the work item, and the work item’s own completion status
is rolled back if it claimed one — never an in-process poll, and nothing is
marked for a person at this point. The pipeline returns an additional
settled gate state, parked (alongside passed/failed/blocked), and
the work-loop slot frees on that return exactly like any other settled
verdict.
A human gate and propose mode never double-file an approval: a human
gate is a gate — it runs and is judged before integration ever starts (an
internal “should we even try to land this” call), through a wholly
different door than propose mode’s own tracking item. A factory can
declare both — the human gate judges the change itself pre-integration,
and propose mode’s PR is the org’s independent, external review-and-merge
gate on the same change afterward. Neither one knows the other exists.
Resolving a parked item is a separate later pass, never a resume of the
run that parked it. Because a parked run’s own pipeline is already
settled, it is never re-entered to ask “did the PR land yet” — instead
spor work’s loop periodically checks every parked run’s PR via gh pr view:
- Still open — a no-op; nothing is written until the next pass.
- Merged onto the expected
target_ref— writes a secondart-merge-…fact for the same run (a distinct id segment keeps it from colliding with the first), carrying aresolvesedge onto the tracking item — the one point in this pipeline where an integration fact retires something rather than merely recording it — then restores the work item’s own completion status and closes the tracking item. - Merged onto a different base than expected — a
base-mismatchverdict: records a fact (the PR number, the actual base, and the expectedtarget_ref) but resolves and restores nothing, leaving the tracking item parked for a person to reconcile — the PR technically landed, but not where the pipeline verified it against. - Closed without merging — writes a fact recording it, but restores
nothing and leaves the tracking item open: the PR was rejected on
GitHub’s own review surface, and — same as a
humangate’s own rejected approval — a person decides what happens next, not the worker.
The rescue lane: a strong-model pass before human escalation
Section titled “The rescue lane: a strong-model pass before human escalation”A failing gate does not escalate on the first refusal — spor work --factory <id> loops implementer fix cycles against the gate’s own declared cap first.
Only once that budget is spent (or the refusal is not one a fix cycle could
address at all) does the gate escalate: file a person-facing item that
blocks the work item and roll back the item’s own completion status if it
claimed one — the same demotion the integration step
above applies to its own failures, so the refusal is a graph fact rather than
a machine-local cooldown.
The rescue lane is an optional, factory-level step that runs in between:
one strong-model pass, dispatched before that escalation ever fires. It is
declared once, beside gates and integration, and covers every gate’s
exhaustion rather than being configured per gate:
{ "rescue": { "profile": "profile-claude-fable", "attempts": 1, "await_ms": 3600000, "instructions": "…" }}| Field | Means |
|---|---|
profile |
required — the profile the rescue dispatches under. A strong model by intent, and profile-routed like an agent-review gate: the graph names the lane, the machine’s own capabilities decide what actually runs |
attempts |
default 1, at most 3 — rescue attempts per pipeline. A second attempt is handed the first’s diagnosis and asked why it did not land |
await_ms |
default one hour — how long the runner follows the rescue run before treating it as unrun |
instructions |
optional factory-specific guidance added to the rescue’s prompt |
When it runs. Only when a pipeline would otherwise escalate to a person —
a gate that has spent its fix cycles, or refused for a reason nothing has
already filed a person’s item for. Not every refusal qualifies: a protected
path a command gate caught, and a human gate’s rejected or still-pending
approval, have each already run their own gate and filed their own
person-facing item, so there is no fresh escalation for a rescue to get
ahead of. A declined run is
excluded for a different reason — it is never gated at all, since it
carries no claim of completion to test. All three go on exactly as before;
none of them are rescuable.
What it does. Dispatched into the run’s own checkout — always
supervised, whatever this box’s own dispatch.claudeLaunchMode default is,
so the runner can read its final report the way it reads any gate
dispatch — with the work item, the diff, every commit on the branch, the
refused gate’s evidence, the fix cycle history, and any earlier rescue’s
diagnosis, the rescue is asked to:
- diagnose the refusal into one of four categories —
reviewer-drift,real-defect,stale-premise, orenvironment; - fix it in that checkout and commit, naming the findings it addressed, or change nothing it cannot justify; and
- file at least one Spor task proposing the factory, gate, prompt, or item change that would have prevented the pattern.
It is told, and it is true, that it never marks a gate passed itself. Reading
its diagnosis is deliberately fail-soft — a rescue that fixed the tree but
forgot to state a category still gets its fix judged. A rescue that could
not be dispatched, or never reached a terminal state inside await_ms, is
recorded as unrun, and the refusal it was handed escalates exactly as it
would have with no rescue lane at all.
After the rescue. The runner re-runs the whole gate list — every gate, from the first, on the tree the rescue left — as a fresh pass keyed to the rescue attempt, so its facts never collide with the original pass’s. Each gate gets a fresh fix-cycle budget, but the finding ledger from before the rescue carries forward: a review after a rescue is a review under the same stateful fix-cycle protocol as any other, required to clear or confirm each prior finding by id rather than starting over.
- The rescue pass passes — the item stands: nothing escalates, nothing is demoted, and the integration step follows as usual.
- It refuses and attempts remain — the next rescue attempt is handed everything above, plus the earlier diagnoses.
- It refuses and attempts are exhausted — the escalation fires, and its body opens with the rescue’s diagnosis: the category, the one-line conclusion, whether it committed a fix, and what it filed. The person reads what a strong model already concluded before deciding.
A factory that declares no rescue: block behaves exactly as it did before
the lane existed.
The report artifact
Section titled “The report artifact”The filed report is an ordinary artifact node, deliberately not a
resolver — it carries relates-to, never resolves, because filing a
report must never itself retire the item; the whole point is the work
returns to the queue carrying the report rather than vanishing.
---id: art-dispatch-report-<stem>-<short-run-id>type: artifactproject: <slug> # when knowntitle: Dispatch report — <target-node-id>summary: Final report from the dispatched <harness> run on <target>, <ended without resolving it | whose outcome was not verified against the graph>: <first line of report>date: <YYYY-MM-DD>edges: - {type: relates-to, to: <target-node-id>}---
Final report from dispatched run `<run-id>` (<harness>), which ended `<state>`.It is filed here so the run's work reaches the graph instead of vanishing intoa dead run; nothing here resolves the target.
<one of:>The run left no resolving edge on <target>, so the item returns to the queuecarrying this report.<or, on an out-of-scope target type:>Whether <target> is complete was NOT verified: a `<type>` node of this type isretired by its status rather than by a resolving edge, which is the onlysignal this runner checks.
<the worker's own final report text, verbatim>Deterministic, idempotent id. art-dispatch-report-<stem>-<short-run-id>,
where <stem> is the target node id with its type prefix stripped
(≤ 40 chars) and <short-run-id> is the first 8 hex chars of the run id.
The same run filing the same report twice — a retry after a transient write
failure — lands one node, not two: write with if_exists: "skip". A 207
partial-success from the batch POST /v1/nodes door, or a per-entry
status: "skipped", both count as landed — only a hard transport
failure or a rejected entry means the write did not happen.
Size discipline. The server caps a node’s summary at 500 chars and its
body at 8192 bytes; a filed report stays comfortably under both so a long
final report is truncated here, never rejected wholesale (a rejected
write is a lost report): body truncated at 7000 bytes (byte-exact, cut
back to the last clean UTF-8 boundary, with a trailing [report truncated — see the run log for the full text] notice), summary at 460 chars, id
stem at 40 chars.
The run record
Section titled “The run record”Every dispatched run gets one persistent local record, readable with spor runs --json — {reconciled, count, runs: [<record>]}. reconciled: false
means a native-harness live-agent listing failed for this particular call,
so any shown native-background record that isn’t yet terminal may be stale.
Each record spans two independent dimensions: process (how the run’s
process ended — always present) and outcome (what this protocol’s
terminal-states algorithm found, once it has run against this record).
Consumers should treat unlisted or absent fields as null/absent, not as a
schema violation — new fields may be added additively.
A run the harness no longer reports live is finalized against its own
evidence, and the evidence it’s finalized against depends on the launch
mode: a native-background run is resolved against its session transcript,
while a supervised-jsonl run is finalized by its own supervisor process
straight off its log when the child exits — never a transcript, since a
supervised harness has none of its own.
Process dimension (every record):
| Field | Type | Meaning |
|---|---|---|
run_id |
string (uuid) | this run’s unique id |
node_id |
string | null | the target node, or null for a free-text dispatch |
name |
string | null | the launch name (defaults to the node id, or the first few words of free text) |
harness |
string | adapter id: claude-code, codex, opencode, copilot, … |
launch_mode |
string | "native-background" (detaches into the harness’s own daemon) or "supervised-jsonl" (runs under a supervisor Spor owns) |
state |
string | one of "launching", "running", or a terminal process state: "done", "failed", "failed_launch", "vanished" — see the lifecycle note below |
cwd |
string | the run’s working directory |
model |
string | null | native-background records only — the model override this launch resolved, null when none did. A supervised record carries no model key at all, since its model is fixed into the harness argv at launch |
created_at / finished_at |
ISO 8601 | when the record was opened / went terminal |
started_at |
ISO 8601 | supervised runs only — when the child process actually started |
launched_at |
ISO 8601 | native runs only — when the launcher observed the harness hand off to its background daemon |
exit_code / signal |
int | null / string | null | supervised runs only |
termination_class |
string | a broad bucket: "completed", "environment" (credit/rate/auth exhaustion — re-dispatchable, not a real failure), "launch", "failed", "idle" (a run that stopped writing anything and was stopped for it), or "unknown" — an open vocabulary; do not exhaustively switch on it |
termination_reason |
string | a human-readable one-line explanation |
session_id |
string | null | the harness’s own session/thread id, possibly bound after launch (see Agent identity and attribution) |
transcript_path |
string | native runs only, when a transcript was found |
log_path / report_path |
string | supervised runs only — the raw log, and where the harness’s own final-message text landed, if any |
A run only sometimes visits running. It starts at launching; the
harness never starting closes it straight to failed_launch without ever
reaching running, otherwise it advances to running and from there to one
of the other three terminal states (done, failed, vanished).
The two launch modes carry different fields, and that asymmetry is part
of the schema, not an omission to read around. A native-background
record carries launched_at (never started_at), plus transcript_path
and model; a supervised-jsonl record carries started_at, exit_code,
signal, log_path, and report_path — and no model key at all.
The idle bucket measures silence, not a wedged agent specifically. A
run whose observable output — the supervisor’s log, or a native launch’s
session transcript — stops moving for spor work --run-idle (the
work.runIdleMs config key, default 45 minutes) is stopped and recorded
failed with termination_class: "idle", so a wedged agent does not hold
its worker’s slot, lease, and worktree until the much longer --run-max
watchdog (default 24 hours). A single tool call that legitimately runs
longer than the ceiling — a full test matrix, a slow build — looks
identical to a stuck run and is stopped mid-work just the same; raise
--run-idle (or set it to 0) for a lane whose steps genuinely take that
long. A run with no observable channel at all (a native-background launch
whose session was never bound) is never judged idle — it falls through to
--run-max instead, which frees the slot without making any claim about
the run. Being stopped for idleness is a process fact, not an outcome: an
agent that had already written its resolver before going quiet still reads
resolved once the terminal-states algorithm re-reads the graph.
Outcome dimension (present once the terminal-states algorithm above has
run against this record — absent while state is still non-terminal):
terminal_state, terminal_enforced, resolved_by, resolved_edge,
report_node_id, lease_released, and terminal_note, plus — declined
runs only — declined_reason, finding_node_id, and readiness_cleared.
The full field table is documented alongside spor runs on Dispatch →
runs, and those fields map directly onto
Terminal states above. A record
with terminal_state unset (or state still non-terminal) has not
finished; poll or watch the record file rather than assuming absence means
failure.
A run dispatched under a factory’s implementation:/completion: blocks
carries a further, additive dimension on top of the two above: impl_state,
the tip impl_candidate (and the full impl_candidates chain), the pinned
impl_claim, gates_state/integration_state, and the completion_*
family. Absent on every legacy record and on any record whose factory
declares neither block; see Factory → The
candidate
and → Controller
completion
for the field-by-field reference and example output.
Terminal records age out after dispatch.runRetentionMs (default 14 days,
config-cascade only, no env override) — read a record’s outcome before that
window closes if it needs to outlive the run itself. The graph (the report
artifact, the resolving edge) is the durable copy; this record is an
operational journal.
Minimal conformance checklist
Section titled “Minimal conformance checklist”A worker (and its launcher, if separate) is a conforming Spor worker when it:
- authenticates as an agent-scoped token, not a person’s own credential, for every graph write it makes while doing the work
- claims its target node before starting, and renews before the lease’s
expires_in_mshorizon closes if the work runs long - reads the compiled briefing for its target before acting non-trivially — a worker that skips this reinvents decisions the graph already settled
- on finishing, checks the target for a live resolving edge rather than declaring victory itself
- if no resolving edge exists, files its final report as an artifact
relates-tothe target — never silently drops the work - if the item itself is wrong rather than the work unfinished, declines
with a first line of exactly
DECLINED: <reason>instead of leaving a vague report or forcing a change through - releases the lease only after the report (or decline) write is confirmed — a refused write leaves the lease held, never released with nothing to show for it
- never claims a
readiness: humanitem, and never routes a mention-less question without stamping the session’s project
Companion specs: Reads and
Writes (the REST contract this protocol is built
from), Nodes and
Edges (the format resolves/answers
edges and report artifacts follow), Leases
(the full claim/renew/extend/release/reserve family), and
Dispatch → capabilities
(the satisfiability check propose mode’s gh requirement is checked
through).