Fix spawn into mise/direnv repos: inherit trust, retry start, add spawn reap #4

Merged
forgejo-admin merged 2 commits from fix-spawn-agent-start-race into main 2026-07-31 20:15:08 +00:00

Two celilo spawns created the worktree but never started an agent, leaving orphans behind.

Root cause

The failed pane sat at a shell prompt full of this:

mise ERROR  Config files in ~/.herdr/worktrees/celilo/sticky-container-storage/.mise.toml are not trusted.
direnv: error /Users/pbanka/.herdr/worktrees/celilo/sticky-container-storage/.envrc is blocked.

herdr agent start requires the pane to be at its interactive prompt. Spawn called it the instant worktree create returned, and a worktree with untrusted config spends its shell startup printing errors instead of getting there. celilo and lunacycle both have .mise.toml and .envrc — precisely the repos where this bit.

Confirmed a race, not a hard failure: the identical agent start on the identical pane succeeded minutes later.

Fix 1 — inherit trust instead of re-asking (removes the cause)

Being prompted to approve "this particular worktree of a project I already trust" is friction with no security value. It's the same file.

The safety rule is what makes this sound:

Inherit only when the worktree's copy is byte-identical to the file actually trusted in the source checkout, and the source checkout trusts it.

If the content differs — the base moved ahead of your checkout — that's genuinely new, and spawn says so and leaves it. It can never grant trust the source lacks.

Content equality is the correct test because direnv's own trust is content-keyed: the allow filename is not sha256(path) (44eaa8…96da11…), which is why editing an .envrc revokes it.

Detection uses each tool's own query interface — direnv status --jsonstate.foundRC.allowed == 0, and mise trust --show<path>: trusted, matched exactly since untrusted contains trusted as a substring.

Fix 2 — retry the start

Three attempts, 3s backoff. Belt and braces now that Fix 1 removes the usual cause.

Fix 3 — spawn reap <workspace>

From the same incident: the agent that hit the failure couldn't clean up after itself. The documented recipe was a compound shell line (herdr worktree remove … && git branch -D …), the permission classifier blocked it, and two orphaned worktrees were left for the operator. A recovery path an agent can't execute isn't a recovery path.

One first-party command now does it. The branch logic is the part worth reviewing:

branch state action why
no commits of its own force-delete plain git branch -d refuses a spawn branch whenever local main trails origin/main — most of the time (celilo is 23 behind)
has unmerged commits keep, list them, offer force cmd -D everywhere would silently eat real work
not a linked worktree refuse entirely cannot eat a primary checkout

Failure paths now print spawn reap <ws> instead of the two-command recipe.

Verification

check result
spawn into celilo inherited direnv+mise trust, briefed agent, TRUST-OK reply
worktree trust state after spawn mise: trusted · direnv: allowed=0
interactive shell in the new worktree zero trust errors
spawn → reap round trip on celilo 5.5s to briefed agent, ROUNDTRIP-OK, clean reap
reap a branch with an unmerged commit branch kept, commit listed by sha, force cmd offered
reap w1P (primary checkout) / unknown workspace both refused, exit 1
selftest: untrusted source / drifted content / missing files all yield no inherited trust
openspec validate --all 4 passed

Spec gains a trust-inheritance requirement (4 scenarios), a retry scenario, a single-command-recovery scenario, and a reap requirement (4 scenarios).

Residual

The retry makes the race survivable but doesn't prove it's gone — it never reproduced on demand. Fix 1 should make it moot for the repos where it actually happened.

🤖 Generated with Claude Code

Two celilo spawns created the worktree but never started an agent, leaving orphans behind. ## Root cause The failed pane sat at a shell prompt full of this: ``` mise ERROR Config files in ~/.herdr/worktrees/celilo/sticky-container-storage/.mise.toml are not trusted. direnv: error /Users/pbanka/.herdr/worktrees/celilo/sticky-container-storage/.envrc is blocked. ``` `herdr agent start` requires the pane to be **at** its interactive prompt. Spawn called it the instant `worktree create` returned, and a worktree with untrusted config spends its shell startup printing errors instead of getting there. **`celilo` and `lunacycle` both have `.mise.toml` and `.envrc`** — precisely the repos where this bit. Confirmed a race, not a hard failure: the *identical* `agent start` on the *identical* pane succeeded minutes later. ## Fix 1 — inherit trust instead of re-asking (removes the cause) Being prompted to approve "this particular worktree of a project I already trust" is friction with no security value. It's the same file. The safety rule is what makes this sound: > Inherit only when the worktree's copy is **byte-identical** to the file actually trusted in the source checkout, **and** the source checkout trusts it. If the content differs — the base moved ahead of your checkout — that's genuinely new, and spawn says so and leaves it. **It can never grant trust the source lacks.** Content equality is the correct test because direnv's own trust is content-keyed: the allow filename is *not* `sha256(path)` (`44eaa8…` ≠ `96da11…`), which is why editing an `.envrc` revokes it. Detection uses each tool's own query interface — `direnv status --json` → `state.foundRC.allowed == 0`, and `mise trust --show` → `<path>: trusted`, matched exactly since `untrusted` contains `trusted` as a substring. ## Fix 2 — retry the start Three attempts, 3s backoff. Belt and braces now that Fix 1 removes the usual cause. ## Fix 3 — `spawn reap <workspace>` From the same incident: **the agent that hit the failure couldn't clean up after itself.** The documented recipe was a compound shell line (`herdr worktree remove … && git branch -D …`), the permission classifier blocked it, and two orphaned worktrees were left for the operator. A recovery path an agent can't execute isn't a recovery path. One first-party command now does it. The branch logic is the part worth reviewing: | branch state | action | why | |---|---|---| | no commits of its own | force-delete | plain `git branch -d` **refuses** a spawn branch whenever local `main` trails `origin/main` — most of the time (celilo is 23 behind) | | has unmerged commits | **keep**, list them, offer force cmd | `-D` everywhere would silently eat real work | | not a linked worktree | refuse entirely | cannot eat a primary checkout | Failure paths now print `spawn reap <ws>` instead of the two-command recipe. ## Verification | check | result | |---|---| | spawn into **celilo** | `inherited direnv+mise trust`, briefed agent, `TRUST-OK` reply | | worktree trust state after spawn | `mise`: trusted · `direnv`: allowed=0 | | interactive shell in the new worktree | **zero** trust errors | | spawn → reap round trip on celilo | 5.5s to briefed agent, `ROUNDTRIP-OK`, clean reap | | reap a branch with an unmerged commit | branch **kept**, commit listed by sha, force cmd offered | | reap `w1P` (primary checkout) / unknown workspace | both refused, exit 1 | | selftest: untrusted source / drifted content / missing files | all yield **no** inherited trust | | `openspec validate --all` | 4 passed | Spec gains a trust-inheritance requirement (4 scenarios), a retry scenario, a single-command-recovery scenario, and a `reap` requirement (4 scenarios). ## Residual The retry makes the race survivable but doesn't prove it's gone — it never reproduced on demand. Fix 1 should make it moot for the repos where it actually happened. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Two failures spawning into celilo: the worktree was created but no agent ever
started, leaving an orphan. The pane sat at a shell prompt full of:

  mise ERROR Config files in ~/.herdr/worktrees/celilo/… are not trusted
  direnv: error … .envrc is blocked. Run `direnv allow`

`herdr agent start` requires the pane to be AT its interactive prompt, and
spawn called it the instant `worktree create` returned. Repos with .mise.toml
and .envrc spend longer getting there and print errors while doing it —
celilo and lunacycle both have them. Retrying the identical command on the
identical pane succeeded, which is what makes this a race and not a hard
failure.

Fix: retry agent start up to 3 times with a 3s backoff.

Second problem, from the same incident: the agent that hit this could not clean
up. The documented recipe is a compound shell line (`herdr worktree remove … &&
git branch -D …`) and the permission classifier blocked it, so two orphaned
worktrees and branches were left for the operator.

Adds `spawn reap <workspace>`: one first-party command that removes the
workspace, the checkout, and the branch. It deletes the branch only when that
branch has no commits of its own — plain `git branch -d` would refuse a spawn
branch whenever local main trails origin/main, which is most of the time, while
`-D` would silently eat real work. A branch carrying commits is kept, its
commits listed, and a force command offered. It refuses any workspace that is
not a linked worktree, so it cannot eat a primary checkout.

Failure paths now print `spawn reap <ws>` instead of the compound recipe.

Verified: full spawn+reap round trip against celilo (5.5s to briefed agent,
ROUNDTRIP-OK reply, clean reap); reap keeps a branch with an unmerged commit
and lists it; reap refuses w1P (a primary checkout) and an unknown workspace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Removes the root cause behind the agent-start race rather than only tolerating
it. An untrusted .envrc/.mise.toml makes the worktree's shell spend its startup
printing errors instead of reaching a prompt, which is exactly what
`herdr agent start` needs.

A worktree is a checkout of a repo the operator already trusts, so being asked
to approve "this particular worktree of a project I already trust" is friction
with no security value — the file is the same file.

The safety rule is what makes this sound: inherit only when the worktree's copy
is BYTE-IDENTICAL to the file actually trusted in the source checkout, and only
when the source checkout trusts it. If the content differs — the base moved
ahead of the operator's checkout — that is a genuinely new thing to trust, and
spawn says so and leaves it alone. It can never grant trust the source lacks.

Content equality is the right test because direnv's own trust is
content-keyed: the allow filename is not sha256(path), which is why editing an
.envrc revokes it.

Detection uses each tool's own query interface — `direnv status --json`
(state.foundRC.allowed == 0) and `mise trust --show` (`<path>: trusted`, matched
exactly since "untrusted" contains "trusted" as a substring).

Verified: spawning into celilo now prints "inherited direnv+mise trust", the
worktree reports trusted to both tools, and an interactive shell started there
emits no trust errors at all. Selftest gains three cases: untrusted source,
drifted content, and missing files — all must yield no inherited trust.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
forgejo-admin changed title from Retry agent start, and add spawn reap for recoverable cleanup to Fix spawn into mise/direnv repos: inherit trust, retry start, add spawn reap 2026-07-31 20:15:07 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
forgejo-admin/herdrmastr!4
No description provided.