diff --git a/docs/harness-limits.md b/docs/harness-limits.md index 1d5449d4..95f1fa44 100644 --- a/docs/harness-limits.md +++ b/docs/harness-limits.md @@ -89,7 +89,7 @@ Vertical bands inside the Wasm root (not a DOM panel): | Slots | **sandbox** (`activeSandboxId` → `sandbox ` short label) · **cwd** (workspace-relative session cwd) · **git** (`branch@sha[∗]`, Phase 2 #540) · **context/usage** (`N in · M out · T tok`, Phase 3 #539) | | Bridge carrier | Additive status-slot store (`inv_set_status_slot` / `inv_status_slot_len/copy` / `inv_status_slots_clear`), bridge protocol **v13** (old exports untouched). A host push **replaces** one slot; `len==0` clears it (slot hidden) | | Host fold | After hydrate/restore, after **every** agent turn — success **and** fail (PR #543: a 403-clear or committed `change_dir` on a cancelled/timed-out turn repaints the pack; same fold-before-persist discipline as `attachedSlugs`), and **live mid-turn on tool results** (Phase 2 #627 / #625: a confirmed `change_dir` or successful `meta_sandbox_switch` repaints sandbox/cwd immediately, plus the host persists via `onSessionPatch`). Git slot also refreshes on-demand after a successful `exec` or `meta_sandbox_switch` mid-turn (not only the 10 s cadence). The host folds session state into the pack (`lib/harnessChat.ts` `foldStatusSlots`, call sites: hydrate + live tool events + `runHarnessTurn` success + `runHarnessTurn` fail). Clear/New session clears the pack | -| Git slot (Phase 2 #540) | Classic status slot, filled by the **DOM host** polling the read-only **`GET /api/harness/status`** server probe on a **~10 s cadence** (`lib/harnessChat.ts` `refreshGitStatusSlot`, wired in `app/harness/HarnessHost.tsx`). The **host cadence is the primary throttle**; the server rate cap is only a per-instance backstop. Fail-soft: any network/abort/429 error keeps the last git value (never flashes it away on a transient refresh); a genuinely **empty** probe result clears the slot (no stale prior linger). Oversize git values are host-ellipsized to the slot cap before the wire | +| Git slot (Phase 2 #540) | Classic status slot, filled by the **DOM host** polling the read-only **`GET /api/harness/status`** server probe on a **~10 s cadence** (`lib/harnessChat.ts` `refreshGitStatusSlot`, wired in `app/harness/HarnessHost.tsx`). The **host cadence is the primary throttle**; the server rate cap is only a per-instance backstop. Fail-soft: any network/abort/429 error keeps the last git value (never flashes it away on a transient refresh); a genuinely **empty** probe result clears the slot (no stale prior linger). **SHA-only partial results** (server returns `git.sha` but no `git.branch` — detached HEAD / transient git lock) are **suppressed** — the host keeps the last honest `branch@sha` rather than overwriting with an unreliable partial. The structured `git.branch` / `git.sha` fields are authoritative; a real `@`-prefixed branch name (`@hotfix`) has both fields and passes through. Oversize git values are host-ellipsized to the slot cap before the wire | | Git probe (server) | **`GET /api/harness/status`** (`app/api/harness/status/route.ts`) resolves the caller's **envelope-authoritative** active bind (envelope `meta.activeSandboxId` wins over a Redis-safe `?sandboxId=` carry) and runs a **bounded, argv-only, read-only** git probe at the **bind workspace root** (`lib/agent/statusProbe.ts` — `rev-parse --abbrev-ref HEAD` + `--short HEAD` + `status --porcelain`; probe `cwd` is always `.`, never a caller session cwd). Non-git / empty-git-dir / probe error → `{ git: {} }` (fail-soft, still 200; git slot stays muted). stdout truncated to `STATUS_GIT_PROBE_OUT_MAX_BYTES` (512). Server-side **per-instance best-effort** rate cap `STATUS_PROBE_MIN_INTERVAL_MS` (2000) serves a cached value + `rate_limited:true` when inside the window (never 429-spam). Auth edge = middleware matcher + in-route `requireSessionUser` (dual gate, mirror `/api/agent`/`/api/sandboxes`). No bind secrets / `base_url` / token ever on the wire | | Context slot (Phase 3 #539 + #628) | Paints **provider token usage** — absolute tokens only (`N in · M out · T tok`; **no `% of window` — v1 has no model max-context source**). Updated **live mid-stream** when the AI SDK reports **aggregate** usage on a `finish` part (SSE `usage` event — never `finish-step` per-step counts), and reconciled at the final `done.usage` / JSON result / chat result. Carrier: `mapProviderUsage` (`lib/agent/usageSummary.ts`, cap `USAGE_SUMMARY_MAX_BYTES` = 96); the host parses it (`sanitizeUsageSummary`), mirrors it on `SessionSnapshot.usage`, and `foldStatusSlots` paints the slot from `formatUsageSummary`. **Default on missing usage = hidden** — never a client estimate. A completed turn with no provider usage **clears** the slot; an **aborted/cancelled** turn (no completion) carries the prior honest value forward. Live `usage` events with no usable counts are never emitted (no flicker). Rides reserved cloud `meta.usage` (JSON string; drop-to-unset on poison); host folds from `SessionSnapshot.usage`. Read-side re-sanitized in the fold so a poisoned in-memory usage can never paint | | Cap | Per slot **`STATUS_SLOT_MAX_BYTES` = 96** UTF-8 bytes (`lib/sessionCloudCaps.ts` == TS bridge `MAX_STATUS_SLOT_LEN` == Wasm `MAX_STATUS_SLOT_LEN`). The host **ellipsizes** fold values to the cap at a UTF-8 boundary (`harnessChat.ts` `truncateStatusValue`, trailing `…`) before pushing, so a present-but-oversize bound/cwd renders `<…>` instead of being dropped; a raw over-cap push is still **rejected** at the bridge/Wasm (authoritative, never a silent wire truncation) | diff --git a/lib/harnessChat.test.ts b/lib/harnessChat.test.ts index 73931f9c..9f72d38b 100644 --- a/lib/harnessChat.test.ts +++ b/lib/harnessChat.test.ts @@ -2853,6 +2853,97 @@ describe('refreshGitStatusSlot (phase 2, plan #540)', () => { expect(painted.endsWith('…')).toBe(true); expect(new TextEncoder().encode(painted).length).toBeLessThanOrEqual(96); }); + + it('suppresses a SHA-only probe (sha present, branch absent) — keeps the last honest branch@sha (plan #660)', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + bridge.setStatusSlot(StatusSlot.Git, 'main@abc1234'); + // Detached-HEAD probe: sha=abc1234, no branch → formatted value @abc1234. + stubFetch({ value: '@abc1234', git: { sha: 'abc1234' } }); + await refreshGitStatusSlot(bridge, { ...createEmptySession('s'), activeSandboxId: 'sbx_x' }); + expect(statusSlotAt(exp, StatusSlot.Git)).toBe('main@abc1234'); + }); + + it('suppresses a dirty SHA-only probe (sha+dirty, branch absent)', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + bridge.setStatusSlot(StatusSlot.Git, 'main@abc1234'); + // Detached HEAD + dirty tree: sha present, branch absent, dirty=true. + stubFetch({ value: '@abc1234*', git: { sha: 'abc1234', dirty: true } }); + await refreshGitStatusSlot(bridge, { ...createEmptySession('s'), activeSandboxId: 'sbx_x' }); + expect(statusSlotAt(exp, StatusSlot.Git)).toBe('main@abc1234'); + }); + + it('passes through an @-prefixed branch name (@hotfix@sha) — structured git.branch is authoritative (L1 fix)', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + // `git branch @hotfix` is legal; git check-ref-format accepts @-prefixed + // branch names. Structured fields have both branch AND sha → passes through. + stubFetch({ value: '@hotfix@abc1234', git: { branch: '@hotfix', sha: 'abc1234' } }); + await refreshGitStatusSlot(bridge, { ...createEmptySession('s'), activeSandboxId: 'sbx_x' }); + expect(statusSlotAt(exp, StatusSlot.Git)).toBe('@hotfix@abc1234'); + }); + + it('passes through a normal branch@sha value (both branch and sha present)', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + stubFetch({ value: 'main@abc1234', git: { branch: 'main', sha: 'abc1234' } }); + await refreshGitStatusSlot(bridge, { ...createEmptySession('s'), activeSandboxId: 'sbx_x' }); + expect(statusSlotAt(exp, StatusSlot.Git)).toBe('main@abc1234'); + }); + + it('passes through a dirty branch@sha value (branch+sha+dirty)', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + stubFetch({ value: 'main@abc1234*', git: { branch: 'main', sha: 'abc1234', dirty: true } }); + await refreshGitStatusSlot(bridge, { ...createEmptySession('s'), activeSandboxId: 'sbx_x' }); + expect(statusSlotAt(exp, StatusSlot.Git)).toBe('main@abc1234*'); + }); + + it('passes through a branch-only value (branch present, no sha)', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + stubFetch({ value: 'main', git: { branch: 'main' } }); + await refreshGitStatusSlot(bridge, { ...createEmptySession('s'), activeSandboxId: 'sbx_x' }); + expect(statusSlotAt(exp, StatusSlot.Git)).toBe('main'); + }); + + it('clears the slot on genuinely empty probe after SHA-only was suppressed earlier (not stale)', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + // First: SHA-only probe → suppressed (keep main@abc). + bridge.setStatusSlot(StatusSlot.Git, 'main@abc'); + stubFetch({ value: '@xyz', git: { sha: 'xyz' } }); + await refreshGitStatusSlot(bridge, { ...createEmptySession('s') }); + expect(statusSlotAt(exp, StatusSlot.Git)).toBe('main@abc'); + // Then: a real clear (empty value) → clear the slot. + stubFetch({ value: '' }); + await refreshGitStatusSlot(bridge, { ...createEmptySession('s') }); + expect(statusSlotAt(exp, StatusSlot.Git)).toBe(''); + }); + + it('suppression does not fire on network error (keep-last code path — no structured check needed)', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + bridge.setStatusSlot(StatusSlot.Git, 'main@abc'); + // network throw — code path never reaches the structured git guard. + fetchMock = vi.fn(async () => { + throw new Error('network down'); + }); + global.fetch = fetchMock as unknown as typeof fetch; + await refreshGitStatusSlot(bridge, { ...createEmptySession('s') }); + expect(statusSlotAt(exp, StatusSlot.Git)).toBe('main@abc'); + }); + + it('suppression does not fire on 429 (keep-last short-circuits before JSON parse)', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + bridge.setStatusSlot(StatusSlot.Git, 'main@abc'); + stubFetch({}, true, 429); + await refreshGitStatusSlot(bridge, { ...createEmptySession('s') }); + // 429 short-circuits before the JSON parse → before the structured guard. + expect(statusSlotAt(exp, StatusSlot.Git)).toBe('main@abc'); + }); }); describe('hydrate/turn-refresh git wiring (phase 2, plan #540 — pr #544 #3)', () => { const realFetch = global.fetch; diff --git a/lib/harnessChat.ts b/lib/harnessChat.ts index 5aa19fd8..3c03d055 100644 --- a/lib/harnessChat.ts +++ b/lib/harnessChat.ts @@ -437,6 +437,9 @@ export function foldStatusSlots( * rate-limit, so a refresh loop can't flicker the header). * - a valid empty probe result (non-git / no bind) → clear the git slot (stale * prior value must not linger). + * - a SHA-only result (sha present, branch absent — detached HEAD / transient + * git lock) → keep the last honest branch@sha (structured git fields are + * authoritative: a real @-prefixed branch has both fields and passes through). * - an oversize value is ellipsized to the status-slot byte cap via * `truncateStatusValue` before the wire. */ @@ -462,10 +465,21 @@ export async function refreshGitStatusSlot( ...(signal ? { signal } : {}), }); if (!res.ok || res.status === 429) return; // keep last value - const data = (await res.json()) as { value?: string }; + const data = (await res.json()) as { + value?: string; + git?: { branch?: string; sha?: string; dirty?: boolean }; + }; // Rate-limited replies carry the cached value and are not a clear; only a // genuinely empty probe result clears the git slot. if (typeof data.value === 'string' && data.value.length > 0) { + // When the server returns a sha but no branch (detached HEAD / transient + // git lock), keep the last honest branch@sha. The structured git fields + // are authoritative: a real @-prefixed branch has both git.branch AND + // git.sha and passes through — only the sha-present+branch-absent case is + // an unreliable SHA-only partial (adversarial review L1: git check-ref-format + // accepts @-prefixed branch names, so the string prefix is not a reliable + // discriminator). + if (data.git?.sha && !data.git?.branch) return; bridge.setStatusSlot(StatusSlot.Git, truncateStatusValue(data.value)); } else { bridge.clearStatusSlot(StatusSlot.Git);