feat(harness): Stop/Esc never drains the queue; idle ▶ promotes (plan #760) - #763
Conversation
…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.
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
|
Implemented plan #760. Summary of verification: Gates (all green):
Tests added: 8 TS (5 Plan #760 → Status: 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
left a comment
There was a problem hiding this comment.
Adversarial review — PR #763
Verdict: CONCERNS
Repo: btipling/invincible
Scope: main ← plan/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).
…te gate (PR #763 review round 1)
btipling
left a comment
There was a problem hiding this comment.
Adversarial review — PR #763 (round 2)
Verdict: PASS WITH NOTES
Repo: btipling/invincible
Scope: main ← plan/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_promoteand the Ctrl+Enter empty-composer arm both passstate.queue_editing_index != nullintotryPromoteQueued.canPromote(.editing=true)refuses; FIFO depth stays. Covered bygate 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 bindson_promotelike production. - Validation test actually calls
runHarnessTurn(..., ' ')and assertssendAgentis 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.
…(PR #763 review round 2)
btipling
left a comment
There was a problem hiding this comment.
Adversarial review — PR #763 (round 3)
Verdict: PASS WITH NOTES
Repo: btipling/invincible
Scope: main ← plan/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_closedon 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. erris not a promotable terminal.shouldAutoPromote(.busy, .err, false, true, …)is false even withallowed=true. Matches sibling #774’s “err is not success.” This host still only writes Ready viacompleteTurn, 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.
Closes #760
What
Stop / Esc (Busy) no longer drain the submit queue. Today a Stop aborts then
sets
Lifecycle.Ready; Wasm seesbusy→readyand pops the queue head into a newturn — 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)
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()andinv_clear_messagesboth re-arm it.Listed in
build.zigexport_symbol_namesso the wasm-linker roots it.harnessChat.ts): singlecompleteTurn(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.
ui.zig): terminal-promote block gated onhasQueuePromoteAllowed().Idle ▶ and Ctrl/Cmd+Enter with an empty composer + non-empty queue fall back to
tryPromoteQueued(false)(new bridge-freeon_promotechrome action). Emptycomposer + empty queue stays a no-op.
Layer / caps
(sole lifecycle writer / only
classifyTurnFailureobserver).MAX_ITEMS(16) /SUBMIT_CAP(262144) untouched.Verification
zig build harness -Doptimize=Debug,zig build test-rich,zig build test-rich-invariants,zig fmt --check— all green (the 4dvui_testinglayout "failed command" lines are the pre-existing missing-font artifacts,
byte-identical on clean
main).build-harness(wasm32-freestanding release) — awaited, green (theauthoritative Wasm gate →
harness-wasm-pr-760).npm run typecheckgreen;scripts/di-gate.mjsOK; fullvitest rungreen.
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_messagesre-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 runsuite: 1955/1955 passed (126 files). This PR adds 8 TStests (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 v19row). Timeless wording.