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

Closed

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

Activity

  1. serge-ivo commented on Aug 8, 2026

    @serge-ivo
    ContributorAuthor

    Shipped in 8f5afaa, deployed. Three of the four criteria are code and are done; the fourth needs a live run and is why this stays open.

    CI green, Deploy API Worker green.

    What shipped

    1. step N/M → instruction 3 of up to 50 (this one has been running 9m). Named for what it counts (instructions the orchestrator has SENT), capped with "up to" so it cannot read as a denominator of work, and paired with elapsed-time-in-step so a long step is legible as a long step rather than as no progress. The old test asserted "step 1/10"; it now asserts the new phrasing and that /step \d+\/\d+/ does not appear, so the counter cannot come back by the obvious wrong fix.

    2. Liveness is stated positively. A running run inside the window now carries the verdict explicitly:

    run <id> · running · objective: … · instruction 3 of up to 50 (this one has been running 9m) ·
    NOT stalled — a run counts as stalled only after 15m with no progress, and this one is inside that
    window · started 9m ago
    

    3. The honesty rule now covers asserted FAILURE. Everything in that block guarded false success; nothing guarded a false stall. It now forbids asserting a run is stalled/blocked/stuck/dead unless a tool result says so, tells the agent to quote the report's verdict, and says explicitly that one instruction is a whole engine turn that can take many minutes.

    Where I did NOT do what the ticket asked, and why

    Criterion 1 says "a healthy run's report states that the engine is working". It does not — deliberately, and on the ticket's own regression note.

    lastProgressAt is the orchestrator's last recorded iteration, not the engine's last output. The engine's live runState sits behind /capture on the coding surface, and work-report.ts describes loop runs that may have no engine at all — the ticket's own second regression bullet. Deriving engine: working (last output 12s ago) from this column would be a claim the data does not support: it would read "working" for a run whose orchestrator ticked while the engine sat dead, which is exactly the false all-clear the first regression bullet warns about, and it is the same defect this issue is about facing the other way.

    So the positive claim is scoped to what the platform actually knows — is this run stalled by the platform's own definition — which is the question the agent got wrong, and isStalled is the answer it should have quoted. A test pins that the report never contains the word "engine", so a later well-meaning enrichment has to argue with a failing test rather than slip in.

    Wiring /capture's live runState into the run report is a genuinely good idea and I think it is a separate ticket: it is a cross-surface read (coding-only, runner-dependent, must be live rather than cached per the getBoundRunnerConn precedent the ticket cites) and it would couple work-report.ts to a surface it is currently independent of. Happy to file it.

    Acceptance

    • A healthy run's report states it is not stalled, with a recency signal (elapsed-in-step) — but see above: the recency is the RUN's, not the engine's
    • The step counter cannot be read as a completion percentage
    • The honesty rule covers asserted failures
    • An agent asked about a run under STALLED_AFTER_MS does not describe it as stalled or blocked — not verified live

    Why the last one is not ticked

    That criterion is about model behaviour on a real run, and there is no runner connected to this account right now — GET /v1/terminals/nodes reports every node connected: false, so no coding session can be started and no running loop run exists to ask about. I did not start one: per pags/CLAUDE.md an agent does not run the runner itself.

    The report shape is asserted by tests (24 in work-report.test.ts, including the boundary one second either side of the threshold, and that the stalled branch never also carries the reassurance), but "the model reads this and stops saying stalled" is a claim about a live turn on bd43f4de-… with a session actually working. That is the check worth making, and it needs a machine with pags up running. Leaving open for it.

    Ratchet

    agent-think.ts 1067 → 1076, raised deliberately in the same commit with the reason recorded in check-file-size.mjs: the growth is prompt PROSE, not logic. Noted there and repeating it here so it is not lost — the better long-term home for the HONESTY block is a prompt module beside agent-style-prompt.ts, exactly as #315 moved the capability-naming literals out, but extracting a whole prompt block is a prompt-composition change and does not belong inside a bug fix.

    Gates: pnpm typecheck (11 projects) · pnpm vitest run (415 files, 6956 tests) · biome --error-on-warnings · design-tokens · agents-allowlist · qa-config · file-size ratchet · check-migrations --require-history · test-isolation · docs:drift. test:e2e skipped: no store/, no agents/coder/web.

  2. serge-ivo commented on Aug 8, 2026

    @serge-ivo
    ContributorAuthor

    Follow-up filed as #465 for criterion 1 ("the report should say the engine is working"), which 8f5afaa deliberately left out.

    The reason it was left out holds up under a re-trace: last_progress_at is written only by recordIteration (lib/agent-loop-store.ts:154-157), and the coding Pilot calls it only on type === "action" (workflows/coding-session.ts:501, :509-511) — once per instruction SENT. Between instructions it sits in waitIdle polling capture for up to ~8 minutes (:485-495). So during exactly the 9-minute healthy engine turn this issue reports, the column does not move at all.

    #465 carries the real fix (link the run to its session, read runState live via describeTerminal) plus two traps an implementer would otherwise hit: coding_sessions.last_activity_at measures watchers not the engine (migration 0082:17-19), and both capture routes default runState to "idle" when the runner is unreachable (routes/coding.ts:220, :226, :397) — which would re-introduce a false stall through the fix meant to remove it.

    The remaining criteria (2, 3, and the NOT stalled verdict) are delivered here.

  3. serge-ivo commented on Aug 15, 2026

    @serge-ivo
    ContributorAuthor

    Verified as DELIVERED in code on all four criteria. No further code written — the valuable output here is the evidence.

    Re-checked against main today. 8f5afaa (#459), f8c97e94 (#465) and fd1c323 (#580) are all
    ancestors of HEAD. #465 — the follow-up 8f5afaa deliberately carved out for criterion 1 — is
    closed as completed
    , so that criterion is no longer outstanding.

    1. A healthy run's report states the engine is working, with a recency signal. ✅ (via #465)
    engineClause (lib/work-report.ts:342-357) emits engine: working (as of this turn) /
    engine: idle — not producing output right now / engine: could not be read this turn — do NOT infer idle or finished / engine: the machine running it is offline. The I/O sits in the handler
    (lib/tool-registry.ts:63-108), capped at 3 sessions, and calls the runner directly rather than
    the route — which is how it avoids the trap #465 named, that both capture routes default runState
    to "idle" when the runner is unreachable and would re-introduce a false stall through the fix.
    Caveat, by design: the recency is a this-turn qualifier, not a timestamp — /capture returns no
    engine-output time, and #465 chose that over synthesising a number.

    2. An agent under STALLED_AFTER_MS does not call the run stalled. ⚠️ not verifiable from code.
    Every input is now in place and pinned — the report carries the verdict explicitly (work-report.ts:394):

    NOT stalled — a run counts as stalled only after 15m with nothing ticking, and this one is inside that window

    …and the prompt tells the model to quote it. But the criterion is a claim about model behaviour on
    a live turn
    . It needs a machine running pags up with a real running coding run to ask about.
    That is exactly why 8f5afaa left this open and nothing since has changed it.

    3. The step counter cannot read as a completion percentage. ✅
    instruction 3 of up to 50 (this one has been running 9m), with
    expect(s).not.toMatch(/step \d+\/\d+/) at work-report.test.ts:82-83 blocking the obvious wrong fix.

    4. The honesty rule covers asserted failures. ✅ in prompt text (agent-think.ts:707-711) —
    but pinned by no test; filed as #620.

    fd1c323's claim about a pre-existing false stall holds up

    runHealth (work-report.ts:179-193) reads run.lastAliveAt ?? run.lastProgressAt ?? run.startedAt
    — liveness first. Pinned at work-report.test.ts:133-142: a run with lastProgressAt: NOW - 4h and
    lastAliveAt: NOW - 30s asserts isStalled === false. The write side is
    lib/agent-loop-store.ts:259-271, where last_progress_at moves only in a CASE WHEN ?2 > iteration
    while last_alive_at moves unconditionally (migration 0127). So the healthy 20-minute engine turn
    that used to cross the 15-minute line no longer does.

    The inference #580 rejected is genuinely absent — checked, not assumed

    I grepped every non-test consumer of lastProgressAt/last_progress_at. No site combines fresh
    liveness with stale progress to conclude "stalled".
    The three readings are: work-report.ts:386
    renders elapsed-in-instruction as prose; lib/subordinate-observation.ts:160-179 calls the shared
    runHealth and emits quietForMinutes as a bare number beside it; lib/connectors/supervision.ts:764
    localizes a timestamp for display. Both MCP tool descriptions carry the warning verbatim to callers
    (workers/mcp/src/instance-tools/composition.ts:274, coding.ts:188).

    Tests: work-report (52), run-liveness (14), run-health-readers (13) — 79 passed.

    Closing

    Criteria 1, 3 and 4 are delivered in code; 2 is delivered in inputs and cannot be closed by a test.
    Closing on that basis — if the next live coding run produces an agent calling a healthy run stalled,
    that is a fresh observation with fresh evidence and deserves its own ticket rather than keeping this
    one open indefinitely. #620 carries the one real gap found while verifying.

  4. added a commit that references this issue on Aug 29, 2026
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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions