diff --git a/AGENTS.md b/AGENTS.md index f2cfba2..fb214ec 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 **v19** 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) | +| 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) | | 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/agent-stream.md b/docs/agent-stream.md index d20af8b..7f24b3e 100644 --- a/docs/agent-stream.md +++ b/docs/agent-stream.md @@ -40,7 +40,7 @@ Each SSE block is one `data: \n\n` line: | `text_delta` | `text` (chunk) | Grow Assistant bubble(s) | | `usage` | `usage` | **Live mid-stream** provider token summary from a `finish` part (aggregate only). `finish-step` never emits this. The host folds the context slot immediately; `done.usage` is the final reconcile. Absent when the part carried no usable counts — never a clear/flicker | | `done` | `text`, optional `toolTrace`, optional `cwd`, optional `usage` | Collapse open thinking; finalize session; apply `cwd` on success only; fold bounded provider `usage` (Phase 3 #628 — `done.usage` is the conclusive reconcile); Ready | -| `error` | `error`, optional `status` | Collapse open thinking; Error message; Ready | +| `error` | `error`, optional `status` | Collapse open thinking; a host **retryable** failure retries the same turn up to 5 attempts (**1 attempt** if any ring row has already been painted mid-stream — re-painting would duplicate tools/bubbles) before give-up; give-up paints the Error message and the turn lands on **Error** — never consuming the operator queue | Unknown types are ignored (forward-compatible). String fields are redacted server-side with the same secret list as JSON responses. @@ -105,7 +105,7 @@ Every harness turn paints a final line: |---------|------| | Model finished | `Turn ended · model finished` (System) | | User Stop | `Turn ended · you stopped` (System) | -| Error / timeout / empty | `Turn ended · error · …` / timed out / empty (Error) | +| Error / timeout / empty | `Turn ended · error · …` / timed out / empty (Error). A retryable error retries the **same** turn up to **5 attempts** with bounded backoff before give-up (**1 attempt once a ring row has been painted mid-stream**); on give-up the host sets the turn lifecycle to **Error** (so a queued head is never drained) and, if the operator queue is non-empty, inserts `Continue the current turn` as the new head. Permanent failures (the `PERMANENT_TURN_STATUS` whitelist — 400/401/403/404/413/422) give up after a single attempt; **408/429/5xx and timeout/empty stay retryable** (retry the same turn up to 5 attempts) | | Standalone chat (`/api/chat`) | `Turn ended · chat finished` (System) — kept helper only; a failed agent turn does **not** fall back here | These markers are **not** folded as tools into the next prompt. diff --git a/docs/harness-limits.md b/docs/harness-limits.md index 1047596..f2bfc5a 100644 --- a/docs/harness-limits.md +++ b/docs/harness-limits.md @@ -104,8 +104,10 @@ 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 armed true). A **Stop / Esc / error / timeout / validation** terminal never promotes (gate armed false). 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 | -| 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). Only an explicit idle ▶ / Ctrl+Enter (empty composer, non-empty queue) starts the next item | +| 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 | +| 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) | | Lifetime | Wasm-ephemeral. Refresh, New session, Clear, and session switch wipe it. Not stored on the cloud session | | Full (16) | Further enqueues are rejected; the composer text stays so nothing is silently dropped | | Auto-continue | A later host auto-continue (session TODO drain) must not start while the queue is non-empty or a pending submit is unacked | diff --git a/lib/harnessBridge.test.ts b/lib/harnessBridge.test.ts index 0a1ca3e..5796d40 100644 --- a/lib/harnessBridge.test.ts +++ b/lib/harnessBridge.test.ts @@ -139,6 +139,7 @@ function makeMockExports(overrides?: Partial): HarnessBrid inv_set_queue_promote_allowed: (v: number) => { promoteAllowed = v !== 0; }, + inv_queued_insert_front: () => 1, inv_set_can_load_earlier: (v: number) => { canLoad = v ? 1 : 0; if (!canLoad) loadEarlier = false; @@ -816,7 +817,7 @@ describe('skill_attached kind (protocol v12)', () => { // Distinct from the protocol version (13) — a hardcoded kind 13 would be an // unknown kind to the Wasm painter. expect(MessageKind.SkillAttached).not.toBe(HARNESS_PROTOCOL_VERSION); - expect(HARNESS_PROTOCOL_VERSION).toBe(19); + expect(HARNESS_PROTOCOL_VERSION).toBe(20); }); it('push/readback round-trips a skill_attached row', () => { @@ -845,8 +846,8 @@ describe('setTurnElapsed (protocol v14)', () => { expect(exp.__turnElapsed()).toBe(0); }); - it('version bumped to 19 and the export is REQUIRED (fail-closed when missing)', () => { - expect(HARNESS_PROTOCOL_VERSION).toBe(19); + it('version bumped to 20 and the export is REQUIRED (fail-closed when missing)', () => { + expect(HARNESS_PROTOCOL_VERSION).toBe(20); const exp = makeMockExports() as unknown as WebAssembly.Exports; expect(isHarnessBridgeExports(exp)).toBe(true); // A rebuilt Wasm that omits inv_set_turn_elapsed fails bridge-load closed, @@ -915,7 +916,7 @@ describe('status-slot pack (protocol v13)', () => { describe('queuedCount (protocol v18)', () => { it('reads inv_queued_count and fails closed when the export is missing', () => { - expect(HARNESS_PROTOCOL_VERSION).toBe(19); + expect(HARNESS_PROTOCOL_VERSION).toBe(20); const exp = makeMockExports(); const bridge = new HarnessBridge(exp); expect(bridge.queuedCount()).toBe(0); @@ -937,7 +938,7 @@ describe('setQueuePromoteAllowed (protocol v19, plan #760)', () => { }); it('export is REQUIRED (fail-closed when missing from the wasm)', () => { - expect(HARNESS_PROTOCOL_VERSION).toBe(19); + expect(HARNESS_PROTOCOL_VERSION).toBe(20); const exp = makeMockExports() as unknown as WebAssembly.Exports; expect(isHarnessBridgeExports(exp)).toBe(true); const record = exp as unknown as Record; @@ -945,3 +946,32 @@ describe('setQueuePromoteAllowed (protocol v19, plan #760)', () => { expect(isHarnessBridgeExports(record as WebAssembly.Exports)).toBe(false); }); }); + +describe('queuedInsertFront (protocol v20, plan #759)', () => { + it('writes the new head via inv_queued_insert_front', () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + let inserted = ''; + exp.inv_queued_insert_front = (ptr: number, len: number) => { + inserted = new TextDecoder().decode(new Uint8Array(exp.memory.buffer, ptr, len)); + return 1; + }; + expect(bridge.queuedInsertFront('Continue the current turn')).toBe(true); + expect(inserted).toBe('Continue the current turn'); + }); + + it('reports failure when the export rejects (full queue)', () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + exp.inv_queued_insert_front = () => 0; + expect(bridge.queuedInsertFront('x')).toBe(false); + }); + + it('inv_queued_insert_front is REQUIRED (fail-closed when missing)', () => { + const exp = makeMockExports() as unknown as WebAssembly.Exports; + expect(isHarnessBridgeExports(exp)).toBe(true); + const record = exp as unknown as Record; + delete record.inv_queued_insert_front; + expect(isHarnessBridgeExports(record as WebAssembly.Exports)).toBe(false); + }); +}); diff --git a/lib/harnessBridge.ts b/lib/harnessBridge.ts index 7fb5886..785c083 100644 --- a/lib/harnessBridge.ts +++ b/lib/harnessBridge.ts @@ -35,7 +35,10 @@ import { // 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). Additive, now REQUIRED. -export const HARNESS_PROTOCOL_VERSION = 19 as const; +// v20 (plan #759): submit-queue insert-at-front — `inv_queued_insert_front` +// (host inserts `Continue the current turn` as the new queue head on give-up +// with a non-empty queue). Additive, now REQUIRED. +export const HARNESS_PROTOCOL_VERSION = 20 as const; /** XOR constant used by `inv_ping` on the Wasm side. */ export const INV_PING_XOR = 0xa5a5 as const; @@ -155,6 +158,8 @@ export type HarnessBridgeExports = { // timeout / validation Ready so the Wasm terminal-promote block never drains // a queued head after a non-success. inv_set_queue_promote_allowed: (v: number) => void; + // Protocol v20 (plan #759) — insert operator text as the new queue head. + inv_queued_insert_front: (ptr: number, len: number) => number; inv_set_can_load_earlier: (v: number) => void; inv_has_pending_load_earlier: () => number; inv_ack_pending_load_earlier: () => void; @@ -239,6 +244,7 @@ const REQUIRED_FNS: Exclude[] = [ 'inv_ack_pending_submit', 'inv_queued_count', 'inv_set_queue_promote_allowed', + 'inv_queued_insert_front', 'inv_set_can_load_earlier', 'inv_has_pending_load_earlier', 'inv_ack_pending_load_earlier', @@ -588,6 +594,20 @@ export class HarnessBridge { this.exports.inv_set_queue_promote_allowed(allowed ? 1 : 0); } + /** + * Protocol v20 (plan #759) — insert `text` as the NEW queue head, shifting + * existing operator items down one (never pops). Returns true on accept; + * false when the queue is full or the text is blank (fail closed — no drop). + */ + queuedInsertFront(text: string): boolean { + const { ptr, len } = this.writeUtf8(text); + try { + return this.exports.inv_queued_insert_front(ptr, len) !== 0; + } finally { + if (len > 0) this.exports.gpa_free(ptr, len); + } + } + /** Read + ack pending Wasm→JS submit, or null if none. */ takePendingSubmit(): string | null { if (!this.hasPendingSubmit()) return null; diff --git a/lib/harnessChat.test.ts b/lib/harnessChat.test.ts index 768a743..15c2459 100644 --- a/lib/harnessChat.test.ts +++ b/lib/harnessChat.test.ts @@ -3,9 +3,12 @@ import { coalesceToolRunMessages, collapseThinkingDisplay, classifyTurnFailure, + classifyTurnRetry, + CONTINUE_TURN_PROMPT, describeTurnEnd, foldStatusSlots, getSessionCwd, + HARNESS_QUEUE_MAX_ITEMS, isTurnEndLine, parseChangeDirCwd, pushSessionToBridge, @@ -33,6 +36,7 @@ import { } from './harnessBridge'; import type { ChatResult } from './chatApi'; import type { AgentResult } from './agentApi'; +import type { AgentStreamEvent } from './agent/agentStream'; import { TOOL_RUN_ITEMS_MAX, addToolResult, @@ -57,6 +61,7 @@ function makeMockExports(): HarnessBridgeExports & { __canLoadEarlier: () => number; __statusSlots: (string | undefined)[]; __promoteAllowed: () => boolean; + __queue: string[]; } { let buf = new ArrayBuffer(64 * 1024); const memory = { @@ -72,6 +77,8 @@ function makeMockExports(): HarnessBridgeExports & { let promoteAllowed = true; const messages: { kind: number; text: string }[] = []; const statusSlots: (string | undefined)[] = new Array(8).fill(undefined); + // plan #759 — operator follow-up FIFO mirror (inv_queued_insert_front unshifts). + const queue: string[] = []; const gpa_u8 = (len: number) => { if (len <= 0) return 0; @@ -131,10 +138,14 @@ function makeMockExports(): HarnessBridgeExports & { inv_pending_submit_len: () => 0, inv_pending_submit_copy: () => 0, inv_ack_pending_submit: () => {}, - inv_queued_count: () => 0, + inv_queued_count: () => queue.length, inv_set_queue_promote_allowed: (v: number) => { promoteAllowed = v !== 0; }, + inv_queued_insert_front: (ptr: number, len: number) => { + queue.unshift(len === 0 ? '' : read(ptr, len)); + return 1; + }, inv_set_can_load_earlier: (v: number) => { canLoadEarlier = v ? 1 : 0; }, @@ -190,6 +201,7 @@ function makeMockExports(): HarnessBridgeExports & { __canLoadEarlier: () => canLoadEarlier, __statusSlots: statusSlots, __promoteAllowed: () => promoteAllowed, + __queue: queue, }; } @@ -271,7 +283,7 @@ describe('runHarnessChat', () => { expect(sent).toContain('User: again'); }); - it('pushes ember error message and stays ready for retry', async () => { + it('pushes ember error message and lands on Error on give-up (plan #759)', async () => { const exp = makeMockExports(); const bridge = new HarnessBridge(exp); const send = vi.fn(async (): Promise => ({ @@ -290,7 +302,11 @@ describe('runHarnessChat', () => { text: describeTurnEnd('error', 'AI_GATEWAY_API_KEY is not configured.'), }, ]); - expect(exp.__lifecycle()).toBe(Lifecycle.Ready); + // plan #759 — a failed turn lands on Error (give-up), never Ready, so the + // Wasm promote gate (Ready-only) never consumes a queued item. The operator + // can still re-send (Error is not Busy). + expect(exp.__lifecycle()).toBe(Lifecycle.Error); + expect(exp.__queue).toHaveLength(0); // empty queue → no Continue insert }); it('rejects empty prompt without calling send', async () => { @@ -316,7 +332,7 @@ describe('protocol v19 promote gate arming (plan #760)', () => { expect(exp.__promoteAllowed()).toBe(true); // success → auto-promote stays }); - it('runHarnessChat failure (agent error) arms promote_allowed=false then Ready', async () => { + it('runHarnessChat give-up (agent error) arms promote_allowed=false then lands Error (plan #759 supersedes #760)', async () => { const exp = makeMockExports(); const bridge = new HarnessBridge(exp); const send = vi.fn(async (): Promise => ({ @@ -325,7 +341,10 @@ describe('protocol v19 promote gate arming (plan #760)', () => { status: 503, })); await runHarnessChat(bridge, 'hello', { send }); - expect(exp.__lifecycle()).toBe(Lifecycle.Ready); + // A non-stop failure is a give-up → Error (never terminal for the promote + // gate, so a queued head is never drained). The gate is STILL armed false + // (plan #760) so a later Stop→Ready also cannot auto-drain. + expect(exp.__lifecycle()).toBe(Lifecycle.Error); expect(exp.__promoteAllowed()).toBe(false); // failure / stop → never drain }); @@ -436,12 +455,14 @@ describe('runHarnessTurn', () => { ]); }); - it('503 agent failure does NOT fall back to chat (hard-fail, phase 3 #476)', async () => { + it('agent hard failure does NOT fall back to chat (hard-fail, phase 3 #476)', async () => { const exp = makeMockExports(); const bridge = new HarnessBridge(exp); + // plan #759 — permanent 422 so this terminal/no-fallback test stays + // single-attempt (retryable 5xx would retry TURN_RETRY_ATTEMPTS times). const sendAgent = vi.fn(async (): Promise => ({ ok: false, - status: 503, + status: 422, error: 'Sandbox not configured. Set SANDBOX_URL and SANDBOX_TOKEN.', })); const send = vi.fn(async (): Promise => ({ ok: true, text: 'PONG' })); @@ -460,12 +481,13 @@ describe('runHarnessTurn', () => { expect(exp.__messages.some((m) => m.kind === MessageKind.Error)).toBe(true); }); - it('agent 500 does not call chat', async () => { + it('agent hard failure does not call chat', async () => { const exp = makeMockExports(); const bridge = new HarnessBridge(exp); + // plan #759 — permanent 422 (terminal); 5xx is retryable and would loop. const sendAgent = vi.fn(async (): Promise => ({ ok: false, - status: 500, + status: 422, error: 'boom', })); const send = vi.fn(async (): Promise => ({ ok: true, text: 'nope' })); @@ -483,14 +505,14 @@ describe('runHarnessTurn', () => { expect(exp.__messages.some((m) => m.kind === MessageKind.Error)).toBe(true); }); - it('503 with non-exact body does not call chat', async () => { + it('hard failure with non-exact body does not call chat', async () => { const exp = makeMockExports(); const bridge = new HarnessBridge(exp); - // Any failed agent turn hard-fails (phase 3 #476) — no 503 → /api/chat - // fallback, regardless of the 503 body shape. + // Any failed agent turn hard-fails (phase 3 #476) — no fallback, regardless + // of the error body shape. plan #759: permanent 422 (terminal; 5xx would retry). const sendAgent = vi.fn(async (): Promise => ({ ok: false, - status: 503, + status: 422, error: 'Upstream overloaded', })); const send = vi.fn(async (): Promise => ({ ok: true, text: 'nope' })); @@ -504,7 +526,7 @@ describe('runHarnessTurn', () => { expect(send).not.toHaveBeenCalled(); expect(result.ok).toBe(false); - expect(result).toMatchObject({ error: 'Upstream overloaded', status: 503 }); + expect(result).toMatchObject({ error: 'Upstream overloaded', status: 422 }); expect(next.messages.map((m) => m.role)).toEqual(['user', 'error']); expect(exp.__messages.some((m) => m.kind === MessageKind.Error)).toBe(true); }); @@ -593,6 +615,7 @@ describe('runHarnessTurn', () => { const bridge = new HarnessBridge(exp); const sendAgent = vi.fn(async (): Promise => ({ ok: false, + status: 422, // plan #759 — permanent (terminal); generic/5xx would retry error: 'down', })); @@ -726,6 +749,253 @@ describe('runHarnessTurn', () => { }); }); +describe('plan #759 — turn errors retry the current turn, never drain the queue', () => { + /** Run a retry-looping turn under deterministic fake timers (the real + * 250ms–4s bounded backoff would breach the 5 s vitest default per-test + * timeout while the loop runs). Always advances well past the total backoff. */ + async function runRetry(fn: () => Promise): Promise { + vi.useFakeTimers(); + try { + const pending = fn(); + await vi.advanceTimersByTimeAsync(60_000); + return await pending; + } finally { + vi.useRealTimers(); + } + } + + it('retries a retryable 500 then succeeds on attempt 3 (no user re-push; queue untouched until success)', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + exp.__queue.push('queued follow-up'); + let n = 0; + const sendAgent = vi.fn(async (): Promise => { + n += 1; + if (n < 3) return { ok: false, status: 500, error: 'Gateway upstream flaked' }; + return { ok: true, text: 'finished' }; + }); + const { result, session: next } = await runRetry(() => + runHarnessTurn(bridge, createEmptySession('s'), 'hi', { + sendAgent, + pushUser: false, + streamAgent: false, + }), + ); + expect(result.ok).toBe(true); + expect(sendAgent).toHaveBeenCalledTimes(3); // 1 + 2 retries before success + // Retries never re-push the user line (single user row in the session). + expect(next.messages.filter((m) => m.role === 'user')).toHaveLength(1); + // The queue is NEVER consumed by a failure — intact until a successful Ready. + expect(exp.__queue).toEqual(['queued follow-up']); + expect(exp.__lifecycle()).toBe(Lifecycle.Ready); // success → Ready + expect(next.messages.at(-1)!.text).toBe(describeTurnEnd('model')); + }); + + it('gives up after 5 retryable failures: Error lifecycle + Continue inserted at QUEUE HEAD (never pops)', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + exp.__queue.push('op item A'); + exp.__queue.push('op item B'); + const sendAgent = vi.fn(async (): Promise => ({ + ok: false, + status: 500, + error: 'flaked', + })); + const { result, session: next } = await runRetry(() => + runHarnessTurn(bridge, createEmptySession('s'), 'hi', { + sendAgent, + pushUser: false, + streamAgent: false, + }), + ); + expect(result.ok).toBe(false); + expect(sendAgent).toHaveBeenCalledTimes(5); // 1 + 4 retries + expect(exp.__lifecycle()).toBe(Lifecycle.Error); // give-up is NOT Ready + // Queue depth unchanged (no pop) + Continue unshifted as the new head. + expect(exp.__queue).toEqual([ + CONTINUE_TURN_PROMPT, + 'op item A', + 'op item B', + ]); + expect(next.messages.some((m) => m.role === 'error')).toBe(true); + expect(next.messages.at(-1)!.text).toBe(describeTurnEnd('error', 'flaked')); + }); + + it('gives up after 5 retries with an EMPTY queue: stop, no Continue insert', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + const sendAgent = vi.fn(async (): Promise => ({ + ok: false, + status: 503, + error: 'flaked', + })); + const { result } = await runRetry(() => + runHarnessTurn(bridge, createEmptySession('s'), 'hi', { + sendAgent, + pushUser: false, + streamAgent: false, + }), + ); + expect(result.ok).toBe(false); + expect(sendAgent).toHaveBeenCalledTimes(5); + expect(exp.__lifecycle()).toBe(Lifecycle.Error); + expect(exp.__queue).toEqual([]); // empty queue → no Continue row + }); + + it('queue FULL (16) at give-up: no insert, no pop — operator items untouched (fail closed)', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + for (let i = 0; i < HARNESS_QUEUE_MAX_ITEMS; i++) exp.__queue.push(`item ${i}`); + const before = [...exp.__queue]; + const sendAgent = vi.fn(async (): Promise => ({ + ok: false, + status: 500, + error: 'flaked', + })); + await runRetry(() => + runHarnessTurn(bridge, createEmptySession('s'), 'hi', { + sendAgent, + pushUser: false, + streamAgent: false, + }), + ); + expect(exp.__lifecycle()).toBe(Lifecycle.Error); + expect(exp.__queue).toEqual(before); // no insert, no pop, no drop + expect(exp.__queue[0]).not.toBe(CONTINUE_TURN_PROMPT); + }); + + it('operator Stop on attempt 1: no retry, no Continue, queue intact, Ready (unchanged)', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + exp.__queue.push('queued item'); + const sendAgent = vi.fn(async (): Promise => ({ + ok: false, + error: 'Request cancelled.', + status: 499, + })); + const { result } = await runHarnessTurn(bridge, createEmptySession('s'), 'hi', { + sendAgent, + pushUser: false, + streamAgent: false, + }); + expect(result.ok).toBe(false); + expect(sendAgent).toHaveBeenCalledTimes(1); // Stop never retries + expect(exp.__lifecycle()).toBe(Lifecycle.Ready); // Stop keeps Ready as today + expect(exp.__queue).toEqual(['queued item']); // untouched, no Continue + }); + + it('permanent 401/403: single attempt, straight to give-up (no 5× loop) + Continue-if-queued', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + exp.__queue.push('op'); + const sendAgent = vi.fn(async (): Promise => ({ + ok: false, + status: 401, + error: 'unauthorized', + })); + const { result } = await runHarnessTurn(bridge, createEmptySession('s'), 'hi', { + sendAgent, + pushUser: false, + streamAgent: false, + }); + expect(result.ok).toBe(false); + expect(sendAgent).toHaveBeenCalledTimes(1); // no 5× loop on permanent auth + expect(exp.__lifecycle()).toBe(Lifecycle.Error); + expect(exp.__queue[0]).toBe(CONTINUE_TURN_PROMPT); // give-up + non-empty queue + }); + + it('a retryable TIMEOUT (408) also retries then gives up with Continue (timeout is not permanent)', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + exp.__queue.push('op'); + const sendAgent = vi.fn(async (): Promise => ({ + ok: false, + status: 408, + error: 'Gateway timeout', + })); + const { result } = await runRetry(() => + runHarnessTurn(bridge, createEmptySession('s'), 'hi', { + sendAgent, + pushUser: false, + streamAgent: false, + }), + ); + expect(result.ok).toBe(false); + expect(sendAgent).toHaveBeenCalledTimes(5); + expect(exp.__lifecycle()).toBe(Lifecycle.Error); + expect(exp.__queue[0]).toBe(CONTINUE_TURN_PROMPT); + }); + + it('classifyTurnRetry is the narrow turn predicate (permanent vs retryable statuses)', () => { + // Non-AgentRetryError input (e.g. withTransientRetry's own abort) fails + // closed to permanent — the turn never loops on something it can't classify. + expect(classifyTurnRetry(new Error('x')).kind).toBe('permanent'); + expect(classifyTurnRetry('string').kind).toBe('permanent'); + }); + + it('a LIVE stream that painted then fails is NOT retried (single attempt, no tool/bubble duplication) [adversarial-review Major L1]', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + exp.__queue.push('op item A'); + let calls = 0; + // Production path is `streamAgent: true` (SSE). Attempt 1 emits a tool card + // + a text delta (both PAINT to the ring), then returns a retryable 500. + // Because content already painted, retrying the SAME prompt onto the SAME + // ring would re-run the tool (side-effect duplication) and push a duplicate + // assistant bubble. The failure must classify PERMANENT → exactly 1 send. + const sendAgentStream = vi.fn( + async ( + _prompt: string, + init?: { onEvent?: (event: AgentStreamEvent) => void | Promise }, + ): Promise => { + calls += 1; + await init?.onEvent?.({ type: 'tool_start', name: 'exec' }); + await init?.onEvent?.({ type: 'text_delta', text: 'partial reply' }); + return { ok: false, status: 500, error: 'flaked after paint' }; + }, + ); + const { result } = await runHarnessTurn(bridge, createEmptySession('s'), 'hi', { + sendAgentStream, + pushUser: false, + streamAgent: true, + }); + expect(result.ok).toBe(false); + expect(sendAgentStream).toHaveBeenCalledTimes(1); // painted → permanent, no retry + expect(calls).toBe(1); + expect(exp.__lifecycle()).toBe(Lifecycle.Error); + // Give-up with non-empty queue inserts Continue at head; no attempt 2. + expect(exp.__queue[0]).toBe(CONTINUE_TURN_PROMPT); + // Exactly ONE tool card + ONE assistant bubble — the re-send never happened. + expect(exp.__messages.filter((m) => m.kind === MessageKind.ToolRun)).toHaveLength(1); + expect(exp.__messages.filter((m) => m.kind === MessageKind.Assistant)).toHaveLength(1); + }); + + it('a live stream that fails BEFORE painting anything still retries (5 attempts) [adversarial-review Minor L6]', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + exp.__queue.push('op item A'); + let calls = 0; + // Stream that NEVER emits a ring-painting event before failing with a + // retryable 503 (gateway not ready, nothing painted) → cleanly retryable. + const sendAgentStream = vi.fn(async (): Promise => { + calls += 1; + return { ok: false, status: 503, error: 'gateway not ready' }; + }); + const { result } = await runRetry(() => + runHarnessTurn(bridge, createEmptySession('s'), 'hi', { + sendAgentStream, + pushUser: false, + streamAgent: true, + }), + ); + expect(result.ok).toBe(false); + expect(sendAgentStream).toHaveBeenCalledTimes(5); // nothing painted → retry loop preserved + expect(calls).toBe(5); + expect(exp.__lifecycle()).toBe(Lifecycle.Error); + expect(exp.__queue[0]).toBe(CONTINUE_TURN_PROMPT); + }); +}); + describe('modelId forwarding', () => { it('forwards modelId to send', async () => { const exp = makeMockExports(); @@ -1325,7 +1595,7 @@ describe('runHarnessTurn session cwd', () => { const sendAgent = vi.fn(async () => ({ ok: false as const, error: 'boom', - status: 500, + status: 422, // plan #759 — permanent (terminal); 5xx would retry })); const session = { ...createEmptySession('s'), cwd: 'keep-me' }; const { session: next } = await runHarnessTurn(bridge, session, 'hi', { @@ -1445,12 +1715,12 @@ describe('runHarnessTurn session cwd', () => { expect(next.cwd).toBe('prior'); }); - it('hard-failed agent turn (503) still keeps the prior known cwd', async () => { + it('hard-failed agent turn still keeps the prior known cwd', async () => { const exp = makeMockExports(); const bridge = new HarnessBridge(exp); const sendAgent = vi.fn(async (): Promise => ({ ok: false, - status: 503, + status: 422, // plan #759 — permanent (terminal); 5xx would retry error: 'Sandbox not configured. Set SANDBOX_URL and SANDBOX_TOKEN.', })); const send = vi.fn(async (): Promise => ({ ok: true, text: 'chat ok' })); @@ -1558,7 +1828,9 @@ describe('runHarnessTurn session cwd', () => { changeDirCwd: 'invincible/sub', }); await init?.onEvent?.({ type: 'error', error: 'Gateway timeout' }); - return { ok: false, error: 'Gateway timeout', status: 504 }; + // status 422 keeps the timeout kind (message match) but is PERMANENT + // (plan #759 — a 4xx never backoff-loops a terminal turn). + return { ok: false, error: 'Gateway timeout', status: 422 }; }, }); expect(result.ok).toBe(false); @@ -1580,7 +1852,7 @@ describe('runHarnessTurn session cwd', () => { ok: false, summary: 'change_dir · ✗ failed · ERROR change_dir: no such dir', }); - return { ok: false, error: 'boom' }; + return { ok: false, error: 'boom', status: 422 }; // plan #759 — terminal }, }); expect(result.ok).toBe(false); @@ -2370,7 +2642,7 @@ describe('runHarnessTurn session activeSandboxId bind', () => { streamAgent: false, sendAgent: async () => ({ ok: false, - status: 500, + status: 422, // plan #759 — permanent (terminal); 5xx would retry error: 'inference down', }), }); @@ -2659,7 +2931,7 @@ describe('skill attach display (phase 2 #517)', () => { const sendAgent = vi.fn(async (): Promise => ({ ok: false, error: 'model boom', - status: 502, + status: 422, // plan #759 — permanent (terminal); 5xx would retry attachedSlugs: ['create-plan'], })); const { session: next } = await runHarnessTurn(bridge, createEmptySession(), 'go', { @@ -3319,7 +3591,7 @@ describe('context/usage slot (phase 3, plan #539 / #327)', () => { }; const sendAgent = vi.fn(async (): Promise => ({ ok: false, - status: 502, + status: 422, // plan #759 — permanent (terminal); 5xx would retry error: 'boom', })); const { result, session: next } = await runHarnessTurn(bridge, session, 'hi', { diff --git a/lib/harnessChat.ts b/lib/harnessChat.ts index 3f47201..72c86b0 100644 --- a/lib/harnessChat.ts +++ b/lib/harnessChat.ts @@ -12,6 +12,7 @@ import { import { sendAgent, sendAgentStream, + type AgentFailure, type AgentResult, type SendAgentFn, type SendAgentStreamFn, @@ -21,6 +22,10 @@ import { type AgentStreamEvent } from './agent/agentStream'; import { TOOL_TRACE_SUMMARY_MAX_CHARS, } from './sandbox/config'; +import { + withTransientRetry, + type VercelErrorClass, +} from './sandbox/resilience'; import { isRedisSafeOpaqueId, normalizeSessionCwd, @@ -96,6 +101,122 @@ export const THINKING_COLLAPSED_MAX = THINKING_DISPLAY_MAX; */ export const TOOL_TRACE_MAX_LINES = Number.POSITIVE_INFINITY; +// ── plan #759 — retryable turn errors retry the current turn, never drain ── +/** Max attempts (1 send + 4 retries) for a retryable agent-turn failure. NEW cap. */ +export const TURN_RETRY_ATTEMPTS = 5; +/** Backoff base (ms) between attempts — matches `withTransientRetry` defaults. */ +export const TURN_RETRY_BASE_MS = 250; +/** Hard backoff cap (ms). */ +export const TURN_RETRY_CAP_MS = 4000; +/** Wasm `MAX_ITEMS` mirror (`submit_queue.zig`) — host fails closed at this depth. */ +export const HARNESS_QUEUE_MAX_ITEMS = 16; +/** Inserted as the new queue HEAD on give-up with a non-empty queue (plan #759). */ +export const CONTINUE_TURN_PROMPT = 'Continue the current turn'; + +/** + * HTTP statuses the turn treats as PERMANENT (no 5× backoff loop): 4xx client + * errors are the operator/request's fault (auth 401/403, validation 400/422, + * not-found 404, too-large 413). 408/429/5xx and timeout/empty stay retryable. + */ +const PERMANENT_TURN_STATUS = new Set([400, 401, 403, 404, 413, 422]); + +/** + * Thrown by the retry wrapper for a FAILED agent attempt so `withTransientRetry` + * (via the narrow classifier) can decide retryable vs permanent from the HTTP + * status + `classifyTurnFailure` kind — the turn classifier keys on both because + * `classifyTurnFailure` does NOT return distinct 401/403/validation kinds. + */ +class AgentRetryError extends Error { + readonly status: number | undefined; + readonly turnKind: TurnEndKind; + /** Session-sticky attached-skills set from the failed AgentFailure — must + * survive the throw/catch round-trip or fold-before-persist (plan #517) + * would silently drop the sticky set on a give-up turn. */ + readonly attachedSlugs?: string[]; + constructor( + error: string, + status: number | undefined, + turnKind: TurnEndKind, + attachedSlugs?: string[], + ) { + super(error); + this.name = 'AgentRetryError'; + this.status = status; + this.turnKind = turnKind; + this.attachedSlugs = attachedSlugs; + } +} + +/** + * Narrow classifier for the in-flight turn (passed to the `classify` seam in + * `withTransientRetry`). Deliberately NOT `classifyVercelError` — a turn failure + * is its own domain, not the sandbox SDK's. stop / 401 / 403 / other permanent + * statuses → permanent (single attempt, straight to give-up); timeout / empty / + * generic 5xx / network → retryable. + */ +export function classifyTurnRetry(err: unknown): VercelErrorClass { + if (err instanceof AgentRetryError) { + if (err.turnKind === 'stop') return { kind: 'permanent', status: err.status }; + if (err.status !== undefined && PERMANENT_TURN_STATUS.has(err.status)) { + return { kind: 'permanent', status: err.status }; + } + return { kind: 'retryable', status: err.status }; + } + // Anything else (incl. withTransientRetry's own AbortError mid-backoff) can't + // be re-classified in the turn domain — fail closed to permanent. + return { kind: 'permanent' }; +} + +/** Rebuild an AgentFailure from a classifier-thrown value (post-retry give-up). */ +function agentFailureFromRetry(err: unknown): AgentFailure { + if (err instanceof AgentRetryError) { + return { + ok: false, + error: err.message, + ...(err.status != null ? { status: err.status } : {}), + ...(err.attachedSlugs !== undefined + ? { attachedSlugs: err.attachedSlugs } + : {}), + }; + } + if (err instanceof Error && err.name === 'AbortError') { + return { ok: false, error: 'Request cancelled.' }; + } + return { ok: false, error: err instanceof Error ? err.message : 'Turn failed.' }; +} + +/** + * plan #759 — on give-up with a non-empty queue, insert `Continue the current + * turn` as the new HEAD (never pops operator items). Fails closed when the + * queue is full: no insert, no drop. + */ +export function insertContinueTurnPrompt(bridge: HarnessBridge): void { + const count = bridge.queuedCount(); + if (count === 0) return; + if (count >= HARNESS_QUEUE_MAX_ITEMS) return; // fail closed — no drop + bridge.queuedInsertFront(CONTINUE_TURN_PROMPT); +} + +/** + * Lifecycle to land on after a FAILED turn: Ready on operator Stop (queue + * untouched, drains only on a later success — unchanged), and Error on give-up + * (plan #759 — `err` is NOT terminal for the Wasm promote gate, so a failed + * turn never consumes a queued operator item; Continue is inserted at head). + */ +function setFailLifecycle(bridge: HarnessBridge, kind: TurnEndKind): void { + // plan #760 — a non-success terminal must never auto-drain: arm the one-shot + // promote gate false (a Stop lands on Ready; without the gate FALSE that Ready + // would drain a queued head). Harmless on the give-up Error path (err is not + // terminal for the promote gate), but the Ready-on-Stop case REQUIRES it. + bridge.setQueuePromoteAllowed(false); + if (kind === 'stop') { + bridge.setLifecycle(Lifecycle.Ready); + } else { + bridge.setLifecycle(Lifecycle.Error); + insertContinueTurnPrompt(bridge); + } +} + export type RunHarnessChatOptions = { signal?: AbortSignal; /** Inject for tests; defaults to sendChat. */ @@ -504,8 +625,11 @@ function completeTurn(bridge: HarnessBridge, promoteAllowed: boolean): void { } /** - * Run one prompt → Gateway → transcript update. - * Sets lifecycle busy → ready (soft API errors leave ready for retry). + * Run one prompt → Gateway → transcript update (standalone chat path, no agent + * tools). Sets lifecycle busy → Ready on success; on failure it gives up to + * Error (or stays Ready on an operator Stop) — the chat path does NOT retry. + * Client-side validation is rejected PRE-Busy: it pushes the error line and + * returns Ready without ever inserting a Continue prompt (nothing drained). */ export async function runHarnessChat( bridge: HarnessBridge, @@ -569,7 +693,9 @@ export async function runHarnessChat( fail.kind === 'stop' ? MessageKind.System : MessageKind.Error, describeTurnEnd(fail.kind, fail.detail), ); - completeTurn(bridge, false); // stop / error / timeout / empty — no auto-promote + // plan #759 — arms the promote gate false (plan #760) then lands Stop on Ready + // (queue untouched) or give-up on Error + Continue-at-head never drains. + setFailLifecycle(bridge, fail.kind); return result; } @@ -891,6 +1017,19 @@ export async function runHarnessTurn( let thinkingSegment = ''; let thinkingSegmentOpen = false; let sawStreamTerminal = false; + /** + * plan #759 / adversarial-review Major — once the LIVE stream has painted + * ANY ring content past the user line (a tool card, assistant text, a + * thinking row, a skill row), a later failure is NOT retryable: replaying + * the same prompt onto the SAME ring would re-run tools (side-effect + * duplication) and push duplicate bubbles. Monotonic — set once, never + * reset within the turn, so any post-paint failure fails closed to + * permanent single-attempt. A failure BEFORE anything painted (immediate + * 5xx / network drop) stays cleanly retryable. JSON tests run the + * non-stream path where `onStreamEvent` never fires, so this flag stays + * false and the 5× loop is unchanged there. + */ + let streamPainted = false; // Last confirmed-successful `change_dir` cwd this turn (phase 2 of #464 / // plan #465): recorded from live tool events (stream) or the JSON toolTrace, // applied on non-success terminals and as a success fallback so an aborted @@ -1205,80 +1344,128 @@ export async function runHarnessTurn( ? session.personaId : undefined; - if (streamAgent) { - agentResult = await sendAgentStreamFn(apiPrompt, { - signal: opts?.signal, - modelId: opts?.modelId, - cwd: sessionCwd, - ...(sessionId ? { sessionId } : {}), - ...(boundPersonaId ? { personaId: boundPersonaId } : {}), - ...(sessionSandboxId ? { sandboxId: sessionSandboxId } : {}), - onEvent: async (ev: AgentStreamEvent) => { - if (ev.type === 'tool_start' || ev.type === 'tool_result') { - handleToolEvent(ev); - return; - } - if (ev.type === 'reasoning_delta') { - growThinking(ev.text); - return; - } - if (ev.type === 'text_delta') { - growAssistant(ev.text); - return; - } - if (ev.type === 'skill_attached') { - // Server sends skill_attached events at the START of the turn (before - // the model). Push the display-only row live; it is a non-tool - // separator for the tool-run predicate. - lastRingRowIsToolRun = false; - lastUiKind = 'assistant'; - next = pushSkillRow(bridge, next, ev); - // Phase 2 (#517 / adversarial-review Blocker + "fold-before-persist - // incl. fail/cancel"): every skill_attached event carries the SAME - // final set, so last-writes-wins here never clears across events, and - // it is applied BEFORE the model runs — so a success, a 502, or a - // user Stop/cancel still ends with the host persisting the sticky set - // as `meta.attachedSkills` (omitted field = leave untouched; `[]` = - // explicit detach-all). - if (Array.isArray(ev.attachedSlugs)) { - next = { ...next, attachedSlugs: [...ev.attachedSlugs] }; - } - return; - } - if (ev.type === 'usage') { - // Phase 3 (plan #628) — live provider usage mid-stream: fold the - // context slot immediately so the operator sees token counts before - // the turn completes. `done.usage` is the final reconcile. - const liveUsage = sanitizeUsageSummary(ev.usage); - if (liveUsage) { - next = { ...next, usage: liveUsage }; - foldStatusSlots(bridge, next); - opts?.onSessionPatch?.(next); - } - return; - } - if (ev.type === 'done') { - sawStreamTerminal = true; - closeThinkingSegment(); - finalizeAssistant(ev.text ?? assistantAcc); - // Do not re-push toolTrace — live lines already shown. - return; - } - if (ev.type === 'error') { - sawStreamTerminal = true; - closeThinkingSegment(); + // plan #759 — a retryable turn failure retries the SAME send up to + // TURN_RETRY_ATTEMPTS (5) with bounded exponential backoff, reusing + // `withTransientRetry` via its additive `classify` seam. The user line is + // already on the ring; nothing re-promotes or sets Ready between attempts — + // the lifecycle stays Busy for the whole retry window. A non-ok AgentResult + // becomes an AgentRetryError so the narrow classifier can map retryable vs + // permanent from HTTP status + classifyTurnFailure kind. + const onStreamEvent = async (ev: AgentStreamEvent) => { + // Fail-closed retry gate (plan #759 adversarial-review Major): any event + // that PAINTS the ring past the user line arms `streamPainted`, so a + // retryable-looking failure after it becomes permanent single-attempt + // (never replay tools/duplicate bubbles onto the same ring). + if ( + ev.type === 'tool_start' || + ev.type === 'tool_result' || + ev.type === 'reasoning_delta' || + ev.type === 'text_delta' || + ev.type === 'skill_attached' + ) { + streamPainted = true; + } + if (ev.type === 'tool_start' || ev.type === 'tool_result') { + handleToolEvent(ev); + return; + } + if (ev.type === 'reasoning_delta') { + growThinking(ev.text); + return; + } + if (ev.type === 'text_delta') { + growAssistant(ev.text); + return; + } + if (ev.type === 'skill_attached') { + // Server sends skill_attached events at the START of the turn (before + // the model). Push the display-only row live; it is a non-tool + // separator for the tool-run predicate. + lastRingRowIsToolRun = false; + lastUiKind = 'assistant'; + next = pushSkillRow(bridge, next, ev); + // Phase 2 (#517 / adversarial-review Blocker + "fold-before-persist + // incl. fail/cancel"): every skill_attached event carries the SAME + // final set, so last-writes-wins here never clears across events, and + // it is applied BEFORE the model runs — so a success, a 502, or a + // user Stop/cancel still ends with the host persisting the sticky set + // as `meta.attachedSkills` (omitted field = leave untouched; `[]` = + // explicit detach-all). + if (Array.isArray(ev.attachedSlugs)) { + next = { ...next, attachedSlugs: [...ev.attachedSlugs] }; + } + return; + } + if (ev.type === 'usage') { + // Phase 3 (plan #628) — live provider usage mid-stream: fold the + // context slot immediately so the operator sees token counts before + // the turn completes. `done.usage` is the final reconcile. + const liveUsage = sanitizeUsageSummary(ev.usage); + if (liveUsage) { + next = { ...next, usage: liveUsage }; + foldStatusSlots(bridge, next); + opts?.onSessionPatch?.(next); + } + return; + } + if (ev.type === 'done') { + sawStreamTerminal = true; + closeThinkingSegment(); + finalizeAssistant(ev.text ?? assistantAcc); + // Do not re-push toolTrace — live lines already shown. + return; + } + if (ev.type === 'error') { + sawStreamTerminal = true; + closeThinkingSegment(); + } + }; + + try { + agentResult = await withTransientRetry( + async () => { + const r = streamAgent + ? await sendAgentStreamFn(apiPrompt, { + signal: opts?.signal, + modelId: opts?.modelId, + cwd: sessionCwd, + ...(sessionId ? { sessionId } : {}), + ...(boundPersonaId ? { personaId: boundPersonaId } : {}), + ...(sessionSandboxId ? { sandboxId: sessionSandboxId } : {}), + onEvent: onStreamEvent, + }) + : await sendAgentFn(apiPrompt, { + signal: opts?.signal, + modelId: opts?.modelId, + cwd: sessionCwd, + ...(sessionId ? { sessionId } : {}), + ...(boundPersonaId ? { personaId: boundPersonaId } : {}), + ...(sessionSandboxId ? { sandboxId: sessionSandboxId } : {}), + }); + if (!r.ok) { + const kind = classifyTurnFailure(r.error, r.status, opts?.signal).kind; + throw new AgentRetryError(r.error, r.status, kind, r.attachedSlugs); } + return r; }, - }); - } else { - agentResult = await sendAgentFn(apiPrompt, { - signal: opts?.signal, - modelId: opts?.modelId, - cwd: sessionCwd, - ...(sessionId ? { sessionId } : {}), - ...(boundPersonaId ? { personaId: boundPersonaId } : {}), - ...(sessionSandboxId ? { sandboxId: sessionSandboxId } : {}), - }); + { + // 5 attempts total = 1 initial send + 4 retries (plan #759 TURN_RETRY_ATTEMPTS). + retries: TURN_RETRY_ATTEMPTS - 1, + baseMs: TURN_RETRY_BASE_MS, + capMs: TURN_RETRY_CAP_MS, + signal: opts?.signal, + // Fail-closed gate (adversarial-review Major): once `streamPainted`, + // no failure is retryable — replaying the same prompt onto the SAME + // ring would re-run just-painted tools (side-effect duplication) and + // push duplicate assistant bubbles. `classifyTurnRetry` still owns + // the status/stop mapping for the clean (never-painted) cases. + classify: (err) => + streamPainted ? { kind: 'permanent' } : classifyTurnRetry(err), + }, + ); + } catch (err) { + // Permanent status / Stop / exhausted retryable / abort during backoff. + agentResult = agentFailureFromRetry(err); } // Safety net: collapse open thinking when the stream ends without a terminal @@ -1503,7 +1690,11 @@ export async function runHarnessTurn( // repaint on cancel). The unscoped fetch is bounded by the fail-soft // catch; it only repaints one slot once. void refreshGitStatusSlot(bridge, failedSession); - completeTurn(bridge, false); // stop / error / timeout / empty — no auto-promote + // plan #759 — arms the promote gate false (plan #760) then lands give-up + // on Error (never consumes the queue head; Continue inserted at head when + // non-empty) unless this was an operator Stop, which stays Ready (queue + // untouched, drains only on a later success). + setFailLifecycle(bridge, fail.kind); return { result: { ok: false, diff --git a/lib/harnessChat.wasm-int.test.ts b/lib/harnessChat.wasm-int.test.ts index a697492..26e1d49 100644 --- a/lib/harnessChat.wasm-int.test.ts +++ b/lib/harnessChat.wasm-int.test.ts @@ -114,9 +114,9 @@ describe('real-Wasm live tool increment (implements #433)', () => { expect(typeof bridge.exports.inv_message_kind_at).toBe('function'); }); - it('real Wasm is protocol v19 and rides the promote-gate export (plan #760)', async () => { + it('real Wasm rides the promote-gate export at the current protocol (plan #760)', async () => { const bridge = await loadBridge(); - // Protocol v19 parity — Wasm `PROTOCOL_VERSION` must equal the host + // Protocol parity — Wasm `PROTOCOL_VERSION` must equal the host // `HARNESS_PROTOCOL_VERSION` (assertRoundTrip also checks it; pin it here). expect(bridge.protocolVersion()).toBe(HARNESS_PROTOCOL_VERSION); // REQUIRED_FNS already proves `inv_set_queue_promote_allowed` reached the @@ -390,5 +390,34 @@ describe('real-Wasm live tool increment (implements #433)', () => { }); }); +describe('real-Wasm protocol v20 (plan #759 — queued insert-at-front)', () => { + it('the built wasm carries protocol v20 + inv_queued_insert_front (REQUIRED_FNS + build.zig whitelist)', async () => { + const bridge = await loadBridge(); + // loadBridge already fails closed if a REQUIRED export is missing; the + // build.zig export_symbol_names entry is what keeps it in the artifact. + // Plan #760 took v19 (promote gate), so this (later) sibling's insert-at-front + // rides v20 and keeps BOTH exports — Wasm PROTOCOL_VERSION must match TS. + expect(bridge.protocolVersion()).toBe(HARNESS_PROTOCOL_VERSION); + expect(typeof bridge.exports.inv_queued_insert_front).toBe('function'); + }); + + it('queuedInsertFront round-trips a real Continue head on the ephemeral queue', async () => { + const bridge = await loadBridge(); + // Fresh wasm instance per loadBridge — the ephemeral queue starts empty. + expect(bridge.queuedCount()).toBe(0); + expect(bridge.queuedInsertFront('Continue the current turn')).toBe(true); + expect(bridge.queuedCount()).toBe(1); + // Inserting a second head shifts the first down one (head stays newest). + expect(bridge.queuedInsertFront('operator item')).toBe(true); + expect(bridge.queuedCount()).toBe(2); + }); + + it('queuedInsertFront rejects a blank head — queue untouched (fail closed)', async () => { + const bridge = await loadBridge(); + expect(bridge.queuedInsertFront(' ')).toBe(false); + expect(bridge.queuedCount()).toBe(0); + }); +}); + // Declared to silence an unused-import guard; the helper is exercised above. void Lifecycle; diff --git a/lib/harnessHostModelPersist.test.ts b/lib/harnessHostModelPersist.test.ts index e1147de..14b110b 100644 --- a/lib/harnessHostModelPersist.test.ts +++ b/lib/harnessHostModelPersist.test.ts @@ -111,6 +111,7 @@ function makeMockExports(overrides?: Partial): HarnessBrid inv_ack_pending_submit: () => {}, inv_queued_count: () => 0, inv_set_queue_promote_allowed: () => {}, + inv_queued_insert_front: () => 1, inv_set_can_load_earlier: () => {}, inv_has_pending_load_earlier: () => 0, inv_ack_pending_load_earlier: () => {}, diff --git a/lib/sandbox/resilience.test.ts b/lib/sandbox/resilience.test.ts index 19fc2f3..79ab2b1 100644 --- a/lib/sandbox/resilience.test.ts +++ b/lib/sandbox/resilience.test.ts @@ -263,6 +263,36 @@ describe('withTransientRetry', () => { await expect(p).rejects.toMatchObject({ name: 'AbortError' }); expect(calls).toBe(1); }); + + it('plan #759 test 11 — NO `classify` still uses classifyVercelError (sandbox back-compat)', async () => { + // The additive classifier seam must NOT change sandbox behavior: with no + // `classify`, a 503 APIError is still retryable (classifyVercelError's rule). + let n = 0; + const fn = vi.fn(async () => { + n += 1; + if (n <= 2) throw apiError(503); + return 'ok'; + }); + await expect( + withTransientRetry(fn, { baseMs: 2, capMs: 10, jitterMs: 0 }), + ).resolves.toBe('ok'); + expect(fn).toHaveBeenCalledTimes(3); // retried via classifyVercelError default + }); + + it('plan #759 test 11 — passing a `classify` override uses it (not classifyVercelError)', async () => { + // A domain caller injects its own classifier; the sandbox default must be + // bypassed entirely — here a 503 is declared permanent (0 retries) by the + // narrow injectable predicate, proving the seam route was taken. + const classify = vi.fn(() => ({ kind: 'permanent' as const, status: 503 })); + const fn = vi.fn(async () => { + throw apiError(503); + }); + await expect( + withTransientRetry(fn, { baseMs: 2, capMs: 10, jitterMs: 0, classify }), + ).rejects.toMatchObject({}); + expect(classify).toHaveBeenCalledTimes(1); // only the first attempt classified + expect(fn).toHaveBeenCalledTimes(1); // permanent → no retry + }); }); describe('EXTEND_THROTTLE_MS constant', () => { diff --git a/lib/sandbox/resilience.ts b/lib/sandbox/resilience.ts index 32f308a..e8f638c 100644 --- a/lib/sandbox/resilience.ts +++ b/lib/sandbox/resilience.ts @@ -270,6 +270,13 @@ export type TransientRetryOptions = { signal?: AbortSignal; /** Called after the last retryable attempt failed (e.g. invalidate latch). */ onExhaustedRetryable?: (err: unknown) => void; + /** + * Additive classifier seam (plan #759): Optional predicate deciding whether a + * thrown error is retryable. Defaults to `classifyVercelError` so existing + * sandbox callers are untouched. A domain (e.g. harness-turn) caller passes a + * narrow classifier that must NOT conflate with the sandbox SDK domain. + */ + classify?: (err: unknown) => VercelErrorClass; }; /** @@ -288,6 +295,7 @@ export async function withTransientRetry( jitterMs = baseMs, signal, onExhaustedRetryable, + classify = classifyVercelError, } = opts; for (let attempt = 0; attempt <= retries; attempt++) { @@ -295,7 +303,7 @@ export async function withTransientRetry( try { return await fn(); } catch (err) { - const cls = classifyVercelError(err); + const cls = classify(err); if (cls.kind !== 'retryable') { // Permanent, or SDK-owned pass_through — never app-retry. throw err; diff --git a/native/harness/README.md b/native/harness/README.md index 27de2bb..8362c11 100644 --- a/native/harness/README.md +++ b/native/harness/README.md @@ -111,6 +111,8 @@ Host is dvui’s `web.js`. Required exports (app + backend): | `inv_clear_session_catalog` / `inv_push_session_catalog_entry` / `inv_session_catalog_count` / `inv_set_current_session` | Protocol v17 session-rail catalog (host → Wasm) | | `inv_has_pending_session_switch` / `inv_pending_session_switch_len` / `_copy` / `inv_ack_pending_session_switch` | Protocol v17 pending session switch (Wasm → host) | | `inv_queued_count` | Protocol v18 — Wasm-ephemeral follow-up queue depth (host / auto-continue seam) | +| `inv_set_queue_promote_allowed` | **v19** — host arms a one-shot per-terminal promote gate so a Stop/Esc/error/timeout Ready never drains a queued head (plan #760) | +| `inv_queued_insert_front` | **v20** — insert a prompt as the new queue head (`Continue the current turn` on give-up, plan #759); never pops, fails closed when full/blank | | `inv_set_turn_elapsed` | **v14** whole-turn busy clock — the host pushes elapsed wall-clock seconds while a turn runs; the Wasm busy row formats/appends `Waiting for model… · mm:ss` in-canvas | Whitelist: `build.zig` → `export_symbol_names` (Zig 0.16 freestanding + `entry = .disabled` strips unrooted exports). @@ -122,7 +124,7 @@ Inference stays on the host: `POST /api/chat` and `POST /api/agent` hold | | | |--|--| -| **Protocol version** | `18` (v17 added the session-rail catalog + pending switch; **v18** adds `inv_queued_count` for the in-canvas submit queue) | +| **Protocol version** | `20` (v17 added the session-rail catalog + pending switch; **v18** adds `inv_queued_count` for the in-canvas submit queue; **v19** adds `inv_set_queue_promote_allowed` — promote when Ready only, so a Stop/Esc/error/timeout Ready never drains; **v20** adds `inv_queued_insert_front` insert-at-front for turn-error Continue) | | **TS** | `lib/harnessBridge.ts` | | **Zig** | `src/bridge.zig` | | **Host** | `app/harness/HarnessHost.tsx` (shell: load + bridge + APIs) | diff --git a/native/harness/build.zig b/native/harness/build.zig index 087e808..b41807c 100644 --- a/native/harness/build.zig +++ b/native/harness/build.zig @@ -89,6 +89,7 @@ pub fn build(b: *std.Build) void { "inv_ack_pending_submit", "inv_queued_count", "inv_set_queue_promote_allowed", + "inv_queued_insert_front", "inv_set_can_load_earlier", "inv_has_pending_load_earlier", "inv_ack_pending_load_earlier", diff --git a/native/harness/src/bridge.zig b/native/harness/src/bridge.zig index b098b16..640b6cf 100644 --- a/native/harness/src/bridge.zig +++ b/native/harness/src/bridge.zig @@ -38,7 +38,10 @@ const submit_queue = @import("submit_queue.zig"); /// 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 does). /// Additive, now REQUIRED. -pub const PROTOCOL_VERSION: u32 = 19; +/// v20: submit-queue insert-at-front — `inv_queued_insert_front` (plan #759: +/// host inserts `Continue the current turn` as the new head on give-up with a +/// non-empty queue). Additive, now REQUIRED. +pub const PROTOCOL_VERSION: u32 = 20; pub const Lifecycle = enum(u8) { boot = 0, @@ -151,6 +154,15 @@ var has_pending_cancel: bool = false; /// polls this flag, folds the live selection into the session snapshot, persists, /// then acks it. var has_pending_model_change: bool = false; +/// Plan #759 / adversarial-review Major — set when a HOST front-insert +/// (`inv_queued_insert_front`, the give-up `Continue` head) successfully shifts +/// every queued slot down one. `submit_queue.insertFront` moves the rows but the +/// operator's open queue-row edit lives in `ui/state.zig (queue_editing_index)`; +/// bridge cannot import state (state imports bridge), so it latches here and the +/// UI layer consumes it on the next frame to bump the edit index (`queue_band.reconcileFrontInsert`). +/// Without this, blur/Ctrl+Enter `saveEdit` on the stale index would overwrite +/// the NEW row that slid into the old slot (losing the row the operator was editing). +var has_pending_front_insert: bool = false; var suppress_refresh: bool = false; const CatalogEntry = struct { @@ -308,6 +320,27 @@ pub fn queuedCount() u32 { return submit_queue.count(&queue); } +/// Insert `text` as the new queue head (plan #759). Never pops; returns false +/// when the queue is full (`Full`) or the text is blank (`Blank`). On success +/// arms `has_pending_front_insert` so the UI layer bumps an open queue-row edit +/// index on its next frame (the insert shifts every slot down one). +pub fn insertQueuedFront(text: []const u8) bool { + submit_queue.insertFront(&queue, text) catch return false; + has_pending_front_insert = true; + refresh(); + return true; +} + +/// Consume-and-clear the front-insert latch (see `has_pending_front_insert`). +/// Returns whether a host front-insert happened since the last frame. Called by +/// `queue_band.reconcileFrontInsert` so the queue-row edit latch follows the +/// shifted row instead of re-anchoring to the neighbor. +pub fn takeFrontInsertSignal() bool { + const v = has_pending_front_insert; + has_pending_front_insert = false; + return v; +} + pub fn queuedItemAt(i: u32) ?[]const u8 { return submit_queue.item(&queue, i); } @@ -350,6 +383,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) + has_pending_front_insert = false; session_catalog.reset(); suppress_refresh = false; catalog_count = 0; @@ -573,6 +607,7 @@ pub export fn inv_clear_messages() void { // Hydrate / New must not leave a queued Send from the previous session. has_pending_submit = false; pending_submit_len = 0; + has_pending_front_insert = false; submit_queue.clear(&queue); // Clear / New also re-arm the promote gate (fresh surface, plan #760). queue_promote_allowed = true; @@ -638,6 +673,12 @@ pub export fn inv_set_queue_promote_allowed(v: u8) void { refresh(); } +/// Protocol v20 — insert `text` as the new queue head (plan #759). Returns 1 on +/// accept, 0 when full or blank (never pops, never drops operator items). +export fn inv_queued_insert_front(ptr: [*]const u8, len: usize) u8 { + return if (insertQueuedFront(ptr[0..len])) 1 else 0; +} + export fn inv_set_can_load_earlier(v: u8) void { const next = v != 0; if (can_load_earlier == next) return; diff --git a/native/harness/src/queue_band.test.zig b/native/harness/src/queue_band.test.zig index 79bd5e1..465b23b 100644 --- a/native/harness/src/queue_band.test.zig +++ b/native/harness/src/queue_band.test.zig @@ -268,6 +268,64 @@ test "cancel glyph is U+2715 (DejaVu subset)" { try t.expectEqual(@as(u21, 0x2715), cp); } +// ── reconcileFrontInsert (plan #759 adversarial-review Major L1) ───────── + +test "reconcileFrontInsert: host front-insert bumps an open edit index by one" { + bridge.reset(); + _ = bridge.enqueueFromUi("A") catch @panic("enqueue failed"); + _ = bridge.enqueueFromUi("B") catch @panic("enqueue failed"); + // Operator is editing row B (index 1). The turn gives up → the host inserts + // `Continue` at the head → [Continue, A, B]. B is now at index 2; the edit + // must follow it (1→2), else blur/Ctrl+Enter saveEdit would overwrite A. + queue_band.beginEdit(1); + try t.expectEqual(@as(usize, 1), state.queue_editing_index.?); + + try t.expect(bridge.insertQueuedFront("Continue the current turn")); + try t.expectEqual(@as(u32, 3), bridge.queuedCount()); + + queue_band.reconcileFrontInsert(); + + try t.expect(state.queue_editing_index != null); + try t.expectEqual(@as(usize, 2), state.queue_editing_index.?); + // The row still under the cursor is B, not A. + const target = bridge.queuedItemAt(@intCast(state.queue_editing_index.?)).?; + try t.expect(std.mem.eql(u8, "B", target)); +} + +test "reconcileFrontInsert: no-op when latch not armed (no host insert)" { + bridge.reset(); + _ = bridge.enqueueFromUi("A") catch @panic("enqueue failed"); + queue_band.beginEdit(0); + + // No front-insert happened — the reconcile must leave the edit untouched. + queue_band.reconcileFrontInsert(); + try t.expectEqual(@as(usize, 0), state.queue_editing_index.?); +} + +test "reconcileFrontInsert: no-op when no edit open even if latch armed" { + bridge.reset(); + _ = bridge.enqueueFromUi("A") catch @panic("enqueue failed"); + state.queue_editing_index = null; + + try t.expect(bridge.insertQueuedFront("Continue the current turn")); + queue_band.reconcileFrontInsert(); + + // No open edit → nothing to bump; the latch is consumed so it can't fire later. + try t.expect(state.queue_editing_index == null); + queue_band.reconcileFrontInsert(); + try t.expect(state.queue_editing_index == null); +} + +test "reconcileFrontInsert: latch is consumed on bridge reset/clear" { + bridge.reset(); + // A stale latch must not survive a reset (fresh session). + _ = bridge.insertQueuedFront("Continue the current turn"); + bridge.reset(); + queue_band.reconcileFrontInsert(); + // reset() cleared the latch; reconcile clears nothing more and edits are null. + try t.expect(state.queue_editing_index == null); +} + // ── enqueue follow (plan #699) ─────────────────────────────────────────── test "followIfRequested: no-op when flag false" { @@ -289,4 +347,3 @@ test "followIfRequested: snaps to bottom and clears flag" { try t.expectEqual(@as(f32, 280), state.queue_list_scroll.viewport.y); try t.expect(!state.queue_follow); } - diff --git a/native/harness/src/submit_queue.test.zig b/native/harness/src/submit_queue.test.zig index f6b7a4b..2587f67 100644 --- a/native/harness/src/submit_queue.test.zig +++ b/native/harness/src/submit_queue.test.zig @@ -151,3 +151,68 @@ test "promoteIf pops only after accept" { test "ITEM_BYTES matches the live Send cap" { try t.expectEqual(@as(usize, 262144), sq.ITEM_BYTES); } + +// ── plan #759 — insertFront (Continue-the-current-turn head) ────────────── + +test "insertFront puts the new item at head, shifting existing items down one" { + var q: sq.Q = .{}; + try sq.push(&q, "three"); + try sq.push(&q, "two"); + try sq.push(&q, "one"); // FIFO from head: three, two, one + try sq.insertFront(&q, "zero"); + try t.expectEqual(@as(u32, 4), sq.count(&q)); + try t.expectEqualStrings("zero", sq.item(&q, 0).?); + try t.expectEqualStrings("three", sq.item(&q, 1).?); + try t.expectEqualStrings("two", sq.item(&q, 2).?); + try t.expectEqualStrings("one", sq.item(&q, 3).?); + // Previous head (three) is preserved at index 1 — nothing dropped. +} + +test "insertFront normalizes (CRLF) and rejects blank, leaving the queue intact" { + var q: sq.Q = .{}; + try sq.push(&q, "a"); + try t.expectError(error.Blank, sq.insertFront(&q, " \n\t")); + try t.expectError(error.Blank, sq.insertFront(&q, "")); + try t.expectEqual(@as(u32, 1), sq.count(&q)); + try t.expectEqualStrings("a", sq.peek(&q).?); + try sq.insertFront(&q, "b\r\nc"); + try t.expectEqualStrings("b\nc", sq.item(&q, 0).?); +} + +test "insertFront is rejected at full capacity — no pop, no drop, no insert" { + var q: sq.Q = .{}; + var i: usize = 0; + while (i < sq.MAX_ITEMS) : (i += 1) { + try sq.push(&q, "x"); + } + try t.expectError(error.Full, sq.insertFront(&q, "overflow")); + try t.expectEqual(@as(u32, sq.MAX_ITEMS), sq.count(&q)); + // Head is unchanged — the rejected insert never touched the FIFO. + try t.expectEqualStrings("x", sq.peek(&q).?); +} + +test "insertFront on a wrapped queue (head wraps) shifts across the wrap boundary" { + var q: sq.Q = .{}; + // Fill + pop so head advances and later inserts wrap the physical array. + try sq.push(&q, "a"); + try sq.push(&q, "b"); + sq.pop(&q); // head now index 1 + sq.pop(&q); // head now index 2 + try sq.push(&q, "keep1"); + try sq.push(&q, "keep2"); + try sq.insertFront(&q, "newhead"); + try t.expectEqual(@as(u32, 3), sq.count(&q)); + try t.expectEqualStrings("newhead", sq.item(&q, 0).?); + try t.expectEqualStrings("keep1", sq.item(&q, 1).?); + try t.expectEqualStrings("keep2", sq.item(&q, 2).?); +} + +test "pending promote: insertFront then promoteIf pops the NEW head (not the old)" { + var q: sq.Q = .{}; + try sq.push(&q, "old-head"); + try sq.insertFront(&q, "continue"); + g_accept = true; + try t.expect(sq.promoteIf(&q, acceptSubmit)); + try t.expectEqualStrings("old-head", sq.peek(&q).?); // continue already consumed + try t.expectEqual(@as(u32, 1), sq.count(&q)); +} diff --git a/native/harness/src/submit_queue.zig b/native/harness/src/submit_queue.zig index ca3b73a..331edd7 100644 --- a/native/harness/src/submit_queue.zig +++ b/native/harness/src/submit_queue.zig @@ -59,6 +59,30 @@ pub fn pop(q: *Q) void { q.len -= 1; } +/// Insert `text` at the head (index 0), shifting every existing item down one +/// (plan #759 — `Continue the current turn` on give-up with a non-empty queue). +/// Never pops; fails `Full` at `MAX_ITEMS` and `Blank` on a blank/whitespace +/// input. Normalizes via the same `composer_text.normalizeInto` path as `push`, +/// so an index-0 head behaves exactly like a normal enqueued row. +pub fn insertFront(q: *Q, text: []const u8) error{ Blank, Full }!void { + if (q.len >= MAX_ITEMS) return error.Full; + // Normalize into a scratch first so a Blank input never mutates the queue. + var tmp: [ITEM_BYTES]u8 = undefined; + const norm = composer_text.normalizeInto(text, tmp[0..], ITEM_BYTES); + if (norm.is_blank) return error.Blank; + // Shift existing items up one physical slot (tail-1 → tail … head → head+1). + var j: usize = q.len; + while (j > 0) : (j -= 1) { + const dst_i = (q.head + j) % MAX_ITEMS; + const src_i = (q.head + j - 1) % MAX_ITEMS; + copySlot(&q.slots[dst_i], &q.slots[src_i]); + } + const head = &q.slots[q.head]; + @memcpy(head.data[0..norm.text.len], norm.text); + head.len = @intCast(norm.text.len); + q.len += 1; +} + fn copySlot(dst: *Slot, src: *const Slot) void { dst.len = src.len; if (src.len > 0) { diff --git a/native/harness/src/ui.zig b/native/harness/src/ui.zig index 3d85e0a..8396d69 100644 --- a/native/harness/src/ui.zig +++ b/native/harness/src/ui.zig @@ -563,6 +563,12 @@ pub fn frame() !void { if (queue_band.shouldDropEditOnEmptyQueue()) { queue_band.resetQueueEditState(); } + // 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 + // targets the row being edited — not the new neighbor that slid into `e`. + // No-op when no front-insert is pending and/or no edit is open. + queue_band.reconcileFrontInsert(); // Drop composer arrow-key history when the newest user row's identity // changed since entry (plan #667, adversarial review #686 R2). // This fingerprint check catches session hydrate (same-or-different-count diff --git a/native/harness/src/ui/queue_band.zig b/native/harness/src/ui/queue_band.zig index 502fead..1f51c56 100644 --- a/native/harness/src/ui/queue_band.zig +++ b/native/harness/src/ui/queue_band.zig @@ -47,6 +47,22 @@ pub fn shouldDropEditOnEmptyQueue() bool { return state.queue_editing_index != null 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` +/// (UI-owned; bridge is lower). Consuming `bridge.takeFrontInsertSignal()` tells +/// us a front-insert happened; bump the open edit index by one so blur/Ctrl+Enter +/// `saveEdit` targets the row the operator was actually editing — not the new row +/// that slid into the old slot (which would be overwritten/lost). No-op when no +/// front-insert is pending and/or no edit is open. Exported so host tests can +/// drive the same production path (`queue_band.test.zig`). +pub fn reconcileFrontInsert() void { + if (!bridge.takeFrontInsertSignal()) return; + if (state.queue_editing_index) |e| { + state.queue_editing_index = e + 1; + } +} + pub fn desiredHeight() f32 { const n = bridge.queuedCount(); const editing = state.queue_editing_index != null;