Skip to content

tier2: per-worktree session baselines; one-call session-start snapshot - #5

Merged
Vibherpunk merged 2 commits into
mainfrom
fix/tier2-worktree-baselines
Oct 12, 2026
Merged

Vibherpunk merged 2 commits into
mainfrom
fix/tier2-worktree-baselines

Conversation

@Vibherpunk

Copy link
Copy Markdown
Owner

Problem 1: worktrees held to another worktree's HEAD

Builders in linked worktrees of one git store (/Users/ai/vibehard-build/anton-go: TT-sales, TT-marketing, V2-D2S0, …) were halted with E4:

HEAD is not a descendant of session baseline 75a4cfb

75a4cfb was V2-D2S0's HEAD. Two builders then merged that unrelated branch into their own to satisfy the gate (e799c02, 7e48b06).

Cause

This is pre-existing code, not PR #3.

  • get_or_set_session_baseline keyed its git-ref layer by session only: refs/hardtruth/baseline/<conv_hash>.
  • Refs live in the git common dir, which every linked worktree shares.
  • is_valid_commit only checks the shared object store, so a ref recorded in one worktree, or a SHA from the session-keyed cache file, was accepted as another worktree's baseline.
  • Hook payloads without a session_id normalise to "unknown", so all such invocations shared one ref and one cache file. ~/.hardtruth/halts/baseline_unknown_b23a6a84….json holds TT-marketing's 7e48b06. The store has 29 baseline refs, including two at 75a4cfb.

Fix

  • Per-worktree ref name. baseline_ref_name(conv, realpath(toplevel)) is used by both get_or_set_session_baseline and check_manifest_tampering. The legacy shared name is never read.
  • Cache file match. The session cache file's single baseline_sha is used only for the tree its workspace_path names, in both the hook and tier2_runner.
  • No baseline without a session id. Nothing is recorded or read for an id-less (unknown / default) session. Sharing one is worse than having none.
  • E4 wording. Both E4 messages now say: do not merge, rebase or cherry-pick other branches to satisfy this check; if the baseline commit is not one this branch should descend from, stop and report it to the operator.

Problem 2: the PR #3 session-start snapshot never recorded anything

All 67 production SessionStarts since the PR #3 install have repos_snapshotted: 0. Every edit outside the workspace therefore failed closed. That is safe, but unusable.

Cause

  • The daemon resolves each new key by scanning its whole ledger (113k lines): about 0.9s per registration, and requests are handled one at a time (16 in parallel took 18.7s).
  • The snapshot made 2 registrations per repo, so with ~126 calls nearly all of them hit the 1.5s client timeout.
  • PR tier2: verify session edits outside the workspace in their own repo #3's tests used a fake daemon that answers instantly, which hid this.

Fix

  • The snapshot is now one manifest, {realpath: {head, stash}}, written to the halt dir. Its sha256 is registered in one daemon call (first write wins).
  • Stop trusts the manifest only if it hashes to the daemon's digest. Editing or deleting it means the repo fails closed. The trust level is the same as before, at one call instead of one per repo.
  • Linked worktrees of every discovered repo are included, each keyed by its own top-level with its own HEAD.

Live check of the real session_start command against the real daemon: 94 working trees recorded in 5.2s, within the 10s hook timeout. TT-sales → 5cc39c6 and V2-D2S0 → e0986d8, each its own HEAD, matching git worktree list.

Tests

tests/test_tier2_out_of_workspace_roots.py now has 42 tests (12 new).

New TestLinkedWorktreeBaselines: one store with two linked worktrees on different branches.

  • Each worktree gets its own HEAD through the ref layer (daemon down).
  • The cache file is not reused for a sibling worktree.
  • The legacy shared ref is ignored.
  • An unknown session gets no baseline and no refs.
  • The tamper check doesn't use a sibling worktree's baseline: no false pytest.ini tamper.
  • End to end: a session that touched V2-D2S0, then stops in TT-sales, is not halted with E4.
  • E4 text includes "Do not merge".
  • The snapshot from the main checkout records each linked worktree's own HEAD.

Snapshot tests:

  • one daemon call for 12 repos;
  • daemon down at SessionStart → fail closed;
  • edited manifest → fail closed;
  • deleted manifest → fail closed.

Run against main's code: 11 of the 12 targeted tests fail, including the exact reported E4. The 12th, daemon-down, already fails closed on main.

Full suite:

  • before (main a77df23): 223 passed, with the 3 known failures plus the test_concurrency_load load flake;
  • after: 235 passed, 6 skipped, only the 3 known failures.

Notes

  • Who is calling the hook without a session_id? It was invoked without one for TT-marketing. Since no baseline is now recorded under "unknown", Phase 5 lineage is skipped for such calls, but the ledger premise is still shared across them. That caller should pass a session id.
  • Merges already made: the two gate-induced merges (e799c02, 7e48b06) remain on those branches. Whether to revert them is for the coordinator to decide.
  • Probe records: while diagnosing, I wrote a few probe-ht-* conversation keys to the live daemon. They are harmless and namespaced.
  • Not in this PR: the daemon's O(ledger) lookup on a key miss is the underlying performance issue. An in-memory index of __session_baseline__ records on load would fix it, but needs a daemon rebuild.

🤖 Generated with Claude Code

Builders in linked worktrees of one git store (anton-go: TT-sales,
TT-marketing, V2-D2S0, ...) were halted with E4 against another worktree's
HEAD (75a4cfb, V2-D2S0), and two merged that unrelated branch to satisfy the
gate (e799c02, 7e48b06).

Cause: get_or_set_session_baseline keyed its git-ref layer by session only
(refs/hardtruth/baseline/<conv_hash>). Refs live in the git common dir shared
by every linked worktree, and is_valid_commit only checks the shared object
store, so a ref or cached SHA recorded in one worktree was returned as
another's baseline. Hook payloads without a session id normalise to
"unknown", so all such sessions shared one ref and one cache file
(~/.hardtruth/halts/baseline_unknown_*.json held TT-marketing's 7e48b06).

- Baseline ref is now per (session, worktree realpath): baseline_ref_name,
  used by get_or_set_session_baseline and check_manifest_tampering. The
  legacy shared name is never read.
- The session cache file's single baseline is used only for the tree its
  workspace_path names (hook and tier2_runner).
- No baseline is recorded or read for an id-less ("unknown"/"default")
  session.
- E4 messages tell the agent not to merge, rebase or cherry-pick other
  branches to satisfy the check, and to report a wrong baseline.

Also fixes the PR #3 session-start snapshot, which recorded 0 repos in all
67 production SessionStarts since install: the daemon resolves each new key
by scanning its 113k-line ledger (~0.9s, serialized), so 2 registrations per
repo never fit the budget. The snapshot is now a manifest {realpath: head,
stash} in the halt dir whose sha256 is registered in ONE daemon call; Stop
trusts it only if the digest matches. Linked worktrees of every discovered
repo are included with their own HEAD. Live: 94 trees in 5.2s, TT-sales and
V2-D2S0 each at their own HEAD.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Vibherpunk

Copy link
Copy Markdown
Owner Author

Note: id-less hook calls (Antigravity agy -p)

The id-less invocation behind baseline_unknown_*.json (TT-marketing) was a Gemini builder run through agy -p (Antigravity CLI). Its hooks fire through Antigravity's integration, which apparently doesn't pass a session id, so normalize_payload sets conversationId = "unknown".

What this PR does for such calls

It does not fail closed. It records no baseline and reads none: get_or_set_session_baseline, the snapshot, and the tamper-check lookups all return nothing for unknown / default.

As a result:

  • Phase 5 lineage and stash checks are skipped for those Stops. Before this PR, they compared against a baseline shared by every id-less session, which is how the false E4s happened.
  • The ledger premise is still shared across id-less calls. This PR doesn't change that.

Failing closed at Stop when there's no session id

This would be a one-line change. Its effect: every agy -p builder halts at Stop until the Antigravity hook wiring passes an id. It hasn't been done here; it's left for the coordinator to decide, since it blocks those builders.

An id-less Stop (conversationId "unknown"/"default", e.g. Antigravity
`agy -p`, whose hook payload carries no session_id) gets no session-scoped
baseline, so the Phase 5 lineage and stash checks were skipped and the Stop
was allowed: a fail-open path. It now halts (non-remediable) with:
"this session has no session id; HardTruth cannot verify lineage ... Ask the
operator; do NOT merge, rebase or cherry-pick to work around this."

Tests: direct ("unknown", "default", "") and through normalize_payload; both
return allow without the change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Vibherpunk

Copy link
Copy Markdown
Owner Author

Update (6f90a06): id-less sessions now fail closed, per the coordinator's decision. This supersedes the note above.

  • Change: a Stop with no session id (unknown / default / empty) halts, non-remediable. The halt message: this session has no session id; HardTruth cannot verify lineage … Ask the operator; do NOT merge, rebase or cherry-pick to work around this.
  • Tests: two new ones, one calling the hook directly and one going through normalize_payload. Both return allow without the change.
  • Full suite: 235 passed, 6 skipped; the 3 known failures plus two test_concurrency_load daemon 599s. That file passes 6/6 when run alone.

@Vibherpunk
Vibherpunk merged commit dd14731 into main Oct 12, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant