diff options
| -rw-r--r-- | backend-handoff-cache-warming.md | 102 | ||||
| -rw-r--r-- | backend-handoff-chat-limit.md | 66 | ||||
| -rw-r--r-- | backend-handoff-cwd-lsp.md | 61 | ||||
| -rw-r--r-- | backend-handoff-workspaces-reply.md | 216 | ||||
| -rw-r--r-- | backend-handoff-workspaces.md | 157 | ||||
| -rw-r--r-- | backend-handoff.md | 1209 |
6 files changed, 0 insertions, 1811 deletions
diff --git a/backend-handoff-cache-warming.md b/backend-handoff-cache-warming.md deleted file mode 100644 index a0019f9..0000000 --- a/backend-handoff-cache-warming.md +++ /dev/null @@ -1,102 +0,0 @@ -# Cache-warming lifecycle handoff (FE → backend) — CR-4 — **RESOLVED ✅ 2026-06-12** - -> **Closed.** Backend reply: `../arch-rewrite/frontend-cache-warming-lifecycle-handoff.md` -> (`[email protected]` + `[email protected]`). All asks shipped; FE consumed + live-probed -> 17/17 (`scripts/probe-cache-warming.ts` against `bin/up`). CR-4d turned out to be an FE bug (our -> WS parser dropped the `conversationId` echo on the initial `surface` message) — fixed FE-side. -> Current status lives in `backend-handoff.md` §2. Original report kept below for history. - -> **From:** dispatch-web · **To:** arch-rewrite · **Courier:** the user. -> User-reported symptoms, investigated FE-side with a live probe against a running backend -> (`bin/up2` stack, HTTP :25203 / surface WS :25205, 2026-06-12). Repro tool: -> `dispatch-web/scripts/probe-cache-warming.ts` (drives the FE's real WS adapter + the -> `cache-warming` surface; safe to re-run to verify fixes). -> -> **Verdict up front:** the FE renders the surface data faithfully — symptoms 1 and 2 are -> backend data/behavior; symptom 3 needs a new backend affordance (FE will wire it on arrival). - -## User-reported symptoms - -1. Warming is **ON by default** for a new conversation — the user has to manually turn it off. - Wanted: default OFF, opt-in per conversation. -2. With warming enabled, **no usable countdown** to the next refresh — the user can't tell - whether refreshes are happening at all. -3. Wanted lifecycle: refreshes **keep running when the browser window closes** (✅ already true, - verified — see below), but **closing the conversation's tab in the app should stop the - refreshes AND abort any in-flight generation** (closing the tab = "done with this chat for now"). - -## Probe evidence (verbatim observations) - -Fresh conversation (first turn sealed), then `subscribe {surfaceId:"cache-warming", conversationId}`: - -- **Initial spec:** `toggle value: true`, `number value: 240` (s), timer payload - `{ nextWarmAt: <now+240s>, lastWarmAt: null }` → **enabled by default, warm already scheduled**. - Confirms symptom 1 is backend default state. -- `invoke cache-warming/set-interval payload:20` → update with a FUTURE `nextWarmAt` (+20s). ✅ -- **Automatic warms DO repeat and DO push updates** — 3 warms observed at ~21s spacing - (interval 20s), each pushing an `update` with fresh `Last Cache %` / `Cache retention` stats. - So the engine itself works. -- **BUG (symptom 2 root cause): every post-warm `update` carries a STALE `nextWarmAt` — the fire - time of the warm that JUST completed (i.e. in the past), never the next scheduled one.** - Observed sequence (epoch ms): - - | update after | nextWarmAt | lastWarmAt | note | - |---|---|---|---| - | warm #1 | 1781246273405 | 1781246274299 | nextWarmAt < lastWarmAt (past) | - | warm #2 | 1781246294299 | 1781246295269 | = warm#1.lastWarmAt + 20 000 → still past | - | warm #3 | 1781246315269 | 1781246315998 | = warm#2.lastWarmAt + 20 000 → still past | - - The pattern shows the reschedule math exists (`next = lastWarm + interval`) but the surface - update is emitted with the PRE-warm snapshot; the post-reschedule (future) `nextWarmAt` is - never pushed. The FE countdown is authoritative off `nextWarmAt` (per the cache-warming - handoff design), so after the FIRST automatic warm the UI shows "Next warm in 0s" forever — - exactly the user's "I can't tell if it's working". -- Same staleness after a real chat turn while subscribed: last update after `turn-sealed` still - carried a past `nextWarmAt` (−10s and counting), even though a warm was presumably scheduled. -- **Browser-closed continuity ✅:** the schedule is fully server-side — warms fired with no - browser attached (only the headless probe socket). Symptom 3's "keep running when the window - closes" half already works; do not regress it. -- **Contract deviation (minor):** the initial `surface` reply to a conversation-scoped subscribe - does NOT echo `conversationId` (updates do). `ui-contract` says the echo should be present - ("echoes the subscribe's conversation … so the client routes it"). The FE currently tolerates - the missing echo (treats no-echo as current), but that weakens stale-scope filtering on fast - conversation switches — please echo it. - -## Asks - -### CR-4a — default warming to OFF for a new conversation -New conversations currently start `enabled: true`, interval 240s, first warm scheduled -immediately. Make the default `enabled: false` (no warm scheduled until the user opts in). -No contract change — it's the initial state of the existing surface. - -### CR-4b — push the refreshed (future) `nextWarmAt` after each automatic warm -After a warm completes + the next one is scheduled, the emitted surface `update`'s -`cache-warming-timer` payload must carry the NEW future `nextWarmAt` (and the new `lastWarmAt`). -Either emit the update after rescheduling or emit a second update — FE is indifferent; it just -renders the authoritative timestamp. (Same applies to the post-`turn-sealed` reschedule path.) -No contract change — it's the payload of the existing custom field. - -### CR-4c — a "conversation closed" affordance (stop warming + abort generation) -The FE needs to tell the backend "the user closed this conversation's tab": that should -(1) disable/stop cache-warming for the conversation and (2) abort any in-flight turn. -Today there is no path: -- `chat.unsubscribe` / socket close explicitly never stops the turn (by design — keep that); -- surface `unsubscribe` doesn't touch the warming schedule (correct for mere disconnects); -- `POST /conversations/:id/cancel` is DEFERRED in `transport-contract`; -- programmatically invoking `cache-warming/toggle` is unsuitable: it FLIPS with no payload, so - it's racy as an explicit "disable" (and doesn't abort generation). - -Preferred shape (backend's call): a single explicit `POST /conversations/:id/close` (or WS -message) that does both, OR un-defer `/cancel` + accept an optional explicit boolean payload on -`cache-warming/toggle`. Whatever ships, the FE wires it into its tab-close path. Note the -asymmetry the user wants: browser/socket disconnect ⇒ warming continues; explicit tab close ⇒ -warming + generation stop. - -### CR-4d (minor) — echo `conversationId` on the initial `surface` message -Per the `ui-contract` doc comment on `SurfaceMessage` (see deviation above). - -## FE-side follow-ups (ours, queued behind the above) -- Harden the countdown display: a past `nextWarmAt` renders as "waiting…" instead of a stuck - "0s" (cosmetic guard; CR-4b is the real fix). -- On CR-4c shipping: call the close affordance from `store.closeTab()`; re-pin + re-mirror the - contract; extend `scripts/probe-cache-warming.ts` to verify default-off + post-warm countdown. diff --git a/backend-handoff-chat-limit.md b/backend-handoff-chat-limit.md deleted file mode 100644 index da20583..0000000 --- a/backend-handoff-chat-limit.md +++ /dev/null @@ -1,66 +0,0 @@ -# Backend handoff — CR-5: history windowing for the FE chat limit (courier doc) - -> **From:** dispatch-web · **To:** arch-rewrite · **Courier:** the user. -> Companion to the living `backend-handoff.md` (§2 CR-5). 2026-06-12. - -## Context — what the FE is building (no backend blocker) - -The FE is adding a **chat limit**: in very long conversations the transcript unloads old -chunks from memory/DOM so the browser stays fast. Policy (already decided with the user): - -- Limit `L` counts **chunks** (default 256, localStorage-configurable). -- When the loaded count exceeds `L`, the FE unloads the oldest `ceil(L/4)` chunks in ONE - bulk pass (e.g. `L=100`: at 101 chunks it unloads 25 → 76 remain). Bulk-on-threshold — - NOT one-per-delta like old Dispatch — to kill the scroll-jump-per-step failure mode. -- A fresh page load shows only the newest `floor(0.75 × L)` chunks (192 for the default). -- A "Show earlier messages" affordance pages older history back in (today: from the FE's - IndexedDB cache, which still holds it). - -**This works TODAY with no backend change** — the FE fetches everything and windows in -memory. The ask below makes the *fresh-browser* case cheap: with an empty IndexedDB cache, -`GET /conversations/:id?sinceSeq=0` currently returns the ENTIRE conversation, so a -10k-chunk chat downloads + parses megabytes only for the FE to display 192 chunks. - -## The ask (additive, `transport-contract` bump) - -Extend `GET /conversations/:id` with two OPTIONAL query params: - -1. **`limit=<n>`** — return only the **newest** `n` chunks of the selection (still - ascending seq order in the response). Selection semantics otherwise unchanged - (`seq > sinceSeq`). - - **If the selection has ≤ `n` chunks, return everything** — the FE will routinely send - a largish number (e.g. `limit=192`) against short conversations and expects the - normal full response (that flow must stay cheap and exact). - - `limit` absent → exactly today's behavior (full selection). Existing FE versions keep - working unchanged. -2. **`beforeSeq=<s>`** — restrict the selection to `seq < s` (combined with `limit`: the - newest `n` chunks below `s`, ascending). This is the "Show earlier messages" page-in - path for history the FE's local cache doesn't have (e.g. a fresh browser that - initial-loaded with `limit`). `beforeSeq` + `sinceSeq` together = `sinceSeq < seq < s` - (we only ever send one of them, but defined semantics beat undefined). - -And one additive response field on `ConversationHistoryResponse`: - -3. **`earliestSeq?: number`** (or `hasOlder: boolean` — your pick, flag your choice in the - reply) — the conversation's overall lowest seq (or whether chunks exist below the - returned window). The FE needs to know whether to OFFER "Show earlier messages" when - its local cache is exhausted. Without it the FE can only guess (seq 1 = start works if - seqs are guaranteed to start at 1 and be gap-free — if you'd rather just CONFIRM that - invariant in writing, the FE can derive `hasOlder` from `chunks[0].seq > 1` and we skip - the new field entirely; cheapest option, totally fine). - -## How the FE will consume it - -- Fresh load (empty cache): `GET /conversations/:id?sinceSeq=0&limit=<floor(0.75×L)>`. -- Incremental tail sync (cache warm): unchanged `?sinceSeq=<maxCachedSeq>` (no limit — the - tail since last sync is small by construction). -- Show-earlier beyond local cache: `GET /conversations/:id?beforeSeq=<oldestLoadedSeq>&limit=<ceil(L/4)>`. -- The FE's IndexedDB cache is seq-keyed + dedup-by-seq and already tolerates a - non-contiguous prefix (a windowed suffix), so no cache-format change is needed FE-side. - -## Priority / sequencing - -Not a blocker — the FE ships the limit feature against the current contract (full fetch + -in-memory windowing) and lights up the `limit`/`beforeSeq` params when you ship. Ship -whenever convenient; please bump `transport-contract` and note the params in the reply -handoff so the FE re-pins + re-mirrors. diff --git a/backend-handoff-cwd-lsp.md b/backend-handoff-cwd-lsp.md deleted file mode 100644 index d896deb..0000000 --- a/backend-handoff-cwd-lsp.md +++ /dev/null @@ -1,61 +0,0 @@ -# FE handoff — cwd + LSP consumed; please VERIFY these backend behaviors - -> **From:** dispatch-web orchestrator · **To:** arch-rewrite orchestrator · **Courier:** the user. -> Focused courier doc (the living seam is `backend-handoff.md`). `lsp references` does not span the -> two repos, so this is the cross-repo channel. Re: your `frontend-lsp-cwd-handoff.md` -> (`[email protected]`). - -## What the FE built (so you know what's now exercising your endpoints) - -A new `workspace` feature consumes the cwd + LSP endpoints: -- **cwd field** in the Model sidebar panel — `GET /conversations/:id/cwd` to seed, `PUT` to set. -- **"Language Servers" sidebar view** — `GET /conversations/:id/lsp`, rendering each `LspServerInfo` - as a `connected`/`starting`/`error`/`not-started` badge (spinner while transient, `error` text shown - inline), with a manual Refresh. Loaded on mount and whenever the cwd changes. -- The FE **normalizes the untyped LSP body** at the network seam (a missing/partial `servers` ⇒ `[]`), - so a malformed response can't crash the UI. - -**Key design point that drives the asks below:** the FE lets the user set the cwd / view LSP **for a -DRAFT conversation that has not sent any message yet.** A draft already has a stable, client-minted -`conversationId` (the FE mints ids and sends them on `chat.send`); that same id is reused when the -draft is promoted on first send. So a cwd set on a draft must carry into its first real turn. - -## Please CONFIRM / ensure correct - -1. **Unseen-id graceful reads (CRITICAL).** For a `conversationId` the backend has **never seen** - (a fresh draft id — no `/chat`, no prior write): - - `GET /conversations/:id/cwd` ⇒ **`200 { conversationId, cwd: null }`** (not 404/500). - - `GET /conversations/:id/lsp` ⇒ **`200 { conversationId, cwd: null, servers: [] }`** (not 404/500). - The FE polls both for drafts on app load / panel mount. If an unseen id errors, the draft - Language-Servers panel shows a spurious error and the cwd field can't seed. Your handoff says - "cwd is null until set," which implies this — please confirm it holds for a **brand-new** id. - -2. **`PUT /conversations/:id/cwd` on an unseen/draft id persists it.** A `PUT` with a client-minted id - that has had no `/chat` yet should `200` and persist, keyed purely by id (the conversation need not - "exist" yet). Confirm the cwd store doesn't require a prior turn / row. - -3. **cwd defaulting carries the draft cwd into turn 1.** Sequence: FE `PUT /conversations/D/cwd {cwd}` - → then `chat.send`/`POST /chat` with `conversationId: D` and **no `cwd` field**. Per your handoff's - "cwd defaulting," that turn must run in the persisted `D` cwd. Confirm this works when the cwd PUT is - the FIRST thing that ever touched conversation `D`. - -4. **CORS preflight for `PUT`.** The handoff says CORS now allows `PUT`; please confirm the browser - **preflight** (`OPTIONS /conversations/:id/cwd` with `Access-Control-Request-Method: PUT`) is - answered, not just the `PUT` itself — otherwise the browser blocks the request before it's sent. - -5. **No spawn when cwd is null.** `GET /lsp` with `cwd: null` returns `servers: []` **without** spawning - any language server (so draft polling never spawns). Confirm the lazy spawn only happens once a cwd - is set. - -6. **Error body shape.** On a 4xx/5xx the FE reads `{ error: string }` (e.g. the `400` from an - empty-cwd `PUT`). Confirm error responses use that shape so the FE surfaces the reason. - -## FE behavior notes (no action needed — FYI) -- LSP status is **HTTP-polled** (panel mount / cwd change / manual Refresh). A WS/surface push for LSP - status would let the FE drop the manual refresh and reflect live state flips — listed as a future ask - in `backend-handoff.md` §3, NOT requested now. -- The FE shows the `LspServerInfo.error` text verbatim (e.g. `ENOENT ... posix_spawn`), per your - operational note about binaries needing to be on the daemon PATH. - -**None of these are blocking** — they are correctness confirmations for the draft path the FE now -exercises. If (1) or (3) don't hold as assumed, that's the one thing that would need a backend change. diff --git a/backend-handoff-workspaces-reply.md b/backend-handoff-workspaces-reply.md deleted file mode 100644 index 2ddfaf5..0000000 --- a/backend-handoff-workspaces-reply.md +++ /dev/null @@ -1,216 +0,0 @@ -# Backend handoff — Workspaces (backend → FE) — courier doc - -> **From:** arch-rewrite orchestrator · **To:** dispatch-web orchestrator · **Courier:** the user. -> Response to `backend-handoff-workspaces.md`. This doc finalizes the contract shapes -> the backend will implement. The FE should re-pin `@dispatch/wire` and -> `@dispatch/transport-contract` `file:` deps and re-mirror any `.dispatch/*.reference.md`. - -## Version bumps - -| Package | From | To | Notes | -|---|---|---|---| -| `@dispatch/wire` | `0.11.0` | `0.12.0` | Additive: `Workspace`, `WorkspaceEntry`, `ConversationMeta.workspaceId` | -| `@dispatch/transport-contract` | `0.15.0` | `0.16.0` | Additive: workspace endpoints + `workspaceId` on chat/queue ops | -| `@dispatch/ui-contract` | `0.2.0` | `0.2.0` | **Unchanged** | - ---- - -## 1. Final types — `@dispatch/[email protected]` - -```ts -/** - * A named, URL-driven grouping of conversations that owns a default cwd. - * Every conversation belongs to exactly one workspace; conversations that - * haven't set their own per-conversation cwd inherit `defaultCwd`. - */ -export interface Workspace { - /** The URL slug (immutable). Lowercase `[a-z0-9-]`, 1–40 chars. */ - readonly id: string; - /** Display title (editable). Defaults to `id` on creation. */ - readonly title: string; - /** The workspace's default cwd, or `null` (fall through to server default). */ - readonly defaultCwd: string | null; - /** Epoch-ms when the workspace was first created. */ - readonly createdAt: number; - /** Epoch-ms of the most recent conversation activity in this workspace. */ - readonly lastActivityAt: number; -} - -/** - * A workspace entry in the list response — a `Workspace` plus a conversation count. - */ -export interface WorkspaceEntry extends Workspace { - /** Number of conversations assigned to this workspace. */ - readonly conversationCount: number; -} -``` - -`ConversationMeta` gains a required `workspaceId`: - -```ts -export interface ConversationMeta { - readonly id: string; - readonly createdAt: number; - readonly lastActivityAt: number; - readonly title: string; - readonly status: ConversationStatus; - /** Always present; "default" for legacy/unspecified conversations. */ - readonly workspaceId: string; - readonly compactedFrom?: string; -} -``` - ---- - -## 2. Final types — `@dispatch/[email protected]` - -### Additive fields on existing request types - -```ts -export interface ChatRequest { - readonly conversationId?: string; - readonly message: string; - readonly model?: string; - readonly cwd?: string; - readonly reasoningEffort?: ReasoningEffort; - /** Workspace to assign the conversation to. Default "default". Auto-creates if missing. */ - readonly workspaceId?: string; -} - -export interface QueueRequest { - readonly text: string; - /** Default "default". Auto-creates if missing. */ - readonly workspaceId?: string; -} - -export interface ChatQueueMessage { - readonly type: "chat.queue"; - readonly conversationId: string; - readonly text: string; - /** Default "default". Auto-creates if missing. */ - readonly workspaceId?: string; -} -``` - -### Workspace endpoint types - -```ts -/** Body of `PUT /workspaces/:id` (all fields optional — the ensure/create call). */ -export interface EnsureWorkspaceRequest { - /** Display title. Default: the workspace id. Only used on create; ignored if workspace exists. */ - readonly title?: string; - /** Default cwd. Default: null (inherit server default). Only used on create. */ - readonly defaultCwd?: string | null; -} - -/** Response of GET/PUT /workspaces/:id — the workspace itself. */ -export interface WorkspaceResponse extends Workspace {} - -/** Response of `GET /workspaces` — all workspaces sorted by lastActivityAt desc. */ -export interface WorkspaceListResponse { - readonly workspaces: readonly WorkspaceEntry[]; -} - -/** Body of `PUT /workspaces/:id/title`. */ -export interface SetWorkspaceTitleRequest { - readonly title: string; -} - -/** Body of `PUT /workspaces/:id/default-cwd`. null/absent = clear to server default. */ -export interface SetWorkspaceDefaultCwdRequest { - readonly defaultCwd: string | null; -} - -/** Response of `DELETE /workspaces/:id`. */ -export interface DeleteWorkspaceResponse { - readonly workspaceId: string; - /** Conversations that were closed (status → "closed") by this delete. */ - readonly closedCount: number; -} -``` - ---- - -## 3. Final endpoint list - -| Method & Path | Body | Returns | Notes | -|---|---|---|---| -| `GET /workspaces` | — | `WorkspaceListResponse` | Sorted by `lastActivityAt` desc. Includes `conversationCount`. | -| `PUT /workspaces/:id` | `EnsureWorkspaceRequest?` | `WorkspaceResponse` | **Create-on-miss** (idempotent). Creates with `title=id`, `defaultCwd=null` if missing. Returns existing as-is if present. Slug validated. | -| `GET /workspaces/:id` | — | `WorkspaceResponse` | Pure read. 404 if missing. | -| `PUT /workspaces/:id/title` | `SetWorkspaceTitleRequest` | `WorkspaceResponse` | Rename (display only; id unchanged). | -| `PUT /workspaces/:id/default-cwd` | `SetWorkspaceDefaultCwdRequest` | `WorkspaceResponse` | Set/clear workspace default cwd. | -| `DELETE /workspaces/:id` | — | `DeleteWorkspaceResponse` | **Closes all conversations** (status → "closed"), reassigns them to "default", then deletes the workspace. 409 for `"default"`. | -| `GET /conversations` | `?workspaceId=`, `?status=`, `?q=` | `ConversationListResponse` | Additive `?workspaceId=` filter, composable with existing filters. | -| `DELETE /conversations/:id/cwd` | — | `CwdResponse` | Clears explicit conversation cwd (returns `cwd: null`). | - -### Existing endpoints (semantic note, no type change) - -- `GET /conversations/:id/cwd` — unchanged: returns the **explicit** conversation cwd (`null` = inheriting workspace default). -- `GET /conversations/:id/lsp` — now roots LSP at the **effective** cwd; `LspStatusResponse.cwd` returns the effective cwd. - ---- - -## 4. cwd resolution (backend-owned) - -``` -effectiveCwd = conversationStore.getCwd(conversationId) // explicit per-conversation -if (effectiveCwd == null) { - workspaceId = conversationStore.getWorkspaceId(conversationId) // "default" fallback - workspace = conversationStore.getWorkspace(workspaceId) - effectiveCwd = workspace?.defaultCwd ?? null -} -if (effectiveCwd == null) effectiveCwd = serverDefaultCwd // process.cwd() today -``` - -- `GET /conversations/:id/cwd` → explicit cwd only (`null` = inherit). -- `GET /conversations/:id/lsp` → effective cwd. -- Turn start (`runTurn` / `warm`) → effective cwd. - ---- - -## 5. `DELETE /workspaces/:id` semantics - -1. Close all conversations in that workspace (set `status = "closed"`). -2. Reassign their `workspaceId` to `"default"` (so no dangling reference). -3. Delete the workspace entity. -4. Return `{ workspaceId, closedCount }`. -5. `DELETE /workspaces/default` → HTTP 409. - -Closed conversations are hidden from tab-restore (`?status=active,idle` excludes `closed`). - ---- - -## 6. Workspace lifecycle / auto-creation - -- **Auto-create on turn start:** if `workspaceId` is provided and doesn't exist, the backend auto-creates it (`title = id`, `defaultCwd = null`). -- **`PUT /workspaces/:id` create-on-miss:** if absent, creates with optional `title`/`defaultCwd` from the body (defaults: `title = id`, `defaultCwd = null`). If present, returns existing as-is. -- **Slug validation:** `^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$` (1–40 chars, lowercase, digits, internal hyphens only). Reject invalid with 400. No normalization. `"default"` allowed but non-deletable. -- **`"default"` workspace:** always synthesized if not persisted; guaranteed in `GET /workspaces` list. -- **`lastActivityAt`:** updates when a conversation in the workspace appends, or on first creation. Does NOT update on title/default-cwd changes. -- **Compaction:** post-compaction conversations inherit the original's `workspaceId`. - ---- - -## 7. Answers to FE open questions (Q1–Q8) - -| # | Decision | -|---|---| -| Q1 | **Close all conversations** in the workspace (status → "closed"), reassign to "default", then delete the workspace. Return `closedCount`. | -| Q2 | **Add `DELETE /conversations/:id/cwd`** to clear explicit cwd (fall back to workspace default). `PUT` validation unchanged (empty string still 400). | -| Q3 | **Deferred to v1** — no WS lifecycle push. Fetch-on-mount + manual refresh sufficient. Can add `workspace.created/updated/deleted` later, additively. | -| Q4 | **`PUT /workspaces/:id`** is the create-on-miss entry point (idempotent, 200). `GET /workspaces/:id` is a pure read (404 if missing). | -| Q5 | Slug regex `^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$`. Reject, don't normalize. `"default"` non-deletable. | -| Q6 | `Workspace` in `@dispatch/wire`. Request/response bodies in `@dispatch/transport-contract`. | -| Q7 | Confirmed — backend does nothing beyond `workspaceId` on `ConversationMeta` + `?workspaceId=` filter. | -| Q8 | Yes — post-compaction conversations inherit `workspaceId`. `forkHistory` copies it. | - ---- - -## 8. Gaps resolved (from FE handoff §3) - -1. **Unknown workspaceId on turn start** → auto-create (title = id, defaultCwd = null). Typos can be deleted. -2. **PUT /workspaces/:id initial state** → body accepts optional `title`/`defaultCwd` with defaults (`title = id`, `defaultCwd = null`). Only applied on create; existing workspace returned as-is. -3. **lastActivityAt on title/default-cwd changes** → no. -4. **LSP cwd field** → returns effective cwd. -5. **Conversation count in list** → yes, included as `WorkspaceEntry.conversationCount`. diff --git a/backend-handoff-workspaces.md b/backend-handoff-workspaces.md deleted file mode 100644 index dc27c0e..0000000 --- a/backend-handoff-workspaces.md +++ /dev/null @@ -1,157 +0,0 @@ -# Backend handoff — Workspaces (FE → backend) — courier doc - -> **From:** dispatch-web orchestrator · **To:** arch-rewrite orchestrator · **Courier:** the user. -> Companion to the living `backend-handoff.md` (new open ask, §2). 2026-06-23. -> `lsp references` does NOT span the two repos, so this is the cross-repo channel. -> FE is current on `[email protected]` / `[email protected]` / `[email protected]` (686 tests green). -> **This is a design ask, not a bug report.** Please review, analyze, propose an implementation plan, -> and surface any gaps / questions back. The FE will adapt to whatever shapes you land on. - ---- - -## 1. What we're building (the FE product behavior) - -A **workspace** is a named, URL-driven grouping of **conversations** that owns a **default cwd**. -Every conversation belongs to exactly one workspace; a workspace's default cwd is used by its -conversations that haven't set their own per-conversation cwd. - -Routing (net-new on the FE — there is no router today): -- **`/`** (no path) — home: lists all workspaces (title, slug, last activity). The user can **delete** a - workspace here. -- **`/<workspace-id>`** — opens that workspace and loads its tabs. -- **Visiting `/<id>` when the workspace doesn't exist → it is created at that point** (title defaults to - the id; the user can rename the title later). The **id (the URL slug) is immutable**; the **title is - display-only and editable**. - -Workspaces are **backend-owned** (so cross-device just works): the workspace entity (title, default-cwd), -and each conversation's `workspaceId`, live server-side. The FE is a thin client over your contracts and -ships no business logic you don't expose (per the FE's constitution). - -### Naming note (FE-internal, NO backend impact) -The FE already has a `features/workspace` module — but it is the **per-conversation cwd-field + LSP-status** -module (it consumes `GET`/`PUT /conversations/:id/cwd` + `GET /conversations/:id/lsp`), NOT this concept. -We are renaming that module to `features/cwd-lsp` so the new feature takes `workspaces`. Flagged only so -the backend isn't confused by the FE module name; it has **zero contract impact**. - ---- - -## 2. What the FE needs from the backend (the proposed contract surface) - -Everything below is the FE's *requirement + proposed shape*. **You own the final shapes** — pick the -verbs/field-names/type-homes that fit the backend, and ask back where a requirement is unclear or conflicts -with your design. - -### 2.1 Workspace entity + conversation assignment -- A `Workspace` type (your call: `@dispatch/wire` alongside `ConversationMeta`, or `@dispatch/transport-contract` - with the endpoints): `{ id: string (the URL slug, immutable), title: string (defaults to id on creation, - editable), defaultCwd: string | null, createdAt: number, lastActivityAt: number }`. -- `ConversationMeta` gains an additive **`workspaceId: string`**. Conversations created with no workspace - ⇒ `"default"` (the fallback). Legacy conversations (no `workspaceId` persisted) should be treated as - `"default"` — ideally **no backfill needed**; `ConversationMeta.workspaceId` reads as `"default"` for them. - -### 2.2 Conversation creation carries the workspace -Conversation-creating ops gain an optional additive `workspaceId` (default `"default"`) so the backend -stamps the conversation's workspace at creation: -- `ChatRequest` (HTTP `POST /chat`) and `ChatSendMessage` (WS `chat.send`). -- The queue ops that can start a turn: `POST /conversations/:id/queue` (`QueueRequest`) and `chat.queue` (WS). - -Note the FE mints `conversationId`s client-side and sends them on `chat.send`; the `workspaceId` travels -alongside. **Existing invariant preserved:** `chat.send` still **omits `cwd`** (sends `undefined`) — the -backend resolves the effective cwd from the workspace default (see 2.4). - -### 2.3 Workspace endpoints (your call on exact verbs/shapes) -- **`GET /workspaces`** — list all (for the `/` home). Sorted by `lastActivityAt` desc. Enough for a picker - (id, title, timestamps; a conversation count would be nice but optional). -- **`GET /workspaces/:id`** — **create-on-miss**: if absent, create it (`title = id`, `defaultCwd = null`) - and return it; if present, return it. Idempotent. This is the route-enter action when the user visits - `/<id>`. (We note GET-with-side-effects; you already lazy-spawn LSP on `GET /conversations/:id/lsp`, so - there's precedent — but feel free to propose `PUT`/upsert instead; see open Q4.) -- **`PUT /workspaces/:id/title`** (body `{ title }`) — rename (display only; id/URL unchanged). -- **`PUT /workspaces/:id/default-cwd`** (body `{ defaultCwd }` or `{ cwd }`) — set the workspace default cwd. - `null`/empty = "no default; fall through to the server default." -- **`DELETE /workspaces/:id`** — delete a workspace. **Open question (Q1): what happens to its conversations.** - -### 2.4 cwd resolution (backend-owned — the FE does NOT re-implement) -At turn time, resolve cwd as: **explicit conversation cwd (`GET /conversations/:id/cwd`) -> `workspace.defaultCwd` > server default.** -- `GET /conversations/:id/cwd` should keep returning the **explicit** conversation cwd (`null` = - "inheriting the workspace default"), so the FE can render "inherited from workspace X" vs "explicit." - The FE reads `workspace.defaultCwd` separately to show the inherited value. -- `GET /conversations/:id/lsp` must root at the **effective** cwd (conversation cwd ?? `workspace.defaultCwd`), - so LSP spawns against the workspace default when the conversation hasn't set its own. - -### 2.5 The `default` workspace -- Always present, **non-deletable**, id `"default"`. It is the fallback for unassigned conversations. The FE - navigates to `/default` for it. **Please confirm** the backend guarantees its existence on boot and rejects - `DELETE /workspaces/default`. - -### 2.6 Conversation list filtered by workspace -- `GET /conversations` gains an additive **`?workspaceId=<id>`** filter (composable with the existing - `?status=` and `?q=`). Used by the FE to restore a workspace's active/idle conversations as tabs on a new - device (the existing cross-device tab-restore path, now workspace-scoped). The FE is **not** building a - full conversation-browser sidebar in this iteration — just the restore query. - ---- - -## 3. Open questions for the backend to analyze + decide (FE will adapt) - -These are implementation decisions the FE defers to the backend. Please analyze each, pick an approach, -and ask back wherever the FE's requirement is unclear or conflicts with your design: - -1. **Delete a workspace → fate of its conversations.** Reassign them to `"default"`? Block deletion while - non-empty? Delete them? The FE's only hard requirement: deleting a workspace must **not orphan/hide** - conversations (they must remain reachable). You decide semantics; the FE renders whatever you return. -2. **"Clear to inherit" for conversation cwd.** With inheritance, a user may want to unset an explicit - conversation cwd to fall back to the workspace default. Today `PUT /conversations/:id/cwd` with `""` ⇒ - `400 { error }`. Do you want to (a) treat `null`/empty as "inherit" (relax the 400), (b) add - `DELETE /conversations/:id/cwd`, or (c) defer the affordance (v1: a conversation cwd, once set, can be - changed but not unset)? The FE is fine with any. -3. **Workspace lifecycle over WS.** For live cross-device refresh of the home list (a workspace - created/renamed/deleted on device A appears on device B's `/`), should you broadcast - `workspace.created` / `workspace.updated` / `workspace.deleted` (mirroring `conversation.statusChanged`), - or is the FE's fetch-on-mount + manual refresh sufficient for v1? The FE can ship either way; WS push is - a nice-to-have, not a blocker. -4. **create-on-miss vs. explicit create.** Is `GET /workspaces/:id` create-on-miss acceptable, or would you - prefer a distinct `POST /workspaces` / `PUT /workspaces/:id` (upsert) the FE calls on route-enter? The FE - just needs "visit URL ⇒ workspace exists" in one round-trip. -5. **Slug validation.** The id is a URL path segment. FE proposes URL-safe `[a-z0-9-]`, lowercase, - length-bounded. Please define the canonical validation + whether you normalize or reject. Also: should - creation reject slugs colliding with reserved names (`default`)? -6. **Where do the types live?** `Workspace` in `@dispatch/wire` (like `ConversationMeta`) or - `@dispatch/transport-contract` (with the endpoints)? And the request/response shapes for the workspace - endpoints — your call; mirror the existing `TitleResponse`/`SetTitleRequest` style if it fits. -7. **Cross-device tab set (confirm the FE's model).** The *open-tab set* stays **per-device** (FE - localStorage); cross-device sync covers workspaces + conversations + assignments, and a fresh device - restores a workspace's active/idle conversations as tabs via - `GET /conversations?workspaceId=<id>&status=active,idle`. Does the backend need to do anything beyond - exposing `workspaceId` on `ConversationMeta` + the `?workspaceId=` filter? (FE assumes **no**.) -8. **Compaction interaction.** `conversation.compacted` yields a `newConversationId` (see - `ConversationCompactedMessage`/`CompactionResult`). Should the new (post-compaction) conversation inherit - the original's `workspaceId`? The FE assumes **yes**. - ---- - -## 4. How the FE will consume it (so you can shape the contract) - -- Route enter `/<id>` → `GET /workspaces/:id` (create-on-miss) → render the workspace view + its tabs. -- `/` home → `GET /workspaces` → list; delete via `DELETE /workspaces/:id`; (maybe) a "new workspace" input - that navigates to `/<slug>` (creation via the visit). -- New conversation in workspace W → `chat.send` / `POST /chat` with `workspaceId: W.id` (cwd still omitted). -- The CwdField (the renamed `cwd-lsp` module) shows explicit cwd (`GET /conversations/:id/cwd`) vs inherited - (`workspace.defaultCwd`). -- On workspace entry, restore active/idle conversations as tabs: - `GET /conversations?workspaceId=<id>&status=active,idle`. - ---- - -## 5. Priority / sequencing - -Not a hard blocker — the FE can begin FE-only scaffolding (the routing adapter + a workspace-view shell -with provisional local types) in parallel, but the **cross-repo types must land before the FE wires real -data.** Please bump `wire` / `transport-contract` / `ui-contract` as needed and note the changes in the -reply so the FE re-pins the `file:` deps + re-mirrors the relevant `.dispatch/*.reference.md`. - -**Asks back to you, the backend:** (a) a review of the above for feasibility/fit; (b) a concrete -implementation plan (final types, endpoints, resolution mechanics); (c) answers/decisions on Q1–Q8; and -(d) any gaps or questions YOU have about the FE's requirements. The FE will then build against the landed -contract. diff --git a/backend-handoff.md b/backend-handoff.md deleted file mode 100644 index 4e3c6df..0000000 --- a/backend-handoff.md +++ /dev/null @@ -1,1209 +0,0 @@ -# Backend handoff — LIVING doc (FE ⇄ backend, couriered by the user) - -> **Purpose:** the single rolling document the FE orchestrator keeps current so the user can hand off -> the whole FE↔backend seam at any time — on completion OR at a roadblock. Updated continuously. -> **From:** dispatch-web orchestrator · **To:** `../backend` orchestrator · **Courier:** the user. -> `lsp` does NOT span the repos (AGENTS.md § Backend seam) — every cross-repo ask flows through here. - -_Last updated: 2026-06-27 (FE-only slice: **workspace-active indicator** — loading-dots on -workspace cards when a workspace has ≥1 active/queued conversation. New `AppStore.workspaceHasActiveConversations(workspaceId)` derives from the existing open-tab set (every active/queued -conversation has an open tab stamped with its `workspaceId`) × the backend lifecycle statuses; a -`hasActive?: (workspaceId: string) => boolean` port on `WorkspacesHome`/`WorkspaceCard` is wired at -`src/App.svelte`. DaisyUI `loading-dots` (same as the tab/composer active indicator). **No backend / -contract change** — no re-pin/re-mirror. typecheck 0/0, 1029 tests green (+10), biome clean, build OK.)_ -_Last updated: 2026-06-27 (§2j-update-3 — concurrency-fixes: configurable + persisted per-provider release -cooldown, adaptive headroom auto-reduce banner, usage gate. ADDITIVE to `[email protected]`, NO version -bump — `ConcurrencyStatusEntry` gained `cooldownMs`/`autoReduced`/`autoReducedFrom?`/`notice?`; NEW -`ConcurrencyCooldownResponse`/`SetConcurrencyCooldownRequest` + `GET`/`PUT /concurrency/cooldown/:providerId`. -FE re-synced the `file:` dep + re-mirrored `.dispatch/transport-contract.reference.md`; built the cooldown -view-model + inline-edit row, a dismissible auto-reduce banner with "Restore to N", store `getConcurrencyCooldown`/ -`setConcurrencyCooldown`, + 49 new tests. typecheck 0/0, 1050 tests green (run TWICE), biome clean, build OK. -Worktree env note: an untracked `dispatch-backend → backend` symlink was created in the worktree parent so the -canonical `file:../dispatch-backend/...` paths resolve — NOT committed.) Prior: §2j (vision consult_vision + image -storage)._ -_Last updated: 2026-06-27 (workspace starring — backend `feature/workspace-star` shipped `Workspace.starred: boolean` -(additive to `[email protected]`, NO version bump) + `PUT`/`DELETE /workspaces/:id/star` endpoints (no body; create-on-miss; -return the updated `Workspace`). FE consumed: `adapter/http` `star`/`unstar`, pure `sortWorkspaces`/`applyStarred`, -`store.setStarred` (optimistic + revert, `$derived`-sorted list), `WorkspaceCard` star toggle (filled gold ★ / outline ☆). -Re-mirrored `.dispatch/wire.reference.md`. typecheck 0/0, 1045 tests green (+27), biome clean, build OK. No open backend asks.)_ -**FE is current on `[email protected]` / `[email protected]` / `[email protected]`.** Open asks: **CR-9** -_Last updated: 2026-06-26 (backend: concurrency limits now PERSISTED across reboots — no API contract change, no FE -re-pin/re-mirror needed; §2j updated. FE: brief "Saved." confirmation on the limit row after a successful save. 926 tests -green.) Prior: CR-13 (`"queued"` ConversationStatus) RESOLVED; dev merged (e81df4c)._ -_Last updated: 2026-06-26 (§2j UPDATED — Image storage: persisted `ImageChunk.url`s are now compact relative HTTP -paths (`/images/<conv>/<uuid>.png`) served by `GET /images/:conversationId/:imageId` (images stored on disk under tmp, -not SQLite). FE resolves relative urls against the API base via a new pure `resolveImageUrl` helper; the optimistic -echo's data URL passes through unchanged; `ChatRequest.images` (send) is unchanged. typecheck 0/0, 959 tests green -(+11), biome clean, build OK. §2i unchanged.)_ -**FE is current on `[email protected]` / `[email protected]` / `[email protected]`.** Open asks: **CR-9** -(`system:os` should detect WSL + include Linux distro — backend behavior change, no contract bump). The SSH-divergence -(§2d) is RESOLVED. CR-13 (`"queued"` ConversationStatus) is RESOLVED. -Backend shipped CR-10 (workspace id on `conversation.open` / `conversation.statusChanged`), CR-11 -(per-conversation model persistence), and CR-12 (`GET /conversations/:id/mcp`); FE has consumed all three. -The backend also added the transient `provider-retry` `AgentEvent` (retry-with-backoff warning) to -`[email protected]` on `dev` (additive — no version bump); FE consumed + re-mirrored `.dispatch/wire.reference.md` — see §2c. -FE re-pinned + re-mirrored `transport-contract`; `selectModel` persists to -`PUT /conversations/:id/model` and conversation focus recalls the persisted model via `GET /conversations/:id/model`. -FE consumes the MCP status slice (`GET /conversations/:id/mcp`, mirroring `/lsp`) — see §2b. - ---- - -## 1. Pinned backend contracts (consumed by the FE) - - -| Package | Used for | -|---|---| -| `@dispatch/ui-contract` | surfaces + surface WS protocol | -| `@dispatch/wire` | `Chunk`/`StoredChunk`(+`seq`)/`ChatMessage`/`AgentEvent`/`TurnSealedEvent`/`TurnProviderRetryEvent`(transient retry-warning)/`Usage`/`StepId` + metrics: `StepMetrics`/`TurnMetrics`, `usage.stepId`, `step-complete`, `done.durationMs`/`done.usage`, `tool-result.durationMs`, `done.contextSize`/`TurnMetrics.contextSize`, `ReasoningEffort`, `QueuedMessage`/`QueuePayload`/`TurnSteeringEvent`, `ConversationMeta`/`ConversationStatus`, `Workspace`/`WorkspaceEntry`(+`defaultComputerId`,+`starred`)/`Computer`/`ComputerEntry` (SSH handoff #1) | -| `@dispatch/transport-contract` | `ChatRequest`(+`reasoningEffort`)/`ModelsResponse`/`ConversationHistoryResponse`/`ConversationMetricsResponse` + `WarmRequest`/`WarmResponse` + `CwdResponse`/`SetCwdRequest` + `ReasoningEffortResponse`/`SetReasoningEffortRequest` + `QueueRequest`/`QueueResponse`/`ChatQueueMessage` + `ConversationOpenMessage`/`ConversationStatusChangedMessage`/`ConversationListResponse`/`LastMessageResponse`/`OpenConversationResponse`/`SetTitleRequest`/`TitleResponse` + LSP (`LspStatusResponse`/`LspServerInfo`/`LspServerState`) + MCP (`McpStatusResponse`/`McpServerInfo`/`McpServerState`) + concurrency (`ConcurrencyLimitsResponse`/`SetConcurrencyLimitRequest`/`ConcurrencyLimitResponse`/`ConcurrencyStatusEntry`/`ConcurrencyStatusResponse`) + WS chat ops + `WsClientMessage`/`WsServerMessage` | - -Endpoints in use (HTTP **24203**, WS **24205**, CORS `*` incl. `PUT`): -`POST /chat` (NDJSON) · `GET /models` · -`GET /conversations/:id?sinceSeq=<n>&beforeSeq=<s>&limit=<k>` (CR-5 windowing) · -`GET /conversations/:id/metrics` · `GET`/`PUT /conversations/:id/cwd` · -`GET`/`PUT /conversations/:id/reasoning-effort` (sticky thinking-depth; `null` ⇒ default `high`) · -`GET`/`PUT /conversations/:id/model` (sticky per-conversation model persistence) · -`GET /conversations/:id/lsp` · `GET /conversations/:id/mcp` (MCP server status; mirrors `/lsp`) · `POST /chat/warm` · `POST /conversations/:id/close` (explicit -tab-close: abort turn + stop/disable warming) · `POST /conversations/:id/queue` (enqueue -steering message; auto-starts a turn if idle) · WS `chat.send`→`chat.delta` · -WS `chat.subscribe`/`chat.unsubscribe` (watch a conversation's turns without sending; replay + live) · -WS `chat.queue` (enqueue steering; fire-and-forget — surface updates on success) · -WS `conversation.open` (broadcast: CLI `--open` flag signals the FE to open/focus a tab; carries `workspaceId`) · -WS `conversation.statusChanged` (broadcast: lifecycle status change — `active`/`idle`/`closed`; carries `workspaceId`) · -`GET /concurrency/limits` · `GET`/`PUT`/`DELETE /concurrency/limits/:providerId` · `GET /concurrency/status` -(per-provider in-flight caps + oldest-agent-first queueing + 429-pause backoff; the `concurrency` extension — when not -loaded the list + status endpoints return empty arrays, the single/PUT/DELETE return `503`). -`GET`/`PUT /concurrency/cooldown/:providerId` (per-slot release cooldown — configurable + persisted; 0 = no cooldown; -`GET` 404 when the provider has no concurrency config at all, `PUT` 400 on a non-negative-int body — see §2j-update-3). - -Mirrored in-repo for headless agents: `.dispatch/{ui-contract,wire,transport-contract}.reference.md` -(regenerate on any contract bump; all current as of `[email protected]` / -`[email protected]` / `[email protected]`). - -### FE invariants to keep (don't regress) - -- **`chat.send` must omit `cwd`** (send `undefined`), never `cwd:""`/`cwd:null`. The `/chat` `cwd` - field treats any non-`undefined` value as "provided". Verified safe: `chat/store.svelte.ts` builds - `chat.send` with only `type`/`conversationId`/`message`/`model` — no `cwd` field. -- **Per-conversation seqs are 1-based, monotonic, gap-free** (CR-5 contractual guarantee on - `StoredChunk`). The FE derives `hasOlder = oldestLoaded.seq > 1`. -- **Warming opt-in is NOT re-hydrated across a backend restart** — a conversation reads disabled - until toggled again (fail-safe). Backend offered boot hydration if it becomes a product need. -- **`PUT /conversations/:id/cwd` sends the active `workspaceId`** (CR-8) so a relative cwd set on a - new/draft tab is assigned to the workspace BEFORE persisting — the subsequent `GET /lsp` then - resolves it against `workspace.defaultCwd`. The store reads `activeWorkspaceId` with `untrack` at - call time (never reactive inside the async). `chat.send` still omits `cwd` (the persisted cwd wins). -- **`conversation.open` and `conversation.statusChanged` WS broadcasts carry `workspaceId`** so tabs opened - by the CLI `--open` flag (or by a cross-device `active` status change) are stamped with the conversation's - actual workspace instead of the viewer's current workspace. The FE ignores any broadcast missing `workspaceId` - (parser returns null) — acceptable because the backend contract is updated in lockstep. - ---- - -## 2. Open asks FOR THE BACKEND - -### CR-9 — `system:os` variable: include WSL detection + Linux distro → **OPEN** - -The `system:os` system-prompt variable (resolved by the backend at construction time) should -return a richer OS string: - -1. **WSL detection** — when running under Windows Subsystem for Linux, the resolved `system:os` - value should indicate WSL (e.g. `"Linux (WSL)"` or `"WSL2"` rather than just `"Linux"`). - Detection: check for the presence of `/proc/sys/fs/binfmt_misc/WSLInterop` or - `Microsoft` in `/proc/version`, or the `WSL_DISTRO_NAME` environment variable. -2. **Linux distro** — on Linux, include the distribution name (e.g. `"Ubuntu 22.04"` or - `"Ubuntu"` rather than just `"Linux"`). Source: `/etc/os-release` (`PRETTY_NAME` or - `NAME`/`VERSION_ID`). - -No wire/transport-contract/ui-contract change needed — this is a backend behavior change in -how the `system:os` variable is resolved (the type shape is unchanged: it's still a `string`). -The FE is unaffected (it only inserts `[system:os]` into the template; the backend resolves it). - -### CR-13 — Per-conversation `"queued"` status for the concurrency queue → **RESOLVED ✅ (backend shipped; FE consumed + tested)** - -Small UX ask: when a conversation's request is WAITING in the per-provider concurrency queue (not yet -generating tokens), the FE shows the loading **RING** (spinner) on that tab + in the composer corner, -instead of the loading **DOTS** (dots = actively generating). - -**Backend (shipped — additive to `[email protected]`, NO version bump):** `ConversationStatus` widened to -`"active" | "queued" | "idle" | "closed"`. When the concurrency manager CANNOT grant a slot -immediately (at limit or paused), `onQueued` fires → the orchestrator broadcasts -`conversation.statusChanged` with `status: "queued"` (carrying `workspaceId`, same shape as before). -`"queued"` is BROADCAST ONLY — it is NOT persisted (the persisted status stays `"active"`; on restart -conversations show `"active"`, never stuck in `"queued"`). When the slot is granted (`acquire` -resolves), `onAcquired` fires → broadcasts `"active"` again. The existing `"idle"` on turn seal is -unchanged. A request that gets a slot immediately never emits `"queued"`. Also: `GET /conversations?status=queued` -now works ("queued" added to the valid status filter set). - -**FE (DONE + verified):** -- Re-synced the `@dispatch/wire` `file:` dep (`bun install`); `node_modules/@dispatch/wire/dist` now has - `"queued"` in `ConversationStatus`. Re-mirrored `.dispatch/wire.reference.md` (widened the type + - header delta note). -- WS parser (`src/adapters/ws/logic.ts`): accepts `"queued"` in the `conversation.statusChanged` - status set (was hard-coded to `active/idle/closed`). -- Store handler (`onConversationStatusChanged`): `"queued"` updates the status map (drives the tab - spinner) AND opens a tab for a new cross-device queued conversation (like `"active"`; `"idle"` - never opens). `closed` still removes the tab. -- `TabList.svelte`: `status === "queued"` → loading-**ring** (`loading-spinner`, `aria-label="Queued"`, - `title="Waiting for a concurrency slot"`); `status === "active"` → loading-**dots** (unchanged); no - status → no spinner. -- `Composer.svelte`: `status` type widened to `ComposerStatus = "idle" | "running" | "queued" | "error"` - (exported via `features/chat/index.ts`). `"queued"` → a loading-ring status icon (`aria-label="Queued"`) - + placeholder "Queued for a slot…". `"queued"` behaves like `"running"` for the send button - (`inFlight = running || queued` → steer/stop) — the turn is in flight, just waiting for a slot. -- `App.svelte`: `composerStatus` derived (`error > queued > running > idle`) — `conversationStatus(id) - === "queued"` wins over `generating` so the corner shows a ring during the wait (`turn-start` fires - before the slot is granted, so `generating` is already `true` while `conversationStatus === "queued"`; - the explicit queued check is what distinguishes them). -- Tests: WS parser accepts `"queued"`; store handler sets the status + opens a cross-device tab + - transitions `queued → active → idle`; TabList renders a ring for `"queued"` + dots for `"active"`. - -**Verification:** typecheck 0/0, **925 tests green** (run TWICE — no cross-test pollution; the store -handler tests feed real WS frames through the parser), biome clean, build OK. Live probe NOT run (the -backend is the user's process; never booted headless). To confirm end-to-end: set a provider's -concurrency limit to 1, start 2 turns on that provider, watch the second tab show a ring ("Queued") -until the first finishes, then flip to dots ("active"). - -### CR-7 — Workspace cwd fallthrough bug + relative-path resolution → **RESOLVED ✅ (backend shipped; FE code unchanged)** - -Fixed backend-side (reply from arch-rewrite agent ab13). **No wire/transport-contract/ui-contract -bumps needed; FE does NOT need a re-pin or re-mirror.** - -**What was fixed:** -1. Workspace `defaultCwd` now applies when the conversation has no explicit per-conversation cwd. -2. A per-turn `cwd` (or persisted `cwd`) that is **relative** is now resolved against the workspace - `defaultCwd`, not raw → falls through to `process.cwd()`. -3. `DELETE /conversations/:id/cwd` now actually clears the persisted cwd (was a no-op stub). -4. New-conversation timing: first turn assigns the workspace before resolving cwd, so a relative - per-turn cwd on a brand-new conversation resolves against the correct workspace. - -**Resolution algorithm (backend-owned):** -``` -workspaceCwd = workspace?.defaultCwd ?? null -conversationCwd = persisted per-conversation cwd OR per-turn cwd from chat.send (null if omitted) -if (conversationCwd == null) effectiveCwd = workspaceCwd ?? serverDefaultCwd // process.cwd() -else if (conversationCwd[0] === "/") effectiveCwd = conversationCwd -else effectiveCwd = path.resolve(workspaceCwd ?? serverDefaultCwd, conversationCwd) -``` - -**FE impact:** none — the FE already sends `workspaceId` and **omits `cwd`** on `chat.send` -(`src/features/chat/store.svelte.ts`). The persisted cwd is set separately via `PUT /conversations/:id/cwd`, -and the backend resolves it at turn start. `GET /conversations/:id/cwd` still returns the raw explicit -value (e.g., `"gameplay"`) for the CwdField; `GET /conversations/:id/lsp` returns the resolved effective -cwd. - -**Optional future FE enhancement:** Add a "Clear" button to CwdField that calls -`DELETE /conversations/:id/cwd`, letting the user reset a conversation to inherit the workspace -`defaultCwd`. Not required for the fix. - ---- - -### CR-10 — `workspaceId` on `conversation.open` / `conversation.statusChanged` WS broadcasts → **RESOLVED ✅ (`[email protected]`; FE consumed)** - -**Bug:** When summoning an agent via the CLI `--open --workspace <id>` flag, the tab opened across -ALL workspaces instead of just the one it was assigned to. The backend knew the conversation's -workspace but dropped it from the broadcast — the `ConversationOpenMessage` and -`ConversationStatusChangedMessage` WS messages carried only `conversationId`. The FE then fell back -to stamping the tab with `activeWorkspaceId` (the viewer's current workspace), so the tab appeared in -every open browser tab's workspace view. - -**Backend fix (shipped):** Additive `workspaceId: string` on both broadcast messages -(`[email protected]`). The backend resolves the conversation's persisted workspace -(`"default"` fallback) at broadcast time — not the per-turn start option — and includes it in the -`conversation.open` and `conversation.statusChanged` fan-out. - -**FE fix (consumed):** -- WS parser (`src/adapters/ws/logic.ts`): parse + require `workspaceId` on both message types. -- `openConversation()` (`src/app/store.svelte.ts`): signature changed to - `(conversationId, workspaceId)`; the tab is stamped with the message's `workspaceId`, not - `activeWorkspaceId`. The `onConversationOpen` and `onConversationStatusChanged` handlers pass - `msg.workspaceId` through. -- Re-mirrored `.dispatch/transport-contract.reference.md`. -- Tests updated: `logic.test.ts`, `index.test.ts`, `conformance.test.ts`. - -**Note:** The FE parser now rejects `conversation.open` / `conversation.statusChanged` messages -missing `workspaceId` (returns null). This is acceptable because the backend contract is updated in -lockstep; a mixed-version setup (old backend + new FE) would silently drop those broadcasts. - ---- - -### CR-11 — Per-conversation model persistence → **RESOLVED ✅ (`[email protected]`; FE consumed)** - -**Backend (shipped):** -- `[email protected]` adds `ModelResponse` and `SetModelRequest`. -- New endpoints: - - `GET /conversations/:id/model` returns `{ conversationId, model: string | null }`. - - `PUT /conversations/:id/model` with body `{ model: string | null }` persists or clears the - per-conversation sticky model selection. -- The backend resolves the model per turn: explicit `ChatRequest.model` override wins, else persisted - model for the conversation, else the server default. - -**FE (consumed):** -- Imported `ModelResponse` + `SetModelRequest`; re-mirrored `.dispatch/transport-contract.reference.md`. -- Added `refreshModel()` (`src/app/store.svelte.ts`) — fetches via `GET /conversations/:id/model` on every - focus change (tab switch, workspace switch, boot, reconnect) and updates `activeModel`, the active tab's - stored model, and the active chat store's model when a non-null model is returned. -- Updated `selectModel(model)` to persist the choice via `PUT /conversations/:id/model` when a real - conversation tab is active; drafts still only update session-local state. -- Tests added (`src/app/store.test.ts`): model selection triggers a `PUT /model` with the right body, - and a persisted model is recalled when focusing a new conversation. - ---- - -### Workspaces — backend-owned conversation grouping with a default cwd → **RESOLVED ✅ (backend shipped; FE build in progress)** - -A **workspace** is a URL-driven (`/<id>`) grouping of conversations that owns a default cwd (used by its -conversations that haven't set their own). Backend-owned so cross-device just works. The backend shipped -the finalized contract (`[email protected]`/`[email protected]`): `Workspace`/`WorkspaceEntry` types, -`workspaceId` on `ConversationMeta` + `ChatRequest`/`QueueRequest`/`ChatQueueMessage`, workspace endpoints -(`GET /workspaces`, `PUT`/`GET /workspaces/:id`, `PUT .../title`, `PUT .../default-cwd`, -`DELETE /workspaces/:id`), `?workspaceId=` on `GET /conversations`, and `DELETE /conversations/:id/cwd` -(clear-to-inherit). Q1–Q8 decisions + full shapes in `backend-handoff-workspaces-reply.md`. FE re-pinned + -re-mirrored; FE feature build in progress. - -### Workspace starring (concurrency priority) → **CONSUMED ✅ (backend shipped `feature/workspace-star`; FE built + green)** - -**Backend contract change (additive to `[email protected]`, NO version bump):** `Workspace` gains a required -`starred: boolean` (defaults `false` on creation). A starred workspace's agents receive PRIORITY in the -concurrency limiter queue — they jump ahead of agents from non-starred workspaces (oldest-agent-first -within each group); takes effect immediately for already-queued agents (backend-owned, via -`setWorkspaceStarred` + `concurrencyService.notifyWorkspaceStarred`). Two dedicated endpoints (no body; -both create-on-miss; return the updated `Workspace`): -- `PUT /workspaces/:id/star` → star (400 on invalid slug; 200 `Workspace{starred:true}`). -- `DELETE /workspaces/:id/star` → unstar (400 on invalid slug; 200 `Workspace{starred:false}`). - -`PUT /workspaces/:id` does NOT accept a `starred` field (the dedicated endpoints are the only path). No -new `transport-contract` request/response types — both reuse `WorkspaceResponse`. - -**FE consumed (all green — typecheck 0/0, 1045 tests, biome clean, build OK):** -- `adapter/http.ts`: `star(id)`/`unstar(id)` → `WorkspaceResult<Workspace>` (PUT/DELETE `/workspaces/:id/star`, no body). -- `logic/view-model.ts`: pure `sortWorkspaces` (starred-first, then `lastActivityAt` desc, stable) + pure - `applyStarred` (the optimistic apply/revert transformation). Both unit-tested. -- `store.svelte.ts`: `setStarred(id, starred)` — optimistic flip (the `$derived` sorted list re-orders - reactively) with error revert; no full refresh on success (avoids flicker). Re-mirrored `.dispatch/wire.reference.md`. -- `ui/WorkspaceCard.svelte`: a star toggle button (filled gold ★ when starred, outline ☆ when not; spinner - while in flight; `aria-pressed`/`aria-label`; tooltip notes concurrency priority). Clicking calls - `store.setStarred(ws.id, !ws.starred)`. -- Tests: http star/unstar (5), view-model sort+applyStarred (12), store optimistic+revert+re-sort (6), WorkspaceCard star button (4). - -**No open asks for the backend.** Live probe of the star endpoints against a running backend is the only -remaining human step (the `scripts/live-probe.ts` does not cover workspace endpoints; a manual `curl` or a -click in the home view confirms the round-trip). - -### CR-6 — Assign seq during generation → **RESOLVED ✅** (backend shipped; FE adoption pending) - -The backend now persists chunks **incrementally at step boundaries** during generation: -1. Turn starts → user message is `append`ed immediately (gets seq). -2. Each step completes → step's messages are `append`ed immediately (get seq). -3. Turn seals → `turn-sealed` emitted (no batch append needed — already persisted). - -`GET /conversations/:id?sinceSeq=N` returns committed, seq'd chunks **during generation**. The -FE's existing `syncTail` already polls this — it will find new chunks as each step completes. No -wire/transport-contract change needed (`StoredChunk` already has `seq`; `AgentEvent` types unchanged). - -**FE adoption: NOT pursuing syncTail-during-generation.** Investigation revealed -the kernel emits `step-complete` (line 360 of `run-turn.ts`) BEFORE calling -`onStepComplete` (line 542) — the step's chunks are persisted only AFTER tool -results come back, not when `step-complete` fires. So `syncTail` triggered by -`step-complete` finds nothing. Moving the emission after `onStepComplete` would -be a kernel change. - -Instead, the FE now trims provisional chunks directly in `trimTranscript` when -committed is exhausted — no `syncTail` needed. Dropped provisional chunks are -lost temporarily (no "Show earlier" for them) but come back as committed when -the turn seals and `syncTail` fetches everything. - ---- - -### Resolved CRs (for reference) - -| CR | Summary | Status | -|---|---|---| -| CR-12 | `GET /conversations/:id/mcp` MCP server-status endpoint (mirrors `/lsp`) | ✅ `[email protected]`; backend shipped; FE consumed + verified (3 LSP-shape diffs handled) | -| CR-10 | `workspaceId` on `conversation.open` / `conversation.statusChanged` WS broadcasts | ✅ `[email protected]`; FE consumed | -| CR-7 | Workspace cwd fallthrough bug + relative-path resolution | ✅ resolved (backend-only) | -| CR-1 | Loaded Extensions as a true table (`rendererId: "table"`) | ✅ shipped + consumed | -| CR-2 | catalog `scope` flag (`"global"` / `"conversation"`) | ✅ `[email protected]` | -| CR-3 | `user-message` event (watcher sees user prompt mid-turn) | ✅ `[email protected]` | -| CR-4 | cache-warming lifecycle (default OFF, future `nextWarmAt`, `POST /close`) | ✅ `[email protected]` | -| CR-5 | history windowing (`?limit=`, `?beforeSeq=`, 1-based gap-free seqs) | ✅ `[email protected]` / `[email protected]` | -| CR-6 | Assign seq during generation (incremental persist at step boundaries) | ✅ shipped; FE adoption pending | - ---- - -## 2b. MCP server status slice → **RESOLVED ✅ (backend shipped CR-12; FE verified)** - -Consumes the backend's `GET /conversations/:id/mcp` endpoint, mirroring `GET /conversations/:id/lsp` -exactly. Contract types in `[email protected]`: `McpServerState` -(`"connecting" | "connected" | "error" | "disconnected"`), `McpServerInfo` -(`{ id, state, error?, toolCount, configSource? }`), `McpStatusResponse` -(`{ conversationId, cwd: string|null, servers: McpServerInfo[] }`). Full backend handoff: -`../backend/frontend-mcp-status-handoff.md`; shape/behavior details: -`../backend/reports/transport-http-mcp.md`. - -**Backend (shipped CR-12):** endpoint behaves identically to `/lsp` — no persisted cwd → -`{ cwd: null, servers: [] }` (HTTP 200, empty); MCP extension not loaded → -`503 { error: "MCP service not available" }`. The FE handles both gracefully (empty/no-cwd/503 paths -never crash the renderer; the 503 `error` string surfaces in red). - -**FE (DONE + verified against the 3 LSP-shape differences the backend flagged):** -- Re-pinned `transport-contract` 0.20.0 → 0.22.0; re-mirrored `.dispatch/transport-contract.reference.md` - (added the MCP section + the previously-missing `configSource` on `LspServerInfo`). -- New feature library `src/features/mcp/`: pure `logic/view-model.ts` (`viewMcpServer`/`viewMcpServers`/ - `summarizeMcpServers`, state→badge/label/busy mapping), `ui/McpStatusView.svelte` (mirrors - `LspStatusView`'s structure — refresh button, loading, summary, server list), `index.ts` - (`McpStatusView`/`manifest`/types). 9 view-model tests green. -- `AppStore.mcpStatus()` (`src/app/store.svelte.ts`) — mirrors `lspStatus()`: normalizes the untyped - body at the network seam (`servers` guaranteed an array), returns `McpResult | null`. -- Wired into `src/app/App.svelte`: `"mcp"` view kind, `loadMcpStatus` adapter, `McpStatusView` in the - `viewContent` snippet (re-mounts per conversation via `{#key store.currentConversationId}`). - -**Three shape differences from LSP — verified handled, do NOT regress:** -1. **Fields differ.** `McpServerInfo` has only `{ id, state, error?, toolCount, configSource? }` — NO - `name`/`root`/`extensions`. The MCP row markup (`McpStatusView.svelte`) renders `id`/state badge/ - `toolCount`/`error`/`configSource` only; it does NOT reference any LSP-only field. `McpServerView` - is a distinct type from `LspServerView` (the latter carries `name`/`root`/`extensionsLabel`). Do not - reuse the LSP row component verbatim for MCP. -2. **Enum differs.** `McpServerState = "connecting" | "connected" | "error" | "disconnected"` — a - DIFFERENT enum from LSP's `"connected" | "starting" | "error" | "not-started"`. `viewMcpServer`'s - switch handles all four MCP cases (exhaustive vs the contract): `connected`→success, `connecting`→ - warning+busy(spinner), `disconnected`→neutral (NOT busy — a stable idle state), `error`→error. Do - not share the LSP badge mapping. -3. **`configSource?` is currently always absent** on this path (the `McpServerStatus` source doesn't - carry it) but the wire type leaves it optional. The FE renders it defensively: view-model coerces - `server.configSource ?? null`; the template guards `{#if server.configSource}` with an empty-`<span>` - fallback, so absence renders nothing. Verified correct for the current always-absent reality. - -**Live probe:** NOT run — the backend was not reachable on `:24203` at verify time (the backend is the -user's process; never booted headless). The unit suite (775 tests, incl. 9 mcp) is green; a live probe -against a running backend remains a nice-to-have for the actual network seam (it's a plain HTTP GET, -no WS). Start the backend and run `bun scripts/live-probe.ts` (or just open the MCP Servers sidebar view) -to confirm end-to-end. - ---- - -## 2c. Transient `provider-retry` AgentEvent → **CONSUMED ✅ (backend shipped; FE wired + tested)** - -The backend now retries retryable provider errors (e.g. "server overloaded" HTTP 429/5xx) with a stepped -backoff (5s→10s→30s→60s→5m→10m→15m→30m→repeat, up to an 8h budget). Each scheduled retry emits a NEW -**transient** `AgentEvent`: `provider-retry`. The FE renders it as a **yellow warning system-message -bubble**; the actual model reply still streams normally after a retry succeeds. Contract types are in -`[email protected]` (additive — the type was added to the existing version, no bump; the FE's `file:` dep picks -it up automatically, no re-pin needed). `TurnProviderRetryEvent`: -`{ type: "provider-retry", conversationId, turnId, attempt (0-based), delayMs, message, code? }`. - -**FE (DONE + verified):** -- Re-mirrored `.dispatch/wire.reference.md` (added `TurnProviderRetryEvent` to the `AgentEvent` union + - its interface). The FE already resolved the new type via the `file:` symlink (no re-pin). -- `src/core/chunks/types.ts`: added `providerRetry: TurnProviderRetryEvent | null` to `TranscriptState` - (mirrors the `generating` UI-indicator pattern — event-stream-derived state that is NOT a chunk). -- `src/core/chunks/reducer.ts`: `foldEvent` SETS the banner on a `provider-retry` (coalescing — latest - attempt+delay replaces previous → a single updating banner) and CLEARS it when content resumes - (`text-delta`/`reasoning-delta`/`tool-call`/`tool-result`), the turn ends (`done`/`turn-sealed`/`error`), - or a new turn starts (`turn-start`); metadata events (`status`/`usage`/`step-complete`/`tool-output`) - leave it untouched. The clearing is centralized via a `RETRY_CLEARING_EVENTS` Set in a thin `foldEvent` - wrapper over the renamed inner `reduceEvent`. `clearGenerating` (WS reconnect) also drops a stale banner. -- `src/core/wire/conformance.ts` + `.test.ts`: added the `provider-retry` case to the exhaustiveness guard - and bumped the variant count 14 → 15 (the `satisfies never` guard is what flagged the new variant). -- `src/core/chunks/selectors.ts` (`selectProviderRetry`) + new `retry-banner.ts` view-model - (`viewProviderRetry` → `{ attemptLabel: "Retry #N", delayLabel: "5s"/"30m", message, code }`, - `formatRetryDelay`), exported from `chunks/index.ts` + re-exported from `features/chat/index.ts`. -- `src/features/chat/store.svelte.ts`: `providerRetry` getter + `ChatStore` interface field (mirrors - `generating`). -- `src/features/chat/ui/ChatView.svelte`: new `providerRetry` prop → renders a DaisyUI `alert alert-warning` - yellow bubble at the end of the transcript (where the reply would appear), with `⚠ Retry #N — retrying in - {delay}…`, a `code` badge, and the endpoint error verbatim. Re-mounted per conversation. -- `src/app/App.svelte`: passes `providerRetry={store.activeChat.providerRetry}`. -- 19 new tests (reducer: set/coalesce/clear-on-resume/clear-on-turn-end/clear-on-new-turn/clear-on-reconnect/ - not-a-chunk/metadata-preserves; retry-banner: delay formatting + view-model). 775 tests green. - -**Transient / never-persisted guarantee (the critical invariant):** `provider-retry` is NEVER a `Chunk` -(it's not in the `Chunk.type` union — `assertChunkExhaustive` is unchanged). It lives only in -`TranscriptState.providerRetry`, set/cleared by `foldEvent`. It never enters `committed` (seq'd history) or -`provisional`, so it can NEVER pollute the model's prompt or be sent back as a message. On a reload/replay of -past turns it is NOT replayed (only committed seq'd chunks are history) — `providerRetry` starts null and is -set only when a NEW `provider-retry` arrives. On a WS reconnect mid-turn, `clearGenerating` drops any stale -banner (past retries aren't replayed). If the 8h budget exhausts, the existing terminal `error` `AgentEvent` -fires and seals the turn — rendered as the existing error state (the banner is cleared by the `error` case). - -**Note on `step-complete` timing:** when retries occur, the `step-complete` event's `genTotalMs` includes -the retry-sleep time (backend-side, cosmetic). The FE surfaces per-step timing unchanged — no action needed. - -**Open follow-up (optional, not blocking):** the "countdown" is a STATIC label derived from `delayMs` -("retrying in 5s…"), matching the backend's examples — NOT a live ticking timer (which would be a component -effect + re-render churn). A live ticking countdown (5,4,3,2,1…) could be added later as a Svelte -`$effect`/`setInterval` in ChatView if desired, but the static label is accurate and keeps the component thin. - -**Live probe (run, backend up):** `scripts/live-probe-provider-retry.ts` — 8/8 checks passed: -- REGRESSION: a real text turn through the REAL WS socket + the updated `foldEvent` sealed cleanly and - `providerRetry` stayed NULL throughout (no spurious banner; the `reduceEvent` wrapper didn't break streaming). - (The repo-wide `scripts/live-probe.ts` also still passes 23/23 — text/tool/metrics/CR-5 all green with the - updated reducer.) -- PARSER+REDUCER SEAM: a synthetic `provider-retry` `chat.delta` JSON frame through the REAL - `parseServerMessage` is ACCEPTED (not rejected as unknown) → `foldEvent` SETS `providerRetry` (attempt/ - delayMs/code correct) and adds NO chunk → a 2nd coalesces → a subsequent `text-delta` CLEARS it and the - reply lands as a chunk. This is the JSON-parse boundary the unit tests skip (they pass constructed events). -- NOT exercised live: the actual banner rendering in the browser (a real `provider-retry` from an overloaded - provider can't be forced from a probe — it needs a human at the page with a 429'ing provider). The full - data path (wire parse → reducer → state) IS verified live; only the Svelte render of the yellow bubble - remains a human-confirm (open the chat, trigger an overloaded provider, confirm the yellow "⚠ Retry #N — - retrying in 5s…" banner appears, updates per attempt, and clears when the reply streams). - ---- - -## 2d. SSH support — handoff #1 (wire types) → **CONSUMED ✅ (provider-retry divergence RESOLVED)** - -The first of a few incremental SSH handoffs. The backend's `@dispatch/wire` (pinned `file:` dep) gained SSH-computer -types — **additive to `[email protected]`, NO version bump** (same pattern as `provider-retry`). The full HTTP API surface -(computer endpoints, `chat.send computerId`) comes in a LATER handoff; this one is wire-types only. - -**New wire types (consumed + re-mirrored):** -- `Workspace` gained a REQUIRED `defaultComputerId: string | null` (null = local / no SSH; the computer analog of - `defaultCwd`). Resolution is SERVER-owned (per-conv `computerId` → `workspace.defaultComputerId` → `null`/local). -- `Computer` — a read-only view of a discovered `~/.ssh/config` `Host` target: - `{ alias, hostName, port, user, identityFile, knownHost }`. `alias` IS the `computerId` users select. NOT an - editable entity (no CRUD store — the user edits `~/.ssh/config` to add one). -- `ComputerEntry extends Computer` — adds `usageCount` (for `GET /computers`). - -**FE (DONE):** -- Re-synced the `@dispatch/wire` `file:` dep (worktree layout note below) so the new types resolve. -- Re-mirrored `.dispatch/wire.reference.md`: added `Workspace.defaultComputerId`, the `Computer`/`ComputerEntry` - section, and a header delta note. -- Fixed the 2 `Workspace`/`WorkspaceEntry` test literals that broke (the handoff's expected break): - `src/features/workspaces/ui/WorkspaceCard.test.ts` (`fakeEntry`) and - `src/features/workspaces/logic/view-model.test.ts` (`ws` factory) — both now supply `defaultComputerId: null`. - (The conformance test's `provider-retry` case is the unrelated divergence below — NOT a `defaultComputerId` site.) -- Added `computer` / `computerId` to `GLOSSARY.md` (backend-canonical, adopted verbatim). - -**NOT started (correctly deferred):** the `computer` feature folder, the per-conversation + workspace-default -selectors, the connection-status badge, and `chat.send computerId` — all wait on the later HTTP-API handoff. - -### `provider-retry` divergence → **RESOLVED ✅ (backend merge `de022ce`)** - -**Was:** the backend `feature/ssh-support` branch (cut from `8a74335`) was MISSING `TurnProviderRetryEvent` / -`provider-retry` (on `dev`), causing 11 FE typecheck errors. **Resolved:** backend merged `dev` into -`feature/ssh-support` (merge commit `de022ce`, in the shared `../backend` worktree); `packages/wire/dist/index.d.ts` -now exports `TurnProviderRetryEvent` (AgentEvent union line 231 + interface line 384) ALONGSIDE the SSH types -(`Workspace.defaultComputerId`, `Computer`). Backend post-merge: `tsc -b` EXIT 0, biome clean, 1730 vitest pass. -The auto-merge was clean — `computerId` threading + retry-with-backoff coexist. - -**FE (DONE, zero code changes):** re-synced both `file:` deps (`bun install`); `node_modules/@dispatch/wire` + -`@dispatch/transport-contract` both resolve `TurnProviderRetryEvent` (the transport-contract re-export confirmed). -`bun run typecheck` is now **GREEN — 0 errors, 0 warnings** (the 11 cleared with NO FE code changes; the -`provider-retry` consumption was already complete + tested). Full suite: 795/795 tests, biome clean, build OK. - -### Worktree environment note (not a contract change) -This worktree lays the repos out as `…/worktrees/ssh-support/{backend,frontend}`, but `package.json`'s canonical -`file:` paths point at `../backend` (correct for the main `dispatch/{dispatch-backend,dispatch-web}` layout). -To keep `package.json` canonical (no worktree-specific hack committed), a symlink `../backend → ../backend` -was created in the worktree parent (untracked, outside the repo), then `bun install` re-synced `node_modules/@dispatch/*`. -The backend wire `dist/` was already built + current (has the new types); no backend edit was made. - ---- - -## 2e. SSH support — handoff #2 (full computer HTTP API) → **CONSUMED ✅ (FE built; fully green post §2d merge)** - -The backend shipped the full SSH computer HTTP/WS API (transport-contract types stable; the `ssh` extension that -provides the ComputerService is the last backend wave — until it lands, `GET /computers` returns `[]` and statuses -return `disconnected`, which the FE renders gracefully). The FE mirrors the existing `cwd`/`workspaces` UI. - -**New transport-contract types consumed (additive to `[email protected]`, NO version bump):** `ComputerListResponse` -(`GET /computers`), `ComputerResponse`, `ComputerStatusResponse` (`GET /computers/:alias/status`), `TestComputerResponse` -(`POST /computers/:alias/test`), `SetConversationComputerRequest` + `ConversationComputerResponse` -(`GET`/`PUT`/`DELETE /conversations/:id/computer`), `SetWorkspaceDefaultComputerRequest` -(`PUT /workspaces/:id/default-computer`), and `computerId?: string` on `ChatRequest`/`ChatSendMessage`/`QueueRequest`. -`Computer`/`ComputerEntry` are `@dispatch/wire` (handoff #1). Re-mirrored `.dispatch/transport-contract.reference.md` -(added the Computers section + `ChatRequest.computerId`). - -**FE (DONE — mirrors cwd-lsp's consumer-defines-port pattern):** -- New feature library `src/features/computer/`: pure `logic/view-model.ts` (`viewComputer`/`viewComputerStatus`/ - `viewTestResult`/`summarizeComputers`/`formatHost`/`knownHostLabel` + state→badge mapping for the 4 - `ComputerStatusResponse.state`s + the `SaveComputer`/`LoadComputerStatus`/`TestComputer`/`LoadComputers` ports) — 20 - view-model tests green; `ui/ComputerField.svelte` (per-conversation selector: dropdown + connection-status badge + - Test-connection, polling the selected alias) + `ui/ComputerSelect.svelte` (a reusable Local/computers dropdown, shared - with the workspace default-computer control); `index.ts` (`ComputerField`/`ComputerSelect`/`manifest`/types). -- `AppStore` (`src/app/store.svelte.ts`): `computerId` reactive state + `refreshComputer()` (parallel to `refreshCwd`, - called at every focus site: boot, workspace switch, draft→tab, newDraft, selectTab, removeTabLocally) + - `setComputer(computerId: string | null)` (`PUT /conversations/:id/computer`, null = clear) + a global `computers` - catalog (`GET /computers` on boot, like `models`) + `computerStatus(alias)` + `testComputer(alias)`. New result types - `ComputerResult`/`ComputerStatusResult`/`TestComputerResult`. `chat.send` UNCHANGED (computer resolved server-side - from the persisted per-conversation value, exactly like cwd). -- `src/app/App.svelte`: `ComputerField` mounted in the "Model" sidebar view next to `CwdField`, keyed on - `currentConversationId`; adapted ports (`saveComputer`/`loadComputerStatus`/`testComputer`) wrap the store. -- Workspaces: `setDefaultComputer` added to `WorkspaceHttp` + `WorkspaceStore` (`PUT /workspaces/:id/default-computer`); - a default-computer selector (reusing `ComputerSelect`) added to `WorkspaceCard.svelte` next to the default-cwd control; - the router (`src/App.svelte`) passes `store.computers` through `WorkspacesHome` → `WorkspaceCard`. - -**Transparency invariant (held):** the computer is USER-facing only — it is a tool-execution target forwarded to tools -and NEVER part of the model prompt (so it does not affect prompt caching); the agent never sees it. Documented in the -feature's pure core + surfaced in the `ComputerField` helper text. - -**NOT done (correctly deferred):** a per-send `computerId` override (the MVP UI doesn't expose it; persisted per-conversation -suffices). No `chat.send` change. - -**Verification:** 795/795 tests green (50 files; +20 computer view-model); biome clean; `vite build` succeeds. `svelte-check` -reports **0 errors from the computer feature**. (The pre-existing §2d `provider-retry` divergence — 11 errors — is -now RESOLVED via the backend `dev`→`feature/ssh-support` merge `de022ce`; `bun run typecheck` is fully GREEN.) -Live probe NOT run (the backend `ssh` extension isn't live yet → `GET /computers` returns `[]` end-to-end; a live probe + -human confirm of the dropdown/badge/test should run once `ssh` is wired + the `provider-retry` divergence is merged). - ---- - -## 2f. Heartbeat (workspace autonomous-agent loop) → **CONSUMED ✅ (backend shipped; FE built)** - -The backend shipped a workspace-scoped **heartbeat** — an autonomous agent loop that periodically runs a turn in a -dedicated conversation using a configured system prompt, task prompt, model, reasoning effort, and interval. The FE -exposes the config, run history, and a per-run live chat in a new sidebar **Heartbeat** view (branch `feature/heartbeat`). - -**Backend API (plain REST — NOT a transport-contract type):** -- `GET /workspaces/:id/heartbeat` → `{ enabled, systemPrompt, taskPrompt, intervalMinutes, model, reasoningEffort }` -- `PUT /workspaces/:id/heartbeat` (partial body) → updated config -- `GET /workspaces/:id/heartbeat/runs` → `{ runs: [{ id, conversationId, triggeredAt, status }] }` (`status: running|completed|stopped`) -- `POST /workspaces/:id/heartbeat/runs/:runId/stop` → `{ ok: true }` - -**Contract note (important):** the heartbeat shapes are NOT in `@dispatch/transport-contract` / `@dispatch/wire` -(verified: no `heartbeat` symbol in either `dist/`). So the FE owns the types locally in `src/features/heartbeat/logic/types.ts` -(consumer-defines-port, mirroring the `mcp`/`computer` result-type pattern) and coerces the untyped JSON at the network -seam via pure `normalizeHeartbeatConfig`/`normalizeHeartbeatRuns` (a malformed/partial response can never crash the -renderer). **If the backend later promotes these to a shared contract package, swap the local types for the imports + -re-mirror `.dispatch/*.reference.md`.** No contract version bump on the FE side (no `file:` dep re-pin needed). - -**FE (DONE + verified):** -- New feature library `src/features/heartbeat/`: pure `logic/view-model.ts` (`viewRun`/`viewRuns`/`badgeForStatus`/ - `statusLabelFor`/`formatRunTime`/`relativeLabel`/config-form helpers `formFromConfig`/`patchFromForm`/`formDiffers`/ - `normalizeInterval` (1–1440 clamp)/network normalizers + `effortOptions` re-exported from `features/chat`) — 35 - view-model tests green; `ui/HeartbeatView.svelte` (config panel: enable toggle (saves immediately), system + task - prompt textareas, model dropdown, reasoning-effort dropdown, interval input, Save button with `hasChanges` guard; - scrolling runs list polling every 4s with a spinner when running + per-row Stop) + `ui/RunModal.svelte` (fullscreen - modal that reuses `features/chat`'s `ChatView` to render the run's conversation; live-streams via the store's - `watchConversation`/`chat.subscribe` while `generating`, with a Stop button → `POST .../runs/:runId/stop`); - `index.ts` (`HeartbeatView`/`RunModal`/`manifest`/types). The reasoning-effort ladder is REUSED from `features/chat` - (sanctioned cross-feature import through its public exports) — no drift. -- `AppStore` (`src/app/store.svelte.ts`): `heartbeatConfig`/`setHeartbeatConfig`/`heartbeatRuns`/`stopHeartbeatRun` - (workspace-scoped; read `activeWorkspaceId` with `untrack`) + **`watchConversation`/`unwatchConversation`** — a new - "watch" mechanism for the modal: reuses an open tab's `ChatStore` (already subscribed) or creates an EPHEMERAL watch - store in a new `watchStores` map (separate from tabs — never opens a tab) + subscribes via `chat.subscribe` + loads - history; deltas route to it (the `handleChatMessage` hot path now also checks `watchStores`); `onReopen` re-subscribes - + resyncs watch stores; `dispose` disposes them. `unwatchConversation` is a no-op for a tab conversation (it keeps its - store + stream) — only the ephemeral watch store is disposed + unsubscribed. -- Wired into `src/app/App.svelte`: `"heartbeat"` view kind (in `viewKinds`), `heartbeatManifest` in `loadedModules`, - thin adapter functions, the `viewContent` snippet branch, + `{#key heartbeatRun.id}` RunModal render when a run is - selected. -- Store tests (`src/app/store.test.ts`): +7 — config load/PUT-merge/error, runs load, stop POST, watch subscribe + - live-delta routing, tab-reuse (no extra subscribe) + unwatch no-op for a tab. -- Also fixed a PRE-EXISTING `WorkspaceCard.test.ts` typo (`onNavigate` shorthand used before declaration → - `onNavigate: vi.fn()`) that was red on the branch HEAD independent of heartbeat. - -**Verification:** 837/837 tests green (run TWICE — no cross-test pollution; the watch stores are plain `Map`s, not -shared globals, but the methodology's double-run is honored); `svelte-check` 0 errors; biome clean; `vite build` -succeeds. Live probe NOT run (the backend's heartbeat loop + endpoints were not reachable headless at verify time; the -unit + store tests fully cover the data path + the WS routing seam). To confirm end-to-end: start the backend, open the -Heartbeat sidebar view, toggle enable, edit+Save the config, watch a run appear + stream live in the modal, click Stop. - -**Vocabulary note (heads-up, not blocking):** `GLOSSARY.md` marks **"view"** as RESERVED (old-Dispatch sidebar -affordance, future). The heartbeat UI follows the codebase's ESTABLISHED convention — sidebar panels are already called -"views" pervasively (`viewKinds`, `ViewSidebar`, `viewContent`, "Model view"/"LSP view"/"Settings view"). The feature -module itself is named `heartbeat` (a feature module). No new term was coined. If the reserved-"view" cleanup happens -later, the heartbeat view kind renames in lockstep with the rest. - ---- - -## 2g. Heartbeat follow-up UI — prompt editor modal + hours/minutes timer → **FE BUILT; 1 BACKEND ASK** - -A follow-up to §2f (branch `feature/heartbeat`, uncommitted-to-this-handoff commit). Two UI changes on the heartbeat -config panel, each analyzed below for backend impact. **One needs a backend change (variable resolution in the -heartbeat prompts); the other needs none.** - -### Change A — Prompt editor modal (replaces the two textareas) → **1 BACKEND ASK (CR-HB-1)** - -The config panel's two inline textareas (system prompt + task prompt) are replaced by a single "Edit prompts" button -that opens a full-width modal (`src/features/heartbeat/ui/PromptEditor.svelte`): -- LEFT side: two stacked text editors (top = system prompt, bottom = task prompt). -- RIGHT side: the variable palette (grouped by type, same `[type:name]` tag insertion as the global system-prompt - builder). Clicking a variable inserts `[type:name]` at the cursor of whichever textarea is focused. -- Save persists both prompts via the existing `PUT /workspaces/:id/heartbeat` with `{ systemPrompt, taskPrompt }` - (a partial patch — no new endpoint, no data-shape change). - -**What the FE reuses (NO new endpoint needed):** -- The variable palette is sourced from the EXISTING global `GET /system-prompt/variables` endpoint — the SAME one the - global System Prompt builder uses. The heartbeat feature imports the pure helpers (`buildTag`, `groupVariables`, - `insertTag`, `isDynamicVariable`) from `features/system-prompt` (its public `index.ts` — `isDynamicVariable` was added - to that export in this slice, an additive cross-unit seam change). So there is **no new variable source or endpoint**; - the heartbeat prompt editor offers the exact same variable tags the global system prompt does. -- The persisted strings carry literal `[type:name]` placeholders (plain text), exactly like the global template. - -**CR-HB-1 — Resolve `[type:name]` variables in the heartbeat system/task prompts → ASK (needs backend confirmation + likely implementation):** - -The global system-prompt template is resolved by the backend: `[type:name]` placeholders (e.g. `[system:os]`, -`[system:date]`, `[file:path]`, `[if system:wsl]…`) are substituted with their resolved values at construction time -(once per conversation at first turn, then persisted for prompt-cache safety). **The critical question: does the -backend apply this SAME variable resolution to the heartbeat's `systemPrompt` and `taskPrompt` fields?** - -- If YES (the heartbeat prompts already flow through the same resolver as the global template): **no backend change - needed** — the FE just inserts the same `[type:name]` tags and the backend resolves them. Confirm + close. -- If NO (the heartbeat prompts are inserted into the model turn as raw text, so `[system:os]` would reach the model - literally as the string `[system:os]` rather than the resolved OS string): **the backend should resolve `[type:name]` - placeholders in the heartbeat `systemPrompt` + `taskPrompt` using the SAME resolver/variable set as the global - system prompt** (so a variable inserted in either place resolves identically). This is the expected gap — the heartbeat - prompts are a NEW surface that predates the variable system, so they likely bypass the resolver. - - - Resolution timing: mirror the global template's behavior — resolve once when a heartbeat run's turn is constructed - (NOT on every interval tick, to stay prompt-cache-safe; a stable resolved prompt keeps the cache warm across runs). - If the heartbeat re-resolves per-run anyway (intervals may want fresh `[system:time]`), that's a backend product - decision — the FE doesn't care WHEN it resolves, only THAT `[type:name]` becomes its value. - - Variable set: the SAME catalog `GET /system-prompt/variables` returns (system/file/prompt/git groups). No - heartbeat-specific variables are required for this slice. - - No wire/transport-contract/ui-contract change needed — this is a backend behavior change (the prompt strings are - still `string`; the FE is unaffected once the backend resolves them). - -**Optional future enhancement (NOT required now):** heartbeat-specific variables (e.g. `[heartbeat:runCount]`, -`[heartbeat:lastResult]`, `[heartbeat:elapsed]`). The FE's prompt editor would offer these if `GET /system-prompt/variables` -(or a new `GET /workspaces/:id/heartbeat/variables`) returned them. Defer until there's a product need. - -### Change B — Hours + minutes interval timer → **NO backend change needed** - -The interval input is split into two fields: an HOURS input (left) + a MINUTES input (right, 0–59). The conversion is -entirely FE-side: -- On load: the backend's single `intervalMinutes` is split into `{ hours, minutes }` via the pure `splitInterval` - helper (`Math.floor(total/60)` hours, remainder minutes). -- On save: the FE recombines via `joinInterval(hours, minutes)` → `hours*60 + minutes` (clamped to the 1–1440 range - = 1 min–24 h) and sends a SINGLE `intervalMinutes` integer to `PUT /workspaces/:id/heartbeat`. - -So **the backend's `intervalMinutes` field is UNCHANGED** — still one integer of total minutes. The hours/minutes split -is pure FE presentation. No endpoint, data-shape, or behavior change. (The existing clamp to 1–1440 also stands; a -0h0m entry clamps to 1 minute — the FE guards this, and the backend's own validation should too, but that's pre-existing.) - -### FE summary (this slice) -- `src/features/heartbeat/logic/view-model.ts`: `HeartbeatFormState` now carries `intervalHours` + `intervalMinutes` - (0–59); new pure `splitInterval`/`joinInterval` round-trip helpers; `formFromConfig`/`patchFromForm`/`formDiffers` - updated to split/recombine. +4 tests (split/join round-trip, form split, differs-after-edit). -- `src/features/heartbeat/ui/PromptEditor.svelte` (new): two-pane modal (system+task editors left, variable palette - right), focus-aware variable insertion, Save/Reset. Reuses `features/system-prompt`'s pure helpers. -- `src/features/heartbeat/ui/HeartbeatView.svelte`: two textareas → "Edit prompts" button + modal; interval → hours+minutes inputs. -- `src/features/system-prompt/index.ts`: added `isDynamicVariable` to the public exports (additive — needed by the - heartbeat editor's dynamic `file:<path>` row). -- `src/app/App.svelte`: passes `loadVariables={loadSystemPromptVariablesPrompt}` to `HeartbeatView`. - -**Verification:** typecheck 0/0, tests green, biome clean, build OK (see the commit). The variable-resolution behavior -(CR-HB-1) can only be confirmed end-to-end against a running backend with variable-laden heartbeat prompts — a human -should set `[system:os]` in a heartbeat prompt via the new editor, trigger a run, and confirm the resolved value (not the -literal `[system:os]`) appears in the model's context / run transcript. - ---- - -## 2h. Heartbeat system-prompt default + reset button → **FE BUILT; 1 BACKEND ASK (CR-HB-2)** - -A follow-up to §2f/§2g (branch `feature/heartbeat`). Two UX changes on the heartbeat prompt editor: -1. The system prompt now **defaults to the workspace's regular system prompt** (the global `GET /system-prompt` - template — there is no per-workspace system prompt; `Workspace` has no `systemPrompt` field, and the system prompt - is global, resolved once per conversation). When the heartbeat's `systemPrompt` is empty, the editor pre-fills the - textarea with the global default so the user can see + tweak what will run — but a pre-filled default is NOT an - explicit edit (no `hasChanges` until the user edits away from it). -2. A **"Reset to default" button** reverts the system prompt to the global default (clearing any override → inherit). - -### Semantics: heartbeat `systemPrompt` is an OVERRIDE; empty = inherit the global default - -Following the codebase's established resolution-chain pattern (cwd / reasoning-effort / model / computer — all -"persisted value OR fall back to a default; resolution is SERVER-owned"), the heartbeat's `systemPrompt` is now treated -as an **override**: -- `systemPrompt === ""` (empty) → **inherit** the global system prompt (the workspace's regular prompt). -- non-empty → an explicit override. - -The FE never duplicates the global default into the heartbeat config: when the editor's text matches the default (or is -empty), it persists `systemPrompt: ""` (inherit) — so a later change to the global default still flows through. A -distinct edit persists the override verbatim. Pure helpers in `src/features/heartbeat/logic/view-model.ts`: -`effectiveSystemPrompt(override, default)`, `isInheritingSystemPrompt(override)`, -`persistedSystemPrompt(editable, default)` (+6 tests). - -### CR-HB-2 — Resolve an empty heartbeat `systemPrompt` to the global system prompt at run time → **ASK (backend behavior change)** - -The FE sends `systemPrompt: ""` to inherit. **The backend must resolve an empty heartbeat `systemPrompt` to the GLOBAL -system prompt template (`GET /system-prompt`) at heartbeat-run construction time** — so a heartbeat with no override -actually runs the workspace's regular system prompt (not an empty one). - -- **Resolution:** when building a heartbeat run's turn, if the heartbeat's persisted `systemPrompt` is `""`, substitute - the global system prompt template (the same one `GET /system-prompt` returns / that conversations resolve). If - non-empty, use the override as-is. -- **Composes with CR-HB-1:** after resolving empty → global (CR-HB-2), the resulting prompt's `[type:name]` variable - placeholders must still be resolved (CR-HB-1). So both apply, in order: (1) empty ⇒ global template, (2) resolve - `[type:name]` placeholders in whichever prompt is in effect. (For an override, only step 2 applies.) -- **Timing / cache-safety:** mirror the global template's behavior — resolve once per heartbeat run (or once + persist, - whichever the heartbeat scheduler already does for prompt-cache safety). The FE doesn't care WHEN; only THAT empty - becomes the global prompt. -- **No wire/transport-contract/ui-contract change** — `systemPrompt` is still a `string`; empty = inherit. The FE is - unaffected once the backend resolves it. **No new endpoint needed** — the FE already reads the global default via the - existing `GET /system-prompt` (passed through as `loadDefaultPrompt`). - -### FE summary (this slice) -- `src/features/heartbeat/logic/view-model.ts`: 3 pure inheritance helpers (+6 tests). -- `src/features/heartbeat/ui/PromptEditor.svelte`: `loadDefaultPrompt` port (the global `GET /system-prompt`); loads - the default on open + pre-fills the system textarea when inheriting; `hasChanges` diffs against the EFFECTIVE prompt - (override or default) so a pre-filled default isn't an unsaved change; save persists `""` when the text matches the - default (inherit); new "Reset to default" button + "Inheriting workspace default" badge + status hint. -- `src/features/heartbeat/ui/HeartbeatView.svelte`: passes `loadDefaultPrompt` through; `onSaved` syncs the raw - override (`""` = inherit) into the form. -- `src/app/App.svelte`: passes `loadDefaultPrompt={loadSystemPromptPrompt}` (reuses the existing `store.loadSystemPrompt()` adapter) to `HeartbeatView`. - -**Verification:** typecheck 0/0, 849 tests green, biome clean, build OK. The inheritance RUNTIME behavior (CR-HB-2) can -only be confirmed against a running backend: set the heartbeat system prompt to empty (or click "Reset to default" + -Save), trigger a run, and confirm the run used the GLOBAL system prompt (not an empty one). Until CR-HB-2 ships, an -empty heartbeat `systemPrompt` would run with no system prompt — the FE flags this as the known gap. - ---- - -## 2i. Heartbeat next-run countdown timer → **FE BUILT; 1 BACKEND ASK (CR-HB-3)** - -A follow-up to §2f/§2g/§2h (branch `feature/heartbeat`). The Heartbeat sidebar view now shows a live countdown to the -next scheduled run ("Next run in 4m 32s") beneath the Enabled/Disabled status. The FE computes it from a server- -authoritative next-run timestamp + a 1s ticking clock. - -### CR-HB-3 — `GET /workspaces/:id/heartbeat/next-run` → **ASK (new endpoint)** - -**New endpoint:** -``` -GET /workspaces/:id/heartbeat/next-run -``` -**Response shape:** -```json -{ "nextRunAt": "2026-06-25T14:05:00Z" } -``` -or `null` when the heartbeat is **disabled** or **no run is scheduled**: -```json -{ "nextRunAt": null } -``` - -**Semantics:** -- `nextRunAt` is the server-authoritative timestamp of the NEXT scheduled heartbeat run (the moment the scheduler will - fire it), as an ISO 8601 string. It is DERIVED server-side from the scheduler state (the last run's start + the - configured `intervalMinutes`, or the moment `enabled` was toggled on + `intervalMinutes` for the first run) — the FE - cannot derive it accurately (it doesn't know when the last run fired relative to "now" + scheduling jitter, paused- - while-running, etc.). -- `null` when the heartbeat is disabled, or when no run is currently scheduled (e.g. a run is in flight and the next - hasn't been queued yet — the FE then shows no countdown, not a fabricated one). -- Recompute on each call (a cheap read of the scheduler's next-fire time). The FE polls it on the same 4s cadence as - the runs list, so a run completing (→ next run scheduled) reflects within ~4s. - -**Why a dedicated endpoint (not a field on the config/runs response):** the user explicitly asked for "a timestamp -endpoint." It's also the most efficient for polling (a lightweight read of just the next-fire time, vs. re-fetching the -full config or runs). The FE polls it alongside the runs list every 4s. - -**No wire/transport-contract/ui-contract change** — the response is a plain JSON object (the heartbeat API is a plain -REST surface, not a transport-contract type; the FE owns the `HeartbeatNextRunResult` type locally in -`src/features/heartbeat/logic/types.ts` and coerces the untyped body at the network seam). - -### FE behavior (this slice) — works BEFORE the backend ships the endpoint - -- The store's `heartbeatNextRun()` calls `GET /workspaces/:id/heartbeat/next-run`; on **404/error** it returns - `ok: false` (non-fatal). The FE then sets a `nextRunEndpointFailed` flag and **stops polling the endpoint** (no 404 - spam) and falls back to an APPROXIMATION: the latest run's `triggeredAt` + the configured `intervalMinutes` - (`approximateNextRunEpoch`, pure + tested). So the countdown shows (approximate) immediately, and becomes ACCURATE - once the backend ships CR-HB-3 (the FE prefers the server value when available). -- A 1s ticking clock (`now` state) recomputes the countdown locally from `effectiveNextRun` (server value, else the - approximation) — no per-second network churn. Pure `formatCountdown(remainingMs)` → "4m 32s" / "32s" / "1h 05m" / - "due" (≤0) / "—" (unknown). -- The countdown only renders when the heartbeat is **enabled** AND a next-run time is known (`effectiveNextRun !== - null`); disabled → no countdown (just "Disabled"). -- Edge: when the countdown reaches "due" (≤0), a run should be firing; the next 4s poll refreshes `nextRunAt` to the - newly-scheduled run. The approximation similarly refreshes when a new run appears in the runs list. - -### FE summary (this slice) -- `src/features/heartbeat/logic/types.ts`: `HeartbeatNextRunResult` + `LoadHeartbeatNextRun` port. -- `src/features/heartbeat/logic/view-model.ts`: pure `nextRunEpoch` (parse ISO), `formatCountdown`, `approximateNextRunEpoch` (+12 tests). -- `src/app/store.svelte.ts`: `heartbeatNextRun()` (GET `.../heartbeat/next-run`; graceful 404 → `ok:false`). -- `src/features/heartbeat/ui/HeartbeatView.svelte`: polls `nextRun` (stop-on-fail + fallback), 1s countdown clock, "Next run in …" under the status. -- `src/app/App.svelte`: `loadHeartbeatNextRun` adapter → `HeartbeatView`. - -**Verification:** typecheck 0/0, 865 tests green (+12 next-run helpers), biome clean, build OK. The accurate countdown -can only be confirmed against a running backend with CR-HB-3 shipped (enable the heartbeat, watch "Next run in …" tick -down, confirm it matches when a run actually fires). Until CR-HB-3 ships, the FE shows the APPROXIMATE countdown -(latest run + interval) — flagged as the known gap. - ---- - -## 2j. Provider concurrency limits → **CONSUMED ✅ (backend shipped; FE built)** - -The backend tracks + limits how many concurrent token-generating API requests are in flight PER -PROVIDER. When the cap is reached, further requests QUEUE and are granted slots oldest-agent-first -(a 429 backoff PAUSES a provider's queue until `pausedUntil`). The cap is per-provider, managed via a -new GLOBAL REST surface under `/concurrency/...` provided by the `concurrency` extension. **The limits -are now PERSISTED across reboots** (backend update on `feature/provider-concurrency` — `PUT`/`DELETE` -also write to storage; `GET /concurrency/limits` reads in-memory state pre-populated from storage on -boot). **No API contract change** — the endpoints, request bodies, and response shapes are identical, -so the FE needs no re-pin/re-mirror; a limit set via the UI now survives a server restart. (Earlier -backend-only changes since the last handoff, also no FE impact: a 200ms release cooldown for internal -slot recycling, and the `"queued"` `ConversationStatus` — which the FE already shipped, CR-13.) -**`[email protected]`** added the 5 types: `ConcurrencyLimitsResponse`, -`SetConcurrencyLimitRequest`, `ConcurrencyLimitResponse`, -`ConcurrencyStatusEntry`, `ConcurrencyStatusResponse`. - -**Backend API (plain REST — the types ARE in `[email protected]`):** -- `GET /concurrency/limits` → `{ limits: [{ providerId, limit }] }` (empty when extension not loaded) -- `GET /concurrency/limits/:providerId` → `{ providerId, limit }` · 404 (not configured) · 503 (not loaded) -- `PUT /concurrency/limits/:providerId` body `{ limit }` (positive int) → `{ providerId, limit }` · 400 (bad body) · 503 -- `DELETE /concurrency/limits/:providerId` → `{ ok, providerId }` · 404 · 503 -- `GET /concurrency/status` → `{ providers: [{ providerId, limit, inFlight, queued, paused, pausedUntil? }] }` (empty when not loaded) - -**Contract note:** the concurrency shapes ARE in `@dispatch/[email protected]` (a real version -bump, unlike the additive `provider-retry`/`computer` deltas). The FE re-pinned the `file:` dep -(`bun install`) + re-mirrored `.dispatch/transport-contract.reference.md` (appended the 5 types + -bumped the snapshot header). The FE imports the contract types directly (no consumer-defines-port -needed for the data shapes), exactly mirroring `mcp`/`computer`. - -**FE (DONE + verified):** -- New feature library `src/features/concurrency/`: - - `logic/types.ts` — re-exports the 5 contract types + defines the FE-owned result types - (`ConcurrencyLimitsResult`/`ConcurrencyLimitResult`/`ConcurrencyDeleteResult`/ - `ConcurrencyStatusResult`) + the 5 injected ports (`LoadConcurrencyLimits`/ - `GetConcurrencyLimit`/`SaveConcurrencyLimit`/`DeleteConcurrencyLimit`/`LoadConcurrencyStatus`). - - `logic/view-model.ts` (pure) — `viewConcurrencyStatus`/`viewConcurrencyStatuses` (badge + - busy + "2/4" in-flight + queue + `paused — resumes in Ns` countdown), `viewConcurrencyLimit`/ - `Limits`, `summarizeLimits`/`summarizeStatus`, `parseLimitInput`/`normalizeLimit` (positive-int - validation), `formatPauseDuration`/`pauseLabel`, + the network-seam normalizers - `normalizeConcurrencyLimits`/`normalizeConcurrencyLimit`/`normalizeConcurrencyStatus` (defensive - coercion — a malformed/empty `{}` body never crashes the renderer). 34 view-model tests green. - - `ui/ConcurrencyView.svelte` — a sidebar panel with TWO sections: (1) **Concurrency limits** - (config): an add form (provider id text input + positive-int limit + Add → PUT) + a list of - `ConcurrencyLimitRow` (inline-edit limit + Save → PUT + ✕ Remove → DELETE); (2) **Live status**: - a summary + per-provider cards (in-flight "2/4", queued, paused indicator with a live countdown), - polling `GET /concurrency/status` every 2s + a 1s countdown clock (both intervals disposed on - unmount). Reloads limits+status on every mutation. - - `ui/ConcurrencyLimitRow.svelte` — one editable limit row (inline-edit seeded via the ChatLimitField - pattern so a save echo / refresh re-syncs without clobbering an in-flight edit). - - `index.ts` — `ConcurrencyView`/`ConcurrencyLimitRow`/`manifest`/types/exports. -- `AppStore` (`src/app/store.svelte.ts`) — 5 methods (GLOBAL, not workspace-scoped): `concurrencyLimits()`, - `getConcurrencyLimit(providerId)`, `setConcurrencyLimit(providerId, limit)`, - `deleteConcurrencyLimit(providerId)`, `concurrencyStatus()`. Each normalizes the untyped JSON at the - network seam + surfaces HTTP errors (incl. 400/404/503) as `ok:false` with the backend's `error` - string. Interface declarations added to `AppStore`. -- `src/app/App.svelte` — new `"concurrency"` viewKind (sidebar "Concurrency"), `concurrencyManifest` - in `loadedModules`, thin passthrough adapters, + the `viewContent` branch rendering `<ConcurrencyView>` - (global — no `{#key}`, stays mounted across tab switches). -- Tests: 34 view-model + 5 component (`@testing-library/svelte`, faking the 4 ports) + 9 store - (load/empty/503/404/PUT/400/DELETE/status coerce/empty). - -**Verification:** typecheck 0/0, **914 tests green** (run TWICE — no cross-test pollution; the polling -intervals are per-component, cleaned up on unmount by `@testing-library/svelte`'s auto-cleanup), biome -clean, `vite build` succeeds (the lone build warning is PRE-EXISTING — a Tailwind/DaisyUI `file:path` / -`heartbeat:elapsed` arbitrary-CSS ambiguity, not from concurrency). Live probe NOT run (the backend -was the user's process; never booted headless). To confirm end-to-end: start the backend with the -`concurrency` extension loaded, open the Concurrency sidebar view, add a provider limit, watch the live -status poll (in-flight/queued/paused), update + remove it. If the extension isn't loaded, the limits + -status lists render empty (graceful). - -**Note (the single-provider GET):** the FE implements all 5 API client functions (incl. -`getConcurrencyLimit`, endpoint #2), but the UI uses only 4 — the limits LIST covers the configured -providers; `getConcurrencyLimit` is an API-client function for completeness/future use (a detail view). -**No `chat.send` change** — concurrency is a transport-layer concern the backend applies to outbound -provider calls; the agent/model prompt never sees it (does not affect prompt caching). - -**Worktree environment note (same as §2d):** this worktree lays the repos out as -`…/worktrees/provider-concurrency/{backend,frontend}`, but `package.json`'s canonical `file:` paths -point at `../dispatch-backend/...` (kept canonical — no worktree hack committed). An UNTRACKED symlink -`dispatch-backend → backend` was created in the worktree parent, then `bun install` re-synced -`node_modules/@dispatch/*` to pick up `[email protected]`. -## 2j. Vision & vision handoff → **CONSUMED ✅ (backend shipped; FE built + verified)** - -The backend shipped image/vision support: a user can attach images to a chat message, vision-capable -models receive them natively, and non-vision models get an auto-transcribed text description (the -"vision handoff"). The FE now pastes/picks/drops images in the composer, renders `image` chunks in the -transcript, and shows a vision badge in the model picker. Additive to `[email protected]` / -`[email protected]` (**NO version bump** — `ImageChunk`/`ImageInput` were added to the existing -versions; the FE's `file:` dep picks them up automatically, no re-pin needed). - -**New wire/transport types consumed + re-mirrored:** -- `ImageChunk` (`{ type: "image", url, mimeType? }`) — a NEW `Chunk` variant. `url` is a base64 data URL - (`data:image/…;base64,…`) OR an `http(s)://` URL. `ImageInput` (`{ url, mimeType? }`) is the transport- - facing input shape (`ChatRequest.images`); the orchestrator converts each into an `ImageChunk` on the - persisted user message. -- `ChatRequest.images?: readonly ImageInput[]` (so `ChatSendMessage` — which `extends ChatRequest` — - carries it on `chat.send`). `ChatQueueMessage` (steering) does NOT carry `images` — steering is - text-only (correctly: a mid-turn injection has no image surface). -- `ModelMetadata.vision?: boolean` — `true` when the model natively accepts images; absent/`false` → the - server's vision handoff transcribes images to text before the model sees them. -- Re-mirrored `.dispatch/wire.reference.md` (added `ImageChunk` to the `Chunk` union + the `ImageChunk`/ - `ImageInput` interfaces) and `.dispatch/transport-contract.reference.md` (added `images` to `ChatRequest`, - `vision` to `ModelMetadata`, + `ImageChunk`/`ImageInput`/`Computer`/`ComputerEntry` to the re-export). - -**FE (DONE + verified):** -- **Core (`core/chunks`):** the `assertChunkExhaustive` conformance guard caught the new `image` variant - (its purpose) → added the `case "image"` (this was the build break). `appendUserMessage(state, text, - images?)` now echoes a `[text, image, image, …]` user run (text first, then images in order; images-only - when text is empty). The `user-message` event carries ONLY text (never images — images arrive via history/ - loadSince + the optimistic echo), so its de-dup was generalized: it now scans the trailing provisional - USER run for a matching text chunk (not just the last chunk — which would be an image when images were - pasted, causing a duplicate text bubble). `applyHistory`'s during-generation de-dup was generalized to - match a multi-chunk user echo against the trailing committed user run by content equality - (`chunkContentEquals` — text/thinking/image/error/system/tool-call/tool-result), dropping the whole echo - only when fully backed (a partial match is kept until turn-seal drops all provisional wholesale). New - pure helpers `chunkContentEquals` + `trailingRun` (both internal). +16 reducer tests. -- **Transcript (`ChatView.svelte`):** a user `image` chunk renders as an `<img>` (lazy + async-decoded, - max-h-80) inside the user bubble, using the chunk's `url` directly. A non-vision model's persisted user - message keeps the original `image` chunk AND a `text` transcription (`[Image analysis (via <model>)]: …`) - in the SAME message — both render (image, then analysis text). The `read_image` tool call/result - renders like any other tool (its `toolName` is generic — no special-casing). +3 ChatView tests. -- **Composer (`Composer.svelte`):** image paste (clipboard `paste` — extracts image `File` items, - `preventDefault` only when an image is present so text paste still works), an attach-image button + - hidden `<input type=file accept=image/* multiple>`, and drag-drop onto the form. Files are read to - base64 data URLs (`FileReader.readAsDataURL`), capped at 8 MiB, staged as thumbnail previews with - remove buttons. `onSend` signature widened to `(text, images?)`; an image-only send (empty text) is - allowed; `images` is OMITTED on the wire (not `[]`) when none are staged (backward compatible). Steering - (`onQueue`) never forwards images. +6 Composer tests. -- **Store wiring:** `ChatStore.send(text, images?)` forwards `images` on the `chat.send` WS op + echoes - them; `AppStore.send(text, images?)` threads images through the draft→tab promotion; `App.svelte`'s - `handleSend(text, images?)` passes them through. The model catalog already captured `modelInfo` (now - with `vision`); `GET /models` is unchanged. +5 store/app tests. -- **Model picker (`ModelSelector.svelte` + `model-select.ts`):** new pure `isVisionModel(modelInfo, - fullName)`; the model dropdown marks vision-capable models (`" · vision"`), and an indicator below shows - "Vision — this model sees images natively" vs the handoff hint "Pasted images are auto-described". Wired - `modelInfo={store.modelInfo}` from `App.svelte`. +8 model-select/ModelSelector tests. - -**Invariants held:** -- `chat.send` STILL omits `cwd` (the persisted cwd wins) — only `images` was added to the message. -- The `user-message` event still carries only text; images are NEVER expected on it (a watcher fetches - them from history). The de-dup was made robust to the multi-chunk echo rather than reaching for images - on the event. -- `providerRetry`/`generating` unchanged; `image` is a normal committed/provisional chunk (it IS in the - `Chunk.type` union, unlike the transient `provider-retry`), so it persists + replays on reload. -- The `read_image` tool is rendered generically (no surface-id special-casing — the tool-name dispatch is - already identity-free). - -**Verification:** `svelte-check` 0/0; vitest **901/901** (run TWICE — no cross-test pollution; +34 new: -16 reducer, 3 ChatView, 6 Composer, 5 store/app, 4 model-select), biome clean, `vite build` succeeds (the -one CSS warning is PRE-EXISTING — `[file:path]`/`[heartbeat:elapsed]` attribute selectors, unrelated). -**Live probe NOT run:** the backend was not reachable headless at verify time (it is the user's process; -never booted headless). The full data path (paste → data URL → `chat.send` `images` → reducer echo → -transcript render; `GET /models` `vision` → badge; history `image` chunk → render) is covered by unit + -component + store tests. To confirm end-to-end: start the backend, paste an image into the composer with -a vision model selected (e.g. any `kimi/*`), send, confirm the image renders + the model responds to it; -then switch to a non-vision model (e.g. `umans/glm-5.2`), paste an image, send, confirm the image renders -AND a numbered placeholder `[Image N attached — call consult_vision…]` text bubble appears (the non-vision -handoff — see the update below; the old `[Image analysis (via …)]` auto-transcription is GONE); confirm -the vision badge shows/hides per model in the picker. - -### 2j-update. consult_vision tool + vision settings API → **CONSUMED ✅ (backend updated; FE built + verified)** - -A follow-up to §2j. The backend revised the vision surface: the `read_image` tool is REPLACED by -`consult_vision`, non-vision models get NUMBERED PLACEHOLDERS (not auto-transcriptions), and there is a -NEW global vision-settings API. Additive to `[email protected]` / `[email protected]` (NO version bump); -re-mirrored `.dispatch/transport-contract.reference.md` (added `VisionSettingsResponse` / -`SetVisionSettingsRequest` + a delta note). - -**What changed (backend):** -- **`read_image` → `consult_vision`** (`{ question: string (req), imageIds?: number[], path?: string }`): - opens a NEW conversation tab with a vision-capable model (Kimi), attaches the image + question, returns - the conversation id + the vision model's answer (suggests the dispatch CLI for follow-ups). Rendered like - any tool call/result (generic `toolName` dispatch — no special-casing). -- **Non-vision models get NUMBERED PLACEHOLDERS** instead of auto-transcriptions: a pasted image on a - non-vision model persists an `image` chunk + a `text` chunk `[Image N attached — call consult_vision - with imageIds=[N] and a specific question to analyze it]`. (The old `[Image analysis (via <model>)]: …` - auto-transcription is GONE.) -- **Image compaction** (transparent): when a vision model has > `imageLimit` images in history, the oldest - are transcribed to `[Compacted image]: <description>` `text` chunks (the persisted `image` chunk stays - for rendering). Both placeholder + compacted chunks are REGULAR `text` chunks — render as-is (no special - handling). -- **NEW global vision settings API:** `GET /settings/vision` → `VisionSettingsResponse` - (`{ imageLimit: number (default 10), compactionModel: string|null (null = auto) }`); `PUT /settings/vision` - ← `SetVisionSettingsRequest` (partial: `imageLimit?` non-negative int, 0 = disable compaction; - `compactionModel?` `<key>/<model>` or null). - -**FE (DONE + verified):** -- **Tool rendering:** the ChatView `read_image` test → `consult_vision` (rendering is generic — renders by - `toolName`, so no component change; only the test name/input updated). +2 ChatView tests for the - placeholder text chunk + the compacted-image text chunk (both render as regular text). -- **New `vision` feature library** (`src/features/vision/`): pure `logic/view-model.ts` (32 tests) — - `VisionSettings`/`VisionSettingsPatch` (owned locally, consumer-defines-port; the shapes are a plain REST - surface, mirroring heartbeat/mcp), `LoadVisionSettings`/`SaveVisionSettings` ports + result types, - `normalizeVisionSettings` (network-seam coercion — a malformed body can't crash the renderer), - `parseImageLimit`/`imageLimitChanged` (dirty-check), `compactionModelOptions` (filters `GET /models` to - vision-capable models via the chat feature's public `isVisionModel` export + an "Auto" sentinel), - `selectedCompactionValue`/`compactionModelFromValue` round-trip, `imageLimitLabel`; `ui/VisionSettingsView.svelte` - (imageLimit text input + Save, compactionModel dropdown with Auto + vision-capable models, load-on-mount, - save-on-change, error/saved feedback; 9 component tests); `index.ts`. -- **Cross-unit seam:** `isVisionModel` was added to `features/chat`'s public `index.ts` (additive — the - vision feature imports it through the public surface, not the chat internals). -- **Store wiring** (`src/app/store.svelte.ts`): `visionSettings` reactive state + `refreshVisionSettings()` - (`GET /settings/vision`, normalized at the seam) + `setVisionSettings(patch)` (`PUT /settings/vision`, - returns merged settings) + `VisionSettingsResult`; seeded on boot; exposed on `AppStore`. +4 store tests. -- **Mounted in `App.svelte`:** a new "Vision" sidebar view kind (`viewKinds`) + `VisionSettingsView` in the - `viewContent` snippet (not conversation-scoped — no `{#key}`); `loadVisionSettings`/`saveVisionSettings` - adapters wrap the store; `visionManifest` in `loadedModules`. - -**Verification:** `svelte-check` 0/0; vitest **948/948** (run TWICE — no cross-test pollution; +47 new -since the §2j baseline: 32 vision view-model, 9 VisionSettingsView, 4 store, +2 ChatView -placeholder/compacted, and the read_image→consult_vision test update), biome clean, `vite build` succeeds. -**Live probe NOT run** (backend not reachable headless). To confirm end-to-end: open the Vision sidebar -view, confirm the imageLimit + compactionModel load; change imageLimit → Save → confirm it persists on -reload; set compactionModel to a vision model → confirm the dropdown reflects it; paste an image with a -non-vision model → confirm the `[Image N attached — call consult_vision…]` placeholder renders (not an -auto-transcription); trigger a `consult_vision` tool call → confirm it renders like a tool. - -### 2j-update-2. Image storage (tmp, not SQLite) → **CONSUMED ✅ (backend updated; FE built + verified)** - -A follow-up to §2j. The backend no longer persists images as base64 data URLs in the conversation store — -they are saved to a tmp directory and served via HTTP. The `ImageChunk.url` field's FORMAT changed (the -TYPE is unchanged — still `string`); `GET /images/:conversationId/:imageId` is a new endpoint serving raw -bytes + the correct Content-Type. **NO wire/transport-contract type change** (behavior only); re-mirrored -the delta notes in `.dispatch/wire.reference.md` + `.dispatch/transport-contract.reference.md`. - -**What changed (backend):** -- **BEFORE:** `ImageChunk.url` was a base64 data URL (`data:image/png;base64,…`). -- **NOW:** `ImageChunk.url` for PERSISTED chunks (history/replay) is a compact relative HTTP path - (`/images/<conversationId>/<uuid>.png`), served by `GET /images/:conversationId/:imageId` (raw image - bytes + Content-Type). Images live on disk under tmp, NOT in the SQLite conversation store (keeps - payloads small). -- **`ChatRequest.images` (`ImageInput.url`) is UNCHANGED** — the FE still sends data URLs; the backend - saves them to tmp and returns compact paths in the persisted chunks. -- **Optimistic echo:** the FE's provisional echo still uses the data URL it sent (immediate render); - when the persisted chunk arrives (via `loadSince`/`syncTail`/event stream), it carries the compact path - and the FE switches to rendering via the HTTP endpoint. -- The vision settings API, `consult_vision`, and image compaction are UNCHANGED (compaction resolves - compact URLs internally). - -**FE (DONE + verified):** -- **New pure helper `resolveImageUrl(url, apiBase)`** (`core/chunks/image-url.ts`, +8 tests, exported from - `core/chunks` + re-exported from `features/chat`): a `data:` URL or an `http(s)://` URL passes through - unchanged; a relative path (`/images/…`) is prepended with the API base (no double slash; an empty base - yields a root-relative path a browser resolves against its origin). Pure — zero DOM/Svelte. -- **`ChatView.svelte`:** new `apiBaseUrl` prop (default `""`); the `<img src>` now uses - `resolveImageUrl(rendered.chunk.url, apiBaseUrl)`. The optimistic echo's data URL + any absolute URL - pass through; persisted relative paths resolve against the base. +3 ChatView tests (relative-path - resolution, data-URL pass-through with a base set, root-relative when no base). -- **Store + wiring:** `httpBase` (the resolved HTTP API base) is now exposed as a getter on `AppStore`; - `App.svelte` passes `apiBaseUrl={store.httpBase}` to `ChatView` AND to the heartbeat `RunModal` (its - `ChatView` also renders image chunks — added an `apiBaseUrl` prop there, threaded from `App.svelte`). - -**Verification:** `svelte-check` 0/0; vitest **959/959** (run TWICE — no cross-test pollution; +11 since the -prior commit: 8 `resolveImageUrl`, 3 ChatView resolution), biome clean, `vite build` succeeds. -**Live probe NOT run** (backend not reachable headless). To confirm end-to-end: paste an image with a -vision model, send, confirm the image renders immediately (data-URL echo) AND continues to render after -the turn seals (the persisted compact-path `/images/…` resolved against `httpBase`); reload the -conversation → confirm the persisted image renders from the `/images/…` endpoint (not a data URL). - ---- - -## 2k. Step-level context-window usage (progressive) → **FE BUILT; no backend change** - -The context-window usage indicator at the bottom of the screen (Composer status -bar) now updates **after each step** during a multi-step turn, instead of only -when the turn seals. Pure FE change — consumes wire events the backend ALREADY -sends (`usage` per step + `step-complete` + `done.contextSize`); no contract -change, re-pin, or re-mirror needed. - -- `selectCurrentContextSize` (`core/metrics/reducer.ts`) now, for an IN-FLIGHT - (not-done) turn, returns the most recent step WITH USAGE's - `inputTokens + outputTokens` as the live context occupancy. Per the wire - contract each step's input already includes all prior context (the prompt is - re-prefilled every step), so the last step's input+output is the true occupancy - — the same definition `TurnDoneEvent.contextSize` stamps at turn end. -- A finalized turn (`done` / durable) still wins with its authoritative - `contextSize`; durable still wins over live for a shared `turnId`. An in-flight - turn with no step usage yet falls back to the next older finalized turn (never - `0`). -- New helper `liveTurnContextSize`; updated doc on `ChatStore.currentContextSize` - + the Composer `contextSize` prop. 7 new reducer tests (35 total), 1026 green. - -### FE summary (this slice) -No backend ask. The backend already emits per-step `usage` (token counts, may -arrive mid-stream) and `step-complete` (timing) joined by `stepId`, plus -`done.contextSize` (final step's input+output) — the FE just wasn't reading the -per-step usage for the live indicator. Now it does. -## 2j-update-3. Concurrency-fixes (cooldown + adaptive headroom + usage gate) → **CONSUMED ✅ (backend shipped; FE built + verified)** - -A follow-up to §2j. The backend's per-provider concurrency surface gained (a) a configurable + persisted -per-provider release **cooldown**, (b) **adaptive headroom** — a 429 auto-reduces the provider's limit by 1 -(one-way, persisted) and the FE renders a visible banner, and (c) a **usage gate** (backend polls upstream -`concurrent_sessions` before admitting a queued agent — no FE surface). The signal rides on -`GET /concurrency/status` (no new WS push). **Additive to `[email protected]`, NO version bump** -(the FE's `file:` dep picks the new types up via re-sync, no re-pin needed). Backend commit -`feature/concurrency-fixes` `2d27666` ("usage-gate + adaptive headroom + configurable cooldown"). - -**Contract changes (re-mirrored in `.dispatch/transport-contract.reference.md`):** -- `ConcurrencyStatusEntry` gains FOUR new fields: `cooldownMs: number` (REQUIRED — per-slot release cooldown in - ms, default 350; a recycled slot is held this long before the next waiter is admitted, covering the upstream - provider's accounting lag — configurable + persisted), `autoReduced: boolean` (REQUIRED — true when the limit - was auto-reduced by 1 after a 429, one-way + persisted; the FE renders a banner), `autoReducedFrom?: number` - (present only when `autoReduced===true` — the original limit before reduction), and `notice?: string` - (present only when `autoReduced===true` — a human-readable banner message). -- NEW cooldown types: `ConcurrencyCooldownResponse` (`{ providerId, cooldownMs }`) + - `SetConcurrencyCooldownRequest` (`{ cooldownMs }` — non-negative integer, 0 = no cooldown / instant re-admission). -- NEW endpoints: `GET /concurrency/cooldown/:providerId` → `ConcurrencyCooldownResponse` (404 when the provider - has no concurrency config at all — no limit AND no cooldown; 503 when the extension isn't loaded); - `PUT /concurrency/cooldown/:providerId` ← `SetConcurrencyCooldownRequest` → `ConcurrencyCooldownResponse` - (400 on an invalid body; 503 when not loaded). Persists + applies immediately to subsequently recycled slots. -- A manual `PUT /concurrency/limits/:providerId` CLEARS `autoReduced` server-side (the restore path). - -**FE (DONE + verified):** -- **Pure core (`logic/view-model.ts`):** `DEFAULT_COOLDOWN_MS = 350`; `parseCooldownInput` (non-negative integer — - unlike the limit, **0 is valid**); `normalizeCooldown` (defensive default 350 on garbage); - `cooldownLabel` ("350ms" / "1.2s" / "0ms (off)"); `viewConcurrencyStatus` extended to carry `cooldownMs` + - `cooldownLabel` + `autoReduced` + `autoReducedFrom` (auto-reduce → `warning` badge but NOT `busy` — a reduced - limit still admits agents); `viewAutoReduce`/`autoReduceNotices` (banner view — prefers the backend `notice` - verbatim, synthesizes a fallback when absent, `fromLimit` = `autoReducedFrom` for "Restore to N"); - `summarizeStatus` gained an "N auto-reduced" fragment; `normalizeConcurrencyStatus` coerces the new fields - (builds the readonly entry immutably — `autoReducedFrom`/`notice` only when `autoReduced===true`, dropped when - false even if present in the JSON); `normalizeConcurrencyCooldown` (network-seam coercion). -- **Types (`logic/types.ts`):** re-exports the 2 new contract types + `ConcurrencyCooldownResult` + - `GetConcurrencyCooldown`/`SaveConcurrencyCooldown` ports. -- **UI:** new `ui/ConcurrencyCooldownRow.svelte` (per-provider inline-edit cooldown input + Save → PUT, seeded via - the ChatLimitField pattern so a status-poll refresh re-syncs without clobbering an in-flight edit; "Saved." - confirmation); new `ui/AutoReduceBanner.svelte` (the dismissible banner — backend `notice` + "Was N, now M." + - "Restore to N" PUT button + ✕ dismiss). `ConcurrencyView.svelte`: cooldown label per status card + - `ConcurrencyCooldownRow`; an auto-reduce banner section at the top of the panel. The banner is DISMISSIBLE + - persists while `autoReduced===true`: a dismissed provider stays hidden while still auto-reduced, and is - un-dismissed the moment a poll shows it restored (a `$effect` reconciles the dismissed set against the live - auto-reduced providers). "Restore to N" PUTs the limit back to `autoReducedFrom` via `saveLimit` → the next - status poll shows `autoReduced===false` → the banner drops automatically. -- **Store (`store.svelte.ts`):** `getConcurrencyCooldown` (`GET .../cooldown/:id`) + `setConcurrencyCooldown` - (`PUT .../cooldown/:id` ← `{ cooldownMs }`) — both surface 400/404/503 as `ok:false` with the backend's `error` - string + normalize the body at the seam. Interface declarations added. (Mirrors the §2j "FE implements all API - client functions but the UI uses a subset" note: the UI seeds cooldown from the live status `cooldownMs`, so - `getConcurrencyCooldown` is an API-client function for completeness/future use — `saveCooldown` is the wired one.) -- **Wired in `App.svelte`:** `saveConcurrencyCooldown` adapter → `ConcurrencyView`'s `saveCooldown` prop. -- **Tests:** +47 (view-model: `parseCooldownInput`/`cooldownLabel`/`normalizeConcurrencyCooldown`/`viewAutoReduce`/ - `autoReduceNotices`/`summarizeStatus` auto-reduced/`normalizeConcurrencyStatus` new-field coercion; component: - cooldown label render, cooldown PUT flow, negative-input rejection, auto-reduce banner render, restore clears - banner, dismiss-while-auto-reduced; store: `getCooldown` load/404, `setCooldown` PUT echo + 400). - -**Verification:** `svelte-check` 0/0; vitest **1050/1050** (run TWICE — no cross-test pollution; the polling -intervals are per-component, cleaned up on unmount by `@testing-library/svelte`'s auto-cleanup), biome clean, -`vite build` succeeds (the one CSS warning is PRE-EXISTING — `[file:path]`/`[heartbeat:elapsed]` attribute -selectors, unrelated). **Live probe NOT run** (the backend is the user's process; never booted headless). To -confirm end-to-end: start the backend with the `concurrency` extension loaded, open the Concurrency sidebar view, -confirm the cooldown label + edit field per provider; set a cooldown → Save → confirm it persists on reload; -trigger a 429 on a limited provider → confirm the auto-reduce banner appears (with `notice` + "Was N, now M.") → -click "Restore to N" → confirm the banner drops on the next poll. - -**Post-review fixes (folded into the same commit):** a Kimi review flagged a MEDIUM bug + 2 LOW issues, all fixed: -- **MEDIUM — restore failure gave no inline feedback:** `restoreLimit` now returns a `RestoreOutcome` - (`{ ok: true } | { ok: false; error }`) and `AutoReduceBanner.handleRestore` shows the error INLINE next to the - restore button (cleared on retry) instead of silently re-enabling the button. New `RestoreOutcome` type in - `logic/types.ts` + exported. 2 new tests (inline error on failure; error clears on a retry that succeeds). -- **LOW — a11y:** the "Restore to N" text now stays visible while loading (spinner prepended, not replacing the - text), so the button keeps its accessible name during the PUT (a spinner-only button loses its name for SR users). -- **LOW — dismissed-banner persistence:** confirmed INTENTIONAL (not a bug). The dismissed set is component-local - (resets on remount): `autoReduced` is a REAL persisted degraded state, so re-showing the banner on a fresh mount - (sidebar view switch / reload) reminds the user; persisting dismissal in localStorage would risk HIDING an ongoing - degradation (a footgun), and AGENTS.md forbids module-global ambient state. Documented in a code comment. - -**Worktree environment note (same as §2d/§2j):** this worktree lays the repos out as -`…/worktrees/concurrency-fixes/{backend,frontend}`, but `package.json`'s canonical `file:` paths point at -`../dispatch-backend/...` (kept canonical — no worktree hack committed). An UNTRACKED symlink -`dispatch-backend → backend` was created in the worktree parent, then `bun install` re-synced -`node_modules/@dispatch/*` to pick up the additive `[email protected]` types. - ---- - -## 3. Likely NEXT backend asks (heads-up, not yet requested) - -- **Model max context-window LIMIT** → **CONSUMED ✅** — `GET /models` now returns - `modelInfo[model].contextWindow`. The Composer uses the real value (falls back to - 1,000,000 when absent). The hardcoded `MAX_CONTEXT` is gone. -- **Percentage-based auto-compact** → **CONSUMED ✅** — `compact-threshold` endpoint - renamed to `compact-percent`; field is now `percent` (0-100, default 85, 0 = manual). - CompactionView UI updated from token count to percent input (0-100). -- **`GET /conversations`** — conversation list / sidebar (history explorer / switcher); could also - expose a per-conversation "last model" so a reopened tab seeds its model from the server. -- **LSP status over WS** (push) — today the FE HTTP-polls `GET /conversations/:id/lsp` on panel mount - / cwd change + a manual refresh; a live surface/WS push would remove the manual refresh and reflect - a server flipping to `error`/`connected` without a reload. |
