Skip to content

[bug] Agent reports a healthy run as "stalled" — step N/M reads as progress, liveness is only ever stated negatively, and the honesty rule covers false success but not false failure #459

Description

@serge-ivo

The agent reported a healthy run as "stalled" while the engine was demonstrably working — the platform hardened against false success and not against false failure

Live from bd43f4de-… today. The owner asked "why is it blocked?" because the agent told them it was.

What the agent said

12:54:53  assistant | "Run fe269e09 is still shown at step 1/50 with no further progress recorded —
                       it hasn't advanced since it started. The terminal scrollback shows it's
                       actively running Claude Code though…"
12:58:27  assistant | "Still at step 3/50 after 9 minutes — that's stalled. … Nothing I can do
                       until it advances or times out"

What was actually happening, 90 seconds later

GET …/coding/sessions/csess_8e6a4cdb…/capture:

runState: thinking | alive: True | ready: False | runnerConnected: True

⚙ Edit  …/web/src/App.tsx  ↳ has been updated successfully
⚙ Bash  python3 … patched masters.sqlite.test.ts
⚙ Bash  pnpm --filter @chess-academy/web exec vitest run src/lib/masters.sqlite.test.ts …

The engine was mid-edit, patching tests and running a vitest suite. Nothing was stalled. The working tree confirms real progress — mcp.json and App.tsx modified, tool count brought from over-cap down to 119.

Note the agent had the contradicting evidence in the same sentence at 12:54:53 — "The terminal scrollback shows it's actively running Claude Code though" — and concluded "stalled" anyway, then hardened that into "Nothing I can do" four minutes later.

Why — and the platform's own logic was right

lib/work-report.ts:61-68 already models this correctly:

export const STALLED_AFTER_MS = 15 * 60 * 1000;
export function isStalled(run, now) {
    if (run.status !== "running") return false;
    const last = run.lastProgressAt ?? run.startedAt;
    return now - last > STALLED_AFTER_MS;
}

with the reasoning stated at :57-59"'running' alone is not evidence that anything is happening… 15 minutes is well past a normal engine step." At 9 minutes the run was not stalled, and describeLoopRun correctly did not say it was.

The gap is what the report does say (:76-88):

run <id>: running · objective: … · step 3/50 · started 9m ago

Two problems:

1. step N/M reads as a progress bar and is not one. It counts how many instructions the orchestrator has sent, not how much work is done. A single step is one full Claude Code turn — reading files, editing, running a test suite — which legitimately takes ten minutes. "step 3/50 after 9 minutes" looks like 6% in 9 minutes; it is actually one long, healthy step. The label invites exactly the misreading that happened.

2. Liveness is only ever stated negatively. When a run is stalled the report says so loudly. When it is healthy it says nothing about liveness at all — and the model fills that silence with an inference. The fact that would have settled it (runState: "thinking", plus the timestamp of the last engine output) exists, but in a different tool (/capture), and never reaches the run report the agent is reasoning from.

The framing that matters

agent-think.ts carries a strong HONESTY block, and #395/#406 hardened it further — all of it aimed at false success: never claim an action succeeded when the tool returned an error, never invent results.

Nothing guards false failure. Here the agent asserted a failure state that no tool reported, contradicted by the tool output it had just read, and followed it with a declaration of helplessness. The harm is symmetric and arguably worse: a user told their work is stuck will intervene — restart the session, kill the engine, re-issue the objective — and destroy work that was progressing normally. This owner's next question was "why is it blocked?", which is precisely that path.

What to do

1. State liveness positively in the run report. describeLoopRun should carry an engine-liveness fact when the run is healthy — e.g. engine: working (last output 12s ago) — not only the STALLED warning when it is not. The model should never have to infer liveness from a counter.

2. Rename or requalify step N/M. Either label it for what it is (instruction 3 of up to 50) or pair it with elapsed-time-in-step, so a long step is legible as a long step rather than as no progress.

3. Extend the honesty rule to failure claims. The prompt forbids claiming success without evidence; it should equally forbid claiming a stall, a block, or a failure that no tool result supports. isStalled is the platform's answer to "is this run stuck?" — the agent should be told to quote it rather than form its own view, exactly as check_work already instructs for "did that happen?" (agent-self-description.ts:232).

4. Consider surfacing lastProgressAt directly. It is what isStalled uses and it answers the question in one number.

Alternatives considered and rejected

  • Lower STALLED_AFTER_MS. Rejected — it would make the platform produce the same false report the agent produced. 15 minutes is defended in the code and is right; a real Claude Code step can exceed 9 minutes easily.
  • Have the agent poll /capture before commenting on a run. It effectively did, and reported "actively running Claude Code" in the same breath as "hasn't advanced". More data did not fix a reasoning gap; the report needs to carry the verdict.
  • Treat this as a one-off model error. Rejected — it recurred twice in four minutes and escalated. The report shape makes the wrong inference the natural one.

Acceptance criteria

  • A healthy run's report states that the engine is working, with a recency signal.
  • An agent asked about a run under STALLED_AFTER_MS does not describe it as stalled or blocked.
  • The step counter cannot be read as a completion percentage.
  • The honesty rule covers asserted failures, not only asserted successes.

Regression risk

  • A positive liveness claim is itself a claim that can be wrong — runState must be read live, not cached, or this replaces a false stall with a false "working". getBoundRunnerConn's live-check (rather than the DB status column) is the precedent for getting this right; that distinction is the root of the machine-switch defect already fixed once.
  • Adding engine state to the run report couples work-report.ts to the coding surface; a non-coding loop has no engine and must not gain an empty or misleading field.

Not a platform issue (recorded so it is not re-investigated)

The other half of the owner's question — the blocked deploy — is a project issue in proappstore-online/chess-academy, not PAGS. The Deploy to R2 workflow fails at the tools-registration step with tools registration failed: "max 120 tools per app", enforced server-side by api.proappstore.online. The agent diagnosed it correctly and is consolidating tools (working tree now at 119). Worth noting that an earlier attempt — commit 8a20ba0 "raise tool-count cap to 130" — could not have worked, because the cap is enforced by the PAS platform, not by the app repo.

Related: #395/#406 (the false-success hardening this mirrors), #429 (the same instance reporting contradictory run state), #256/#318 (check_work and the run record it is meant to quote).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions