Skip to content

[coordination] Platform-provided durable agent loop — the Loop is an agent-level primitive, not a browser poll #158

Closed

Description

@serge-ivo

Re-scoped 2026-08-04 — un-deferred, and now a precondition for #184

Originally filed as "converge the client-driven Loop into the durable Pilot" — framed as Coder cleanup. It is bigger than that: the autonomous loop is a general agent capability the platform should provide, and today it is half-built in the wrong place.

What is actually true in the code

The platform already owns the thinking:

  • POST /v1/instances/:id/loop-decide lives in routes/instances.ts — not coding.ts — with a pure, tested lib/loop-decide.ts. It is instance-scoped and agent-generic (BYOK Claude decides continue/done/escalate/failed).

The browser owns the persistence:

  • store/console/src/pages/InstanceDetail.tsx polls /loop-decide and sends the next instruction. Close the tab, lock the phone, sleep the laptop — the loop dies mid-objective.

Meanwhile the Pilot (CodingSessionWorkflow) is a durable server-side loop, but Coder-bound and tied to the workflow closed enum (#160).

So there are three overlapping things: a generic decide endpoint, a client-driven driver, and a durable-but-hardcoded workflow. The decide step is already right. What is missing is a durable, generic loop runner that any agent can use, with the Pilot as one configuration of it.

Why this is now blocking, not deferred

You cannot budget a loop you do not own. #184 requires bounding spend on unattended work — but today the Loop runs in the user's browser, so the platform cannot stop it, cannot reliably count its iterations, and cannot reserve against a pool. Every enforcement point #184 needs is on the server side of a loop the server does not drive.

Making the loop platform-owned is therefore a precondition for #184, not parallel work.

It is also a straight UX win

Today closing the tab kills an in-flight objective. A durable loop survives tab close, phone lock, and laptop sleep, and can be reattached from another device. That is an improvement to the current Coder experience, not just plumbing — and it is what makes max iterations (already in the Loop UI) meaningful rather than best-effort.

Scope

  • A durable, generic loop runner at instance level: objective → snapshot → loop-decide → act → repeat, until done / escalate / budget exhausted.
  • The console Loop becomes a thin UI over it — start, watch, interject, stop — rather than a parallel client-driven brain.
  • The Pilot (CodingSessionWorkflow) becomes one configuration of the primitive; Coder keeps its behaviour.
  • Works for non-coding agents too (the Chat-tab Loop already targets these).
  • Budget hooks ([coordination] Autonomous work budget — bound spend across every unattended entry point #184) enforced by the runner, once, for every agent.
  • Interject / stop / resume survive disconnect and are addressable from another device.

Acceptance

Part of #154. Pairs with #160 (retiring the workflow enum makes the loop fully declarative). Design: docs/supervision.md.

Activity

  1. added
    backendBackend / Worker / API work
    coordinationAgent coordination / delegation primitives
    on Aug 3, 2026
  2. added a commit that references this issue on Aug 3, 2026
  3. changed the title [-][coordination][deferred] Converge the client-driven Loop into the durable Pilot[/-] [+][coordination] Platform-provided durable agent loop — the Loop is an agent-level primitive, not a browser poll[/+] on Aug 3, 2026
  4. serge-ivo commented on Aug 4, 2026

    @serge-ivo
    ContributorAuthor

    Server side landed in 60b59fc (migration 0062, workflow binding AGENT_LOOP).

    Shipped: AgentLoopWorkflow · lib/agent-loop.ts (pure policy) · lib/agent-loop-store.ts · lib/loop-orchestrator.ts · POST /v1/instances/:id/loop, GET …/loop, GET …/loop/:runId, POST …/loop/:runId/cancel.

    Each iteration: cancel? → reserve → act (the instance's own brain, with ITS tools and guardrails per #185) → decide → apply the pure policy.

    Two policy rules worth recording:

    • Terminal decisions are honoured before the iteration cap, so a run that genuinely finished on its last allowed iteration reports done, not the misleading max_iterations.
    • A no_progress guard stops a loop repeating the same instruction. parseLoopDecision already infers "failed" from obvious self-repetition in prose, but that depends on the model noticing; this does not.

    This closes #184's open half. Reserve before the model runs, settle the actual cost measured as an ai_usage delta across the iteration. My first pass settled the reserved amount — which means the refund never happens and the pool drains at the worst case every iteration, defeating reserve-and-refund entirely. Caught and fixed before commit. Settled even on a failed step, since the tokens were spent either way. Cancellation is cooperative (checked at the top of an iteration) so a stop cannot strand a reservation and leak headroom.

    One regression I introduced and fixed: refactoring /loop-decide onto the shared orchestrator swallowed the missing-API-key 402 into a silent escalate. Credential errors are now rethrown and handled explicitly in both callers — they fail identically on every retry, so neither the route nor the workflow should burn iterations on them.

    51 new tests; full suite 1639 passing.

    Not included: the console migration. The browser Loop in InstanceDetail.tsx still polls /loop-decide directly, so until that is repointed at these routes the durability is available but unused by the UI. That is the remaining work on this ticket.

  5. serge-ivo commented on Aug 4, 2026

    @serge-ivo
    ContributorAuthor

    Console migration done in 67eb915 — this ticket is now complete.

    InstanceDetail.tsx no longer is the loop. It POSTs /loop, watches the run, and cancels cooperatively. Removed: the client-side continuation and the effect that kicked the next iteration after a human interjected — a kick from the client would now race the workflow and double-send.

    Two details worth recording:

    • The watcher refreshes the transcript each poll, because turns now arrive from the server rather than from calls the component made. Without that the chat would sit frozen while the run progressed.
    • A transient read failure does not stop the watcher. The run is durable whether or not this tab can see it — treating a failed poll as "the loop died" would reintroduce exactly the fragility this ticket removes.

    Net effect: an objective now survives closing the tab, locking a phone, or sleeping a laptop, and can be watched from another device.

  6. serge-ivo commented on Aug 4, 2026

    @serge-ivo
    ContributorAuthor

    Done. Server side in 60b59fc (AgentLoopWorkflow, migration 0062); console migrated off the browser poll in 67eb915; autonomous-runs history in 8042f7a. An objective now survives closing the tab, and the platform drives the loop — which is what made #184 enforceable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    backendBackend / Worker / API work

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions