loopy-loop

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):

{
  "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:

{
  "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:

{
  "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.
  • 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 and 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:

{
  "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.
  • 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.

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.

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.