Skip to content

feat(harness): Stop/Esc never drains the queue; idle ▶ promotes (plan #760) - #763

Merged
btipling merged 3 commits into
mainfrom
plan/stop-no-drain-idle-play
Aug 22, 2026
Merged

feat(harness): Stop/Esc never drains the queue; idle ▶ promotes (plan #760)#763
btipling merged 3 commits into
mainfrom
plan/stop-no-drain-idle-play

Conversation

@btipling

Copy link
Copy Markdown
Owner

Closes #760

What

Stop / Esc (Busy) no longer drain the submit queue. Today a Stop aborts then
sets Lifecycle.Ready; Wasm sees busy→ready and pops the queue head into a new
turn — the operator asked to stop, the harness kept draining. Now Stop consumes
nothing: the queue stays exactly as-is, and only an explicit idle ▶ or
Ctrl/Cmd+Enter (empty composer + non-empty queue) starts the next item.
Successful turns keep auto-promoting exactly as before.

Design (plan #760 — locked)

  • Bridge (protocol v19): new additive scalar inv_set_queue_promote_allowed
    — host arms it true on a successful Ready (auto-promote stays), false on
    Stop / Esc / error / timeout / validation Ready. Default true keeps a legacy
    host on today's behavior. reset() and inv_clear_messages both re-arm it.
    Listed in build.zig export_symbol_names so the wasm-linker roots it.
  • Host (harnessChat.ts): single completeTurn(bridge, promoteAllowed)
    helper wraps scalar + life-cycle on every terminal (success→true,
    stop/error/timeout/validation→false) so no Ready path forgets to arm it.
  • Wasm (ui.zig): terminal-promote block gated on hasQueuePromoteAllowed().
    Idle ▶ and Ctrl/Cmd+Enter with an empty composer + non-empty queue fall back to
    tryPromoteQueued(false) (new bridge-free on_promote chrome action). Empty
    composer + empty queue stays a no-op.

Layer / caps

  • Harness (Wasm) = promote gate + idle-▶ promote; DOM host = arms the scalar
    (sole lifecycle writer / only classifyTurnFailure observer).
  • No caps added/changed — the new bridge signal is a single boolean scalar.
    MAX_ITEMS (16) / SUBMIT_CAP (262144) untouched.
  • Cloud ops: N/A (no Production mutate).

Verification

  • Zig: zig build harness -Doptimize=Debug, zig build test-rich, zig build test-rich-invariants, zig fmt --check — all green (the 4 dvui_testing
    layout "failed command" lines are the pre-existing missing-font artifacts,
    byte-identical on clean main).
  • CI build-harness (wasm32-freestanding release) — awaited, green (the
    authoritative Wasm gate → harness-wasm-pr-760).
  • TS: npm run typecheck green; scripts/di-gate.mjs OK; full vitest run
    green.
  • wasm-int suite green against the freshly-built Debug artifact (round-trips the
    new export + protocol v19 through real Wasm).

Tests added (counts)

  • native/harness/src/bridge.test.zig (+4): promote gate default / set-read /
    reset() re-arm / inv_clear_messages re-arm.
  • composer_layout.test.zig (+3): idle ▶ empty→promote, typed→send, empty+empty→no-op.
  • harnessChat.test.ts (+5): host arms scalar on success / fail / stop / validation + Ready.
  • harnessBridge.test.ts (+2): setQueuePromoteAllowed round-trip + REQUIRED-fail-closed.
  • harnessChat.wasm-int.test.ts (+1): protocol v19 + promote-gate export present/works.

Full vitest run suite: 1955/1955 passed (126 files). This PR adds 8 TS
tests (5 harnessChat + 2 harnessBridge + 1 wasm-int) and 7 Zig tests (4 bridge +
3 composer_layout) over the pre-PR baseline, so the suite grew accordingly.

Docs

docs/harness-limits.md (removes the "drains after Ready" defect; When-it-runs /
Stop / Esc / composer rows), docs/feature-divide.md + AGENTS.md (protocol v19
row). Timeless wording.

…lan #760)

Stop and Esc (Busy) today look like a finished turn: the host aborts then sets
Lifecycle.Ready, Wasm sees busy->ready and pops the queue head. This makes
Stop consume nothing: the queue stays exactly as-is, and only an explicit
idle ▶ / Ctrl+Enter (empty composer + non-empty queue) starts the next item.
Successful turns keep auto-promoting exactly as before.

- bridge.zig: protocol v19 scalar `inv_set_queue_promote_allowed` (default true
  for legacy host); reset() and inv_clear_messages both re-arm it; build.zig
  export whitelist entry so the wasm-linker roots it.
- harnessBridge.ts: `inv_set_queue_promote_allowed` REQUIRED export + setter,
  HARNESS_PROTOCOL_VERSION 18->19.
- harnessChat.ts: completeTurn(bridge, promoteAllowed) wraps scalar+lifecycle on
  every terminal — true on success Ready, false on Stop/error/timeout/validation.
- ui.zig: gate the terminal-promote block on hasQueuePromoteAllowed(); idle ▶
  and Ctrl+Enter fall back to tryPromoteQueued with an empty composer + non-empty
  queue (composer_chrome on_promote action).
- Tests: bridge.test.zig (10/11 scalar reset/clear), composer_layout dispatch
  (goals 2/3/4), harnessChat + harnessBridge host arming, wasm-int protocol-v19
  round-trip. Docs: harness-limits.md (removes 'drains after Ready' defect),
  feature-divide.md, AGENTS.md v19 row.
@btipling btipling self-assigned this Aug 22, 2026
@vercel

vercel Bot commented Aug 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
invincible Ignored Ignored Aug 22, 2026 3:22am

Request Review

@btipling

Copy link
Copy Markdown
Owner Author

Implemented plan #760. Summary of verification:

Gates (all green):

  • zig build harness -Doptimize=Debug — full compile ✓
  • zig build test-rich + zig build test-rich-invariants — exit 0 ✓ (the 4 dvui_testing layout "failed command" lines are the pre-existing missing-font artifacts, byte-identical on clean main)
  • zig fmt --check on all touched Zig ✓
  • CI build-harness (wasm32-freestanding release) — green, uploaded harness-wasm-pr-760 ✓ (authoritative Wasm gate; proves the new export is rooted in the release build)
  • npm run typecheck ✓ · scripts/di-gate.mjs OK ✓ · npm run build
  • Full vitest run1955/1955 passed (126 files) ✓, including wasm-int (10 tests) round-tripping protocol v19 + the promote-gate export through the real Wasm (locally built Debug artifact copied to public/harness).

Tests added: 8 TS (5 harnessChat host-arming + 2 harnessBridge REQUIRED/round-trip + 1 wasm-int v19) + 7 Zig (4 bridge.test.zig scalar/reset/clear + 3 composer_layout idle-▶ dispatch).

Plan #760Status: IMPLEMENTED.

Protocol-version note: main was v18 and sibling #759 (adds another export) has no open PR yet, so this PR takes v18→v19 per the plan's union rule; if #759 merges later it takes v20. Not merged — stop at merge-ready; happy to take adversarial review on the new head.

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #763

Verdict: CONCERNS
Repo: btipling/invincible
Scope: mainplan/stop-no-drain-idle-play · 15 files · protocol v19 promote gate (plan #760)
HEAD: 0735b6a31bf8ac870d78751171a627ab7e38cf28
Lenses run: L1, L2, L3, L4, L6, L8, L9 (skip: L5 no poll/alloc/history change; L7 no clone bind)
AGENTS.md read: yes · docs/feature-divide.md read (Wasm still owns composer/queue; host only arms the scalar)

The Stop-doesn't-drain shape is right: host is the sole outcome observer, completeTurn(bridge, allowed) wraps every runHarnessChat / runHarnessTurn Ready, scalar is set before lifecycle so a refresh() between the two JS calls cannot see busy→ready with a stale gate, default-true + REQUIRED v19 fails closed on mismatch, reset() and inv_clear_messages re-arm, idle ▶ / Ctrl+Enter bypass the gate, Busy ▶ is untouched, export is in build.zig export_symbol_names, build-harness green. Feature-divide holds. No DI/cost hit.

The explicit-Play path does not honor the edit lock the terminal path and the docs both claim.

Findings

Sev Lens Finding Break scenario Refutation attempt Confidence
Major L1 ui.zig on_promote and the Ctrl+Enter empty-composer arm both call bridge.tryPromoteQueued(false) — the false is editing. The terminal-promote block correctly uses state.queue_editing_index != null. Explicit Play does not. composer_chrome idle ▶ has no edit awareness; keymap only disables the key submit chord during queue-edit (.composer = queue_editing_index == null), not the mouse ▶. Queue two items. Click ✎ on the head (or any row) so queue_editing_index != null. Leave the composer empty. Click idle ▶. canPromote sees editing=false → peek/submit/pop. The head starts as a user turn while the editor is open; unsaved edit buffer is discarded (index shift / closeEdit on empty FIFO). Docs harness-limits.md When-it-runs: “Promote is held while a row is being edited.” Plan #760 Design §3 gated both auto-triggers on !editing for this reason. Defender: “Ctrl+Enter is the save-edit chord, so keyboard is safe.” Survives: the new Play control is the mouse ▶, which is how goal 2 is specified. Defender: “operator shouldn’t click Send while editing.” The PR just taught empty ▶ to mean Play; that click is now load-bearing and punches through the existing lock. Fix: pass state.queue_editing_index != null into tryPromoteQueued (same value the terminal gate already computes). high
Minor L6 Plan testing rows 1 / 2 / 7 (Busy+queued+Stop/Esc → depth unchanged; successful Ready still auto-promotes through the ui.zig gate) are unimplemented. New tests prove host arming (harnessChat.test.ts) and chrome dispatch (composer_layout.test.zig) and the scalar itself (bridge.test.zig). None execute if (… and bridge.hasQueuePromoteAllowed()) tryPromoteQueued. Goal 1 — the bug this PR exists to fix — has no failing-before / passing-after case. A future edit that keeps completeTurn(false) but drops the hasQueuePromoteAllowed() conjunct (or inverts it) stays green. The edit-lock Major above is the same class of hole: Play is untested against editing. Defender: “ui.frame() isn’t host-unit-testable; plan row 6 allowed operator smoke for Shift+click.” This is not a font/hit-test problem. tryPromoteQueued + hasQueuePromoteAllowed + a FIFO are already host-testable in bridge.test.zig the way submit_queue.canPromote is. The missing piece is one function or a tiny extracted shouldAutoPromote(prev, cur, editing, allowed) with two asserts. high
Nit L6 runHarnessTurn validation arms promote_allowed=false in harnessChat.test.ts actually calls runHarnessChat(' '). The runHarnessTurn validation branch (lib/harnessChat.ts completeTurn(bridge, false) at the validatePrompt early return) is untested under its own name. Chrome goal-4 test binds on_promote: null; production ui.zig always binds it and relies on tryPromoteQueued no-op. Misnamed test / prod-divergent no-op fixture. Does not by itself ship a drain. Defender: “same completeTurn(false) helper.” The helper is shared; the call site is not the one the title names. high

Residual risk

Wasm-int round-trip cannot read the scalar (no getter); it only proves the export exists and doesn’t trap. HarnessHost still has four raw setLifecycle(Ready) sites (no-model, boot, session-switch leftover, New/Clear). They are not busy→ready turn terminals (New/Clear goes through inv_clear_messages, which wipes the FIFO and re-arms true), so they did not survive as drain bugs — they remain a footgun if a future Ready is added outside completeTurn. Live operator smoke of Stop-with-queue and Play-while-editing was not run in this attack.

Merge guidance

CONCERNS: do not merge until the Major is fixed (tryPromoteQueued must observe the same editing flag as the terminal gate). The L6 Minor should land in the same pass (a host test that Stop + non-empty queue does not pop). Nit optional.

What was not attacked

Live DO runner beyond the already-green build-harness job; prod Gateway; in-browser Stop/Play smoke; sibling plan #759 (error retry / protocol-version union).

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #763 (round 2)

Verdict: PASS WITH NOTES
Repo: btipling/invincible
Scope: mainplan/stop-no-drain-idle-play · 15 files · protocol v19 promote gate (plan #760)
HEAD: df4780ea8c37eb9ba02b2389da90c0bc41fcc4fe (round-1 follow-up on 0735b6a)
Lenses run: L1, L2, L3, L4, L6, L8 (skip: L5 no poll/alloc; L7 no clone bind; L9 no palette)
AGENTS.md read: yes · docs/feature-divide.md read (Wasm owns composer/queue; host only arms the scalar)

Round 1 (CONCERNS on 0735b6a) asked for a Major (Play ignored the edit lock), an L6 Minor (gate untested), and a Nit (misnamed validation test). This head landed all three:

  • Play edit lock: idle ▶ on_promote and the Ctrl+Enter empty-composer arm both pass state.queue_editing_index != null into tryPromoteQueued. canPromote(.editing=true) refuses; FIFO depth stays. Covered by gate e2e: Play-while-editing never pops.
  • Gate tests: shouldAutoPromote(prev, cur, editing, allowed) is the extracted busy→terminal predicate; Stop + 2-deep FIFO keeps depth 2; success pops the head. Chrome empty+empty now binds on_promote like production.
  • Validation test actually calls runHarnessTurn(..., ' ') and asserts sendAgent is not called.

completeTurn still wraps every chat/turn Ready (scalar before lifecycle). Default-true + REQUIRED v19 fails closed. reset() / inv_clear_messages re-arm. Busy ▶ still only enqueues. Export is in export_symbol_names. build-harness green on this SHA. Feature-divide holds.

Findings

Sev Lens Finding Break scenario Refutation attempt Confidence
Nit L6 shouldAutoPromote covers trigger A (busy→ready|err) only. ui.zig still ORs trigger B at the call site: (trigger_b and !editing and hasQueuePromoteAllowed()). That OR is not in the extracted function and has no FIFO test. A future or trigger_b that drops allowed would auto-promote on edit-close after Stop (Ready, queue_closed_edit, allowed still false in the real code today — the conjunct is present, just untested). Plan #760 named this exact drain. Fold trigger B into the predicate, or one e2e: Stop → shouldAutoPromote false → simulate trigger_b && !editing && allowed and assert depth unchanged. Defender: “the OR copies the same two guards.” Survives as coverage, not as a live drain — the guards are there. high
Nit L1 shouldAutoPromote(.busy, .err, false, true) is true (err is terminal). This host never writes Lifecycle.Error (completeTurn always Ready), so it does not drain today. The test named “error terminal never auto-promotes” only passes because it passes allowed=false, not because err is excluded. Sibling #774 wants err not terminal. If that PR’s host sets Error while this predicate still treats err as success-like and a Ready path left allowed=true, a busy→err edge would pop the head. Merge-order only. Defender: “pre-existing terminal = ready or err; host doesn’t set err.” Correct for this PR alone. high

Residual risk

Wasm-int still cannot read the scalar (no getter). HarnessHost still has four raw setLifecycle(Ready) sites (no-model / boot / session-switch leftover / New/Clear) — not busy→ready turn terminals (New/Clear goes through inv_clear_messages). Sibling #774 also ships protocol v19 with a different REQUIRED export; whichever merges second must take v20 and keep both. Live Stop-with-queue / Play-while-editing smoke was not run in this attack.

Merge guidance

PASS WITH NOTES. Round-1 Major is fixed; this is safe to merge from this attack. The two Nits are coverage / merge-order, not a live drain. Protocol-union with #774 is a merge-time must.

What was not attacked

Live DO runner beyond green build-harness; in-browser Stop then idle ▶; sibling #774 retry path.

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #763 (round 3)

Verdict: PASS WITH NOTES
Repo: btipling/invincible
Scope: mainplan/stop-no-drain-idle-play · 15 files · protocol v19 promote gate (plan #760)
HEAD: d6b795acee9df363fcb7107d1c89bb8bc37c9746 (round-2 follow-up on df4780e)
Lenses run: L1, L2, L3, L4, L6, L8 (skip: L5 no poll/alloc; L7 no clone bind; L9 no palette)
AGENTS.md read: yes · docs/feature-divide.md read (Wasm owns composer/queue; host only arms the scalar)

Round 2 (PASS WITH NOTES on df4780e) asked for two Nits. This head landed both inside shouldAutoPromote:

  • Trigger B is now edit_closed on the predicate ((turn_ended or (edit_closed and cur==ready)) and !editing and allowed). ui.zig no longer ORs a duplicate-guard at the call site. FIFO e2e: Stop then edit-close keeps depth 2; success then edit-close pops.
  • err is not a promotable terminal. shouldAutoPromote(.busy, .err, false, true, …) is false even with allowed=true. Matches sibling #774’s “err is not success.” This host still only writes Ready via completeTurn, so no live path changes.

Play still passes state.queue_editing_index != null. completeTurn still wraps every chat/turn Ready (scalar before lifecycle). Default-true + REQUIRED v19 fails closed. reset() / inv_clear_messages re-arm. Busy ▶ still only enqueues. Export is in export_symbol_names. build-harness green. Feature-divide holds.

Findings

No remaining findings from this attack. Round-2 Nits are fixed; round-1 Major stays fixed.

Residual risk

Wasm-int still cannot read the scalar (no getter). HarnessHost still has four raw setLifecycle(Ready) sites (no-model / boot / session-switch leftover / New/Clear) — not busy→ready turn terminals (New/Clear goes through inv_clear_messages). Sibling #774 also ships protocol v19 with a different REQUIRED export; whichever merges second must take v20 and keep both. Live Stop-with-queue / Play-while-editing smoke was not run in this attack.

Merge guidance

PASS WITH NOTES. Safe to merge from this attack. The leftover is merge-order with #774, not a live drain.

What was not attacked

Live DO runner beyond green build-harness; in-browser Stop then idle ▶; sibling #774 retry path.

@btipling
btipling merged commit 13f31bf into main Aug 22, 2026
4 checks passed
@btipling
btipling deleted the plan/stop-no-drain-idle-play branch August 22, 2026 05:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant