Skip to content

plan: harness — Stop/Esc never drains the queue; idle ▶ promotes when composer empty (#757) #760

Description

@btipling

Plan header

Field Value
Status IMPLEMENTED
Review notes Reviewed 2026-08-22 (plan-review). No blockers. Two minors applied: (1) new WASM export must be added to native/harness/build.zig export_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) Post-success empty-Promote/gate-close behavior intact

Non-goals / out of scope

  • Do not auto-drain the queue after Stop, error, or cancel
  • Do not change Busy ▶ (still enqueues when the field has text)
  • Do not consume the queue on error — that is #756 (retry / Continue-at-head, plan plan: harness — turn errors retry the current turn, never drain the queue (#756) · IMPLEMENTED (PR #774) #759)
  • 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 harness native/harness/src/ui.zig ~227–236, native/harness/src/bridge.zig Wasm owns the only promote path (tryPromoteQueuedqueueSubmitFromUi) and the ephemeral queue; it must not infer outcome from lifecycle
Arm promote_allowed per terminal DOM host lib/harnessChat.ts runHarnessChat + runHarnessTurn (success/fail/validation) + lib/harnessBridge.ts Host is the sole lifecycle writer and the only observer of classifyTurnFailure (stop vs success), so it owns the decision; Wasm just honors the scalar
Idle ▶ / Ctrl+Enter empty→promote harness native/harness/src/ui/composer_chrome.zig, ui.zig request_submit block, ui/state.zig (opt. flag) Chrome is callback-injected (no bridge dep) to stay host-testable; promote action injected like on_send/on_stop
Docs living docs/harness-limits.md, AGENTS.md Timeless "what it does now" wording

Current baseline (live code)

Claim Path / symbol Notes
Promote on any terminal native/harness/src/ui.zig 227–236 terminal = ready or err; trigger_a = prev busy → terminalbridge.tryPromoteQueued(false)verified
Stop signal stays Wasm-side, host decides Ready native/harness/src/bridge.zig queueCancelFromUi 288–294 · lib/harnessChat.ts fail path queueCancelFromUi sets has_pending_cancel; host aborts, classifyTurnFailurestop, then bridge.setLifecycle(Ready) (same as success) — the defect
tryPromoteQueued pops head bridge.zig 245–259 · submit_queue.zig promoteIf 113–118 Peek → promoteSubmitqueueSubmitFromUihas_pending_submit; pop only on accept. canPromote requires !busy && count>0 && !editing && !pending_submit
Idle ▶ empty field no-ops ui/composer_chrome.zig idle branch idle: if (res.typed.len > 0) on_send(typed) — empty field is a no-op today
Ctrl/Cmd+Enter idle blank ui.zig request_submit block · ui/composer.zig submitOrEnqueue Blank normalize → clearPrompt + return; does not promote
Lifecycle enum parity bridge.zig 39–44 · lib/harnessBridge.ts 72–76 boot=0 ready=1 busy=2 err=3 both sides
REQUIRED_FNS if new export lib/harnessBridge.ts 230+ Version-bumpion precedent v14→v15 (inv_set_busy_tick became REQUIRED)
Protocol current bridge.zig PROTOCOL_VERSION=18 · harnessBridge.ts HARNESS_PROTOCOL_VERSION=18 v18 = inv_queued_count
Wasm export whitelist native/harness/build.zig export_symlink_names Every export 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

Design

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 that
    never writes it keeps today's success auto-promote.
  • var queue_promote_allowed restores true in both reset() and
    inv_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).
  • 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.
  • Bump HARNESS_PROTOCOL_VERSION 18→19 (matches PROTOCOL_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_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:

const terminal = cur_lc == .ready or cur_lc == .err;
const trigger_a = prev_lc == .busy and terminal;
const trigger_b = state.queue_closed_edit and terminal;
state.queue_closed_edit = false;
const editing = state.queue_editing_index != null;
if ((trigger_a or trigger_b) and !editing and bridge.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.zig request_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).
  • Post-Stop idle ▶ with non-empty queue + empty composer: promotes (goal 2).
  • Post-Stop idle ▶ with text: sends the text (goal 3).
  • Session clear / New / hydrate: reset() and inv_clear_messages restore
    promote_allowed=true (matches "fresh surface" and old-host behavior).
  • Post-Stop edit-close of a queued row: gated by the scalar (false after Stop),
    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

Surface Change Notes
docs/harness-limits.md Submit queue → When it runs successful Ready only; Stop/Esc/error never promote; idle ▶ (empty composer) promotes a queued head timeless wording; remove the "drains after Ready" line under Stop
docs/harness-limits.md Keyboard & focus → Esc Esc Busy = stop, no queue consume one-line amends
AGENTS.md Protocol v19 row under "Harness stream chrome": inv_set_queue_promote_allowed additive REQUIRED export 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

  1. Bridge: Wasm inv_set_queue_promote_allowed + hasQueuePromoteAllowed();
    append the new export to native/harness/build.zig export_symlink_names;
    TS type/REQUIRED_FNS/method + protocol v19.
  2. Wasm: gate ui.zig terminal-promote block on hasQueuePromoteAllowed().
  3. Host: completeTurn helper + arm the scalar on every Ready path in runHarnessChat / runHarnessTurn.
  4. Wasm: idle ▶ + Ctrl/Cmd+Enter empty→promote (composer_chrome.zig on_promote, ui.zig request_submit).
  5. Docs: harness-limits.md, AGENTS.md.
  6. Gates: Zig + TS both (see Testing); build-harness for the Wasm change.

Testing

Wasm cases run in build.zig test-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 truebusy→ready empty-composer promotes head (unchanged)
8 Host sets false on stop terminal host unit vitest lib/harnessChat.test.tsclassifyTurnFailure stopsetQueuePromoteAllowed(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) and zig 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
  • Successful turns still auto-promote (no regression vs harness: turn errors must retry the current turn — never drain the queue #756/plan: harness — turn errors retry the current turn, never drain the queue (#756) · IMPLEMENTED (PR #774) #759 intent)
  • Protocol v19 on both sides + new export in REQUIRED_FNS
  • inv_set_queue_promote_allowed listed in native/harness/build.zig export_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.zig export_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)

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions