You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Reviewed 2026-08-22 (plan-review). No blockers. Four findings applied to the body: (1) Major — withTransientRetry hard-codes classifyVercelError and has no injectable classifier in TransientRetryOptions (resilience.ts 260–273, 298), so the plan's "reuse it, only needs a retryability predicate" is not implementable as written — Design §1 + Architecture decision A now specify an additive optional classify seam (defaulting to classifyVercelError) used by the turn path, plus a back-compat test binding that default; (2) new inv_queued_insert_front-style export must be added to native/harness/build.zigexport_symlink_names or the linker GCs it (same defect sibling #760 fixed) — added to Implement order step 1 + DoD + Risk; (3) protocol question locked to v18→v19 (REQUIRED_FNS + build.zig + bump both PROTOCOL_VERSION/HARNESS_PROTOCOL_VERSION), coordinating with sibling #760 (union v19/v20 — whichever merges second takes the next free version); (4) both Open questions resolved as decisions (named TURN_RETRY_ATTEMPTS=5 NEW cap; no in-canvas retry line). All baselines verified against main (ui.zig 221–231 terminal-promote, bridge.zig Lifecycle 39–44 / tryPromoteQueued 245–259 / SUBMIT_CAP 76, submit_queue.zig MAX_ITEMS 11 / push 44–52 / promoteIf 113–119, harnessBridge.ts Lifecycle 72–77 / REQUIRED_FNS 208+ / v18, harnessChat.ts classifyTurnFailure 609–632 / Ready-sites 550,558,1492,1523-1533, resilience.ts withTransientRetry 280–317 + classifyVercelError 147–214.
Date
2025-06-25
Type
single
Parent
N/A
Source issue
#756 — "harness: turn errors must retry the current turn — never drain the queue" (bug + operator report)
Branch
plan/turn-retry-never-drain-queue
Layers
DOM host + harness (Wasm)
Reusability impact
none
Production mutate?
no
Cloud ops path
N/A — no Production mutate. Retry is client-side around the existing /api/agent turn
Living docs
docs/harness-limits.md (Submit queue → When it runs), docs/agent-stream.md (Error / timeout), AGENTS.md (protocol v19 row + new caps row)
Summary
A turn error today looks like a finished turn: the host sets Ready, Wasm
treats busy → ready|err as terminal, and tryPromoteQueued pops the queue
head into a new user send. The failed work is abandoned; the operator's
follow-up runs instead. This plan makes errors retry the current task (5
attempts, exponential backoff) or stop, and bans errors from ever consuming
a queued item — on give-up with a non-empty queue it inserts a "Continue the current turn" prompt as the new queue head, popping nothing.
Goals
#
Goal
Success signal
1
A turn error never promotes the operator queue
Busy + 1+ queued items + /api/agent failure (after retries) → queue depth unchanged; no new user row from the old head
2
Retry the same prompt 5 times with exponential backoff
5 attempts total (1 + 4 retries), same rawPrompt / session, no extra user ring lines; stays Busy for the whole retry window so promote cannot run
3
Give up after 5 → stop
One Error (or timeout) turn-end line; lifecycle idle; Stop chrome off
4
If the queue is non-empty on give-up, unshiftContinue the current turn as the new head
Existing items shift down one; none popped; queue depth += 1 (unless full — see below)
5
Operator Stop / abort (499) does not retry and does not insert Continue
Queue stays as today; Stop still drains after a later successful Ready (unchanged). Only errors are banned from consuming the queue
6
Permanent 401/403/validation: no backoff loop; same give-up + Continue-if-queued behavior as exhausted retries
Never hammer auth failures 5×
7
Queue full (16) on give-up
No insert, no pop — operator items untouched; fail closed
Non-goals / out of scope
Do not auto-send the Continue item (park it at the head; the operator edits / removes / sends, or a later successful Ready may promote it like any other head)
Do not persist the queue or Continue on the cloud session (still Wasm-ephemeral)
Do not raise MAX_ITEMS (16) / SUBMIT_CAP (262144)
Do not retry Stop
Do not add a DOM error toast / second composer
Forbidden wiring: dual DOM chat · secrets in Wasm · laptop-only Production ops
Architectural decisions
Decision
Options considered
Choice
Why
Retry seam for the in-flight turn
A) reuse withTransientRetry (lib/sandbox/resilience.ts) — B) hand-rolled nested send loop
A (+ classifier seam)
Already 5-attempt + abort-aware + bounded exponential backoff (baseMs 250, capMs 4000), unit-tested. Baseline truth:withTransientRetry currently hard-codes classifyVercelError internally (resilience.ts 298) with no injectable classifier in TransientRetryOptions (260–273) — it is NOT directly reusable for turn failures. Add a new additive optional classify seam to TransientRetryOptions (defaulting to today's classifyVercelError, so sandbox callers are untouched); the turn path passes a new narrow classifier over classifyTurnFailure + HTTP status, intentionally NOT reusing classifyVercelError (that is the sandbox SDK domain). Decision A becomes viable once the seam lands; B is the fallback if the seam is judged too invasive
Promote gate on error
A) host sets Lifecycle.Error on give-up; Wasm promote stays busy → ready only — B) keep Ready + add an error flag to the promote predicate
A
Lifecycle.Error already exists on both sides (bridge.zigerr=3 ↔ harnessBridge.tsError=3); host is the only lifecycle writer, so simplest correct seam is: host sets Error on give-up and Ready only on success / operator Stop; Wasm's terminal drops err. One source of truth for lifecycle
Continue the current turn insert
A) Wasm submit_queue.insertFront + bridge wrapper — B) host re-sends the failed prompt via the normal submit path
A
Queue is Wasm-ephemeral and only Wasm owns its FIFO/cap; insert-at-front renders like a normal editable queue row. B would re-run a possibly-harmful prompt and duplicates the last user row
Give-up timeout/empty handling
A) timeout/empty are retryable like error — B) treat empty as terminal
A
An empty model response / gateway timeout is a transient inference failure; same as the issue's spec — retry then give up. Permanent 401/403/validation never backoff (goal 6)
Protocol version
A) additive export without bump — B) REQUIRED + v18→v19
B
Repo precedent (v14→v15, inv_set_busy_tick became REQUIRED): a new REQUIRED export bumps the version so a mismatched old wasm/host fails closed with the REQUIRED_FNS diagnostic. Locked in this review. Coordinate with sibling #760 (also adding an export + bumping): union v19/v20 — whichever merges second takes the next free version, referenced in implementation notes
Promote stays prev busy && cur ready; err no longer terminal for the queue
insertFront + Continue head + export
Wasm
native/harness/src/submit_queue.zig · native/harness/src/bridge.zig wrapper + new export · native/harness/build.zigexport_symlink_names
Queue is Wasm-owned; additive export read by the host (inv_queued_insert_front + existing queuedCount). Every inv_* export MUST be listed in build.zigexport_symlink_names or the linker GCs it and JS never sees it (bridge.zig header contract) — see protocol note below
classifyTurnFailure → Error/System row → setLifecycle(Lifecycle.Ready) — same as success, so promote fires. verified
Stop vs error
lib/harnessChat.tsclassifyTurnFailure 609–632
499 / abort / Request cancelled. → stop; timeout / empty / other → retryable error kinds. Note: 401/403/validation are NOT returned as distinct kinds here — the new classifier keyed on classifyTurnFailure + HTTP status must treat these as permanent (goal 6)
5 attempts (retries default 4), exponential baseMs 250 / capMs 4000, signal aborts sleep. Critical:withTransientRetry calls classifyVercelError(err) internally (line 298) and the options struct has no classify override — it is not directly reusable for turns; an additive classifier seam is required (see Architecture + Design §1). verified
Everyexport fn inv_* must be listed here or the linker GCs it and JS never sees it (bridge.zig header contract, comment 60–68). A new export requires an entry here in the same change
REQUIRED_FNS
lib/harnessBridge.ts 208–263
Exhaustive list; new export must be appended here + type/method on HarnessExports
Protocol note (locked): adding a Wasm inv_queued_insert_front export is an additive REQUIRED bridge extension. It must be (a) added to REQUIRED_FNS
(harnessBridge.ts 230+), (b) added to native/harness/build.zig export_symlink_names (or the linker GCs it), and (c) paired with a v18→v19
protocol bump on both sides (bridge.zigPROTOCOL_VERSION, harnessBridge.ts HARNESS_PROTOCOL_VERSION) so an old host/wasm fails closed on load instead of
silently missing the export. Sibling #760 also bumps v18→v19 for its own
export — union requires the second one merged to take v20. (A new cap TURN_RETRY_ATTEMPTS is host-TS only — no Zig parity.)
Design
1. Host — retry the in-flight turn (lib/harnessChat.ts / runHarnessTurn)
Stay Busy across attempts. Reuse withTransientRetry (already 5 attempts,
abort-aware) via a new additive classifier seam:
Additive seam (required): extend TransientRetryOptions with an optional classify?: typeof classifyVercelError parameter in lib/sandbox/resilience.ts.
Default is today's classifyVercelError (sandbox callers untouched); the turn
path passes a new narrow classifier that maps classifyTurnFailure result +
HTTP status → { kind: 'retryable' | 'permanent' }. This keeps the sandbox SDK
classifier and turn errors from being conflated.
Retryable: network, 408/429/5xx, timeout, empty model response, other classifyTurnFailureerror that is not 401/403/validation/stop.
Not retryable (single attempt, straight to give-up):signal.aborted /
499, 401, 403, prompt validation.
Do notpushUser on retries (the user line is already on the ring).
Do not promote or setLifecycle(Ready) between attempts.
Backoff: existing defaults (baseMs 250, capMs 4000) unless a named NEW
cap is added (TURN_RETRY_ATTEMPTS = 5, plus base/cap) — locked: name it
in the Caps table + AGENTS.md (new cap; not a change to an existing cap).
Locked decision: no in-canvas per-retry line (Retrying 2/5…) — avoids ring
noise; the turn already stays Busy.
The retryability classifier is a new, narrow predicate keyed on classifyTurnFailure results + status — the existing classifyVercelError
(sandbox SDK domain) is intentionally not reused for the turn.
2. Wasm — promote only on success Ready
tryPromoteQueued must not run on busy → err, nor on busy → ready when the
turn failed.
Cleanest: host sets Lifecycle.Error on give-up (the enum already exists on
both sides) and Lifecycle.Ready only on success / after operator Stop.
Promote stays prev == busy && cur == ready — drop err from terminal for trigger_a. Trigger B (edit closed while idle) unchanged.
Update docs/harness-limits.mdWhen it runs: after the current turn reaches successful Ready, not error.
3. Queue — insert Continue at head, never pop on error
Add submit_queue.insertFront (or pushFront) + a bridge wrapper + export inv_queued_insert_front, and append it to native/harness/build.zig export_symlink_names + REQUIRED_FNS (protocol v19).
On host give-up, if queuedCount() > 0:
If count == 16, do not drop operator items; skip the insert (fail
closed). Still no promote.
Else insert the exact prompt: Continue the current turn (a named TS/Zig
constant).
Same ITEM_BYTES / normalize path as a normal enqueue (not blank).
The Continue row is a normal queue row (preview, edit, ✕). It is not
auto-submitted.
Edge cases
Stop on attempt 2: abort immediately, no further backoff, no Continue.
Queue full (16) at give-up: no insert, no pop; operator items kept.
Permanent 401/403/validation: single attempt, give-up + Continue-if-queued
(no 5× loop).
Continue the current turn when head was edited: normal queue semantics —
it promotes like any head on a later successful Ready.
Empty give-up queue: stop, no Continue row.
Cloud ops path
N/A — no Production mutate. Retry is client-side around the existing /api/agent turn; no env, no GHA, no data write.
Living docs plan
Surface
Change
Notes
docs/harness-limits.md Submit queue → When it runs
successful Ready only; error: retry 5× then stop; never promote; Continue-at-head if queue non-empty
timeless wording; no issue numbers
docs/agent-stream.md Error / timeout
retries then stop; queue not drained
timeless
AGENTS.md
Protocol v19 row under "Harness stream chrome": inv_queued_insert_front additive REQUIRED export; newTURN_RETRY_ATTEMPTS = 5 cap in Caps table + ownership row
keep parity with HARNESS_PROTOCOL_VERSION
README.md
N/A
no visitor-facing behavior change
SECURITY.md
N/A
no trust-boundary / secrets change
.env.example
N/A
no new env
Implementation order
Wasm submit_queue.insertFront + bridge wrapper + export fn inv_queued_insert_front (foundation the host needs); append the new export to native/harness/build.zigexport_symlink_names (linker-GC whitelist).
Protocol v19: bump PROTOCOL_VERSION (bridge.zig) + HARNESS_PROTOCOL_VERSION (harnessBridge.ts); add the export to REQUIRED_FNS + TS type/method.
Add the classify seam to withTransientRetry/TransientRetryOptions (default = classifyVercelError) + the narrow turn classifier.
Host retry loop in runHarnessTurn + lifecycle Error-on-give-up.
Wasm promote gate (drop err from terminal).
Host give-up → Continue the current turn insert (respect full-16).
Gates: TS + Zig both (see Testing); build-harness for the Wasm change (proves the export reached the artifact).
Testing
#
Case
Layer
Type
Command / method
1
Busy + queued + retryable 500, then success on attempt 3
host
unit
vitest lib/harnessChat.test.ts — one user line; assistant from the successful attempt; queue untouched until success Ready
2
5 retryable failures
host + Wasm
unit
Error turn-end; no queue pop; head becomes Continue the current turn when queue non-empty
3
5 failures, queue empty
host
unit
stop; no Continue row
4
Queue full (16) at give-up
Wasm
unit
zig build test-richsubmit_queue — no insert, no pop
5
Operator Stop on attempt 2
host
unit
immediate abort, no backoff, no Continue; queue intact
6
401/403
host
unit
single attempt, give-up path, no 5× loop
7
insertFront unit
Wasm
unit
zig build test-rich — head is new item; previous head is index 1
8
busy → err does NOT tryPromoteQueued
Wasm / ui policy
unit
queue count unchanged on busy → err transition
9
Continue row editable/removable + promotes on later successful Ready
Wasm
unit/integration
normal queue-row semantics on the inserted Continue
10
Retryability predicate (permanent vs retryable statuses)
host
unit
lib/harnessChat.test.ts classify narrow set
11
Classifier seam back-compat
host
unit
lib/sandbox/resilience.test.ts — withTransientRetry with noclassify still calls classifyVercelError (sandbox behavior unchanged); passing a classify uses it
12
Protocol v19 parity + REQUIRED_FNS + export in built wasm
host
unit/integration
harnessBridge type + REQUIRED_FNS contains inv_queued_insert_front; wasm-int round-trip proves it reached the artifact via the build.zig whitelist
Gates (both layers):npm run typecheck + node scripts/di-gate.mjs + vitest run (host) andzig build test-rich + zig build harness -Doptimize=Debug + zig fmt --check (Wasm). CI: wait for build-harness
green (wasm32-freestanding) — local Debug is a host-target smoke only;
build-harness is the release-wasm gate that also proves the new export is in
the shipped artifact.
Caps table
Cap / ceiling
Value
Wire & its ceiling
Defense
Verdict
NEW TURN_RETRY_ATTEMPTS (host)
5 (1 + 4 retries)
Host-side memory/timer; no new transport payload
NEW cap; generous default equal to the existing withTransientRetry default; bounded by signal abort (Stop). No change to an existing cap. Locked in plan-review
OK — new, in table (no human-block; generous default)
Backoff baseMs / capMs (existing)
250 / 4000
host-side timer
unchanged defaults reused; no change
N/A — unchanged
MAX_ITEMS (existing)
16
Wasm ephemeral
not raised / not lowered — explicitly out of scope (goal "do not raise")
N/A — unchanged
SUBMIT_CAP / ITEM_BYTES (existing)
262144
Wasm ephemeral
unchanged
N/A — unchanged
Continue prompt length
"Continue the current turn"
within ITEM_BYTES
fixed constant, trivially below caps
OK
classify seam footprint
one optional fn ref
host memory
additive default preserves sandbox behavior
OK — new, no wire carrier
Definition of done
Goals 1–7 each have a passing case in the Testing table
No dual-chat / DOM composer regression (unchanged paths)
Cloud ops: N/A (no Production mutate) — stated, not silent
Living docs:harness-limits.md (When it runs = successful Ready only), agent-stream.md, and AGENTS.md protocol v19 row + TURN_RETRY_ATTEMPTS caps row updated with timeless wording
Tests green in agent workspace / CI: TS + Zig gates above; build-harness green on the branch
Literal Continue the current turn head inserted only on give-up + non-empty queue; queue full → fail closed (no drop)
Protocol v19 on both sides (PROTOCOL_VERSION / HARNESS_PROTOCOL_VERSION) + inv_queued_insert_front in REQUIRED_FNSandnative/harness/build.zigexport_symlink_names, present in the built wasm (test 12 / wasm-int)
Retrying genuinely repeated side-effects (a post tool already ran server-side before a stream error)
Retry reuses the same session/prompt and the existing "mid-tool cancel re-run" product rule (docs/harness-limits.md) — a continue-after-stall may re-run tools. Same documented stance; do not redact history on retry
A permanent 401/403 mistakenly retried (hammer / spam)
Narrow classifier: 401/403/validation/stop are permanent — single attempt, straight to give-up (test 6)
insertFront when full silently drops operator items
Fail closed: count == 16 → skip insert, no pop, no drop (test 4)
Promote firing between attempts (busy dropped)
Host stays Busy for the whole window; Wasm err no longer terminal. Both locks enforced by test 8
withTransientRetry classified by the wrong domain
Additive classify seam; the turn path passes the narrow turn classifier, sandbox keeps classifyVercelError default (test 11)
New export missing from the linker whitelist → JS never sees it
build.zigexport_symlink_names entry added in the same step; test 12 / wasm-int + build-harness prove it (mirrors sibling #760's caught defect)
Protocol-version collision with sibling #760 (both adding exports, both v18→v19)
Locked union: whichever merges second takes the next free version (v19 or v20); reference the merged state in implementation notes
Laptop-only cutover / missing GHA
N/A — no Production mutate / no GHA surface needed (explicit N/A)
Open questions
Should the retry cap be a named NEW cap (TURN_RETRY_ATTEMPTS = 5, base/cap)
or silently reuse withTransientRetry defaults?RESOLVED (plan-review): name
the cap in the Caps table + AGENTS (TURN_RETRY_ATTEMPTS = 5). New cap, no
human-block, generous default.
In-canvas per-retry line?RESOLVED (plan-review): none. The turn stays
Busy; avoids ring noise. No Retrying 2/5… line.
References
Source issue:#756 — bug + operator report this plan implements.
Plan header
withTransientRetryhard-codesclassifyVercelErrorand has no injectable classifier inTransientRetryOptions(resilience.ts 260–273, 298), so the plan's "reuse it, only needs a retryability predicate" is not implementable as written — Design §1 + Architecture decision A now specify an additive optionalclassifyseam (defaulting toclassifyVercelError) used by the turn path, plus a back-compat test binding that default; (2) newinv_queued_insert_front-style export must be added tonative/harness/build.zigexport_symlink_namesor the linker GCs it (same defect sibling #760 fixed) — added to Implement order step 1 + DoD + Risk; (3) protocol question locked to v18→v19 (REQUIRED_FNS+build.zig+ bump bothPROTOCOL_VERSION/HARNESS_PROTOCOL_VERSION), coordinating with sibling #760 (union v19/v20 — whichever merges second takes the next free version); (4) both Open questions resolved as decisions (namedTURN_RETRY_ATTEMPTS=5 NEW cap; no in-canvas retry line). All baselines verified againstmain(ui.zig 221–231 terminal-promote, bridge.zig Lifecycle 39–44 / tryPromoteQueued 245–259 / SUBMIT_CAP 76, submit_queue.zig MAX_ITEMS 11 / push 44–52 / promoteIf 113–119, harnessBridge.ts Lifecycle 72–77 / REQUIRED_FNS 208+ / v18, harnessChat.ts classifyTurnFailure 609–632 / Ready-sites 550,558,1492,1523-1533, resilience.ts withTransientRetry 280–317 + classifyVercelError 147–214.plan/turn-retry-never-drain-queue/api/agentturndocs/harness-limits.md(Submit queue → When it runs),docs/agent-stream.md(Error / timeout),AGENTS.md(protocol v19 row + new caps row)Summary
A turn error today looks like a finished turn: the host sets Ready, Wasm
treats
busy → ready|erras terminal, andtryPromoteQueuedpops the queuehead into a new user send. The failed work is abandoned; the operator's
follow-up runs instead. This plan makes errors retry the current task (5
attempts, exponential backoff) or stop, and bans errors from ever consuming
a queued item — on give-up with a non-empty queue it inserts a
"Continue the current turn" prompt as the new queue head, popping nothing.
Goals
/api/agentfailure (after retries) → queue depth unchanged; no new user row from the old headrawPrompt/ session, no extra user ring lines; stays Busy for the whole retry window so promote cannot runContinue the current turnas the new headNon-goals / out of scope
MAX_ITEMS(16) /SUBMIT_CAP(262144)Architectural decisions
withTransientRetry(lib/sandbox/resilience.ts) — B) hand-rolled nested send loopbaseMs250,capMs4000), unit-tested. Baseline truth:withTransientRetrycurrently hard-codesclassifyVercelErrorinternally (resilience.ts 298) with no injectable classifier inTransientRetryOptions(260–273) — it is NOT directly reusable for turn failures. Add a new additive optionalclassifyseam toTransientRetryOptions(defaulting to today'sclassifyVercelError, so sandbox callers are untouched); the turn path passes a new narrow classifier overclassifyTurnFailure+ HTTP status, intentionally NOT reusingclassifyVercelError(that is the sandbox SDK domain). Decision A becomes viable once the seam lands; B is the fallback if the seam is judged too invasiveLifecycle.Erroron give-up; Wasm promote staysbusy → readyonly — B) keep Ready + add an error flag to the promote predicateLifecycle.Erroralready exists on both sides (bridge.zigerr=3↔harnessBridge.tsError=3); host is the only lifecycle writer, so simplest correct seam is: host sets Error on give-up and Ready only on success / operator Stop; Wasm'sterminaldropserr. One source of truth for lifecycleContinue the current turninsertsubmit_queue.insertFront+bridgewrapper — B) host re-sends the failed prompt via the normal submit pathtimeout/emptyare retryable likeerror— B) treatemptyas terminalinv_set_busy_tickbecame REQUIRED): a new REQUIRED export bumps the version so a mismatched old wasm/host fails closed with theREQUIRED_FNSdiagnostic. Locked in this review. Coordinate with sibling #760 (also adding an export + bumping): union v19/v20 — whichever merges second takes the next free version, referenced in implementation notesLayer placement
lib/harnessChat.tsrunHarnessTurn(+runHarnessChatvalidation path) · classifier seam +withTransientRetryinlib/sandbox/resilience.ts/api/agentturn, the ring user row, and the lifecycle; Wasm must never see a promote window mid-retryerr)native/harness/src/ui.zig~221–231 (terminal/trigger_a)prev busy && cur ready;errno longer terminal for the queueinsertFront+ Continue head + exportnative/harness/src/submit_queue.zig·native/harness/src/bridge.zigwrapper + new export ·native/harness/build.zigexport_symlink_namesinv_queued_insert_front+ existingqueuedCount). Everyinv_*export MUST be listed inbuild.zigexport_symlink_namesor the linker GCs it and JS never sees it (bridge.zig header contract) — see protocol note belowdocs/harness-limits.mdSubmit queue ·docs/agent-stream.mderror/timeout ·AGENTS.mdprotocol v19 + capsCurrent baseline (live code)
native/harness/src/ui.zig221–231const terminal = cur_lc == .ready or cur_lc == .err;trigger_a = prev_lc == .busy and terminal;→bridge.tryPromoteQueued(false)— verifiedLifecycleenum (Wasm)native/harness/src/bridge.zig39–44boot=0 ready=1 busy=2 err=3Lifecycleenum (TS)lib/harnessBridge.ts72–77Boot Ready Busy Error— parity with Wasmsubmit_queue.zigpromoteIf113–119 ·bridge.zigtryPromoteQueued245–259promoteSubmit→queueSubmitFromUi→has_pending_submit; pop only on acceptlib/harnessChat.tsrunHarnessChat550,558 ·runHarnessTurnagent fail 1492 · chat fail 1523–1533classifyTurnFailure→ Error/System row →setLifecycle(Lifecycle.Ready)— same as success, so promote fires. verifiedlib/harnessChat.tsclassifyTurnFailure609–632Request cancelled.→stop; timeout / empty / other → retryableerrorkinds. Note: 401/403/validation are NOT returned as distinct kinds here — the new classifier keyed onclassifyTurnFailure+ HTTP status must treat these as permanent (goal 6)lib/sandbox/resilience.tswithTransientRetry280–317 ·classifyVercelError147–214 ·TransientRetryOptions260–273retriesdefault 4), exponentialbaseMs250 /capMs4000,signalaborts sleep. Critical:withTransientRetrycallsclassifyVercelError(err)internally (line 298) and the options struct has noclassifyoverride — it is not directly reusable for turns; an additive classifier seam is required (see Architecture + Design §1). verifiedsubmit_queue.zig11MAX_ITEMS=16, 44–52push, 113–119pushat tail only; noinsertFrontyet.MAX_ITEMS16 ·bridge.zig76SUBMIT_CAP262144lib/harnessBridge.ts148queuedCount()· protocol v18 (inv_queued_count)native/harness/build.zigexport_symlink_names(lines 69–89+)export fn inv_*must be listed here or the linker GCs it and JS never sees it (bridge.zig header contract, comment 60–68). A new export requires an entry here in the same changeREQUIRED_FNSlib/harnessBridge.ts208–263HarnessExportsDesign
1. Host — retry the in-flight turn (
lib/harnessChat.ts/runHarnessTurn)Stay Busy across attempts. Reuse
withTransientRetry(already 5 attempts,abort-aware) via a new additive classifier seam:
TransientRetryOptionswith an optionalclassify?: typeof classifyVercelErrorparameter inlib/sandbox/resilience.ts.Default is today's
classifyVercelError(sandbox callers untouched); the turnpath passes a new narrow classifier that maps
classifyTurnFailureresult +HTTP status →
{ kind: 'retryable' | 'permanent' }. This keeps the sandbox SDKclassifier and turn errors from being conflated.
classifyTurnFailureerror that is not 401/403/validation/stop.signal.aborted/499, 401, 403, prompt validation.
pushUseron retries (the user line is already on the ring).setLifecycle(Ready)between attempts.baseMs250,capMs4000) unless a named NEWcap is added (
TURN_RETRY_ATTEMPTS= 5, plus base/cap) — locked: name itin the Caps table + AGENTS.md (new cap; not a change to an existing cap).
Retrying 2/5…) — avoids ringnoise; the turn already stays Busy.
classifyTurnFailureresults + status — the existingclassifyVercelError(sandbox SDK domain) is intentionally not reused for the turn.
2. Wasm — promote only on success Ready
tryPromoteQueuedmust not run onbusy → err, nor onbusy → readywhen theturn failed.
Cleanest: host sets
Lifecycle.Erroron give-up (the enum already exists onboth sides) and
Lifecycle.Readyonly on success / after operator Stop.Promote stays
prev == busy && cur == ready— droperrfromterminalfortrigger_a. Trigger B (edit closed while idle) unchanged.Update
docs/harness-limits.mdWhen it runs: after the current turn reachessuccessful Ready, not error.
3. Queue — insert Continue at head, never pop on error
Add
submit_queue.insertFront(orpushFront) + abridgewrapper + exportinv_queued_insert_front, and append it tonative/harness/build.zigexport_symlink_names+REQUIRED_FNS(protocol v19).On host give-up, if
queuedCount() > 0:count == 16, do not drop operator items; skip the insert (failclosed). Still no promote.
Continue the current turn(a named TS/Zigconstant).
ITEM_BYTES/ normalize path as a normal enqueue (not blank).The Continue row is a normal queue row (preview, edit, ✕). It is not
auto-submitted.
Edge cases
(no 5× loop).
Continue the current turnwhen head was edited: normal queue semantics —it promotes like any head on a later successful Ready.
Cloud ops path
N/A — no Production mutate. Retry is client-side around the existing
/api/agentturn; no env, no GHA, no data write.Living docs plan
docs/harness-limits.mdSubmit queue → When it runsdocs/agent-stream.mdError / timeoutAGENTS.mdinv_queued_insert_frontadditive REQUIRED export; newTURN_RETRY_ATTEMPTS= 5 cap in Caps table + ownership rowHARNESS_PROTOCOL_VERSIONREADME.mdSECURITY.md.env.exampleImplementation order
submit_queue.insertFront+bridgewrapper +export fn inv_queued_insert_front(foundation the host needs); append the new export tonative/harness/build.zigexport_symlink_names(linker-GC whitelist).PROTOCOL_VERSION(bridge.zig) +HARNESS_PROTOCOL_VERSION(harnessBridge.ts); add the export toREQUIRED_FNS+ TS type/method.classifyseam towithTransientRetry/TransientRetryOptions(default =classifyVercelError) + the narrow turn classifier.runHarnessTurn+ lifecycle Error-on-give-up.errfrom terminal).Continue the current turninsert (respect full-16).harness-limits.md,agent-stream.md, AGENTS.md protocol v19 + caps table).Testing
lib/harnessChat.test.ts— one user line; assistant from the successful attempt; queue untouched until success ReadyContinue the current turnwhen queue non-emptyzig build test-richsubmit_queue— no insert, no popinsertFrontunitzig build test-rich— head is new item; previous head is index 1busy → errdoes NOTtryPromoteQueuedbusy → errtransitionlib/harnessChat.test.tsclassify narrow setlib/sandbox/resilience.test.ts—withTransientRetrywith noclassifystill callsclassifyVercelError(sandbox behavior unchanged); passing aclassifyuses itREQUIRED_FNS+ export in built wasmharnessBridgetype +REQUIRED_FNScontainsinv_queued_insert_front;wasm-intround-trip proves it reached the artifact via thebuild.zigwhitelistGates (both layers):
npm run typecheck+node scripts/di-gate.mjs+vitest run(host) andzig build test-rich+zig build harness -Doptimize=Debug+zig fmt --check(Wasm). CI: wait for build-harnessgreen (wasm32-freestanding) — local Debug is a host-target smoke only;
build-harness is the release-wasm gate that also proves the new export is in
the shipped artifact.
Caps table
TURN_RETRY_ATTEMPTS(host)withTransientRetrydefault; bounded bysignalabort (Stop). No change to an existing cap. Locked in plan-reviewbaseMs/capMs(existing)MAX_ITEMS(existing)SUBMIT_CAP/ITEM_BYTES(existing)"Continue the current turn"ITEM_BYTESclassifyseam footprintDefinition of done
harness-limits.md(When it runs = successful Ready only),agent-stream.md, and AGENTS.md protocol v19 row +TURN_RETRY_ATTEMPTScaps row updated with timeless wordingbuild-harnessgreen on the branchContinue the current turnhead inserted only on give-up + non-empty queue; queue full → fail closed (no drop)withTransientRetryclassifier seam landed (additiveclassifydefault =classifyVercelError; sandbox tests unchanged, test 11)PROTOCOL_VERSION/HARNESS_PROTOCOL_VERSION) +inv_queued_insert_frontinREQUIRED_FNSandnative/harness/build.zigexport_symlink_names, present in the built wasm (test 12 / wasm-int)AGENTS.md/README.mdconsidered (AGENTS v19 + caps; README N/A justified)Risks & mitigations
posttool already ran server-side before a stream error)docs/harness-limits.md) — a continue-after-stall may re-run tools. Same documented stance; do not redact history on retryinsertFrontwhen full silently drops operator itemsBusyfor the whole window; Wasmerrno longer terminal. Both locks enforced by test 8withTransientRetryclassified by the wrong domainclassifyseam; the turn path passes the narrow turn classifier, sandbox keepsclassifyVercelErrordefault (test 11)build.zigexport_symlink_namesentry added in the same step; test 12 / wasm-int + build-harness prove it (mirrors sibling #760's caught defect)Open questions
Should the retry cap be a named NEW cap (RESOLVED (plan-review): nameTURN_RETRY_ATTEMPTS= 5, base/cap)or silently reuse
withTransientRetrydefaults?the cap in the Caps table + AGENTS (
TURN_RETRY_ATTEMPTS= 5). New cap, nohuman-block, generous default.
In-canvas per-retry line?RESOLVED (plan-review): none. The turn staysBusy; avoids ring noise. No
Retrying 2/5…line.References
err-non-terminal lock and the v19/v20 protocol union.withTransientRetry(lib/sandbox/resilience.ts, lines 260–317) — reuse seam + requiredclassifyadditive seam.inv_queued_count(lib/harnessBridge.ts,bridge.zig) — base for the v19 bump.