Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 45 additions & 7 deletions app/harness/HarnessHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
type IdSessionRepository,
type SessionSummary,
} from '../../lib/sessionRepository';
import { decideDetach } from '../../lib/detachTurn';
import {
bootCloudSession,
readUrlSessionId,
Expand Down Expand Up @@ -358,6 +359,34 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) {
[writeLocalSession],
);

/**
* Host **detach** seam (plan #789, source #766 — backend-agents slice C).
*
* Closing a viewport (unmount, session switch, New/Clear, logout) is NOT "the
* turn is over" — only a user Stop/Esc cancels. The decision is driven by the
* active session's `meta.turnRunId` via the pure `decideDetach` helper:
* - Durable run (id present, post-E): close this viewport's reader only —
* never abort the run, never a server cancel. At slice C there is no
* separate reader object, so this is the seam that stops consuming the
* socially-owned run without cancelling it (slice F wires the real reader
* detach).
* - Legacy tab-owned turn (no id, the slice-C reality): abort the attached
* fetch so a detached busy tab never leaves the 1800s Function running
* with no persisting writer (the host is the only Blob/envelope writer).
* The abort here is the client AbortController for the /api/agent fetch — the
* dedicated `takePendingCancel` path (Stop/Esc) is unchanged and remains the
* only explicit cancel; no server cancel is ever sent on detach.
*/
const detachTurn = useCallback(() => {
const turnRunId = sessionRef.current?.turnRunId;
const decision = decideDetach(turnRunId);
if (decision.kind === 'abort') {
abortRef.current?.abort();
}
// `close-reader`: no-op at slice C (no durable Workflow reader exists yet);
// slice E/F consume the run identity carried on the envelope.
}, []);

const runPrompt = useCallback(
async (prompt: string, opts?: { pushUser?: boolean }) => {
const bridge = bridgeRef.current;
Expand Down Expand Up @@ -704,7 +733,10 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) {
return () => {
cancelled = true;
canvas.removeEventListener('contextmenu', onCanvasContextMenu);
abortRef.current?.abort();
// Plan #789 (source #766): unmount detaches, not "the turn is over". Only
// aborts the fetch for a legacy tab-owned turn (no turnRunId) so nothing
// burns unpersisted; a durable run (post-E) closes the reader only.
detachTurn();
if (pollRef.current != null) {
clearTimeout(pollRef.current);
pollRef.current = null;
Expand All @@ -725,6 +757,7 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) {
setUrlSessionId,
applySessionModel,
foldPendingModelChange,
detachTurn,
]);

/**
Expand Down Expand Up @@ -796,7 +829,9 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) {

const onClear = useCallback(() => {
if (inflightRef.current || switchInFlightRef.current) return;
abortRef.current?.abort();
// Plan #789 (source #766): Clear/New detaches the previous session's turn
// rather than declaring it over (abort only the legacy no-run-id fetch).
detachTurn();
const repo = repoRef.current;
const bridge = bridgeRef.current;
const clearedId = sessionRef.current.id;
Expand Down Expand Up @@ -869,15 +904,16 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) {
}
void refreshSessions();
})();
}, [writeLocalSession, refreshSessions, setUrlSessionId, resolveNewPersona]);
}, [writeLocalSession, refreshSessions, setUrlSessionId, resolveNewPersona, detachTurn]);

const onNewSession = useCallback(() => {
const repo = repoRef.current;
if (!repo || !repo.enabled || inflightRef.current || switchInFlightRef.current) return;
// Adversarial #642: ack any stale session-switch pending synchronously at
// click before the async repo.create + activateSession.
bridgeRef.current?.takePendingSessionSwitch();
abortRef.current?.abort();
// Plan #789 (source #766): New detaches (abort only the legacy turn).
detachTurn();
void (async () => {
const created = await repo.create();
if (created.action !== 'ok') return; // stay on the current session
Expand All @@ -891,7 +927,7 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) {
setUrlSessionId(created.snapshot.id);
repo.put(created.snapshot.id, empty);
})();
}, [activateSession, setUrlSessionId, resolveNewPersona]);
}, [activateSession, setUrlSessionId, resolveNewPersona, detachTurn]);

const onSwitchSession = useCallback(
(id: string) => {
Expand All @@ -900,7 +936,9 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) {
if (!repo || !repo.enabled || !bridge || inflightRef.current) return;
if (switchInFlightRef.current) return; // another switch already in-flight
if (id === sessionRef.current.id) return;
abortRef.current?.abort();
// Plan #789 (source #766): switching away detaches this viewport's turn
// (abort only a legacy no-run-id fetch; a durable run keeps running).
detachTurn();
const sourceId = sessionRef.current.id; // generation token — guard against stale get
switchInFlightRef.current = true;
void (async () => {
Expand All @@ -918,7 +956,7 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) {
}
})();
},
[activateSession, setUrlSessionId],
[activateSession, setUrlSessionId, detachTurn],
);
onSwitchSessionRef.current = onSwitchSession;

Expand Down
3 changes: 2 additions & 1 deletion docs/feature-divide.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ optional login chrome).
| Image bytes (fetch/decode) | **DOM host** | Browser fetch → RGBA → `inv_image_cache_put`; paint stays Wasm |
| Math pixels (TeX raster) | **DOM host** | Host MathJax SVG → RGBA → `inv_math_cache_put`; paint stays Wasm |
| **Composer + Send** | **Wasm** | Primary input; dynamic absolute-rect from previous-frame measured height: idle hugs one line (~44 px), grows up to cap (124 px), scrolls internally past 120 px content; glyphs inset 5 px from the field border; Send/Stop icon bottom-pinned (`gravity_y = 1.0`) stays on field baseline at all heights (plan #579) |
| **Stop / cancel turn** | **Wasm** control + **DOM** abort | Canvas **Stop** (icon-only ■, plan #457) while busy → pending cancel (protocol v9); host aborts `AbortController` |
| **Stop / cancel turn** | **Wasm** control + **DOM** abort | Canvas **Stop** (icon-only ■, plan #457) while busy → pending cancel (protocol v9); host aborts `AbortController`. **Only a user Stop/Esc cancels** a turn |
| **Turn lifetime + host detach (plan #789, source #766, backend-agents slice C)** | **DOM host** (§ detach seam); the turn itself stays **server-owned** | A viewport close is **not** "the turn is over": the durable turn lives server-side. **unmount / session switch / New-Clear / logout = detach** — the `detachTurn` seam is wired on all four tear-down sites (`lib/detachTurn.ts` `decideDetach` keys on the session's `meta.turnRunId`). **During an in-flight turn only `unmount` actually fires detach**: switch/New/Clear are gated by the pre-existing **#642** inflight lock and their `detachTurn()` runs only on the idle-viewport teardown (a no-op for a live turn at slice C). E/F lifts or justifies those guards when switch-away must detach a durable run. A **durable run** (`turnRunId` present, post-E) survives the tab — the viewport **closes its reader only, never aborts, never sends a server cancel**; a **legacy tab-owned turn** (no `turnRunId`, today's `/api/agent` fetch) still **aborts** on tear-down so no unpersisted 1800 s Function keeps burning with no writer. Reserved carriers `meta.{turnRunId,turnStatus}` ride the envelope ([session-model.md](session-model.md)); **only Stop/Esc cancels** (slice H wires Stop→Workflow cancel; slice F wires the reader attach) |
| Busy / error presentation for turns | **Wasm** | EMBER for errors |
| Whole-turn `mm:ss` clock (Busy) | **Wasm** (busy row) fed by the **DOM** host | The host owns the only reliable wall-clock (no WASI clock in Wasm) and ticks it ~1 Hz, pushing the elapsed seconds into the Wasm busy row via protocol **v14** `inv_set_turn_elapsed` (plan #567). The canvas appends `Waiting for model… · 0:42` in-canvas while a turn runs; reset to 0 on Ready/Stop/error so no `0:00` lingers. Composer/Stop stay **Wasm** |
| 2×4 busy spinner (plan #574) | **Wasm paint** fed by the **DOM** host | **Wasm** paints a 2×4 WARM rectangle grid left of `Waiting for model…` (clockwise pulse; pure LUT `busy_spinner.zig`, zero I/O/alloc in the frame path). **DOM host** drives the pulse phase on the same Busy ticker at **`HARNESS_BUSY_TICK_HZ` = 10 Hz** (`HarnessBridge.setBusyTick` → additive `inv_set_busy_tick`; the v14 `mm:ss` clock is fed every 10th tick ≈ 1 Hz). **Reduced motion** (read fresh at each busy start): the host skips only the per-tick pulse push, grid static at phase 0 — the `mm:ss` **clock keeps ticking** (no reduced-motion clock regression). Idle/Stop/error clears both to 0. Old host + new Wasm degrades to a static grid (busy_tick stays 0) |
Expand Down
11 changes: 8 additions & 3 deletions docs/session-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ The **local** blob uses the opaque client snapshot shape:
| `activeSandboxId` | **Optional** server/origin sandbox id (Redis-safe opaque). **Session-owned, server-resolved** (P1/GAP-1, #452 + #330): synced as `meta.activeSandboxId` and sent on every `/api/agent` POST as the resolve **override**. The host folds it into the turn and, on success, applies the server's post-turn effective bind — `agentResult.activeSandboxId` (the `meta_sandbox_switch` target) with fallback to `sandboxId` — back as the authoritative binding (never the pre-turn `sandboxId` clobbering a switch); a hard 403 of the **grant-honesty class** (set-but-unusable: `Sandbox access denied.` / selection-required) clears the stale value so the next turn honestly re-resolves from preference / selection. A 403 `Workspace instance is not running.` (a usable grant whose instance is down / softContinue) is **kept** — never silently re-resolved to another grant |
| `selectedModel` | **Optional** selected-model id (non-secret printable-ASCII catalog string, e.g. `provider/model`). **Session-owned** (plan #616 / source #610): synced to the cloud record as `meta.selectedModel` (reserved key), so the pick survives a reload and a device-switch adopt. Restore is **by id** after the model catalog is pushed (additive protocol-**v16** host→Wasm set-by-id export, never index math); a stored id missing from the (revoked/changed) catalog → default first-granted. The host also folds a user **Next** cycle into the snapshot via the **pending-model-change** flag (`inv_has_pending_model_change` / `inv_ack_pending_model_change`) observed by the host poll, so a pick persists without waiting for a turn; submit still reads the **live** Wasm selection (`getSelectedModel()`) — the carrier is never a second source of truth on the POST body. `sanitizeModelId` (≤ `MAX_MODEL_ID_LEN` = 128 bytes, printable ASCII) drops a poisoned value to unset — never brick a record |
| `usage` | **Optional** last-completed provider token summary (`UsageSummary`, `source === 'provider'`). Rides reserved `meta.usage` as a JSON string (drop-to-unset on poison / non-provider / oversize — never `INVALID_META`). Restore on pull/adopt paints the context slot; **absent = hide**. Capture is live mid-stream (`usage` SSE events from `finish` parts) and reconciled at stream/JSON `done` (the conclusive replace — absent at `done` clears); abort keeps the prior honest in-memory value until the next persist; New/Clear wipe it |
| `turnRunId` | **Optional** durable turn **run id** (Redis-safe opaque, `^[A-Za-z0-9_-]{1,512}$`). **Session-owned** (plan #789 / source #766, backend-agents slice C): synced to the cloud record as the reserved `meta.turnRunId`. Names **at most one** live run behind the session. **Absent = no durable run / idle** (today's Ready). The host **detach** seam keys on its **presence**: a durable run (id present) is owned server-side and survives the tab — a viewport close only **detaches** (closes the reader), it never aborts; a legacy turn with **no** id is still the tab-owned `/api/agent` fetch and **does** abort on tear-down so nothing burns unpersisted. `sanitizeTurnRunId` (wraps the Redis-safe opaque rule) drops a poisoned id to unset — never a 400. At slice C (backend-agents) nothing populates it yet; slice E ships the Workflow owner that writes it. `getRun(runId)` is the source of truth |
| `turnStatus` | **Optional cached hint** of the turn status (`idle` \| `running` \| `cancelling`). **Hint only** — `getRun(runId)` is the authority when a `turnRunId` is present (the tab can detach and nothing polls it). Rides reserved `meta.turnStatus`; `sanitizeTurnStatus` (exact enum accept, ≤ `TURN_STATUS_MAX_BYTES` = 32) drops a poisoned value to unset, never a 400. Absent = unclear/clear |

Storage key: `invincible.harness.session.v1`.

Expand Down Expand Up @@ -173,7 +175,7 @@ for the open tab).
| `createdAt` | Epoch ms at mint/backfill — immutable after create |
| `updatedAt` | Epoch ms of last accepted write. **New sessions are seeded `0`** (first host PUT with epoch-now ≥ 0 is idempotent-accept, never a spurious 409) |
| Cross-user | Other-user id / nonexistent id → **404** (no existence leak) |
| `meta` | **Schema-typed reserved**: `title`, `legacySnapshotId`, `activeSandboxId`, `logicalCwd`, `personaId`, `personaSnapshot`, `transcriptPointer`, `attachedSkills`, `selectedModel`, `usage` — opaque scalars + serialized size cap; nothing else. **Write contract (all keys):** PUT `meta` is the **full desired set** (replace). `upsertEnvelope` stores `input.meta` as-is (`meta: input.meta ?? {}`). **Absent key = clear** that field. There is no PATCH/merge on the store. Mid-turn server writers (`meta_sandbox_switch`, skill inject) **read-copy-override** the existing envelope meta so a one-key update cannot clear siblings. Host `cloudMetaFor` emits every carrier it knows; it folds `attachedSlugs` on every PUT so a rewrite cannot drop the set (omit would clear). `'[]'` is the empty-set **value** for `attachedSkills`, not a third verb. `personaId` is Redis-safe opaque; `personaSnapshot` is the locked-in persona text (≤ `PERSONA_SNAPSHOT_MAX_BYTES` = 512 KiB) and counts toward the raised whole-`meta` budget (**1 MiB**), so it replays on device switch while a mid-session persona edit never rewrites an in-flight session (injection is active — see [docs/personas.md](personas.md)). `attachedSkills` is a **JSON-encoded string** of skill slugs (≤ 32, dedupe): the server stores **slugs only** and re-resolves bodies every turn. **New session / Clear** mints a fresh session, so `attachedSkills` resets there. `transcriptPointer` is a Redis-safe opaque id of the latest **Blob transcript object**. `selectedModel` is a **non-secret** printable-ASCII model id (≤ 128 bytes); poison is **DROPPED to unset** (`sanitizeModelId`) — never a 400 brick. `usage` is a JSON-encoded last-completed provider `UsageSummary`; poison drops to unset (never 400). GET envelope overlays envelope `meta` as that last desired set: valid values win, **absent/poison clears** the transcript field |
| `meta` | **Schema-typed reserved**: `title`, `legacySnapshotId`, `activeSandboxId`, `logicalCwd`, `personaId`, `personaSnapshot`, `transcriptPointer`, `attachedSkills`, `selectedModel`, `usage`, `turnRunId`, `turnStatus` — opaque scalars + serialized size cap; nothing else. **Write contract (all keys):** PUT `meta` is the **full desired set** (replace). `upsertEnvelope` stores `input.meta` as-is (`meta: input.meta ?? {}`). **Absent key = clear** that field. There is no PATCH/merge on the store. Mid-turn server writers (`meta_sandbox_switch`, skill inject) **read-copy-override** the existing envelope meta so a one-key update cannot clear siblings. Host `cloudMetaFor` emits every carrier it knows; it folds `attachedSlugs` on every PUT so a rewrite cannot drop the set (omit would clear). `'[]'` is the empty-set **value** for `attachedSkills`, not a third verb. `personaId` is Redis-safe opaque; `personaSnapshot` is the locked-in persona text (≤ `PERSONA_SNAPSHOT_MAX_BYTES` = 512 KiB) and counts toward the raised whole-`meta` budget (**1 MiB**), so it replays on device switch while a mid-session persona edit never rewrites an in-flight session (injection is active — see [docs/personas.md](personas.md)). `attachedSkills` is a **JSON-encoded string** of skill slugs (≤ 32, dedupe): the server stores **slugs only** and re-resolves bodies every turn. **New session / Clear** mints a fresh session, so `attachedSkills` resets there. `transcriptPointer` is a Redis-safe opaque id of the latest **Blob transcript object**. `selectedModel` is a **non-secret** printable-ASCII model id (≤ 128 bytes); poison is **DROPPED to unset** (`sanitizeModelId`) — never a 400 brick. `usage` is a JSON-encoded last-completed provider `UsageSummary`; poison drops to unset (never 400). **Plan #789 (source #766, backend-agents slice C):** `turnRunId` is the durable turn run id (Redis-safe opaque, `^[A-Za-z0-9_-]{1,512}$`, ≤ `REDIS_SAFE_OPAQUE_ID_MAX` = 512); `turnStatus` is an **optional cached hint** (`idle`\|`running`\|`cancelling`, ≤ `TURN_STATUS_MAX_BYTES` = 32). Both **DROPPED to unset on poison** via the client-safe predicates (`sanitizeTurnRunId` / `sanitizeTurnStatus`) — never a 400 brick — and absent = clear (no durable run / idle). GET envelope overlays envelope `meta` as that last desired set: valid values win, **absent/poison clears** the transcript field |

### Caps (server + host pre-PUT trim)

Expand All @@ -188,9 +190,12 @@ for the open tab).

Host `trimForCloudPut` folds `cwd` + `activeSandboxId` into `meta.{logicalCwd,activeSandboxId}`
and `attachedSlugs` into `meta.attachedSkills`, `selectedModel` into `meta.selectedModel`,
and last-completed `usage` into `meta.usage` (JSON string; **absent = clear**),
last-completed `usage` into `meta.usage` (JSON string; **absent = clear**), and the
durable turn carriers `turnRunId` / `turnStatus` into `meta.{turnRunId,turnStatus}`
(plan #789; emit when set, omit = clear),
(shared client-safe predicates; a host-absolute cwd / non-Redis-safe id /
non-printable-ASCII model / non-provider usage is dropped to unset),
non-printable-ASCII model / non-provider usage / poisoned or over-`TURN_STATUS_MAX_BYTES`
turn value is dropped to unset),
enforces count/byte/body caps (byte accounting includes `meta`), then PUT.
`parseCloudSessionSnapshot` restores those from `meta` on pull/adopt (fail-open: a poisoned
value drops to unset / [] for `attachedSkills`, never a sticky 400).
Expand Down
Loading