Skip to content

A 2-hour run that closed ten issues and pushed 15 times is reported as "outcome: failed" with a Wrangler docs link — the Cloudflare subrequest ceiling is relayed raw as the objective failing #523

Description

@serge-ivo

What the owner saw

His Coder Lead delegated "Work through all open GitHub issues by priority order — bugs first, then enhancements". Two hours later the Assistant thread said, in full:

Loop stopped (failed)

The board card says more, and it is worse:

Failed · deleg-ae7fa3f2-53a1-43ba-b3ad-921dd8dba7b1
outcome: failed — run error: Too many API requests by single Worker invocation. To configure this limit, refer to https://developers.cloudflare.com/workers/wrangler/configuration/#limits | Acts: pushed directly to the trunk origin main; pushed directly to the trunk origin main; pushed directly to th…

Read from the owner's side: his objective failed, and the remedy is a Cloudflare Wrangler configuration page for a Worker he does not own. There is no sentence anywhere telling him that the run had already implemented and pushed most of the backlog before it died.

What actually happened

Session csess_e80b6a21-a154-4905-b689-fe5c5cd16289, instance bd43f4de-ef35-4051-bdec-43f8571414a1, node RLs-MacBook-Air.local, 2026-08-11 01:29:42 → 03:31:35, status: error.

Measured from GET /v1/instances/:id/coding/sessions/:sid/timeline and agent_trace:

The run did not fail. It was cut off at 2 hours by a platform ceiling, and the only thing it left behind that a human reads says "failed".

Mechanism

Verified. workers/api/src/workflows/coding-session.ts:669-677 wraps the whole loop:

} catch (e) {
    const message = e instanceof Error ? e.message : String(e);
    result = { outcome: "failed", detail: isRunnerGone(e) ? message : `run error: ${message}`, ... };
}

There is exactly one classified escape — isRunnerGone, from #341. Everything else, including a Cloudflare runtime limit, becomes run error: <raw platform string> and is then the detail on the board card, the loop-run row and the chat message.

Verified. The run's dominant subrequest consumer is the idle poll. coding-session.ts:497-509:

// Poll capture until the CLI goes idle … Bounded so the loop can't outrun idleRetry's 10-minute step timeout.
waitIdle: () =>
    guard(runIdle, `s${n++}-waitidle`, async () => {
        await sleep(1500);
        let snap = await capture();
        for (let poll = 0; poll < 240 && snap.runState !== "idle" && snap.alive && !snap.cancelled; poll++) {
            await sleep(2000);
            snap = await capture();
        }
        return snap;
    })

sleep is a plain setTimeout promise (coding-session.ts:795) — not step.sleep. So up to 241 capture() calls happen inside a single step.do. Each capture() (coding-session.ts:383-431) issues, at minimum: one relay callRunner for /coding/capture, one D1 SELECT status FROM coding_sessions, and one isCancelRequested D1 read — plus recordEngineUsage, recordEngineActs, recordAuthorityViolations and touchSessionActivity, all of which reach D1. That is ≥3 and typically 5–7 subrequests per poll, so one waitIdle can spend 700–1700 subrequests. A message action calls waitIdle every step (coding-loop.ts, end of the action branch), 26 times in this run.

Inferred, and labelled as such: that this is where the invocation's budget went. I have not instrumented a live Workflow to attribute the exhausted counter to a specific step, and Cloudflare's step/invocation boundary semantics are not something I verified from the runtime. What is verified is that (a) the ceiling was hit, (b) the platform already knows this ceiling — workers/api/src/lib/repo-ingest-runner.ts:252 describes an incident where "ONE tick drained a 300-file queue (blowing past the 1,000-subrequest cap, which embed's catch swallows)" and a chunk budget was added specifically to stay under it — and (c) workers/api/wrangler.toml declares no [limits] block, so the API Worker runs at whatever the default is.

What to do

1. Say what happened, in the platform's own voice — the cheapest and most valuable step.
Follow the pattern #341 already established in this exact catch block: a failure that KNOWS what it is says so, instead of being inferred from a message at the catch site. Add a classifier beside isRunnerGone for a platform-ceiling error (Too many API requests by single Worker invocation, Too many subrequests, exceeded CPU) and emit something the owner can act on:

This run reached a Cloudflare per-invocation limit after 2h and 26 steps. It was not your objective failing — the work it had already committed and pushed is intact (15 pushes to main). Resume it, or narrow the objective.

Crucially it must not be reported as outcome: failed with no qualification: a delegating supervisor reads check_delegation and the board card, and both currently say the objective could not be done.

2. Stop spending the budget in one invocation. Break waitIdle into bounded chunks: poll ~15 times inside a step, then await step.sleep(...) and continue. step.sleep is already used in this very file for the handoff wait (coding-session.ts:650), for the same reason — a long wait must not be held open inside one invocation. This also makes a long engine turn survive a Workflow eviction instead of burning the retry budget.

3. Consider raising the ceiling explicitly, but not instead of (2). wrangler.toml supports [limits] subrequests (Cloudflare docs: default 10,000 on paid, maximum 10,000,000). Adding it is one line. It is deliberately listed third: raising the ceiling alone means the next run of this shape goes further unsupervised before it dies — a run that pushed to main 15 times without a human reading anything is not obviously improved by letting it push 30 times.

4. Checkpoint, so a cut-off run is resumable. The Pilot's progress lives entirely in the run's in-memory actionLog/transcript (coding-loop.ts). When the invocation dies, so does the plan. A run that has closed 8 of 12 issues should be resumable from issue 9, not from the objective.

Alternatives rejected

  • Just raise [limits] subrequests and close this. Fixes the symptom for a run of this length and leaves the message, the outcome classification and the unresumability exactly as they are. It also does nothing for the CPU-time ceiling, which the same wrapper will report the same way.
  • Cap maxSteps lower. A 26-step run that closes ten issues is the product working. The defect is that the platform cannot tell the owner what happened to it.
  • Poll less often. 2s is what makes the terminal feel live and is what touchSessionActivity is throttled against. The problem is the poll's placement inside one invocation, not its frequency.

Regression risk

Chunking waitIdle across step.sleep boundaries adds durable steps to every engine turn — n (the step-name counter) grows, and a Workflow step name must stay unique within an instance, which this file already manages via s${n++}. The risk to watch is a long engine turn now spanning many steps and colliding with the Workflow's own step-count limits; test with a synthetic engine that stays non-idle for the full 8-minute window and assert the loop still terminates at the same boundary with the same snapshot.

Not reproduced end-to-end: I did not force a subrequest exhaustion in a live Workflow. Everything above is read off the production record of one run plus the code paths it took.

Related: #341 (the precedent — a connectivity failure classifies itself instead of being guessed from its message at the catch site).

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2: correctnessReal defect, no live harm today — inert fields, miscounts, missing guardsbackendBackend / Worker / API workbugSomething isn't workingcoderThe Coder wedge agent (#68) — Engine, Pilot, Co-pilot, Loop, OverseerobservabilityA displayed value the code cannot produce, or that means something other than its labelquestionFurther information is requested

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions