Agent Worktree Isolation — Runbook
Status: active — 2026-04-22.
Owner: devops-engineer.
Related: core#852, client#107, client#110, client#111, core#827, core#3240.
Why this exists
Multiple agents (backend-sme, frontend-sme, qa-engineer,
principal-architect, etc.) are frequently dispatched in parallel on
the same repo. When they all operate on the shared main checkout
(/opt/upsquad/<repo>/), their git checkout, git stash, and
git clean operations silently clobber each other's uncommitted work.
Direct incidents:
| Incident | Cost | Note |
|---|---|---|
| client#107 (Clerk webhook) | multiple rebuilds | "checkouts got clobbered twice mid-build" |
| client#110 (dev-bypass removal) | ~90 minutes | untracked files dropped + tracked edits reverted when parallel branches flipped |
| client#111 (stub regen) | setup tax per agent | "concurrent bot sessions so explicit branch checkout + stash-by-id is required" |
| core#827 (conflict resolution) | forced workaround | agent created an ad-hoc worktree at /tmp/upsquad-761-merge |
Convention
Every agent task works in a dedicated git worktree at
/opt/upsquad-worktrees/<repo>/<agent>-<issue>/, branched from a
freshly fetched origin/main.
| Slot | Value |
|---|---|
| Root | /opt/upsquad-worktrees/<repo>/ — group-owned by upsquad-devs, mode 2775 (setgid) |
| Worktree dir | <agent>-<issue> |
| Branch | <prefix>/<issue>-<agent> (e.g. fix/844-devops-engineer) |
| Prefix | one of fix, feat, chore, docs, refactor, test |
| Base | origin/main at dispatch time |
One-time bootstrap (per dev box / CI runner)
sudo mkdir -p /opt/upsquad-worktrees/upsquad-core \
/opt/upsquad-worktrees/upsquad-client \
/opt/upsquad-worktrees/upsquad-admin \
/opt/upsquad-worktrees/upsquad-web
sudo chown -R "$USER":upsquad-devs /opt/upsquad-worktrees
sudo chmod 2775 /opt/upsquad-worktrees
sudo chmod 2775 /opt/upsquad-worktrees/*
The setgid bit on each directory makes new files/subdirs inherit the
upsquad-devs group so other agents (under any upsquad-devs user)
can read/write them.
Per-dispatch
# Agent prompts receive the issue number. The helper script returns the
# worktree path on stdout; all informational logs go to stderr.
WT=$(AGENT_NAME=backend-sme bash /opt/upsquad/upsquad-core/scripts/agent-worktree.sh 1234)
cd "$WT"
# work, commit, push ...
Script behaviour:
- Fetches
origin/mainfresh, branches from it. - Pins the worktree's commit identity to the agent (see below).
- If the worktree already exists (agent retry), it re-asserts the identity, prints the path and exits 0 — agents can idempotently call it.
- If the branch already exists locally (previous attempt), it reuses the branch and the new worktree checks it out.
- Prints only the path on stdout; every other line goes to stderr.
Commit identity (core#2708)
A linked worktree has no config of its own. It reads the main checkout's
$GIT_COMMON_DIR/config, and — this is the part that bites — a plain
git -C <worktree> config user.name X writes back to that shared file.
Measured on the devbox 2026-08-31: one such call re-pointed the commit
identity of all 211 live worktrees simultaneously.
So neither of the two obvious things works:
| approach | what actually happens |
|---|---|
| do nothing (the pre-#2708 behaviour) | worktree inherits /opt/upsquad/upsquad-core's identity. It was upsquad-qa-engineer[bot], so every agent committed as QA. |
git -C "$WT" config user.name … | silently rewrites the shared config for every concurrent agent. Same bug, inverted. |
The script does the third thing: extensions.worktreeConfig=true on the shared
repo (one-time, idempotent) plus git config --worktree, which writes to
$GIT_DIR/worktrees/<name>/config.worktree — genuinely per-worktree. It then
reads the value back and asserts (a) it is the agent's, (b) it is served by
that config.worktree and not the shared file, and (c) the shared checkout's
user.name did not move. Any of those failing aborts before a path is printed.
Enabling extensions.worktreeConfig is a repo-wide setting, so it is worth
knowing what it does and does not change. It makes git additionally read
config.worktree where one exists. Every worktree without one behaves exactly
as before, so the 211 pre-existing worktrees and the main checkout are
unaffected until the script writes one for them. The two documented hazards are
core.bare and core.worktree needing to move to the main worktree's
config.worktree; on this repo core.bare=false and core.worktree is unset,
so there is nothing to move.
Agent names are now closed-set. unknown, arch, architect-review,
backend, or a typo are hard errors (exit 2, no worktree created) rather than
a worktree that commits as somebody else. The seven canonical names and their
GitHub identities live in scripts/lib/agent-identity.sh.
Never hand-write a bot's noreply email. The numeric prefix in
<id>+<login>[bot]@users.noreply.github.com is what GitHub resolves the commit
to; a wrong one renders the right name and links to no account, showing up as
author: null on the API. Use the script, or source
scripts/lib/agent-identity.sh.
Measured 2026-08-31, and always quote the population with the number — these two differ by ~125x and conflating them is an error that shipped once:
| population | unlinked commits | distinct fabricated ids |
|---|---|---|
| merged PR branches | 505 | 46 numeric (63 bad emails) |
main | 4 | 1 numeric, plus 3 × backend-sme@upsquad.bot |
main is overwhelmingly squash-merged (~1.10 commits per merged PR) and a
squash discards the branch commits' authorship, so most of the damage never
lands. The branch figure measures how often agents get it wrong; the main
figure measures permanent damage. See core#2710.
Guards: tests/scripts/test_agent_worktree_identity.sh (offline, in the
Scripts Unit Tests lane) proves a worktree gets the identity its agent is
owed; scripts/verify-agent-identities.py (nightly Agent Identity Audit)
proves the table itself still matches the GitHub API — the offline suite
structurally cannot, since a fabricated id satisfies every offline assertion.
Scratch files and the rest of the shared substrate (core#3240)
The worktree isolates the checkout. It does not isolate the other three things an agent writes: the session scratchpad, a shared checked-in file, and the remote branch. All three have now produced the same failure — a green result for work that is not yours — which is the absence-scored-as-a-pass family with the subject swapped for somebody else's subject.
The session scratchpad is shared by every concurrent agent, not per-task
Measured 2026-09-08, three incidents in one night:
| incident | what happened |
|---|---|
| #3153 | the agent's mutate.py was overwritten by a #3152 sibling's. Re-running it printed a confident ALL-KILLED for the sibling's mutants. Caught by reading the output, not by the exit code — the exit code was 0 and correct |
| #3157 | lld3138.md replaced by a sibling's file, and pr.md deleted mid-task. One size discrepancy remains unexplained |
| #3139 (PR #3239) | the mutation battery and the guard roll-call were run out of the scratchpad as mutants.py / tests.json. That directory held 2808 files with concurrent writers, and the roll-call input tests.json had already been deleted by something else before the lander checked. Nothing in the results looked wrong — "looked wrong" is not the failure mode |
The lander on #3239 did not rename the files and move on; it re-ran both from a path only that task writes to, against a freshly generated stream, and reported that the numbers were unchanged and now reproducible. That is the standard: a scratchpad result is re-measured, not relabelled.
Where harness and artefact files go
| location | verdict |
|---|---|
sibling directory to the worktree — /opt/upsquad-worktrees/<repo>/<agent>-<issue>-scratch/ | use this. Task-scoped by construction, outside the checkout, and it survives for post-mortem the same way the worktree does |
| inside the worktree | no. Every artefact is an untracked file, and you cannot exclude it: git resolves info/exclude to the common dir — the shared checkout's, which no agent may write — and a per-worktree $GIT_DIR/info/exclude is not consulted. Measured 2026-09-08: a pattern in .git/worktrees/<name>/info/exclude left the file ?? in git status; the same pattern in the common dir's info/exclude cleared it. Untracked files are also a KEEP condition for the reclaim sweep, so leftovers change that verdict too |
| the shared session scratchpad | only under task-scoped filenames — mutate-<issue>-<agent>.py, never mutants.py |
/tmp | never. Stranger-owned stale files corrupted two mutation restores on 2026-09-07 |
Before trusting any re-run of a scratchpad script, verify its ROOT and ids are yours. The filename is not the evidence — #3153's file had the right name throughout.
A text-anchored graft into a shared file must assert its anchor unique on the CURRENT tree
Recorded on PR #3239, then made worse by the act of recording it.
#3239 grafted a roster entry into scripts/check-named-test-guards.py on the anchor
ROSTER: tuple[Guard, ...] = ( — which is a substring of the SQL_ROSTER
declaration, so the file contains it twice. The graft asserted its anchor was
unique, got 2, and stopped. Had it not, the
entry would have landed in the SQL roster, evaluated in the db.yml lane, which
cannot run a unit mutant — and both the roll-call and the count would have been green
with the guard in a lane that could never fire it.
#3237 then found the naive anchor occurs three times on main, not two: the real
roster, the SQL roster, and S1's own prose warning about the trap. Documenting a
hazard added an instance of it. Re-verified at 9e04dd059:
230: ROSTER: tuple[Guard, ...] = ( <- the real one
1167: # ... `SQL_ROSTER: tuple[Guard, ...] = (` CONTAINS the unit roster's ...
1251: SQL_ROSTER: tuple[Guard, ...] = (
So: anchor on \nROSTER: tuple[Guard, ...] = ( — the leading newline is what stops
SQL_ROSTER matching (3 → 1 occurrences) — assert count == 1 before writing,
and verify placement after. A uniqueness assertion computed once and carried forward
is worth nothing; the count moved on a docs commit.
--force-with-lease fails stale info after an inline-token fetch
Worktree fetch and push go through an inline HTTPS token URL, not a named remote
(the devbox remotes are SSH and bots have no SSH key). A URL is not a remote, so
refs/remotes/origin/<branch> is never written for the branch — and the bare
--force-with-lease takes its expected value from exactly that ref. With no ref there
is no lease, and git refuses. Measured 2026-09-08:
! [rejected] topic -> topic (stale info)
stale info reads like a race and is not one — it is an absent tracking ref,
not an outdated one. Nothing was concurrently pushed. Supply the lease explicitly
instead, re-fetching the value in the same command rather than reconstructing it:
git push "https://x-access-token:${GH_TOKEN}@github.com/upsquad-ai/upsquad-core.git" \
--force-with-lease="$BRANCH:$(git ls-remote \
"https://x-access-token:${GH_TOKEN}@github.com/upsquad-ai/upsquad-core.git" \
"refs/heads/$BRANCH" | cut -f1)" \
"$BRANCH:$BRANCH"
Never fall back to bare --force. The lease is the only thing standing between a
rebase and another agent's push to the same branch — #3188 took its lease against the
reviewed head add7d75e precisely so a rebase could not clobber anything else.
Success criteria (from the issue)
- Two agents dispatched simultaneously on the same repo produce independent PRs with zero clobbering.
- Setup overhead under 5 seconds per dispatch.
- Documented convention in
CLAUDE.mdfor every repo.
Measured during core#852 delivery:
$ time (AGENT_NAME=test-b bash scripts/agent-worktree.sh 9999 test-b &
AGENT_NAME=test-c bash scripts/agent-worktree.sh 10000 test-c &
wait)
# ...
real 0m1.800s
1.8 s for two parallel worktrees, well under the 5 s SLA. Separate
AGENT_B_FILE / AGENT_C_FILE writes to each worktree confirmed
isolation — git status in each showed only that worktree's own
untracked file.
Cleanup
Worktrees persist after the agent finishes. This is intentional: if an agent task failed, the next agent picking it up should see the last state rather than a fresh checkout.
A worktree is reclaimed when every commit reachable from its HEAD is
already on origin — including under refs/pull/*/head, which is where a
merged PR's commits live once GitHub deletes the branch — its tree is clean and
readable, it is unlocked, no file in it has been modified for
--min-untouched-hours (default 6), no PR that claims it is open, and its HEAD has not
moved for --min-idle-days (default 14, or --merged-pr-idle-days (default 2)
when the PR is merged):
# Run from the main checkout so the self-protection guard doesn't
# skip a worktree you're currently inside.
cd /opt/upsquad/upsquad-core
bash scripts/prune-agent-worktrees.sh --report # what is here and why (read-only)
bash scripts/prune-agent-worktrees.sh --dry-run # what would be removed
bash scripts/prune-agent-worktrees.sh # do it
The sweep authenticates over HTTPS with a bot token
(scripts/lib/git-https-fetch.sh → gh-token.py), not SSH — the devbox
remotes are git@github.com:... and bots have no SSH key, which is why
the sweep silently never ran and ~150 worktrees accumulated before #1506.
The same helper backs the fresh fetch in agent-worktree.sh, so worktree
creation is clean for bots too. If that fetch fails the sweep aborts
rather than reasoning from stale remote-tracking refs.
Why reachability and not "is the branch merged" (core#2751)
The branch test was a proxy. Measured on the devbox 2026-09-01, it broke in three directions at once and the sweep reclaimed 0 on 1095 of its last 1197 runs:
| defect | consequence |
|---|---|
| undefined for a detached HEAD | 31 review worktrees kept forever |
trivially true for a NEW branch off origin/main | a worktree was deleted 16 minutes after an agent created it |
git ... 2>/dev/null || echo "" swallowed fatal: detected dubious ownership | 10 unreadable trees reported as "detached HEAD"; the sibling status probe reported them clean |
git rev-list --count HEAD --not --remotes=origin == 0 needs no branch and
answers the question that actually matters: would deleting this directory
destroy anything that is not on origin.
It is MORE PERMISSIVE than "branch merged", not stronger. merged-into-main
implies all-commits-on-origin; the converse is false. The counterexample is an
open PR — branch pushed, not merged: old rule KEEP, new rule RECLAIM. 56 of the
62 worktrees it reclaims are exactly that case. What is lost is not commits (on
origin by construction) but git-ignored state — build caches, .env,
scratch notes. --min-idle-days is what replaces the old in-flight protection.
The direction that matters runs the other way. The old rule also removed on
"branch no longer on origin", and could not tell deleted after merge (safe)
from never pushed (not safe) — both give on_origin=0. Over the guard's whole
log: 204 removals, 145 (71%) under that clause, versus 59 merged. 50 were
the principal-architect's own review worktrees, and three mid-review
disappearances previously blamed on "a concurrent sweeper" are named in the log
— pr2739, pr2739b, pr2743, all never-pushed local review branches whose
commits existed nowhere else. That is the real justification for the change.
Safety — all must hold, every one fail-closed. Any probe that errors makes
the worktree unreadable, which is kept and reported as such rather than
folded into another category. Kept if: locked, dirty, has untracked files, has
any commit not on origin, has moved in the last --min-idle-days, or has no
readable reflog. A stash is not per-worktree — refs/stash lives in the
common dir — so removal never destroys one, and objects always survive in the
shared object database.
The PR-head blind spot (core#3190) — why --remotes=origin was the wrong half
--remotes=origin enumerates refs/remotes/origin/* and nothing else. Two
facts about this repo turn that into a permanent false negative:
maintakes squash merges, so a merged PR's branch tip is never an ancestor ofmain. Ancestry is not a merged-detector here at all.- GitHub deletes the head branch at merge, so the tracking ref is removed
by the
fetch --prunethe sweep runs on purpose — permanently.
A worktree whose work landed months ago therefore reads unpushed forever.
Measured 2026-09-10, after a hand-verified reclaim of 83 provably-landed
trees had already been done by hand:
done: removed=0 ... unpushed=54 ... (sanctioned: unpushed=44)
removed=0, with 44 sanctioned trees blamed on "commits not on origin", while
the box sat at 97% and an agent had to override agent-worktree.sh's 95%
refusal to work at all. The false-positive rate on the trees #3190 examined was
153/155 = 98.7%.
The commits are on origin. GitHub retains refs/pull/<N>/head indefinitely
for every PR, branch or no branch. One ls-remote reads all of them:
git ls-remote <origin> 'refs/pull/*/head' # 1328 refs, one round trip
Three things about how it is read, each got wrong first:
| trap | what happens | what the sweep does |
|---|---|---|
fetching into refs/remotes/origin/* | not matched by remote.origin.fetch, so any concurrent agent's fetch --prune deletes it — observed mid-sweep 2026-09-07, after which 154 trees silently read unpushed again | never creates a local ref at all |
| re-reading refs per worktree | the decision set changes underneath a running sweep | snapshots to a file at startup, reads it with rev-list --stdin |
^<sha> for an object we do not have | rev-list dies | filters through cat-file --batch-check; a sha absent locally cannot be an ancestor of a local HEAD, so no verdict changes |
--not is a toggle, not a per-argument negation: HEAD --not A --not B
flips the sense back to positive and reported 2129 where the correct spelling
reported 0. The --stdin form sidesteps it — every snapshot line carries its
own literal ^.
Effect on the live registry, same box, same hour, stock vs fixed:
| verdict | stock | fixed |
|---|---|---|
unpushed | 54 | 11 |
pristine | 9 | 33 |
in-flight (new) | — | 2 |
touched (new) | — | 7 |
The 11 survivors are genuine: architect merge-test trees carrying local-only
commits that exist nowhere else. pristine grew, because trees formerly
blocked at unpushed now reach the core#2751 gate — that protection is intact
and now covers more.
What paid for widening it: two new gates and a scoped floor
Widening the negative set makes the sweep more permissive, and --min-idle-days
was carrying all of the in-flight protection. Now that PR heads are being read
anyway, the real question is answerable:
-
in-flight— any PR that claims the worktree is OPEN ⇒ keep, unconditionally, at any idle age. Strictly more conservative than the old rule, which reclaimed open-PR worktrees once they went quiet (1 in 20 of everything it removed). Live on the devbox: PRs #3270 and #3349, both of which the stock rule was eligible to delete."Any", not "the". A worktree resolves against every
refs/pull/<N>/headmatching its head SHA and every PR for its branch, unioned, then reduced with open-wins semantics. A first-match selector is a selector ordered by PR numbering —ls-remotesorts lexicographically by refname — and in a deleter a wrong selection is a deletion. Reviewed live on PR #3355: a first-match selector deleted an open-PR worktree (removed=5 in-flight=0).Branch reuse is routine here, not hypothetical: 1331 advertised
refs/pull/*/headover 1328 distinct SHAs on this repo (2026-09-10), so three SHAs are claimed by two PRs each — #1758/#1759, #1728/#1729, #2057/#2058, all the "close it, re-PR as the next number" shape. Those three happen to fail safe today because the first match is theclosedone. That is luck, not design.Ambiguity does not inherit merged status. The shortened floor is granted only when identification is unambiguous and merged — exactly one claimant, and it merged. Two claimants, or any claimant whose state could not be read, means the tree is not identified, and an unidentified tree keeps the full
--min-idle-dayshowever many of its claimants merged. -
touched— any file in the tree modified in the last--min-untouched-hours(default 6) ⇒ keep. This is what makes a shorter floor safe: an agent can read, build and run tests for days without moving HEAD, and that activity is invisible to a reflog-derived age.
Only then does --merged-pr-idle-days (default 2) apply, and only to
worktrees whose PR is merged: nothing is in flight, the commits are on
origin, the tree is clean, HEAD has moved at least once, and nobody has touched
a file in 6 hours. What is left to lose is git-ignored state. unknown and
closed keep the full --min-idle-days — no positive evidence, no shortcut.
Every part of it is fail-closed, and the header line says which parts ran:
decision set: origin-tips=362 pr-heads=ok(advertised=1328 usable=1328) \
pr-state=ok freshness=ok(6h) idle-floor=14d merged-pr-floor=2d
An unreadable PR-head list (ls-remote-failed, zero-advertised,
filter-failed), an unavailable gh (--no-pr-state, gh-unauthenticated),
or a find that cannot evaluate the freshness probe each clamp
--merged-pr-idle-days back
up to --min-idle-days and are named in that line. A sweep that quietly
reverted to the old behaviour and logged a clean run is the failure this repo
keeps re-learning.
The freshness probe self-checks, and here is why
find on this devbox is bfs, which rejects relative timestamps.
-newermt '-6 hours' errors on every path, so the gate matches nothing — it
reported 0 of 175 worktrees as fresh on a box running five agents, i.e. it would
have scored a tree in active use as safe to delete. It was caught only because
"zero active worktrees" was implausible, not by anything in the code.
So the cutoff is an absolute ISO stamp, and at startup the gate is fired at a
file created milliseconds earlier. If that does not match, freshness=inert and
the merged-PR shortcut is switched off. The test battery pins this with a find
shim that exits 0 with no output — the silent variant, the only one the
per-tree exit-status check cannot catch.
Any freshness check written here must use an absolute stamp and be probed against a known-fresh path. Absolute stamp alone is not the lesson; the probe is.
The pristine window (core#2751)
Every agent worktree is deletable from creation until its first commit. A tree
fresh out of git worktree add ... origin/main has HEAD at the tip of
origin/main, which is an ancestor of origin/main, so the old rule called it
merged and removed it. Not a naming problem, not a timing problem — a property of
every worktree's initial state, with nothing standing in the way but whether a file
happened to have been written when the sweep fired. The sweep fires every 15 min.
Three observed incidents: this author's worktree at 16 minutes old, 50 architect
worktrees in the log (pr2739, pr2739b, pr2743 mid-review), and a third agent
watching its worktree vanish mid-session the same day.
So there is an unconditional gate, evaluated before the idle gate: a worktree
whose HEAD reflog holds exactly one distinct value — HEAD has never moved since
creation — is always kept, and reported as pristine.
--min-idle-days cannot express this. Idle age is derived from when HEAD last
moved; in the pristine state it never has, so the number measures the worktree's
age, not the agent's activity — an agent can read, build and edit ignored files
for days without moving HEAD. Raising the threshold narrows the window; nothing
expressible in terms of it closes the window.
Two obvious spellings are inert — both were tried and measured:
| spelling | matched |
|---|---|
HEAD == origin/main | 0 of 208 — origin/main advances, so a tree created at yesterday's tip stops matching |
| reflog has exactly one line | 0 of the trees the real creation path makes — git worktree add -b writes two lines at creation, 0000->X then X->X |
| distinct new-SHAs == 1 | 70 of 208 — the property, immune to git's bookkeeping |
Cost: 4 worktrees move from reclaim to pristine (179 MB of 3.58 GB); 58 are
still reclaimed. A pristine tree is byte-identical to a fresh checkout, so a human
who has eyeballed one loses nothing by removing it with git worktree remove.
Age comes from the reflog, never from mtime. git status rewrites the
index, so any tool that inspects a worktree resets an mtime-based age —
including this sweep, on every tree, every 15 minutes.
Scope
The registry is not confined to /opt/upsquad-worktrees/<repo>: on the devbox
101 of 210 registered worktrees live under the Agent SDK's .claude/worktrees/
root, /tmp, or a legacy /opt/upsquad/worktrees. The sweep inspects and
reports all of them, and removes only in the classes named by
--remove-roots (default sanctioned). Widening removal is a reviewed
decision, not a side effect of fixing the reporting.
How it is actually scheduled
worktree-reclaim.timer / worktree-reclaim.service (Sundays 03:30 UTC) are
checked in but not installed on the devbox — verified 2026-09-01, zero
journal entries in 90 days. What actually runs the sweep is
scripts/dev-disk-guard.sh, installed under vb's systemd user manager and
firing every 15 minutes whenever the disk is >= 85%. systemctl --user as
another user cannot see it, which is how "the timer does not exist" and "the
sweep runs 90x/day" both look true. Check the log, not the unit:
tail -40 /var/tmp/dev-disk-guard.log
cat /var/tmp/dev-disk-guard.status
Disk headroom
scripts/agent-worktree.sh prints a disk: line on every dispatch — used
%, free, and the disk-guard marker's state and age — warns at 85%, and refuses
outright at 95% (override: AGENT_WORKTREE_ALLOW_LOW_DISK=1). Running the box
out of disk mid-task does not present as "no space left"; it presents as
unrelated tool errors, and diagnosing that costs far more than being stopped at
the start. The guard's own webhook has never been configured, so its 449 CRIT
events went unread — see #2751.
Relationship to Claude Code Agent SDK isolation: "worktree"
The SDK's built-in isolation: "worktree" param gives subagent tool
calls their own worktree for the duration of the call. It is
complementary, not a replacement, for the filesystem convention
documented here:
- SDK isolation covers subagent tool calls that modify the repo.
Agents SHOULD pass
isolation: "worktree"when spawning a subagent that mightgit checkoutor write files. - Filesystem convention covers human-invoked, cron-invoked, and top-level agent dispatches. Without it, any of those contexts can still clobber an SDK-isolated subagent's parent checkout.
When in doubt, prefer the filesystem convention — it works uniformly across every invocation path.
References
scripts/agent-worktree.sh— dispatch helper.scripts/prune-agent-worktrees.sh— the reclaim sweep (run by the disk guard, not weekly).scripts/lib/git-https-fetch.sh— shared HTTPS bot-token fetch helper.scripts/systemd/worktree-reclaim.{service,timer}— weekly disk reclaim. Not installed on the devbox.scripts/dev-disk-guard.sh— what actually invokes the sweep, every 15 min at >= 85% used. Since #3508 it trims the Go build cache BEFORE the sweep (cheapest and most reversible first) and reports what it could not reclaim; the sweep still runs unconditionally, because it is the only thing bounding worktree count. Details:docs/runbooks/devbox.md→ Resource guards.tests/scripts/test_prune_agent_worktrees.sh— the reclaim-policy regression guard.tests/scripts/test_dev_disk_guard_gocache.sh— the regression guard for the Go build-cache trim (#3508).CLAUDE.md→ "Parallel agent dispatches (MANDATORY)" section.- core#852 — the task that produced this convention.
- core#1506 — SSH→HTTPS auth fix + scheduled reclaim.
- core#2751 — reachability-based reclaim, all-roots reporting, disk preflight.
- core#3240 — the shared-scratchpad hazard, the sibling-directory fix pattern, and the two later refinements (anchor uniqueness on the current tree; the lease after an inline-token fetch). Refs core#3232, PR #3237, PR #3239, core#3188.