export const metadata = {
  title: "HTTP Contract",
  description: "The two coordinator endpoints — POST /register and POST /finished — with exact request and response payloads, stale-response handling, and crash recovery.",
};

# HTTP Contract

The coordinator exposes exactly two HTTP endpoints, and a worker only ever talks to
these two. `POST /register` asks for the next assignment; `POST /finished` reports a
completed one and receives the next. Both return the same `TaskResponse` shape, and
both carry the whole loop's decision-making in a small, precise contract. This page
documents that contract exactly — the payloads, the config snapshot, and the rules
that make it safe across worker crashes and stale retries.

## The response shape

Every response from either endpoint is a `TaskResponse` whose `action` is either
`"run"` or `"stop"`.

- A **run** response carries `workflow_set`, `workflow_id`, `session_id`,
  `iteration`, `attempt_id`, and a `config_snapshot`. The worker loads the named
  workflow, renders its prompt, and executes it.
- A **stop** response carries only a `stop_reason`; the other fields are `null`.
  The worker exits.

## POST /register

A worker calls `/register` with its process identity to receive its first
assignment.

Request — the worker's process identity is **required** (a breaking change in
0.3; pre-0.3 workers are rejected with HTTP 400):

```json
{
  "worker": { "hostname": "buildbox", "pid": 4242, "starttime": "lstart:..." }
}
```

The coordinator stamps the identity onto the dispatched task, so a later
`/register` can verify whether that worker is still alive before reclaiming its
task, and a stale `/finished` is only ever replayed to the task's recorded owner.
The same `worker` field rides on `/finished` (the caller runs the next dispatched
task); `starttime` is null when the worker's team-harness predates process
identity, and verification then degrades to "unknown".

Run response:

```json
{
  "action": "run",
  "workflow_set": "main",
  "workflow_id": "planner",
  "session_id": "20260419_143022_71393ee22450_ab12cd34",
  "iteration": 3,
  "attempt_id": "a1b2c3d4e5f6",
  "config_snapshot": {
    "goal": "Ship a minimal working landing page",
    "goal_hash": "71393ee22450",
    "workflow_set": "main",
    "completion_criteria": ["Homepage renders without errors"],
    "stop_criteria": ["A workflow updates session control.json to stopped"],
    "max_turns": 20,
    "goal_check_consecutive_failures_cap": 3,
    "team_harness_provider": "openai_compat",
    "team_harness_model": "gpt-5.5",
    "team_harness_agents": ["codex"],
    "team_harness_agent_models": {"codex": "gpt-5.5"},
    "team_harness_agent_reasoning_efforts": {"codex": "high"},
    "team_harness_max_retries": null,
    "team_harness_retry_base_delay_s": null,
    "team_harness_retry_max_delay_s": null,
    "team_harness_api_base": "https://openrouter.ai/api/v1",
    "team_harness_api_key_env": "OPENROUTER_API_KEY",
    "team_harness_system_prompt_extension": ""
  },
  "stop_reason": null
}
```

Stop response:

```json
{
  "action": "stop",
  "stop_reason": "goal_met",
  "workflow_set": null,
  "workflow_id": null,
  "session_id": null,
  "iteration": null,
  "attempt_id": null,
  "config_snapshot": null
}
```

Rules for `/register`:

- `config_snapshot.goal` is the resolved goal **text**, loaded from the `goal_file`
  named in `loopy_loop_config.yaml`. Workers and team-harness never receive the goal
  file path as the goal.
- `workflow_set` tells the worker which
  `.loopy_loop/workflow_sets/<workflow_set>/workflows/<workflow_id>/` directory to
  load.
- If a task is already current — meaning the prior assignment remains
  unacknowledged — `/register` first checks the recorded worker's liveness. A
  verifiably-alive worker (same host, matching pid + start-time token, and not a
  zombie) gets **HTTP 409** instead of having its task reclaimed. If that worker is
  hung, kill its process (the 409 names the pid) and register again. When the worker
  is not verifiably alive, the coordinator checks that iteration
  directory for `pending_finished_request.json` or `result.json`. If either proves
  the task completed, the coordinator records the completed task in history before
  checking stop conditions. A task with no recoverable local completion has its
  orphaned agent processes drained or reaped per `recovery_policy` (a `salvage.json`
  records processed-run reports) and is recorded as failed with
  `error="abandoned_after_<policy>"` when something settled (or plain
  `"abandoned"` otherwise). For an identity-tracked harness run, an unsettled
  reaper outcome makes `/register` return **HTTP 409** rather than dispatching a
  second writer. A remote loopy worker cannot provide the same local proof
  because the coordinator cannot reach its processes.
  See
  [Crash recovery](#crash-recovery-and-stale-retries).
- If the loop is already in a terminal state, `/register` immediately returns
  `action: "stop"`.

## The config snapshot

The `config_snapshot` is the coordinator's resolved, immutable view of the run's
configuration, sent with every run response so the worker never has to re-read the
repo config. Its most notable fields:

- `goal` — the resolved goal text (never a file path).
- `goal_hash` — the deterministic hash of the goal, also embedded in the session id.
- `max_turns` and `goal_check_consecutive_failures_cap` — the loop's stop limits.
- The `team_harness_*` fields — provider, model, agents, per-agent model and
  reasoning overrides, retry controls, API base, the name of the API key env var,
  and any system prompt extension.

For where these come from and how they are validated, see
[Concepts](/docs/concepts) and [Configuration](/docs/configuration).

## POST /finished

A worker calls `/finished` to report a completed assignment. The response is the
same `TaskResponse` shape as `/register`, so the worker learns its next task (or to
stop) in the same round trip.

Request:

```json
{
  "workflow_id": "planner",
  "session_id": "20260419_143022_71393ee22450_ab12cd34",
  "iteration": 3,
  "success": true,
  "text": "done",
  "error": null,
  "failure_kind": null,
  "usage": {
    "prompt_tokens": 5210,
    "completion_tokens": 902,
    "turns": 3,
    "turns_without_usage": 0
  },
  "duration_s": 187.4,
  "attempt_id": "a1b2c3d4e5f6",
  "worker": {
    "hostname": "buildbox",
    "pid": 4242,
    "starttime": "lstart:..."
  }
}
```

`attempt_id` must echo the live task's value. `worker` identifies the caller
that will own the next task. `failure_kind`, `usage`, and `duration_s` are
optional; missing usage means unknown, never zero.

Response: same shape as the `/register` response (`action` is `"run"` or `"stop"`).

Rules for `/finished`:

- **Match required.** The report must match the current task on `session_id`,
  `workflow_id`, `iteration`, and the live `attempt_id`. Only then does the
  coordinator record the result, read session artifacts, and dispatch the next
  task. A stale call from a different identified worker gets HTTP 409 rather
  than receiving the live owner's task.
- **`control.json` and `goal_check.json`.** After a matched finish, the coordinator
  reads `control.json` only from the session directory, and reads `goal_check.json`
  only from the current iteration directory when the workflow is `goal_check` or has
  `emits_goal_check: true`. A valid `goal_check.json` is an eval artifact, not a stop
  switch — workflows stop the loop by updating `control.json`. See
  [Success & Control](/docs/success-and-control).
- **Child sessions.** A parent workflow can request a depth-first child loop by
  writing a `schema_version: 1` JSON request with `workflow_set` and `goal` under
  the session's `child_requests/` directory. The next response dispatches the child
  workflow set; after the child stops, the coordinator resumes the parent. See
  [Child Sessions](/docs/child-sessions).

## Crash recovery and stale retries

The contract is designed so that a killed worker, a duplicate retry, or a lost
acknowledgment never corrupts loop state.

**Stale finish (mismatch).** If a `/finished` call's `session_id` +
`workflow_id` + `iteration` + live `attempt_id` does not match the current task,
the call is stale. The coordinator does **not** mutate state or change the
current task. It returns the current task only to that task's recorded owner (or
for a pre-identity legacy task); a different identified caller gets HTTP 409.

**Attempt ids.** Every dispatched task carries a unique `attempt_id`, echoed by
the worker on `/finished`. A call from a superseded attempt — same
session/workflow/iteration, different attempt — is treated as stale, so a late
result from work that was already recovered or abandoned can never be recorded
as the current iteration's outcome.

**Session-stack recovery on `--resume`.** The coordinator walks the durable
parent→child pointers to the deepest live session: a running child continues
where it was (previously a restart silently reopened the parent and orphaned
the child), a terminal child is finalized and its parent resumed, and every
interrupted-dispatch crash window reconciles deterministically.

**Finish with no active task.** If `/finished` arrives when no task is current, the
coordinator dispatches the next available task as if `/register` had been called —
or returns `action: "stop"` if the state is terminal. This makes a post-crash retry
safe.

**Worker crash mid-handoff.** A worker writes `result.json`, then
`pending_finished_request.json`, then calls `/finished`. If it dies inside that
window, the next `/register` recovers the completed result from
`pending_finished_request.json` (or reconstructs it from `result.json` if the
pending file is missing) and records the task as completed — rather than marking it
`abandoned`. A locally written result is treated as authoritative. The per-iteration
files behind this are described in [Session Layout](/docs/session-layout).

**Worker task interrupted mid-run.** With nothing recoverable, `/register`
applies the configured recovery policy to identity-tracked agent processes the
prior worker's harness run left behind — by default **bounded drain**: let
in-flight agents finish while their edits survive in the working tree. One
`recovery_drain_timeout_s` deadline is shared across all of the iteration's
interrupted runs. A `salvage.json` in the interrupted iteration directory records
what the reaper reported, so surviving edits are auditable rather than a mystery
diff. The task's result is never fabricated: abandonment consumes a turn, after
which normal scheduling continues only if no stop condition fires. Recovery runs
outside the state lock (`loopy status`/`stop` stay usable), but `/register` itself
can block roughly up to the drain deadline plus kill grace periods — the bundled
worker uses an unbounded read timeout on `/register` only. Process recovery is
same-host-only. For an identity-tracked harness run, an unsettled reaper outcome
refuses replacement work (HTTP 409) and is captured in `salvage.json`. A remote
loopy worker skips reaping and falls back to legacy abandonment; when local
worker liveness is unverifiable, team-harness recovery is still attempted if its
run data supports it. The recovery settings are coordinator-side only and are
not part of the wire `config_snapshot`.
