Skip to content

plan: git slot — suppress SHA-only partial probe results (source #659) #660

Description

@btipling

Plan header

Field Value
Status HANDOFF-READY
Date 2026-08-24
Type single
Parent N/A
Source issue #659 — harness: status-bar git slot drops branch during a turn (shows @sha only)
Branch plan/fix-git-slot-sha-only
Layers DOM
Reusability impact none
Production mutate? no
Cloud ops path N/A — no Production mutate
Living docs docs/harness-limits.md (status bar line 2 row — add SHA-only guard note)

Review notes

Field Value
Review date 2026-08-24
Reviewer plan-review agent
Verdict ✅ HANDOFF-READY
Blocks none
Majors none
Minors 2 — docs row framing, test count labeling (see notes)

Scores

Axis Score Note
Correctness 5/5 All 9 baseline claims verified against live main. Guard logic (startsWith('@')) is correct — real git branches never start with @. Edge case table complete and matches real paths. Two-path root cause (HEAD rejection + transient git lock) verified in statusProbe.ts lines 69, 97, 100–104. Detached-HEAD tradeoff well reasoned.
Performance 5/5 One O(1) startsWith('@') per ~10 s cadence tick + on-demand post-turn/mid-turn calls. Trivial — zero hot-path impact.
Architectural soundness 5/5 Consumer-side guard is the right layer. refreshGitStatusSlot already owns the fail-soft policy (429, network error, empty → keep last). Adding "SHA-only → keep last" is a natural extension. The probe remains pure. No protocol changes, no Wasm, no backend, no reusability impact.
Testing 4/5 7 unit test rows cover all paths: SHA-only suppression, normal pass-through, dirty, branch-only, empty clear, 429, network error. Row 8 (typecheck) is a build gate, not a test case — minor labeling inconsistency (DoD says "7 cases" correctly). Tests follow existing stubFetch + statusSlotAt pattern in the same describe block.
Cloud ops N/A Pure DOM logic — no Production mutate. Correctly stated.
Living docs 4/5 Doc change correctly identified: add SHA-only suppression note to the status bar line 2 row in docs/harness-limits.md. Minor imprecision: plan says "Git slot row (~line 92)" — there is no dedicated Git slot row; the target is the status bar layout table row line 2 = status-slot pack (sandbox · cwd · git · context). The implementer will find the right spot; the behavioral note is correct. Other surfaces all N/A with valid reasons.
Cap governance N/A No new caps. Correctly stated.

Baseline verification

All 9 claims verified against main:

  • refreshGitStatusSlot at lib/harnessChat.ts:443-477 — line 469: if (typeof data.value === 'string' && data.value.length > 0) → unconditional setStatusSlot
  • probeGitStatus HEAD rejection at lib/agent/statusProbe.ts:97if (res.branch === 'HEAD') delete res.branch;
  • runGit returns '' on non-zero exit at line 69 — if (res.exitCode !== 0) return '';
  • formatGitStatusSlot at lines 100–104 — @sha when branch empty, sha present ✅
  • Mid-turn exec → line 1004 ✅
  • Mid-turn meta_sandbox_switch → line 997 ✅
  • Post-turn success → line 1370 ✅
  • Post-turn fail/cancel → line 1477 ✅
  • Hydrate → line 330 ✅
  • Cadence timer: 10 s in app/harness/HarnessHost.tsx:685, skips mid-turn ✅
  • Server route pipeline: app/api/harness/status/route.ts:146/153formatGitStatusSlot

Review checklist

[x] AGENTS.md + feature-divide read this session
[x] Baseline table grounded in live files (9 claims verified)
[x] Source issue present in header: #659
[x] Caps table: N/A — correctly stated (no new caps)
[x] Layers table filled; no forbidden dual-UI
[x] Architectural decisions present (2 decisions)
[x] Cloud ops path: N/A — correctly stated
[x] Living docs table filled; AGENTS + README considered
[x] No plan to put phase/issue process artifacts into docs/*
[x] Tests matrix has edge cases, not only happy path
[x] DoD checkboxes prove the goals
[x] Secrets stay server-side
[x] Zig-only-on-runner constraint respected (no Wasm changes)
[x] Reusability: no hardcoding single-tenant forever
[x] Open questions empty for in-scope locks
[x] ⛔ No application code was written, touched, or tested (plan issue only)

Corrections applied in review

Topic Plan said Locked by review
Docs row framing "Git slot row (~line 92)" The target is the status bar layout table row line 2 = status-slot pack (sandbox · cwd · git · context) — there is no dedicated Git slot row. The behavioral note is correct; implementer finds the right row
Test count labeling Table lists 8 rows (1–7 + typecheck) Row 8 is a build gate, not a unit test. DoD correctly says "7 cases." The implementer should write 7 it() blocks + 1 typecheck gate

Merge-gate residual risk

None. One line added, one line changed (the if nesting). The guard fires on all paths (cadence + on-demand) because it lives in the single funnel (refreshGitStatusSlot). The startsWith('@') discriminator is safe — git branch names cannot start with @. Existing tests in the same describe block will continue to pass (they use normal values like main@123, feature/x@a1b2c3d — none start with @). The doc update is a one-sentence note.

Summary

The status-bar git slot (line 2, branch@sha[*]) loses the branch name mid-turn and paints only @sha. Root cause: refreshGitStatusSlot unconditionally accepts any non-empty data.value from the server probe, including SHA-only partial results. The probe can produce @sha when rev-parse --abbrev-ref HEAD returns HEAD (stripped by the existing HEAD reject) or exits non-zero (transient git lock during an agent exec). One check in refreshGitStatusSlot — skip SHA-only values — fixes both paths.

Goals

# Goal Success signal
1 Mid-turn exec / meta_sandbox_switch never clobbers the git slot with @sha After an agent exec that transiently blocks git, the slot still shows main@abc1234 (the last honest value)
2 Idle / post-turn cadence probe still updates the slot normally A real branch change (e.g. git checkout feature) is reflected on the next cadence tick
3 Detached HEAD still shows @sha on the cadence (existing behavior preserved) When workspace is genuinely detached, the cadence probe eventually paints @sha after the operator detached

Non-goals / out of scope

  • Changing probeGitStatus or the server route — the probe is correct; the consumer was too trusting
  • Changing STATUS_PROBE_MIN_INTERVAL_MS or the 10 s Busy-skip cadence
  • Adding a bridge getter to read the current git slot value
  • Distinguishing transient-git-lock from genuine-detached-HEAD at probe time (impossible from a single observation)
  • Forbidden wiring: dual DOM chat · secrets in Wasm · laptop-only Production ops

Architectural decisions

Decision Options considered Choice Why
Where to guard A) probeGitStatus — return {} on partial result · B) refreshGitStatusSlot — skip SHA-only values · C) Server route — don't emit value for @-prefix B Probe is a pure data source — it shouldn't know about display policy. The route is a thin pass-through. refreshGitStatusSlot already owns the "keep last value" fail-soft policy (429, network error, empty result); adding a SHA-only guard is a natural extension of that same policy
Detached HEAD still shows @sha? A) Never show @sha — suppress all SHA-only values · B) Allow @sha on the cadence but not mid-turn on-demand A (de facto) The guard fires on every refreshGitStatusSlot call — cadence, hydrate, and mid-turn alike. The cadence runs every ~10 s; the first tick after a genuine git checkout --detach will produce @sha and be suppressed (keeps the last branch@sha). This is acceptable — the operator just detached, and the slot still shows their previous branch. If they stay detached, the slot freezes at the last honest branch@sha. When they re-attach to a branch, the next tick paints the real branch. Tradeoff: a workspace that starts detached (no prior branch to freeze) will never show any git slot — the first probe returns @sha → suppressed, second → suppressed, etc. This is the same UX as today's non-git workspace (slot empty) and is explicitly acceptable per the source issue
Test strategy A) Unit test refreshGitStatusSlot with mock fetch · B) Integration test through the host · C) Manual smoke only A refreshGitStatusSlot is already a standalone async function. Mock fetch to return { value: "@abc1234" } and assert setStatusSlot is NOT called. Two cases: SHA-only suppressed, normal branch@sha still passes through

Layer placement

Concern Layer Path(s) Rationale
Git slot refresh guard DOM lib/harnessChat.tsrefreshGitStatusSlot The host already owns the git-slot refresh policy; this is a one-condition extension of the existing "keep last value" fail-soft logic

Current baseline (live code)

Claim Path / symbol Notes
refreshGitStatusSlot unconditionally accepts any non-empty data.value lib/harnessChat.ts:466 verified — if (typeof data.value === 'string' && data.value.length > 0)setStatusSlot
probeGitStatus strips HEAD branch lib/agent/statusProbe.ts:95 verified — if (res.branch === 'HEAD') delete res.branch;
runGit returns '' on non-zero exit lib/agent/statusProbe.ts:68 verified — if (res.exitCode !== 0) return '';
formatGitStatusSlot produces @sha when branch empty, sha present lib/agent/statusProbe.ts:100-104 verified — const branch = res.branch ?? ''; const sha = res.sha ? '@${res.sha}' : ''; return '${branch}${sha}${res.dirty ? '*' : ''}';
Mid-turn exec success → refreshGitStatusSlot lib/harnessChat.ts:1003-1005 verified
Mid-turn meta_sandbox_switchrefreshGitStatusSlot lib/harnessChat.ts:997 verified
Post-turn success → refreshGitStatusSlot lib/harnessChat.ts:1370 verified
Post-turn fail/cancel → refreshGitStatusSlot lib/harnessChat.ts:1477 verified
Hydrate → refreshGitStatusSlot lib/harnessChat.ts:330 verified

Design

The bug (two paths to @sha)

Path 1 — HEAD rejection:

  1. probeGitStatus runs rev-parse --abbrev-ref HEAD → returns HEAD (exit 0)
  2. probeGitStatus deletes the branch: if (res.branch === 'HEAD') delete res.branch;
  3. SHA still succeeds → result is { sha: "abc1234" } (no branch)
  4. formatGitStatusSlot"@abc1234"
  5. Server returns { git: { sha: "abc1234" }, value: "@abc1234" }
  6. refreshGitStatusSlot sees non-empty data.valuebridge.setStatusSlot(StatusSlot.Git, "@abc1234")overwrites honest main@abc1234

Path 2 — transient git lock:

  1. Agent exec holds a git lock / mutates HEAD
  2. probeGitStatus runs rev-parse --abbrev-ref HEAD → exits non-zero
  3. runGit returns '' → branch is '' → not set in result
  4. SHA still succeeds → result is { sha: "abc1234" } (no branch)
  5. Same downstream as Path 1 → @abc1234 overwrites the honest main@abc1234

The fix

In refreshGitStatusSlot, after parsing the server JSON, add one guard:

if (typeof data.value === 'string' && data.value.length > 0) {
  if (data.value.startsWith('@')) return; // SHA-only → unreliable; keep last value
  bridge.setStatusSlot(StatusSlot.Git, truncateStatusValue(data.value));
} else {
  bridge.clearStatusSlot(StatusSlot.Git);
}

One new lineif (data.value.startsWith('@')) return; — between the non-empty check and the setStatusSlot call. The @ prefix is a reliable signal: a real git branch never starts with @, so @sha always means "branch missing, SHA present" — an unreliable partial result.

Edge cases

Scenario Before fix After fix
Agent exec transient git lock → probe returns @sha @sha overwrites main@abc1234 main@abc1234 preserved
Genuine detached HEAD → probe returns @sha (HEAD stripped) @sha overwrites main@abc1234 main@abc1234 preserved (frozen at last known)
Workspace starts detached (no prior branch) @sha shown Slot stays empty — same UX as non-git workspace
git checkout feature → probe returns feature@abc1234 Updates to feature@abc1234 Updates to feature@abc1234 (doesn't start with @)
SHA fails, branch succeeds → main (no @) main painted main painted (doesn't start with @)
Branch-only dirty → main* main* painted main* painted (doesn't start with @)
Both fail → data.value absent Slot cleared Slot cleared (unchanged)
Server 429 / network error Keep last value Keep last value (unchanged)

Why not fix probeGitStatus?

The probe is a pure data source — it returns what git says. The issue is a display policy decision: "a SHA-only result is not trustworthy enough to overwrite a known-good branch@sha." That policy belongs in the consumer (refreshGitStatusSlot), which already owns the fail-soft rules for 429, network errors, and empty results.

Cloud ops path

N/A — no Production mutate. Pure DOM host logic change; no secrets, no env, no deploy race.

Living docs plan

Surface Change Notes
docs/harness-limits.md Add SHA-only suppression note to the status bar layout table row (line 2 = status-slot pack (sandbox · cwd · git · context)) — the host suppresses probe results whose value starts with @; the git slot keeps its last honest branch@sha timeless behavioral note; no phase/issue process artifacts
AGENTS.md N/A — no agent rule or infra change
README.md N/A — no visitor-facing change
SECURITY.md N/A — no trust boundary change
.env.example N/A — no new env

Implementation order

  1. Add if (data.value.startsWith('@')) return; guard in refreshGitStatusSlot in lib/harnessChat.ts
  2. Add unit tests for refreshGitStatusSlot — mock fetch, assert SHA-only values are suppressed, normal values pass through
  3. Update docs/harness-limits.md status bar line 2 row with SHA-only suppression note
  4. Run npm test + npm run typecheck in agent workspace

Testing

# Case Layer Type Command / method
1 SHA-only value (@abc1234) → keep last (no setStatusSlot call) DOM unit vitest run — mock fetch
2 Normal value (main@abc1234) → setStatusSlot called DOM unit vitest run — mock fetch
3 Normal value with dirty (main@abc1234*) → setStatusSlot called DOM unit vitest run — mock fetch
4 Branch-only value (main) → setStatusSlot called DOM unit vitest run — mock fetch
5 Empty value → clearStatusSlot called DOM unit vitest run — mock fetch
6 Server 429 → keep last (no calls) DOM unit vitest run — mock fetch
7 Network error → keep last (no calls) DOM unit vitest run — mock fetch
8 npm run typecheck clean DOM build gate agent workspace

Definition of done

  • refreshGitStatusSlot suppresses SHA-only values (starts with @)
  • Normal branch@sha values still pass through unchanged
  • Tests green for all 7 cases above
  • npm run typecheck clean
  • docs/harness-limits.md status bar line 2 row updated
  • Cloud ops: N/A — no Production mutate
  • Living docs: docs/harness-limits.md updated (timeless behavioral note)

Risks & mitigations

Risk Mitigation
Operator detaches HEAD and never sees it reflected Acceptable — the slot freezes at the last branch@sha. If they start detached (no prior branch), the slot stays empty (same UX as a non-git workspace). The @sha-only display was never a clear "you are detached" signal anyway
A branch named @foo passes through Git branch names cannot start with @ (git check-ref-format rejects it). The guard is safe
data.value is @ (just the @ sign, no SHA) "@" starts with "@" → suppressed. In practice this cannot happen because formatGitStatusSlot only emits @ when sha is truthy after trim: res.sha ? '@${res.sha}' : ''. The guard handles this degenerate case safely regardless

Open questions

None — all in-scope engineering decisions locked above.

Caps table

N/A — no new caps, no cap changes.

References

  • Source issue: #659 — harness: status-bar git slot drops branch during a turn
  • Related: #540 (git probe), #627 (mid-turn live pack), #625 (live status bar)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions