diff --git a/AGENTS.md b/AGENTS.md index fb214ec..1bebfc3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -295,7 +295,7 @@ invincible/ | Logical agent cwd + workspace-root↔abs canonicalization (`change_dir` / session / default env; **`sandbox_info`** is the bind/cwd/caps/env introspector — do not `exec env`; `canonicalizePath(R, p)` / `workspaceAbsToRel(R, abs)` / `resolvePathForTool(R, cwd, p)` / `rewriteExecRootToRel(R, text)` in `lib/agent/workPath.ts`) + **`search`** (read-grant-only code-grep via `rg`; `lib/agent/tools.ts`) | `lib/agent/workPath.ts`, `lib/agent/tools.ts`, `lib/agent/runAgent.ts`, `lib/agent/agentBody.ts`, `lib/sandbox/config.ts`, `lib/sessionStore.ts`, `lib/harnessChat.ts`, `lib/agentApi.ts`, `lib/sessionCloudCaps.ts` (shared client-safe `sanitizeSessionCwd` + Redis-safe opaque id predicate), [docs/sandbox.md](docs/sandbox.md), [docs/session-model.md](docs/session-model.md), [docs/agent-stream.md](docs/agent-stream.md). Tool paths accept **in-jail absolute paths** on all FS tools + `change_dir` + `exec` cwd: an absolute under the per-binding jail root R (`resolved.value.workspaceRoot` → `RunAgentParams.workspaceRoot` → `createAgentTools`) is canonicalized to the same workspace-relative freshness key as its relative form (BYO + Vercel parity); out-of-jail absolutes and `..`/symlink escapes fail closed. Absolute paths under `R` that **appear in `exec` stdout/stderr** are likewise rewritten to workspace-relative (`rewriteExecRootToRel` in `lib/agent/workPath.ts`, applied to `result.stdout`/`result.stderr` separately) so `exec pwd` ≡ `pwd`/annotations; when `R` is unresolvable the exec output passes through byte-for-byte (fail-open), and rewrites are capped and never throw. When R is unresolvable (BYO daemon down/pre-v2 — `workspaceRoot === null`) absolute is rejected (“root unavailable — use workspace-relative”) while relative + cwd still work. Initial request/session `cwd` stays relative-only; `.` is the workspace-root default session start (there is no `SANDBOX_DEFAULT_CWD` env knob), `..` walks up toward the workspace root and errors only past it, and an **exact ancestor** of cwd re-roots cleanly (`change_dir invincible` from `cwd=invincible/docs` → `invincible`, not the phantom `invincible/docs/invincible`) while a name-prefix sibling is never re-rooted. P1/GAP-1 (#452/#330): `cwd` + `activeSandboxId` are **session-owned** and ride the Redis record (`meta.{logicalCwd,activeSandboxId}`). `activeSandboxId` is now **server-resolved** (routing override via `lib/tenancy/resolveSandbox.ts` `requestedSandboxId`), not carry-only. A **confirmed successful `change_dir`** is persisted as the session cwd even when the turn later cancels / times out / hard-errors (`lib/harnessChat.ts` host-side `liveCwd`); the success path still prefers the authoritative `agentResult.cwd`, and only a confirmed `change_dir` (never an errored one) is stored on a failed/aborted turn. The **`exec` tool** returns a **compact summary**, not a raw dump: first `EXEC_LOG_HEAD_LINES` (10) + last `EXEC_LOG_TAIL_LINES` (10) lines per stdout/stderr with line/byte counts and `... (N lines truncated)`, each shown line byte-clipped (`EXEC_SUMMARY_LINE_MAX_BYTES`=4096) so a single fat stdio line can't inline the stream or truncate the `log:` pointer off; and when either stream is non-empty writes the full redacted output to `/.invincible/logs/exec--.log` via `client.write_file(..., mkdir: true)` (a brand-new hidden workspace dir; backends never auto-create parents; the `-` monotonic counter keeps same-ms parallel execs from overwriting), reporting two `read_file` pointers — `log: ` (cwd-relative, from nested cwd `../.invincible/logs/…`) and `log (root): ` (workspace-root-relative, read from the workspace root `cwd .`, so a depth-changing `change_dir` can never strand the full output); the write stays workspace-root, and `.invincible/` is gitignored; both pointers ride immediately after `exit=`/`TIMED_OUT` — empty output (`exec true`) writes no file, and a log-write failure fails soft with a `⚠ log write failed` note whose reason is **sanitized** (a backend/jail path never surfaces) (caps `EXEC_LOG_HEAD_LINES`/`EXEC_LOG_TAIL_LINES`=10 and `EXEC_LOG_MAX_BYTES`=8 MiB in `lib/sandbox/config.ts`) | | Cloud multi-device harness session (Redis multi-session, `/api/sessions*`, hybrid local+cloud; **phase 0 #515 envelope + Blob transcript carrier**) | `app/api/sessions/*` (+ `app/api/sessions/[id]/envelope/*`, `[id]/transcript/*`), `lib/sessionRepository.ts`, `lib/sessionCloudCaps.ts`, `lib/sessions/*` (+ `lib/sessions/blobStore.ts`, `blobStores.ts`), `lib/tenancy/harnessSessionsRedis.ts`, `lib/tenancy/harnessSessions.ts` (archive read + shared validator), `lib/di/index.ts` (root), `app/harness/HarnessHost.tsx`, `middleware.ts`, [docs/session-model.md](docs/session-model.md), [docs/bring-your-own.md](docs/bring-your-own.md), [SECURITY.md](SECURITY.md) — one-shot Postgres→Redis backfill: GHA **`sessions-redis-backfill`** (idempotent per-user marker); Postgres `harness_sessions` is a read-only archive. P1/GAP-1 (#452): session-carrier `meta.{logicalCwd,activeSandboxId}` folds into the PUT body and restores on pull/adopt; **plan #616 (source #610)** adds the reserved `meta.selectedModel` session carrier for the selected model pick (restore by id after the model catalog push; server **drops a poisoned value to unset**, never a 400). **Phase 0 (#515):** the transcript lives in **Vercel Blob** (`BLOB_READ_WRITE_TOKEN` / BYO S3-R2 seam) pointed to by `meta.transcriptPointer` on the small Redis envelope (`harness:envelope:*`); server mints short-lived scoped upload URLs for **client→Blob** uploads; legacy full-record GET stays for roll-forward while old blobs stay small. Envelope upsert/read: `PUT`/`GET /api/sessions/:id/envelope`; mint/read: `POST`/`GET /api/sessions/:id/transcript` | -| Harness stream chrome (Thinking collapse/caps, live tools) | `lib/harnessChat.ts`, `native/harness/src/ui.zig` (facade + `frame`; transcript band owner), `native/harness/src/ui/thinking.zig` (Thinking kind), `native/harness/src/transcript_split.zig` (collapsible left rail + session list in the transcript band), `native/harness/src/session_catalog.zig` (v17 catalog + pending switch), protocol **v20** in `lib/harnessBridge.ts` (Stop cancel v9; Thinking kind v8; tool-run kind 6 v10→live paint v11; skill-attach kind 7 v12; **status-slot store v13**; **turn-clock feed v14**; **v14 addendum** `inv_set_busy_tick`; **v16** model persist; **v17** session-rail catalog + pending switch; **v18** `inv_queued_count` submit-queue depth; **v19** `inv_set_queue_promote_allowed` — host arms a one-shot per-terminal scalar so a Stop/Esc/error/timeout Ready **never drains the queue**; only idle ▶ / Ctrl+Enter with an empty composer + non-empty queue promotes, plan #760; **v20** `inv_queued_insert_front` — **turn retry that never drains the queue** (plan #759): the host retries a retryable agent-turn error up to `TURN_RETRY_ATTEMPTS`=5 (NEW cap) with bounded backoff via the additive `classify` seam (`lib/sandbox/resilience.ts`), then gives up onto `Lifecycle.Error` (a failed turn is never terminal for the Wasm promote gate — `ui.zig` promotes only on successful Ready), inserting `Continue the current turn` at the queue head (`inv_queued_insert_front`) when non-empty; permanent `PERMANENT_TURN_STATUS` statuses (400/401/403/404/413/422) give up after one attempt (no loop); 408/429/5xx and timeout/empty retry up to 5 attempts — but **1 attempt** once the live stream has painted a ring row past the user line (fail-closed: replaying would re-run tools / duplicate bubbles) | +| Harness stream chrome (Thinking collapse/caps, live tools) | `lib/harnessChat.ts`, `native/harness/src/ui.zig` (facade + `frame`; transcript band owner), `native/harness/src/ui/thinking.zig` (Thinking kind), `native/harness/src/transcript_split.zig` (collapsible left rail + session list in the transcript band), `native/harness/src/session_catalog.zig` (v17 catalog + pending switch), protocol **v20** in `lib/harnessBridge.ts` (Stop cancel v9; Thinking kind v8; tool-run kind 6 v10→live paint v11; skill-attach kind 7 v12; **status-slot store v13**; **turn-clock feed v14**; **v14 addendum** `inv_set_busy_tick`; **v16** model persist; **v17** session-rail catalog + pending switch; **v18** `inv_queued_count` submit-queue depth; **v19** `inv_set_queue_promote_allowed` — host arms a one-shot per-terminal scalar so a Stop/Esc/error/timeout Ready **never drains the queue**; only idle ▶ / Ctrl+Enter with an empty composer + non-empty queue promotes, plan #760; **v20** `inv_queued_insert_front` — **turn retry that never drains the queue** (plan #759): the host retries a retryable agent-turn error up to `TURN_RETRY_ATTEMPTS`=5 (NEW cap) with bounded backoff via the additive `classify` seam (`lib/sandbox/resilience.ts`), then gives up onto `Lifecycle.Error` (a failed turn is never terminal for the Wasm promote gate — `ui.zig` promotes only on successful Ready), inserting `Continue the current turn` at the queue head (`inv_queued_insert_front`) when non-empty; permanent `PERMANENT_TURN_STATUS` statuses (400/401/403/404/413/422) give up after one attempt (no loop); 408/429/5xx and timeout/empty retry up to 5 attempts — but **1 attempt** once the live stream has painted a ring row past the user line (fail-closed: replaying would re-run tools / duplicate bubbles). **In-canvas Pause (submit-queue hold):** a Wasm-internal `queue_paused` latch folded into `submit_queue.canPromote` (via `bridge.tryPromoteQueued`, the single promote seam) holds **every promote path** — auto-promote on successful Ready and idle empty-▶ / empty Ctrl+Enter Play — so the next turn reads from the composer; typed send + FIFO contents + enqueue/edit/remove/Clear unaffected; **auto-clears when the FIFO empties**; TEAL `· paused` toggle on the queue-band header (`n>0`); **no new export / no protocol bump / no cap change** (Wasm-ephemeral like the queue) | | Keyboard shortcuts (keymap, leader, help overlay) | `native/harness/src/keymap.zig` (single chord table + reserved-browser deny-list + leader machine; **NEW caps** `KEYMAP_MAX`=64, `LEADER_WINDOW_MS`=800), `native/harness/src/ui/keymap_dispatch.zig` (one per-frame walk of `dvui.events()`, handled-marking, leader dvui-timer arm/expiry), `native/harness/src/ui/help_overlay.zig` (in-canvas TEAL panel), wired in `native/harness/src/ui.zig` (dispatch before textEntry; overlay paint) + `ui/queue_band.zig` (scan removed; `queue_save`/`cancel_queue_edit` routed via dispatcher). **DOM adds no keyboard UI / `window` keydown / React cheatsheet** | | Workspace status bar (protocol v13 status-slot store; bridge overall **v14** — plan #538/#541 + Phase 2 git #540 + Phase 3 context/usage #539, **two-line bottom status bar under the composer — #554/#555/#570**) | `native/harness/src/{bridge,ui,model_picker,model_catalog}.zig` (status-slot store + two-line 64 px bar directly **below the composer**: **line 1** = identity (spinner · `h:{build-id}` · model menu) relocated from the deleted header band, **line 2** = `paintStatusSlots` right-aligned slot pack — header merged by plan #570; each line has explicit 32 px height so the model picker (`PICKER_TRIGGER_H`=32) fits and slots never clip; sandbox/cwd/git + context/usage slots — context painted generically via `STATUS_SLOT_DROP_ORDER`), `lib/harnessBridge.ts` (`StatusSlot`, `setStatusSlot`/`getStatusSlot`/`clearStatusSlot`/`clearStatusSlots`, `STATUS_SLOT_MAX_BYTES` mirror), `lib/harnessChat.ts` (`foldStatusSlots` — folds `activeSandboxId` + `cwd` + **context/usage** (`formatUsageSummary`, re-sanitized on read) after hydrate, after **every** agent turn — success **and** fail (403-clear / committed `change_dir` repaint the pack — PR #543), and **live mid-turn on tool results** (Phase 2 #627 / #625: a confirmed `change_dir` or successful `meta_sandbox_switch` repaints sandbox/cwd immediately, plus the host persists via `onSessionPatch`); context default **hidden** on missing usage, abort/cancel carries the prior honest value forward; host-ellipsized to the byte cap before the wire; `refreshGitStatusSlot` — host polls the read-only `GET /api/harness/status` probe on a ~10 s cadence **and** on-demand after a successful `exec` or `meta_sandbox_switch` mid-turn (not only the cadence), fail-soft keeps the last git value on transient error/429), `app/harness/HarnessHost.tsx` (Clear/New clears the pack; wires the git cadence + `onSessionPatch` → persist), `app/api/harness/status/route.ts` (read-only git probe: envelope-authoritative bind (`meta.activeSandboxId` wins over Redis-safe `?sandboxId=` carry), `resolveSandbox` → bounded argv-only read-only git at the bind workspace root via `lib/agent/statusProbe.ts`, per-instance rate cap `STATUS_PROBE_MIN_INTERVAL_MS`; middleware matcher + in-route `requireSessionUser` dual gate; never mutates a session/envelope — no Production write), `lib/agent/statusProbe.ts` (`STATUS_GIT_PROBE_OUT_MAX_BYTES`=512, fail-soft `{}`), `lib/sessionCloudCaps.ts` (`STATUS_SLOT_MAX_BYTES` = 96 + `STATUS_PROBE_MIN_INTERVAL_MS` = 2000 — client-safe single sources), **context/usage carrier:** `lib/agent/usageSummary.ts` (bounded provider-usage mapper `mapProviderUsage` / read-side `sanitizeUsageSummary` / host `formatUsageSummary`, `USAGE_SUMMARY_MAX_BYTES` = 96 — NEW cap), emitted **live mid-stream** from `finish` parts (aggregate only — never `finish-step` per-step counts) in `lib/agent/agentStream.ts` (SSE `usage` event), reconciled at the final `done.usage` / JSON result / chat result in `lib/agent/runAgent.ts` (+ `app/api/chat/route.ts`), parsed by `lib/agentApi.ts` / `lib/chatApi.ts`, mirrored on `SessionSnapshot.usage` (`lib/sessionStore.ts`; reserved cloud `meta.usage` JSON string, drop-to-unset on poison), docs: [docs/feature-divide.md](docs/feature-divide.md), [docs/harness-limits.md](docs/harness-limits.md), [docs/agent-stream.md](docs/agent-stream.md), [docs/session-model.md](docs/session-model.md) | | | Tool-run aggregation + expandable transcript control (#325) | `lib/agent/agentStream.ts` (backend `tool_result.preview` — bounded/redacted L2 detail), `lib/toolRun.ts` (encode/decode, host aggregation, `meaningfulDetail` preview→`detail`, `mergeToolRunPayloads`/`encodeToolRunPayload` hydrate coalesce), `lib/harnessChat.ts` (stream/JSON aggregation → kind 6 `tool_run`, **live-painted**: a tool event opens/grows ONE card immediately via `update_last` — grouping keys off the host's `lastRingRowIsToolRun` flag, the only ring writer: grow iff the last ring row is a tool-run, else a NEW card at `1`; a thinking/assistant/user/error row last is a separator; commit-once is removed; reload coalescing of consecutive `tool_run` rows via `coalesceToolRunMessages` in `pushSessionToBridge`), `lib/sessionStore.ts` role `tool_run`, `native/harness/src/rich/toolrun.zig` (decode), `native/harness/src/ui/toolrun.zig` (`paintToolRun` — **headerless**: no `tools` kind band; 📋 copy on the header row; status glyphs as the single channel from embedded faces, `✓`/`✗` DejaVu symbols + `…` Noto; L2 preview in Vera Sans Mono for command/output tools **or any multi-line detail**, body otherwise; short single-line results → static label, no blank expander), `native/harness/src/bridge.zig` + `lib/harnessBridge.ts` (protocol **v11**; additive test-only ring readback `inv_message_*_at`), protocol **v11**; expand state + stick-to-bottom reuse dvui `reorder_tree.zig` / `scrolling.zig` idioms | diff --git a/docs/harness-limits.md b/docs/harness-limits.md index b191752..906ba98 100644 --- a/docs/harness-limits.md +++ b/docs/harness-limits.md @@ -105,7 +105,8 @@ While a turn is **Busy**, follow-up prompts go into an in-canvas FIFO **above** | Enqueue | Busy Ctrl/Cmd+Enter or the extra ▶ icon appends the composer text. Idle ▶ still starts a turn immediately. A **successful** enqueue scrolls the queue list to the newest row and the transcript to the bottom (same “I sent something” follow as idle send). Blank enqueue clears the composer (same as idle empty-send). Full enqueue does not scroll — composer text stays; `· full` is the signal | | Visibility | Compact TEAL list (header + up to 3 rows, extra rows scroll). Each row: first-line preview (100 bytes, UTF-8 safe, slash commands kept) + edit + remove. Enqueue follows the newest row into view when `n > 3` | | Edit / cancel | Edit in place (Ctrl+Enter / blur saves; Escape reverts). ✕ removes one item. **Clear** empties the queue. The in-flight turn is untouched | -| When it runs | After a **successful** turn reaches Ready, the head **automatically** submits as a normal user turn (same `pending_submit` path; protocol v19 promote gate `inv_set_queue_promote_allowed` armed true). A **Stop / Esc / error / timeout / validation** terminal never promotes (gate armed false; a failed turn is not terminal for the promote gate). The queue head starts ONLY on an explicit **idle ▶** or **Ctrl/Cmd+Enter** with an empty composer + non-empty queue (plan #760). Promote is held while a row is being edited | +| When it runs | After a **successful** turn reaches Ready, the head **automatically** submits as a normal user turn (same `pending_submit` path; protocol v19 promote gate `inv_set_queue_promote_allowed` armed true). A **Stop / Esc / error / timeout / validation** terminal never promotes (gate armed false; a failed turn is not terminal for the promote gate). The queue head starts ONLY on an explicit **idle ▶** or **Ctrl/Cmd+Enter** with an empty composer + non-empty queue (plan #760) — **unless paused** (see **Pause**). Promote is held while a row is being edited | +| **Pause** | Operator-initiated hold via an in-canvas **Pause/Resume** toggle on the queue-band header (visible when `n>0`). While paused, **every promote path is held** — a successful Ready no longer auto-promotes and idle empty-▶ / empty Ctrl+Enter do not `tryPromoteQueued` — so the **next turn reads from the composer** (a typed prompt + ▶ / Ctrl+Enter still sends that text normally; typed send bypasses the queue entirely). The FIFO contents, enqueue/edit/remove/Clear, and the paused Busy turn are unaffected — pause is a queue **hold**, never a cancel or a retry. **Unpause** clears the latch and parked items drain by the existing rules on the next successful Ready or explicit empty-▶ Play. Pause **auto-clears when the FIFO empties** (drain / Clear / remove), so a stale hold can never block later promotes. Active pause paints a TEAL-accent `· paused` label (EMBER never — a hold, not an error). **Wasm-ephemeral** like the queue (Refresh / New / Clear / session switch wipe it). New latch only — no bridge export, no protocol bump, no cap change | | Turn-error recovery | A **failed** turn never consumes the queue: the host retries a retryable error up to **5 attempts** (`TURN_RETRY_ATTEMPTS`) — but **1 attempt** once the live stream has painted a ring row past the user line (a tool card / assistant / thinking / skill row; replaying a half-painted turn would re-run tools and duplicate bubbles) — with bounded backoff before giving up, then lands the turn on **Error** — the queue head is never drained by an error. On give-up with a **non-empty** queue, a `Continue the current turn` prompt is inserted at the **head** (`inv_queued_insert_front`, protocol v20) — a normal editable/removable queue row that promotes like any other head on a later successful Ready. It is **never** inserted when the queue is full (16) or empty (fail closed — operator items are never dropped) | | Stop | Cancels **only** the in-flight turn and **consumes nothing** — the queue stays exactly as-is (the old "drains after Ready" behavior is removed, plan #760). **Stop** does **not** retry and does **not** insert a Continue prompt; the queue drains only after a later **successful** Ready, or an explicit idle ▶ / Ctrl+Enter (empty composer, non-empty queue) | | Permanent failures | A permanent **server** turn failure (the `PERMANENT_TURN_STATUS` whitelist — 400/401/403/404/413/422) gives up after a **single** attempt (no backoff loop) with the same give-up + Continue-if-queued behavior. **408/429/5xx and timeout/empty stay retryable** — they retry the same turn up to 5 attempts (still **single-attempt** once a ring row has been painted mid-stream). **Client-side validation** (empty / invalid prompt) is different: it is rejected **before** the turn starts (pre-Busy) — the error line is pushed, the lifecycle stays **Ready**, and no Continue is ever inserted (nothing drained) | diff --git a/native/harness/src/bridge.test.zig b/native/harness/src/bridge.test.zig index b4910a2..a1dbb2d 100644 --- a/native/harness/src/bridge.test.zig +++ b/native/harness/src/bridge.test.zig @@ -193,3 +193,96 @@ test "gate e2e: empty FIFO — Play with allowed=true still stays depth 0 (no-op try t.expect(!promoted); try t.expectEqual(@as(u32, 0), bridge.queuedCount()); } + +// ── plan #777 — operator pause latch (submit-queue hold) ───────────────── +// The pause is gatewayed on `submit_queue.canPromote.paused` via +// `tryPromoteQueued`, so one guard blocks ALL promote paths (auto-promote on +// a successful Ready, idle empty-▶ Play, empty Ctrl+Enter) while leaving +// enqueue/edit/remove/Clear and the typed-send path untouched. + +test "pause: defaults unpaused (promote armed — legacy behavior)" { + bridge.reset(); + try t.expect(!bridge.isQueuePaused()); +} + +test "pause: set/read round-trip via setQueuePausedFromUi" { + bridge.reset(); + bridge.setQueuePausedFromUi(true); + try t.expect(bridge.isQueuePaused()); + bridge.setQueuePausedFromUi(false); + try t.expect(!bridge.isQueuePaused()); +} + +test "pause: tryPromoteQueued blocked while paused (non-empty queue, all gates otherwise met)" { + bridge.reset(); + _ = try bridge.enqueueFromUi("one"); + _ = try bridge.enqueueFromUi("two"); + bridge.inv_set_queue_promote_allowed(1); // a prior success armed auto-promote + bridge.setQueuePausedFromUi(true); // operator paused the queue + // Explicit Play (idle ▶ / empty Ctrl+Enter converge on tryPromoteQueued): + // the pause guard alone refuses promotion — depth unchanged. + const promoted = bridge.tryPromoteQueued(false); + try t.expect(!promoted); + try t.expectEqual(@as(u32, 2), bridge.queuedCount()); +} + +test "pause: auto-promote held after a successful Ready (goal 3)" { + bridge.reset(); + _ = try bridge.enqueueFromUi("one"); + bridge.inv_set_queue_promote_allowed(1); // successful Ready armed the scalar true + bridge.setQueuePausedFromUi(true); // operator paused + // The ui.zig terminal gate decides auto_promote via shouldAutoPromote, then + // calls tryPromoteQueued — the pause guard in canPromote must refuse the pop. + const prev: bridge.Lifecycle = .busy; + const cur: bridge.Lifecycle = .ready; + const editing = false; + const edit_closed = false; + const auto = bridge.shouldAutoPromote(prev, cur, editing, bridge.hasQueuePromoteAllowed(), edit_closed); + try t.expect(auto); // the gate itself fires (turn ended, allowed) + const promoted = bridge.tryPromoteQueued(editing); + try t.expect(!promoted); // ...but the pause latch inside canPromote holds it + try t.expectEqual(@as(u32, 1), bridge.queuedCount()); // depth unchanged + try t.expectEqualStrings("one", bridge.queuedItemAt(0).?); +} + +test "pause: unpause restores drain by existing rules (goal 4)" { + bridge.reset(); + _ = try bridge.enqueueFromUi("one"); + bridge.inv_set_queue_promote_allowed(1); + bridge.setQueuePausedFromUi(true); + // Paused: explicit Play holds. + try t.expect(!bridge.tryPromoteQueued(false)); + try t.expectEqual(@as(u32, 1), bridge.queuedCount()); + // Unpause: the next successful Ready / explicit Play drains the head. + bridge.setQueuePausedFromUi(false); + const promoted = bridge.tryPromoteQueued(false); + try t.expect(promoted); + try t.expectEqual(@as(u32, 0), bridge.queuedCount()); +} + +test "pause: enqueue/edit/remove/Clear keep working while paused (goal 6)" { + bridge.reset(); + bridge.setQueuePausedFromUi(true); // pause the queue + // Enqueue still appends (FIFO ops never consult the latch). + _ = try bridge.enqueueFromUi("A"); + _ = try bridge.enqueueFromUi("B"); + try t.expectEqual(@as(u32, 2), bridge.queuedCount()); + _ = bridge.replaceQueuedAt(0, "A2"); + try t.expectEqualStrings("A2", bridge.queuedItemAt(0).?); + bridge.removeQueuedAt(1); + try t.expectEqual(@as(u32, 1), bridge.queuedCount()); + bridge.clearSubmitQueue(); + try t.expectEqual(@as(u32, 0), bridge.queuedCount()); +} + +test "pause: reset() and inv_clear_messages clear the latch (Wasm-ephemeral, non-goal)" { + bridge.reset(); + bridge.setQueuePausedFromUi(true); + try t.expect(bridge.isQueuePaused()); + bridge.reset(); // New / session (re-)init — fresh surface + try t.expect(!bridge.isQueuePaused()); + + bridge.setQueuePausedFromUi(true); + bridge.inv_clear_messages(); // Clear / New — clears with the queue + try t.expect(!bridge.isQueuePaused()); +} diff --git a/native/harness/src/bridge.zig b/native/harness/src/bridge.zig index 640b6cf..dd93502 100644 --- a/native/harness/src/bridge.zig +++ b/native/harness/src/bridge.zig @@ -143,6 +143,17 @@ var queue: submit_queue.Q = .{}; /// surfaces re-arm the default). Wasm only READS this on the terminal edge; the /// host rewrites it every terminal. var queue_promote_allowed: bool = true; +/// Plan #777 — operator pause latch (in-canvas submit-queue hold). When set, +/// every promote path (auto-promote on a successful Ready, idle empty-▶ Play, +/// empty Ctrl+Enter) is blocked via `submit_queue.canPromote.paused`, so the +/// next turn reads from the composer; the FIFO contents, the typed-send path +/// (`queueSubmitFromUi`), and enqueue/edit/remove/Clear are untouched. Wasm- +/// internal (no cast export, no protocol bump, no build.zig whitelist change). +/// Wasm-ephemeral, mirroring the queue's lifetime: reset on `reset()` and +/// `inv_clear_messages` (fresh surfaces), and auto-cleared when the FIFO empties +/// (ui.zig empty-queue guard) so a stale pause can never silently block later +/// promotes. Default **false** = promote re-armed (legacy behavior). +var queue_paused: bool = false; /// Host sets when SessionStore has messages older than the current ring window. var can_load_earlier: bool = false; var has_pending_load_earlier: bool = false; @@ -275,6 +286,23 @@ pub fn hasQueuePromoteAllowed() bool { return queue_promote_allowed; } +/// Plan #777 — current operator pause state. Read by the in-canvas queue-band +/// toggle (to paint `· paused`) and by the ui.zig empty-queue auto-clear guard. +/// Wasm-internal; no cast export / protocol bump. +pub fn isQueuePaused() bool { + return queue_paused; +} + +/// Plan #777 — set the operator pause latch from the in-canvas queue-band +/// toggle (Pause ↔ Resume). `Pause` blocks every promote path; `Resume` clears +/// the latch so parked items drain by the existing rules on the next terminal. +/// `refresh()` so the paused paint shows/hides immediately. +pub fn setQueuePausedFromUi(v: bool) void { + if (queue_paused == v) return; + queue_paused = v; + refresh(); +} + /// Plan #760 promote-gate predicate (adversarial #763 L6). Whether a turn edge /// MAY auto-promote the queue head. Folds BOTH wasm triggers — the turn-ended /// edge (`prev` busy → `cur` ready) AND the queue-edit-closed edge (`edit_closed` @@ -307,6 +335,7 @@ pub fn tryPromoteQueued(editing: bool) bool { .has_pending_submit = has_pending_submit, .has_pending_load_earlier = has_pending_load_earlier, .count = submit_queue.count(&queue), + .paused = queue_paused, // plan #777 — operator pause holds every promote path })) return false; return submit_queue.promoteIf(&queue, promoteSubmit); } @@ -383,6 +412,7 @@ pub fn reset() void { has_pending_cancel = false; has_pending_model_change = false; queue_promote_allowed = true; // fresh surface re-arms the legacy default (plan #760) + queue_paused = false; // plan #777 — pause latch is Wasm-ephemeral like the queue has_pending_front_insert = false; session_catalog.reset(); suppress_refresh = false; @@ -611,6 +641,7 @@ pub export fn inv_clear_messages() void { submit_queue.clear(&queue); // Clear / New also re-arm the promote gate (fresh surface, plan #760). queue_promote_allowed = true; + queue_paused = false; // plan #777 — pause latch clears with the queue image_cache.clear(); math_cache.clear(); refresh(); diff --git a/native/harness/src/queue_band.test.zig b/native/harness/src/queue_band.test.zig index 465b23b..4192bda 100644 --- a/native/harness/src/queue_band.test.zig +++ b/native/harness/src/queue_band.test.zig @@ -232,6 +232,30 @@ test "shouldDropEditOnEmptyQueue: false when editing + queue non-empty" { try t.expect(!queue_band.shouldDropEditOnEmptyQueue()); } +// ── shouldDropPauseOnEmptyQueue predicate (plan #777) ───────────────────── + +test "shouldDropPauseOnEmptyQueue: true when paused + queue empty" { + bridge.reset(); + bridge.setQueuePausedFromUi(true); + // FIFO is empty after reset and the latch is set → must return true. + try t.expect(queue_band.shouldDropPauseOnEmptyQueue()); +} + +test "shouldDropPauseOnEmptyQueue: false when not paused" { + bridge.reset(); + bridge.setQueuePausedFromUi(false); + // FIFO empty but not paused → nothing to auto-clear. + try t.expect(!queue_band.shouldDropPauseOnEmptyQueue()); +} + +test "shouldDropPauseOnEmptyQueue: false when paused + queue non-empty" { + bridge.reset(); + _ = bridge.enqueueFromUi("x") catch @panic("enqueue failed"); + bridge.setQueuePausedFromUi(true); + // Paused but the queue still holds an item → a pause is meaningful; keep it. + try t.expect(!queue_band.shouldDropPauseOnEmptyQueue()); +} + test "resetTranscriptScroll clears new flags" { state.queue_want_editor_focus = true; state.queue_edit_seen_focused = true; diff --git a/native/harness/src/submit_queue.test.zig b/native/harness/src/submit_queue.test.zig index 2587f67..7d686c5 100644 --- a/native/harness/src/submit_queue.test.zig +++ b/native/harness/src/submit_queue.test.zig @@ -152,6 +152,29 @@ test "ITEM_BYTES matches the live Send cap" { try t.expectEqual(@as(usize, 262144), sq.ITEM_BYTES); } +// ── plan #777 — paused guard (operator submit-queue hold) ───────────────── + +test "canPromote: paused holds promote even when every other gate is met (plan #777)" { + // All other guards pass (not editing, not busy, no pending submit/load, + // non-empty queue) — the pause latch alone must refuse promotion. + try t.expect(!sq.canPromote(.{ + .editing = false, + .busy = false, + .has_pending_submit = false, + .has_pending_load_earlier = false, + .count = 1, + .paused = true, + })); + // Unpausing (default false) re-arms promote for the same otherwise-ready state. + try t.expect(sq.canPromote(.{ + .editing = false, + .busy = false, + .has_pending_submit = false, + .has_pending_load_earlier = false, + .count = 1, + })); +} + // ── plan #759 — insertFront (Continue-the-current-turn head) ────────────── test "insertFront puts the new item at head, shifting existing items down one" { diff --git a/native/harness/src/submit_queue.zig b/native/harness/src/submit_queue.zig index 331edd7..3c8d7c4 100644 --- a/native/harness/src/submit_queue.zig +++ b/native/harness/src/submit_queue.zig @@ -129,9 +129,13 @@ pub fn canPromote(args: struct { has_pending_submit: bool, has_pending_load_earlier: bool, count: u32, + /// Plan #777 — operator pause latch (in-canvas submit-queue hold). Blocks + /// ALL promote paths (auto-promote + idle empty-▶ Play) while keeping the + /// FIFO contents and the typed-send path (`queueSubmitFromUi`) untouched. + paused: bool = false, }) bool { return !args.editing and !args.busy and !args.has_pending_submit and - !args.has_pending_load_earlier and args.count > 0; + !args.has_pending_load_earlier and !args.paused and args.count > 0; } /// If `submit` returns true, pop the head. Used by `bridge.tryPromoteQueued`. diff --git a/native/harness/src/ui.zig b/native/harness/src/ui.zig index 8396d69..e0bd5e5 100644 --- a/native/harness/src/ui.zig +++ b/native/harness/src/ui.zig @@ -563,6 +563,14 @@ pub fn frame() !void { if (queue_band.shouldDropEditOnEmptyQueue()) { queue_band.resetQueueEditState(); } + // Plan #777 — drop the pause latch when the FIFO empties (drain / Clear / + // remove). With nothing to hold, a stale pause would silently block later + // promotes (auto-promote / idle ▶). Mirrors the edit-latch guard above, so + // goal 4 (unpause → drain by existing rules) stays intuitive even when the + // operator never taps Resume. + if (queue_band.shouldDropPauseOnEmptyQueue()) { + bridge.setQueuePausedFromUi(false); + } // Plan #759 / adversarial-review Major — a HOST front-insert (the give-up // `Continue` head) shifts every queued slot down one. An open queue-row edit // must follow the row it was on (bump to e+1) so blur/Ctrl+Enter saveEdit diff --git a/native/harness/src/ui/queue_band.zig b/native/harness/src/ui/queue_band.zig index 1f51c56..2845e67 100644 --- a/native/harness/src/ui/queue_band.zig +++ b/native/harness/src/ui/queue_band.zig @@ -47,6 +47,16 @@ pub fn shouldDropEditOnEmptyQueue() bool { return state.queue_editing_index != null and bridge.queuedCount() == 0; } +/// Plan #777 — predicate extracted from the `ui.zig` empty-FIFO guard so a +/// host-target unit test can prove the condition. When the submit queue has +/// nothing to hold, a stale `paused` latch would silently block later promotes +/// (auto-promote / idle ▶) after all items drain. Mirroring +/// `shouldDropEditOnEmptyQueue`, ui.zig clears `queue_paused` the frame the FIFO +/// empties, keeping promote re-armed (goal 4 stays intuitive). +pub fn shouldDropPauseOnEmptyQueue() bool { + return bridge.isQueuePaused() and bridge.queuedCount() == 0; +} + /// Plan #759 / adversarial-review Major — reconcile the queue-row edit latch /// after a HOST front-insert (the give-up `Continue` head). `submit_queue.insertFront` /// shifts every queued slot down one but can't touch `state.queue_editing_index` @@ -113,6 +123,31 @@ pub fn paint(band_y: f32, band_h: f32, avail_w: f32) void { }); } if (n > 0) { + // Plan #777 — visible paused state + Pause/Resume toggle on the + // queue-band header (in-canvas chrome; no DOM control). An active + // pause shows the TEAL-accent label so the operator always knows + // promotion is held (risk #1 mitigation). EMBER is never used — + // pause is a hold, not an error. + if (bridge.isQueuePaused()) { + dvui.labelNoFmt(@src(), "· paused", .{}, .{ + .gravity_y = 0.5, + .color_text = palette.teal_accent, + }); + } + const paused = bridge.isQueuePaused(); + if (dvui.button(@src(), if (paused) "Resume" else "Pause", .{}, .{ + .gravity_y = 0.5, + .style = .content, + .min_size_content = .{ .w = 64, .h = metrics.TOUCH_H }, + .color_fill = palette.teal_surface, + .color_text = if (paused) palette.teal_accent else palette.teal_text, + .color_border = palette.teal_border, + })) { + // Pause is a queue hold — it does NOT cancel an in-flight turn + // or a row edit. Flip the latch only; enqueue/edit partner state + // (queue_editing_index) is untouched. + bridge.setQueuePausedFromUi(!paused); // flip pause ↔ resume + } if (dvui.button(@src(), "Clear", .{}, .{ .gravity_y = 0.5, .style = .content,