Repository navigation
Description
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
/capturebefore 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_MSdoes 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 —
runStatemust be read live, not cached, or this replaces a false stall with a false "working".getBoundRunnerConn's live-check (rather than the DBstatuscolumn) 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.tsto 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
fix(work-report): a healthy run says it is NOT stalled, so the agent …
serge-ivo commentedon Aug 8, 2026
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 Workergreen.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
runningrun inside the window now carries the verdict explicitly: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.
lastProgressAtis the orchestrator's last recorded iteration, not the engine's last output. The engine's liverunStatesits behind/captureon the coding surface, andwork-report.tsdescribes loop runs that may have no engine at all — the ticket's own second regression bullet. Derivingengine: 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
isStalledis 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 liverunStateinto 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 thegetBoundRunnerConnprecedent the ticket cites) and it would couplework-report.tsto a surface it is currently independent of. Happy to file it.Acceptance
STALLED_AFTER_MSdoes not describe it as stalled or blocked — not verified liveWhy 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/nodesreports every nodeconnected: false, so no coding session can be started and norunningloop run exists to ask about. I did not start one: perpags/CLAUDE.mdan 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 onbd43f4de-…with a session actually working. That is the check worth making, and it needs a machine withpags uprunning. Leaving open for it.Ratchet
agent-think.ts1067 → 1076, raised deliberately in the same commit with the reason recorded incheck-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 besideagent-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:e2eskipped: nostore/, noagents/coder/web.serge-ivo commentedon Aug 8, 2026
Follow-up filed as #465 for criterion 1 ("the report should say the engine is working"), which
8f5afaadeliberately left out.The reason it was left out holds up under a re-trace:
last_progress_atis written only byrecordIteration(lib/agent-loop-store.ts:154-157), and the coding Pilot calls it only ontype === "action"(workflows/coding-session.ts:501, :509-511) — once per instruction SENT. Between instructions it sits inwaitIdlepolling 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
runStatelive viadescribeTerminal) plus two traps an implementer would otherwise hit:coding_sessions.last_activity_atmeasures watchers not the engine (migration0082:17-19), and both capture routes defaultrunStateto"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 stalledverdict) are delivered here.feat(work-report): link coding run to session so check_work can repor…
fix(runs): liveness, progress and a park are three facts, so a run pa…
fix(runs): an apply and a browser task can be stopped, because the ru…
serge-ivo commentedon Aug 15, 2026
Verified as DELIVERED in code on all four criteria. No further code written — the valuable output here is the evidence.
Re-checked against
maintoday.8f5afaa(#459),f8c97e94(#465) andfd1c323(#580) are allancestors of HEAD. #465 — the follow-up
8f5afaadeliberately carved out for criterion 1 — isclosed 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) emitsengine: 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 thanthe route — which is how it avoids the trap #465 named, that both capture routes default
runStateto
"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 —
/capturereturns noengine-output time, and #465 chose that over synthesising a number.
2. An agent under⚠️ not verifiable from code.
STALLED_AFTER_MSdoes not call the run stalled.Every input is now in place and pinned — the report carries the verdict explicitly (
work-report.ts:394):…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 upwith a realrunningcoding run to ask about.That is exactly why
8f5afaaleft 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), withexpect(s).not.toMatch(/step \d+\/\d+/)atwork-report.test.ts:82-83blocking 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 uprunHealth(work-report.ts:179-193) readsrun.lastAliveAt ?? run.lastProgressAt ?? run.startedAt— liveness first. Pinned at
work-report.test.ts:133-142: a run withlastProgressAt: NOW - 4handlastAliveAt: NOW - 30sassertsisStalled === false. The write side islib/agent-loop-store.ts:259-271, wherelast_progress_atmoves only in aCASE WHEN ?2 > iterationwhile
last_alive_atmoves unconditionally (migration0127). So the healthy 20-minute engine turnthat 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 freshliveness with stale progress to conclude "stalled". The three readings are:
work-report.ts:386renders elapsed-in-instruction as prose;
lib/subordinate-observation.ts:160-179calls the sharedrunHealthand emitsquietForMinutesas a bare number beside it;lib/connectors/supervision.ts:764localizes 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.
test(prompt): pin #459 failure-honesty block so it cannot be silently…
fix(runs): bound a park, enforce a cancel, retire a displaced run (#790)