Skip to content

[bug] WorkflowInternalError, traced: it is not the objective failing and not terminal — one run filed its own death twice, and all 8 occurrences classify as unknown #546

Description

@serge-ivo

Follow-up to #529, which asked for classified records and got them. Now that they exist, the first
thing they show is that the largest class is unknown, and every member of it is the same
Cloudflare failure — the one nobody has ever traced. This is that trace.

Population

Across all 32 instances (80 autonomous runs, 30 failed — see #541 for the full distribution),
WorkflowInternalError is the largest live failure class after stuck:

The two message shapes are the same event — CF's internal-error wording differs by surface. That is
8 occurrences, and 3 of them are in the last 24 hours. Of the 4 coding:session rows that exist
since #529 shipped, 3 are this and all 3 classify as unknown.

It is not terminal — one run recorded its own death twice

The decisive evidence. Two error_log rows, eight minutes apart:

row A row B
created_at 2026-08-12 15:05:34 2026-08-12 15:13:35
runId / traceId 82739cb6-… 82739cb6-… (same)
sessionId csess_2dd3124c-… csess_2dd3124c-… (same)
elapsedMs 10,619,147 11,100,612
node Sergeys-Mac-mini.local RLs-MacBook-Air
instructionChars / paneChars 45 / 216 0 / 0
phase s6-decide s6-decide

elapsedMs is Date.now() - runStartedAt (coding-failure.ts:274) and the two differ by 481,465ms
against an observed wall-clock gap of 481,000ms — the same runStartedAt, i.e. one run, not two.
So the workflow's catch block (coding-session.ts:667-679) executed twice for a single run.

And it was not the end even then: the loop-run row for 82739cb6 was finally closed at 15:17:25Z
with a third, different reason — stuck not resolved in time. Neither failure record was terminal.

Why the record duplicates

recordCodingFailure is called directly in the catch, not inside step.do
(coding-session.ts:674-677). Anything outside step.do re-executes on every workflow attempt, so
each attempt writes a fresh row while the journalled step.do results are replayed.

Row B's 0 / 0 is the confirming signature. probe.saw(pane.pane) is at coding-session.ts:397,
inside capture, which is invoked through guard(runRetry, \s${n++}-snapshot`, capture)at line 447 — i.e. **inside**step.do. A replay gets the journalled result and never re-runs the callback, so paneCharsstays 0.probe.at(name)(line 281) *is* outside, which is whyphasestill readss6-decide`.

That directly falsifies the docblock #529 shipped, coding-failure.ts:186-192:

"Everything here is set from code OUTSIDE step.do, which a Workflow replay re-executes … So a
run resumed after an eviction still reports the phase and payload sizes it actually died at,
rather than the zeroes a step-journalled counter would give."

Production row B is exactly "the zeroes". The phase claim holds; the payload-size claim does not.

Three consequences

1. An infra event is reported to the owner as the objective failing.
coding-session.ts:671 wraps anything that is not isRunnerGone into `run error: ${message}`,
and closeDelegation turns that into outcome: failed. Two of the five loop-run rows carry
| Acts: pushed directly to the trunk origin main; pushed directly to the trunk origin main; …
the run had already done real work and pushed it and was still reported as failed. Identical in
shape to #523, and identical in cost: he cannot tell a dead run from a finished one, so he retries.

2. It classifies as unknown, so it is indistinguishable from a genuine unexplained crash.
Walked through classifyCodingFailure (coding-failure.ts:129-162) with
"WorkflowInternalError: Attempt failed due to internal workflows error": not isRunnerGone, not
isRunnerUnreachable, no CEILING_MARKERS hit, isTransientInfraError returns false (it matches
only durable object reset / code was updated / object has been reset / reset because
transient-error.ts:12-15), no credential marker, upstreamStatus null, no rate-limit / overrun /
stall marker, does not start with anthropic → falls through to at("unknown") at line 162.
Result: level: "error" (unknown is not in EXPLAINED, line 173-180) and retryable: null.

3. Nothing collapses, so any frequency count off list_errors over-reports.
collapseRepeat matches on exact message (error-log.ts:116-121). Every CF internal error
carries a distinct reference = <random id>, so no two ever collapse — all three rows show
repeat_count: 1. Combined with (1), row count ≠ run count: 3 rows here are 2 runs.

What to do — cheapest first

(a) Give it a class. Add workflow_internal to CodingFailureClass and match
attempt failed due to internal workflows error + internal error; reference =. Put it in
EXPLAINED so it is warn, and set retryable: true. One-line-ish, and it immediately makes
"is unknown shrinking?" a real question again — today unknown is 75% of the coding rows and
means nothing.

(b) Do not report it as the objective failing. Same argument #523 makes for the subrequest
ceiling: the run was cut off, its committed work is intact. It should read like the isRunnerGone
branch already does at coding-session.ts:671 — reported as itself, without the run error: prefix
that "reads as a crash". A run that pushed to main and then hit an attempt error is not failed.

(c) Strip the volatile reference out of the collapse key. Either normalise the message before
collapseRepeat or add a signature column. Otherwise every future CF internal error is a new row
and #522/#538's repeat machinery is inert for this class — which is the class that most needs a
count.

(d) Fix the coding-failure.ts:186-192 docblock, and either move probe.saw/probe.drove
outside the journalled callbacks or state plainly that payload sizes are absent on a replayed
attempt. As written it invites the next reader to treat paneChars: 0 as "the run read an empty
pane" rather than "this row is a replay" — which is a wrong diagnosis, not just a missing one.

Open question for the owner

Should the catch re-throw instead? If WorkflowInternalError were rethrown, CF's own retry
machinery would handle it and the run might genuinely resume rather than be declared dead. I would
not do this yet: recordCodingFailure, closeDelegation and the finally block all have
side effects that would then repeat per attempt, and #529's own comment
(coding-session.ts:667-668) exists because a run that vanished with no record was the worse
failure. (a)+(b) are safe and independent; the rethrow is a design change that wants its own ticket.

Acceptance criteria

  1. classifyCodingFailure("WorkflowInternalError: Attempt failed due to internal workflows error")
    and …("internal error; reference = abc123") both return class: "workflow_internal",
    retryable: true, level: "warn".
  2. A loop run killed this way does not carry the run error: prefix, and its stopReason is
    distinguishable from a run whose objective genuinely failed.
  3. Two occurrences with different reference ids collapse into one error_log row with
    repeat_count: 2.
  4. A unit test feeds the exact production strings from the 8 occurrences above (as
    coding-failure.test.ts already does for deadlineMessage()), so a reworded CF message fails the
    test rather than silently degrading to unknown again.

Regression risk

  • Moving workflow_internal into EXPLAINED/warn hides it from an error-level triage view. That
    is the intent, but it means (c) must land with it — otherwise the class becomes both quiet and
    uncountable, which is worse than today.
  • Changing the terminal detail string touches closeDelegation's reason mapping
    (coding-session.ts:194-200) and the board-card ok/fail choice (line 232). seed-drift-style
    string assertions elsewhere may pin the old wording — grep -rn "run error:" before changing it.
  • (c) risks over-collapsing if the normaliser is too aggressive: two genuinely different errors
    merging into one row loses a real signal. Normalise only the reference = <id> token, nothing else.

Provenance

The 5 loop-run occurrences are reconstructed from agent_loop_runs.detail via
GET /v1/instances/:id/loop and predate #529 — they have no error_log rows and no phase or
payload data; that era is only readable from the run row and chat. The 3 error_log rows are
post-#529 (2026-08-12) and are where every structural claim above comes from. The duplicate-record
finding is measured. That Cloudflare retried the attempt (as opposed to some other double-entry
path) is inferred — it is the natural reading of "Attempt failed" plus the fresh-probe
signature, but I did not reproduce it, and CF's public Workflows API doc 404s on the
retry-semantics page.

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