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. Two minors applied: (1) new WASM export must be added to native/harness/build.zigexport_symlink_names (bridge.zig header contract) or JS never sees it — added to Implement order + DoD; (2) inv_clear_messages must also reset queue_promote_allowed to true to match the stated "Session clear / New" edge case (Design §1 now specifies it; test 11 added). All baselines verified against main (ui.zig terminal-promote block, bridge.zig v18 + queueCancelFromUi, harnessBridge.ts v18 + REQUIRED_FNS, composer_chrome idle ▶, harnessChat.ts Ready sites + classifyTurnFailure, harness-limits.md "drains after Ready" defect).
Date
2026-08-21
Type
single
Parent
N/A
Source issue
#757 — "harness: Stop/Esc must not drain the queue; idle ▶ promotes when the composer is empty" (bug)
Branch
plan/stop-no-drain-idle-play
Layers
harness (Wasm) + DOM host (bridge + harnessChat.ts)
Reusability impact
none
Production mutate?
no
Cloud ops path
N/A — no Production mutate. Wasm queue + host abort; no env/GHA/data write
Living docs
docs/harness-limits.md (Submit queue → When it runs / Stop; Keyboard & focus → Esc), AGENTS.md (protocol v19 row)
Summary
■ Stop and Esc (Busy) today look like a finished turn: the host aborts then
sets Lifecycle.Ready, Wasm sees busy → ready and tryPromoteQueued pops the queue head into a new user send. The operator asked to stop; the harness kept
going and drained the queue. This plan makes Stop consume nothing: the queue
stays exactly as-is, and the only way to start a queued head afterwards is an explicit Play (idle ▶, or Ctrl/Cmd+Enter, with an empty composer and a
non-empty queue). Successful turns keep auto-promoting exactly as today (#756
goal 1 / #759). Amends #756:
that issue's "Stop still drains after Ready" is wrong — Stop never promotes;
Play does.
Goals
#
Goal
Success signal
1
■ Stop and Esc (Busy, not queue-edit / help) do not promote
Queue depth unchanged; no new user ring line from the old head; composer idle; ■ gone, ▶ back
2
Idle ▶ with queue non-empty and empty composer promotes the head
One click starts that item as a normal user turn; head popped; Busy
3
Idle ▶ with non-empty composer still sends the composer text (today)
Queue untouched until that turn's successful Ready
4
Idle ▶ with empty composer and empty queue stays a no-op
No blank user row (existing empty-send guard)
5
A successful turn still auto-promotes the queue (unchanged)
Do not add a second "Play queue" icon; the existing idle ▶ is the control
Do not change queue-row editor / Esc precedence (Esc still dismisses an open editor first, then closes help, then cancels Busy — existing keymap Context)
Forbidden wiring: dual DOM chat · secrets in Wasm · laptop-only Production ops
Architectural decisions
Required: this changes a bridge protocol surface (new export) and the Wasm
promote gate.
Decision
Options considered
Choice
Why
How Wasm distinguishes success-Ready from Stop-Ready
A) host arms a one-shot "promote allowed" boolean over the bridge before Ready; B) host sends a distinct lifecycle value for Stop; C) promote moves entirely off the lifecycle edge onto explicit Play only
A
The issue's stated operator intent: auto-promote may remain after successful Ready, but a Stop must never consume. Wasm cannot tell the two .ready transitions apart, and the host owns the outcome (classifyTurnFailure: stop vs error/timeout/empty). A tiny additive scalar export the host sets per-terminal is the smallest seam that keeps auto-promote-on-success (§5) while making Stop deterministic. B bloats the lifecycle enum and would make Stop look like a real error; C (Play-only, no auto-drain) contradicts the operator intent that success auto-promote may remain
Gate the promote block
A) gate both trigger_a and trigger_b behind promote_allowed; B) gate trigger_a only
A
The terminal-promote block must not fire after a Stop from any trigger, otherwise an edit-close right after a Stop would drain the queue (violates "after Stop, only ▶ starts the next item"). One clean per-terminal gate covers both
Idle ▶ empty-composer promote
A) extend the existing idle ▶ (and Ctrl/Cmd+Enter) click to fall back to tryPromoteQueued when the field is empty; B) new "Play queue" icon
A
Reuses the existing control + keymap submit chord so keyboard and Play match; no new chrome (non-goal)
Protocol version
A) additive export without version bump; B) REQUIRED + v18→v19
B
Repo precedent (v14→v15): a new REQUIRED export bumps the version so a mismatched old wasm/host fails closed with the REQUIRED_FNS diagnostic instead of silently misbehaving on stop. Coordinate with #759 (also adding an export); if #759 merges first, this plan takes v19 or v20 as the union dictates
Layer placement
Concern
Layer
Path(s)
Rationale
Promote gate (promote_allowed) ← consumes host signal
Everyexport fn inv_* must be listed here or the linker GCs it and JS never sees it (bridge.zig header contract). A new export requires an entry here in the same change
Docs defect
docs/harness-limits.md Submit queue → When it runs / Stop
"Stop … the queue … drains after Ready" — the behavior this plan removes
var queue_promote_allowed: bool = true; — default true so a legacy host that
never writes it keeps today's success auto-promote.
var queue_promote_allowed restores true in bothreset()and inv_clear_messages (matches "fresh surface" on boot / New / Clear / hydrate —
these two exports are independent, and Clear does not call reset()).
pub fn hasQueuePromoteAllowed() bool { return queue_promote_allowed; }
(Wasm reads it on the terminal edge; it is not consumed — the host rewrites
it on every terminal).
List inv_set_queue_promote_allowed in native/harness/build.zig export_symlink_names (required for any new export fn inv_* to reach the JS
bridge — otherwise REQUIRED_FNS fails on load).
lib/harnessBridge.ts:
Add inv_set_queue_promote_allowed: (v: number) => void to HarnessBridgeExports
REQUIRED_FNS, and a setQueuePromoteAllowed(allowed: boolean) method.
2. Host — arm the scalar on every terminal (lib/harnessChat.ts)
The host is the only lifecycle writer and the only observer of the outcome, so it
sets promote_allowed right before setLifecycle(Ready) on every terminal:
Concrete call sites to touch: runHarnessChat success + fail + validation Ready, runHarnessTurn success Ready + failed-agent Ready + validation Ready. Introduce a
tiny local helper (e.g. completeTurn(bridge, promoteAllowed)) that sets the scalar
then setLifecycle(Ready) so no Ready path forgets it.
3. Wasm — promote only when the host says so (ui.zig ~227–236)
Gate both terminal triggers:
constterminal=cur_lc==.readyorcur_lc==.err;
consttrigger_a=prev_lc==.busyandterminal;
consttrigger_b=state.queue_closed_editandterminal;
state.queue_closed_edit=false;
constediting=state.queue_editing_index!=null;
if ((trigger_aortrigger_b) and!editingandbridge.hasQueuePromoteAllowed()) {
if (bridge.tryPromoteQueued(false)) {
busy=true;
}
}
err stays non-promoting (per #759) and a Stop (busy → ready with promote_allowed == false) also cannot promote.
4. Idle ▶ / Ctrl+Enter — empty composer promotes the head
ui/composer_chrome.zig idle branch: the idle ▶ click falls back to a promote
action when the field is empty:
if idle ▶ clicked:
if res.typed.len > 0: on_send(res.typed) // today (goal 3)
else if on_promote: on_promote() // new (goal 2) — tryPromoteQueued
else: no-op // goal 4
on_promote is a new injected Actions callback (like on_send/on_stop) that ui.zig binds to bridge.tryPromoteQueued(false); the chrome stays bridge-free and
host-testable. tryPromoteQueued already no-ops when the queue is empty, so goal 4
needs no extra guard — keep the icon enabled when the field is empty (the issue's
"don't grey" note) with no new visual state.
ui.zigrequest_submit block (Ctrl/Cmd+Enter): same fallback —
if state.request_submit:
if typed.len > 0: submitOrEnqueue(typed)
else if queuedCount() > 0: tryPromoteQueued(false)
else: no-op
Edge cases
Stop on attempt / mid-stream: abort, no promote; queue intact (host sets false on the stop terminal).
Esc precedence: queue editor open → editor dismissed (keymap Context), not
the turn, not promote (unchanged).
Gates: Zig + TS both (see Testing); build-harness for the Wasm change.
Testing
Wasm cases run in build.zigtest-rich (host target) using the existing test_web_backend_stub.zig web-backend stub (precedent: queue_band / composer / toolrun tests) so bridge logic is exercisable without the Wasm
runtime; the real-export round-trip is covered by wasm-int (case 9).
#
Case
Layer
Type
Command / method
1
Busy + 2 queued + ■ Stop
Wasm
unit
queue depth still 2; no new user line from head; hasQueuePromoteAllowed() false after host's stop terminal
2
Busy + 2 queued + Esc
Wasm
unit
same as 1 (keymap cancel_turn → stop terminal)
3
After 1, idle empty composer, click ▶
Wasm
unit
head submits (pending_submit set), queue 1, Busy
4
After 1, type text, click ▶
Wasm
unit
sends the typed prompt; queue still 2
5
Idle, empty composer, empty queue, ▶
Wasm
unit
no-op, no blank row
6
Queue-row editor open + Esc
Wasm
unit
dismisses the editor, not the turn / no promote (existing precedence)
7
Successful Ready still auto-promotes
Wasm
unit
host arms true → busy→ready empty-composer promotes head (unchanged)
8
Host sets false on stop terminal
host
unit
vitest lib/harnessChat.test.ts — classifyTurnFailurestop → setQueuePromoteAllowed(false) then Ready
9
Protocol v19 parity + REQUIRED_FNS
host
unit
harnessBridge type + REQUIRED_FNS contains the new export; wasm-int round-trip (also proves the export reached the wasm via build.zig whitelist)
10
reset() restores promote_allowed=true
Wasm
unit
fresh surface behaves like legacy host
11
inv_clear_messages restores promote_allowed=true
Wasm
unit
Clear / New surface after a Stop behaves like legacy host (mirrors test 10)
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.
Caps table
No new cap, limit, or budget is added or changed by this plan — the new bridge
signal is a single boolean scalar (not a byte budget), and MAX_ITEMS
(16) / SUBMIT_CAP (262144) are explicitly untouched.
Cap / ceiling
Value
Rationale
Code location
promote_allowed default
true
Matches legacy host auto-promote-on-success
native/harness/src/bridge.zig (new)
MAX_ITEMS
16
unchanged (explicit non-goal)
bridge.zig / submit_queue.zig
SUBMIT_CAP / ITEM_BYTES
262144
unchanged (explicit non-goal)
bridge.zig / submit_queue.zig
No raise/lower of any existing cap — no human-blocked decision.
Definition of done
Goals 1–5 each have a passing case in the Testing table
Stop and Esc (Busy) never consume a queued item; only idle ▶ / Ctrl+Enter with empty composer + non-empty queue do
Protocol v19 on both sides + new export in REQUIRED_FNS
inv_set_queue_promote_allowed listed in native/harness/build.zigexport_symlink_names and present in the built wasm (wasm-int round-trip green)
inv_clear_messages (as well as reset()) restores promote_allowed=true
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 / Stop / Esc) and AGENTS.md protocol row updated with timeless wording; the "drains after Ready" line removed
Tests green in agent workspace / CI: TS + Zig gates above; build-harness green on the branch
AGENTS.md / README.md considered (AGENTS v19 row; README N/A justified)
Risks & mitigations
Risk
Mitigation
A legacy host (older wasm artifact) that never writes the scalar still promotes on Stop
Default true keeps success auto-promote; protocol v19 + REQUIRED_FNS fails an old host/wasm load closed with the diagnostic, so the mismatch cannot silently ship
An edit-close right after a Stop auto-drains
Gate both triggers behind hasQueuePromoteAllowed() (A-gate above); test 6 covers the precedence
A Ready path forgets to arm the scalar
Single completeTurn helper in harnessChat.ts wraps scalar+lifecycle on every terminal; test 8 asserts the stop path
New export missing from the linker whitelist → JS never sees it
build.zigexport_symlink_names entry added in the same step; wasm-int round-trip (test 9) + build-harness prove the export is in the artifact
Clear / New after a Stop leaves a stale promote_allowed=false
inv_clear_messages resets it to true; test 11 asserts it
Protocol-version collision with #759 (both adding exports)
Independent plans; whichever merges second takes the next union version (v19 or v20). Call out in implementation notes at plan-review/merge
Laptop-only cutover / missing GHA
N/A — no Production mutate / no GHA surface needed (explicit N/A)
Plan header
native/harness/build.zigexport_symlink_names(bridge.zig header contract) or JS never sees it — added to Implement order + DoD; (2)inv_clear_messagesmust also resetqueue_promote_allowedtotrueto match the stated "Session clear / New" edge case (Design §1 now specifies it; test 11 added). All baselines verified againstmain(ui.zig terminal-promote block, bridge.zig v18 + queueCancelFromUi, harnessBridge.ts v18 + REQUIRED_FNS, composer_chrome idle ▶, harnessChat.ts Ready sites + classifyTurnFailure, harness-limits.md "drains after Ready" defect).plan/stop-no-drain-idle-playharnessChat.ts)docs/harness-limits.md(Submit queue → When it runs / Stop; Keyboard & focus → Esc),AGENTS.md(protocol v19 row)Summary
■ Stop and Esc (Busy) today look like a finished turn: the host aborts then
sets
Lifecycle.Ready, Wasm seesbusy → readyandtryPromoteQueuedpops thequeue head into a new user send. The operator asked to stop; the harness kept
going and drained the queue. This plan makes Stop consume nothing: the queue
stays exactly as-is, and the only way to start a queued head afterwards is an
explicit Play (idle ▶, or Ctrl/Cmd+Enter, with an empty composer and a
non-empty queue). Successful turns keep auto-promoting exactly as today (#756
goal 1 / #759). Amends #756:
that issue's "Stop still drains after Ready" is wrong — Stop never promotes;
Play does.
Goals
Non-goals / out of scope
Architectural decisions
Ready; B) host sends a distinct lifecycle value for Stop; C) promote moves entirely off the lifecycle edge onto explicit Play only.readytransitions apart, and the host owns the outcome (classifyTurnFailure:stopvserror/timeout/empty). A tiny additive scalar export the host sets per-terminal is the smallest seam that keeps auto-promote-on-success (§5) while making Stop deterministic. B bloats the lifecycle enum and would make Stop look like a real error; C (Play-only, no auto-drain) contradicts the operator intent that success auto-promote may remaintrigger_aandtrigger_bbehindpromote_allowed; B) gatetrigger_aonlytryPromoteQueuedwhen the field is empty; B) new "Play queue" iconsubmitchord so keyboard and Play match; no new chrome (non-goal)REQUIRED_FNSdiagnostic instead of silently misbehaving on stop. Coordinate with #759 (also adding an export); if #759 merges first, this plan takes v19 or v20 as the union dictatesLayer placement
promote_allowed) ← consumes host signalnative/harness/src/ui.zig~227–236,native/harness/src/bridge.zigtryPromoteQueued→queueSubmitFromUi) and the ephemeral queue; it must not infer outcome from lifecyclepromote_allowedper terminallib/harnessChat.tsrunHarnessChat+runHarnessTurn(success/fail/validation) +lib/harnessBridge.tsclassifyTurnFailure(stop vs success), so it owns the decision; Wasm just honors the scalarnative/harness/src/ui/composer_chrome.zig,ui.zigrequest_submitblock,ui/state.zig(opt. flag)on_send/on_stopdocs/harness-limits.md,AGENTS.mdCurrent baseline (live code)
native/harness/src/ui.zig227–236terminal = ready or err;trigger_a = prev busy → terminal→bridge.tryPromoteQueued(false)— verifiednative/harness/src/bridge.zigqueueCancelFromUi288–294 ·lib/harnessChat.tsfail pathqueueCancelFromUisetshas_pending_cancel; host aborts,classifyTurnFailure→stop, thenbridge.setLifecycle(Ready)(same as success) — the defecttryPromoteQueuedpops headbridge.zig245–259 ·submit_queue.zigpromoteIf113–118promoteSubmit→queueSubmitFromUi→has_pending_submit; pop only on accept.canPromoterequires!busy && count>0 && !editing && !pending_submitui/composer_chrome.zigidle branch▶idle:if (res.typed.len > 0) on_send(typed)— empty field is a no-op todayui.zigrequest_submitblock ·ui/composer.zigsubmitOrEnqueueclearPrompt+ return; does not promotebridge.zig39–44 ·lib/harnessBridge.ts72–76boot=0 ready=1 busy=2 err=3both sidesREQUIRED_FNSif new exportlib/harnessBridge.ts230+inv_set_busy_tickbecame REQUIRED)bridge.zigPROTOCOL_VERSION=18·harnessBridge.tsHARNESS_PROTOCOL_VERSION=18inv_queued_countnative/harness/build.zigexport_symlink_namesexport fn inv_*must be listed here or the linker GCs it and JS never sees it (bridge.zig header contract). A new export requires an entry here in the same changedocs/harness-limits.mdSubmit queue → When it runs / StopDesign
1. Bridge — additive "promote allowed" scalar (Wasm + TS)
native/harness/src/bridge.zig:var queue_promote_allowed: bool = true;— default true so a legacy host thatnever writes it keeps today's success auto-promote.
var queue_promote_allowedrestorestruein bothreset()andinv_clear_messages(matches "fresh surface" on boot / New / Clear / hydrate —these two exports are independent, and Clear does not call
reset()).export fn inv_set_queue_promote_allowed(v: u8) void { queue_promote_allowed = v != 0; refresh(); }pub fn hasQueuePromoteAllowed() bool { return queue_promote_allowed; }(Wasm reads it on the terminal edge; it is not consumed — the host rewrites
it on every terminal).
inv_set_queue_promote_allowedinnative/harness/build.zigexport_symlink_names(required for any newexport fn inv_*to reach the JSbridge — otherwise
REQUIRED_FNSfails on load).lib/harnessBridge.ts:inv_set_queue_promote_allowed: (v: number) => voidtoHarnessBridgeExportsREQUIRED_FNS, and asetQueuePromoteAllowed(allowed: boolean)method.HARNESS_PROTOCOL_VERSION18→19 (matchesPROTOCOL_VERSION).2. Host — arm the scalar on every terminal (
lib/harnessChat.ts)The host is the only lifecycle writer and the only observer of the outcome, so it
sets
promote_allowedright beforesetLifecycle(Ready)on every terminal:ok; chatok):bridge.setQueuePromoteAllowed(true)(default).classifyTurnFailurekind ==='stop', incl. 499/abort/Request cancelled.):bridge.setQueuePromoteAllowed(false).falsetoo (belt-and-suspendersover plan: harness — turn errors retry the current turn, never drain the queue (#756) · IMPLEMENTED (PR #774) #759, which already moves these to
Lifecycle.Errorand dropserrfromthe Wasm terminal — this plan's scalar makes the non-promotion explicit on the
shared Ready path).
Concrete call sites to touch:
runHarnessChatsuccess + fail + validation Ready,runHarnessTurnsuccess Ready + failed-agent Ready + validation Ready. Introduce atiny local helper (e.g.
completeTurn(bridge, promoteAllowed)) that sets the scalarthen
setLifecycle(Ready)so no Ready path forgets it.3. Wasm — promote only when the host says so (
ui.zig~227–236)Gate both terminal triggers:
errstays non-promoting (per #759) and a Stop (busy → readywithpromote_allowed == false) also cannot promote.4. Idle ▶ / Ctrl+Enter — empty composer promotes the head
ui/composer_chrome.zigidle branch: the idle ▶ click falls back to a promoteaction when the field is empty:
on_promoteis a new injectedActionscallback (likeon_send/on_stop) thatui.zigbinds tobridge.tryPromoteQueued(false); the chrome stays bridge-free andhost-testable.
tryPromoteQueuedalready no-ops when the queue is empty, so goal 4needs no extra guard — keep the icon enabled when the field is empty (the issue's
"don't grey" note) with no new visual state.
ui.zigrequest_submitblock (Ctrl/Cmd+Enter): same fallback —Edge cases
falseon the stop terminal).the turn, not promote (unchanged).
reset()andinv_clear_messagesrestorepromote_allowed=true(matches "fresh surface" and old-host behavior).so it never drains; only explicit ▶ starts the next item.
Cloud ops path
N/A — no Production mutate. Wasm queue + host abort + one scalar bridge write;
no env, no GHA, no data write.
Living docs plan
docs/harness-limits.mdSubmit queue → When it runsdocs/harness-limits.mdKeyboard & focus → EscAGENTS.mdinv_set_queue_promote_allowedadditive REQUIRED exportHARNESS_PROTOCOL_VERSIONREADME.mdSECURITY.md.env.exampleImplementation order
inv_set_queue_promote_allowed+hasQueuePromoteAllowed();append the new export to
native/harness/build.zigexport_symlink_names;TS type/
REQUIRED_FNS/method + protocol v19.ui.zigterminal-promote block onhasQueuePromoteAllowed().completeTurnhelper + arm the scalar on every Ready path inrunHarnessChat/runHarnessTurn.composer_chrome.zigon_promote,ui.zigrequest_submit).harness-limits.md,AGENTS.md.Testing
hasQueuePromoteAllowed()false after host's stop terminalcancel_turn→ stop terminal)pending_submitset), queue 1, Busytrue→busy→readyempty-composer promotes head (unchanged)falseon stop terminallib/harnessChat.test.ts—classifyTurnFailurestop→setQueuePromoteAllowed(false)then ReadyharnessBridgetype +REQUIRED_FNScontains the new export;wasm-intround-trip (also proves the export reached the wasm viabuild.zigwhitelist)reset()restorespromote_allowed=trueinv_clear_messagesrestorespromote_allowed=trueGates (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.
Caps table
No new cap, limit, or budget is added or changed by this plan — the new bridge
signal is a single boolean scalar (not a byte budget), and
MAX_ITEMS(16) /
SUBMIT_CAP(262144) are explicitly untouched.promote_alloweddefaulttruenative/harness/src/bridge.zig(new)MAX_ITEMSbridge.zig/submit_queue.zigSUBMIT_CAP/ITEM_BYTESbridge.zig/submit_queue.zigNo raise/lower of any existing cap — no human-blocked decision.
Definition of done
REQUIRED_FNSinv_set_queue_promote_allowedlisted innative/harness/build.zigexport_symlink_namesand present in the built wasm (wasm-intround-trip green)inv_clear_messages(as well asreset()) restorespromote_allowed=trueharness-limits.md(When it runs / Stop / Esc) and AGENTS.md protocol row updated with timeless wording; the "drains after Ready" line removedbuild-harnessgreen on the branchAGENTS.md/README.mdconsidered (AGENTS v19 row; README N/A justified)Risks & mitigations
truekeeps success auto-promote; protocol v19 + REQUIRED_FNS fails an old host/wasm load closed with the diagnostic, so the mismatch cannot silently shiphasQueuePromoteAllowed()(A-gate above); test 6 covers the precedencecompleteTurnhelper inharnessChat.tswraps scalar+lifecycle on every terminal; test 8 asserts the stop pathbuild.zigexport_symlink_namesentry added in the same step;wasm-intround-trip (test 9) +build-harnessprove the export is in the artifactpromote_allowed=falseinv_clear_messagesresets it totrue; test 11 asserts itReferences
err-non-terminal lock and the protocol-version union.submit_queue.zigcanPromote(!busy && count>0 && !editing && !pending_submit) — reused for the idle ▶ promote.inv_queued_count(lib/harnessBridge.ts,bridge.zig).