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
2 changes: 1 addition & 1 deletion docs/harness-limits.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ Vertical bands inside the Wasm root (not a DOM panel):
| Slots | **sandbox** (`activeSandboxId` → `sandbox <id>` 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) |
Expand Down
91 changes: 91 additions & 0 deletions lib/harnessChat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
16 changes: 15 additions & 1 deletion lib/harnessChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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);
Expand Down