From c6e3f5bf6dc8928de2cfd8b6b08955e8c2c830fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Pe=C3=B1a?= Date: Tue, 23 Jun 2026 02:17:32 -0600 Subject: [PATCH 1/2] =?UTF-8?q?feat(hooks):=20worktree-collision=20guardra?= =?UTF-8?q?il=20=E2=80=94=20block=20batched=20isolation:worktree=20spawns?= =?UTF-8?q?=20+=20git-worktree-add=20warn=20rule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PreToolUse guard (Task/Agent) blocks a 2nd isolation:worktree spawn in the provisioning window (atomic mkdir lock for same-message parallel spawns); git-worktree-add-discipline warn rule nudges toward dedicated-worktree + git -C; references/agent-worktree-orchestration.md documents the pattern + forward-fix recovery. Genericized warn-localhost example host -> dev.example.com. No version bump (coordinate with the audit-engine release). Created by Claude Code on behalf of @lapc506. Co-Authored-By: Claude Opus 4.8 --- hooks/hooks.json | 10 ++ hooks/pre-task-worktree-isolation-guard.sh | 113 +++++++++++++++++++++ hooks/rules/rules.json | 75 +++++++++++++- hooks/rules/rules.yaml | 81 ++++++++++++++- references/agent-worktree-orchestration.md | 39 +++++++ 5 files changed, 311 insertions(+), 7 deletions(-) create mode 100755 hooks/pre-task-worktree-isolation-guard.sh create mode 100644 references/agent-worktree-orchestration.md diff --git a/hooks/hooks.json b/hooks/hooks.json index fc291d0..d0575a1 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -12,6 +12,16 @@ } ] }, + { + "matcher": "Task|Agent", + "hooks": [ + { + "type": "command", + "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/pre-task-worktree-isolation-guard.sh", + "timeout": 5 + } + ] + }, { "matcher": "Edit|Write|MultiEdit|NotebookEdit", "hooks": [ diff --git a/hooks/pre-task-worktree-isolation-guard.sh b/hooks/pre-task-worktree-isolation-guard.sh new file mode 100755 index 0000000..e9c4240 --- /dev/null +++ b/hooks/pre-task-worktree-isolation-guard.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# ============================================================================= +# pre-task-worktree-isolation-guard.sh — PreToolUse guard for the subagent +# spawn tool (Task / Agent). +# +# WHY THIS EXISTS +# Git worktrees share ONE object store and ONE set of branch refs; a branch +# can be checked out in only one worktree at a time. The harness's +# `isolation: worktree` is supposed to provision a fresh worktree per agent, +# but two failure conditions defeat that: +# (a) Provisioning RACE — spawning >=2 isolation:worktree agents in the +# SAME batch races provisioning; one agent fails to get a dedicated +# directory and reuses the parent (or another agent's) worktree. +# (b) cwd-reset + bare git — the agent's shell cwd resets to the shared +# checkout between commands, so a bare `git checkout -b ...` runs +# against whatever worktree the cwd resolves to, switching THAT +# worktree's branch and dragging its uncommitted files along. +# Together they commingle two contexts' work in one working directory. Once +# commits entangle (branch A's commit captures branch B's files), a `git +# revert` cannot separate them — the only recovery is a manual forward-fix. +# (Real incident: the sibling-recheckout damage that needed a forward-fix +# instead of a revert. Memory: reference_worktree_rechecked_out_by_sibling / +# reference_parallel_agent_worktree_collision.) +# +# WHAT IT ENFORCES (the user's Rule 1) +# At most ONE in-flight isolation:worktree spawn per session within a short +# provisioning window. The 2nd+ batched spawn is BLOCKED (exit 2) with +# guidance to either (Rule 1) spawn one-per-message, or (Rule 2 — preferred) +# pre-create a dedicated worktree and pin `git -C ` for ALL git. +# Single / sufficiently-staggered worktree spawns pass. Non-worktree +# subagents (no `isolation` field, or isolation != "worktree") always pass. +# +# FAIL-OPEN +# Missing jq, unparseable input, unwritable lock dir, or +# CLAUDE_DISABLE_PLUGIN_HOOKS=1 all exit 0 — an installation/runtime issue +# with the guard must never block legitimate work. +# ============================================================================= +set -u + +HOOK_NAME="pre-task-worktree-isolation-guard.sh" +# Worktree provisioning window. Two isolation:worktree spawns inside this +# window are treated as a racing batch. The SAFE multi-worktree pattern +# (pre-create + git -C, Rule 2) does NOT use isolation:worktree, so it never +# trips this guard. +WINDOW_SECONDS="${MNM_WORKTREE_GUARD_WINDOW:-90}" + +[[ "${CLAUDE_DISABLE_PLUGIN_HOOKS:-}" == "1" ]] && exit 0 +command -v jq >/dev/null 2>&1 || exit 0 # fail-open: no jq + +INPUT_RAW="$(cat)" + +# Only act on isolation:worktree spawns; everything else passes untouched. +ISOLATION="$(printf '%s' "$INPUT_RAW" | jq -r '.tool_input.isolation // empty' 2>/dev/null || true)" +[[ "$ISOLATION" != "worktree" ]] && exit 0 + +SESSION_ID="$(printf '%s' "$INPUT_RAW" | jq -r '.session_id // "default"' 2>/dev/null || echo default)" +SESSION_ID="${SESSION_ID//[^A-Za-z0-9_.-]/_}" # sanitize for use in a path +LABEL="$(printf '%s' "$INPUT_RAW" | jq -r '.tool_input.name // .tool_input.description // "agent"' 2>/dev/null || echo agent)" + +LOCK_ROOT="${TMPDIR:-/tmp}/mnm-worktree-spawn-guard" +mkdir -p "$LOCK_ROOT" 2>/dev/null || exit 0 # fail-open: unwritable tmp +LOCK_DIR="$LOCK_ROOT/${SESSION_ID}.lockd" + +now="$(date +%s 2>/dev/null || echo 0)" +[[ "$now" =~ ^[0-9]+$ ]] || exit 0 # fail-open: no clock + +# Reclaim a stale lock from a prior (already-provisioned) batch. +if [[ -d "$LOCK_DIR" ]]; then + last="$(cat "$LOCK_DIR/ts" 2>/dev/null || echo 0)" + [[ "$last" =~ ^[0-9]+$ ]] || last=0 + if (( now - last >= WINDOW_SECONDS )); then + rm -rf "$LOCK_DIR" 2>/dev/null || true + fi +fi + +# Atomic acquire. `mkdir` is atomic across simultaneous processes, so when two +# hooks fire at once (parallel tool calls in one assistant message) exactly one +# wins — a plain `[[ -f lock ]]` test would let BOTH through. +if mkdir "$LOCK_DIR" 2>/dev/null; then + printf '%s' "$now" > "$LOCK_DIR/ts" 2>/dev/null || true + exit 0 # the single in-flight spawn +fi + +# Lock held within the window → this is a batched 2nd+ isolation:worktree spawn. +held="$(cat "$LOCK_DIR/ts" 2>/dev/null || echo "$now")" +[[ "$held" =~ ^[0-9]+$ ]] || held="$now" +age=$(( now - held )) + +cat >&2 <=2 isolation:worktree spawns RACES provisioning: +one agent fails to get a dedicated directory, reuses the parent/another +worktree, and its bare git (cwd resets to the shared checkout) switches that +worktree's branch and commingles uncommitted work. Once commits entangle, a +git revert cannot separate them — the only recovery is a manual forward-fix. + +How to fix: + Rule 2 (preferred — eliminates the race entirely): + 1. Pre-create a DEDICATED worktree yourself, off the target branch: + git worktree add .claude/worktrees/wt- origin/develop + 2. Spawn the agent WITHOUT isolation:worktree, and brief it to capture its + toplevel once: MYWT="\$(git rev-parse --show-toplevel)" + and run ALL git as git -C "\$MYWT" ... — never bare git, never + 'git checkout' of another branch, never 'git worktree'. + Rule 1 (also accepted): + Spawn isolation:worktree agents ONE PER MESSAGE and wait for each to finish + provisioning (>${WINDOW_SECONDS}s) before spawning the next. + +Override (intentional, this turn only): + Set CLAUDE_DISABLE_PLUGIN_HOOKS=1, or widen MNM_WORKTREE_GUARD_WINDOW. +EOF +exit 2 diff --git a/hooks/rules/rules.json b/hooks/rules/rules.json index 68e2840..416b5a6 100644 --- a/hooks/rules/rules.json +++ b/hooks/rules/rules.json @@ -2326,7 +2326,7 @@ }, { "id": "warn-localhost-in-pr-body", - "description": "Warn when `gh pr create` / `gh pr edit` body contains a localhost reference (memory feedback_test_on_staging.md — QA target is dev.dojocoding.io, not localhost)", + "description": "Warn when `gh pr create` / `gh pr edit` body contains a localhost reference (memory feedback_test_on_staging.md — QA target is dev.example.com, not localhost)", "applies_to": [ "Bash" ], @@ -2343,7 +2343,7 @@ "action": "warn", "bypass_marker": "localhost-in-pr-body-allowed", "memory_ref": "feedback_test_on_staging.md", - "message": "WARNING: localhost reference in PR body.\n\nPer feedback_test_on_staging.md the QA testing target is\ndev.dojocoding.io (staging), not localhost. Any reviewer who tries\nto verify against localhost will hit env mismatches:\n - Local DB differs from staging\n - Edge Functions missing local secrets\n - Different feature flags\n\nReplace localhost links with dev.dojocoding.io equivalents.\n\nBypass marker (localhost-in-pr-body-allowed) ONLY for steps that\nlegitimately require an explicit local build (e.g. asking the\nreviewer to run `bun run dev` before pushing).\n", + "message": "WARNING: localhost reference in PR body.\n\nPer feedback_test_on_staging.md the QA testing target is\ndev.example.com (staging), not localhost. Any reviewer who tries\nto verify against localhost will hit env mismatches:\n - Local DB differs from staging\n - Edge Functions missing local secrets\n - Different feature flags\n\nReplace localhost links with dev.example.com equivalents.\n\nBypass marker (localhost-in-pr-body-allowed) ONLY for steps that\nlegitimately require an explicit local build (e.g. asking the\nreviewer to run `bun run dev` before pushing).\n", "tests": [ { "name": "warns-pr-create-with-localhost", @@ -2369,7 +2369,7 @@ "name": "allows-pr-create-with-staging-url", "input": { "tool_input": { - "command": "gh pr create --base develop --title \"feat: foo\" --body \"Test on https://dev.dojocoding.io/foo\"" + "command": "gh pr create --base develop --title \"feat: foo\" --body \"Test on https://dev.example.com/foo\"" } }, "expected_exit": 0 @@ -3706,5 +3706,74 @@ "expected_exit": 0 } ] + }, + { + "id": "git-worktree-add-discipline", + "description": "Warn on `git worktree add` — give each parallel agent a dedicated worktree and pin git -C", + "applies_to": [ + "Bash" + ], + "match": [ + { + "field": "command", + "pattern": "git[[:space:]]+worktree[[:space:]]+add\\b" + } + ], + "action": "warn", + "bypass_marker": null, + "memory_ref": "reference_parallel_agent_worktree_collision.md", + "references": [ + "Sibling re-checkout incident — collision needed a forward-fix (revert cannot separate entangled commits)" + ], + "message": "WORKTREE DISCIPLINE: `git worktree add` detected.\n\nParallel subagents share one repo: a branch can be checked out in only ONE\nworktree at a time, and an agent's shell cwd resets to the shared checkout\nbetween commands — so a bare `git checkout` / `git worktree add` can switch\nANOTHER worktree's branch and commingle uncommitted work. Once commits\nentangle, only a forward-fix recovers them (a revert cannot separate them).\n\nWhen orchestrating agents that touch git:\n - Give EACH agent its OWN dedicated worktree off the target branch:\n git worktree add .claude/worktrees/wt- origin/develop\n - Brief each agent to capture its toplevel once:\n MYWT=\"$(git rev-parse --show-toplevel)\"\n and run ALL git as `git -C \"$MYWT\" ...` — never bare git, never\n `git checkout` of another branch, never `git worktree` from an agent.\n - Never spawn >=2 isolation:worktree subagents in one message (the\n pre-task-worktree-isolation-guard hook blocks the 2nd; this is the\n upstream nudge).\n", + "tests": [ + { + "name": "warns-on-worktree-add", + "input": { + "tool_input": { + "command": "git worktree add .claude/worktrees/wt-x origin/develop" + } + }, + "expected_exit": 0, + "expected_stderr_contains": "git-worktree-add-discipline" + }, + { + "name": "warns-on-worktree-add-with-b-flag", + "input": { + "tool_input": { + "command": "git worktree add -b feat/x .claude/worktrees/wt-x origin/develop" + } + }, + "expected_exit": 0, + "expected_stderr_contains": "git-worktree-add-discipline" + }, + { + "name": "allows-worktree-list", + "input": { + "tool_input": { + "command": "git worktree list" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-worktree-remove", + "input": { + "tool_input": { + "command": "git worktree remove .claude/worktrees/wt-x" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-non-worktree-git", + "input": { + "tool_input": { + "command": "git status" + } + }, + "expected_exit": 0 + } + ] } ] diff --git a/hooks/rules/rules.yaml b/hooks/rules/rules.yaml index f16f61d..750e3ed 100644 --- a/hooks/rules/rules.yaml +++ b/hooks/rules/rules.yaml @@ -1856,7 +1856,7 @@ expected_exit: 0 - id: warn-localhost-in-pr-body - description: Warn when `gh pr create` / `gh pr edit` body contains a localhost reference (memory feedback_test_on_staging.md — QA target is dev.dojocoding.io, not localhost) + description: Warn when `gh pr create` / `gh pr edit` body contains a localhost reference (memory feedback_test_on_staging.md — QA target is dev.example.com, not localhost) applies_to: [Bash] match: # Two AND-chained conditions on `command`: the gh subcommand uses @@ -1875,13 +1875,13 @@ WARNING: localhost reference in PR body. Per feedback_test_on_staging.md the QA testing target is - dev.dojocoding.io (staging), not localhost. Any reviewer who tries + dev.example.com (staging), not localhost. Any reviewer who tries to verify against localhost will hit env mismatches: - Local DB differs from staging - Edge Functions missing local secrets - Different feature flags - Replace localhost links with dev.dojocoding.io equivalents. + Replace localhost links with dev.example.com equivalents. Bypass marker (localhost-in-pr-body-allowed) ONLY for steps that legitimately require an explicit local build (e.g. asking the @@ -1902,7 +1902,7 @@ - name: allows-pr-create-with-staging-url input: tool_input: - command: 'gh pr create --base develop --title "feat: foo" --body "Test on https://dev.dojocoding.io/foo"' + command: 'gh pr create --base develop --title "feat: foo" --body "Test on https://dev.example.com/foo"' expected_exit: 0 - name: allows-non-pr-localhost input: @@ -3043,3 +3043,76 @@ tool_input: command: 'gcloud sql import sql my-instance gs://backups/dump.sql --database=mydb # hook-bypass: db-mutation-rule' expected_exit: 0 + +# ----------------------------------------------------------------------------- +# Agent orchestration (warn) — parallel-subagent worktree discipline. +# +# Complements the blocking hook hooks/pre-task-worktree-isolation-guard.sh +# (which stops >=2 isolation:worktree spawns racing in one message). This rule +# is the upstream nudge at the `git worktree add` call site: it reminds the +# orchestrator (or any agent) to give each parallel agent its OWN dedicated +# worktree and pin `git -C ` for all git, so the cwd-reset between +# commands cannot switch another worktree's branch and commingle work. +# Memory: reference_parallel_agent_worktree_collision / +# reference_worktree_rechecked_out_by_sibling (the incident that needed a +# forward-fix because a revert cannot separate entangled commits). +# ----------------------------------------------------------------------------- + +- id: git-worktree-add-discipline + description: Warn on `git worktree add` — give each parallel agent a dedicated worktree and pin git -C + applies_to: [Bash] + match: + - field: command + pattern: 'git[[:space:]]+worktree[[:space:]]+add\b' + action: warn + bypass_marker: null + memory_ref: reference_parallel_agent_worktree_collision.md + references: + - "Sibling re-checkout incident — collision needed a forward-fix (revert cannot separate entangled commits)" + message: | + WORKTREE DISCIPLINE: `git worktree add` detected. + + Parallel subagents share one repo: a branch can be checked out in only ONE + worktree at a time, and an agent's shell cwd resets to the shared checkout + between commands — so a bare `git checkout` / `git worktree add` can switch + ANOTHER worktree's branch and commingle uncommitted work. Once commits + entangle, only a forward-fix recovers them (a revert cannot separate them). + + When orchestrating agents that touch git: + - Give EACH agent its OWN dedicated worktree off the target branch: + git worktree add .claude/worktrees/wt- origin/develop + - Brief each agent to capture its toplevel once: + MYWT="$(git rev-parse --show-toplevel)" + and run ALL git as `git -C "$MYWT" ...` — never bare git, never + `git checkout` of another branch, never `git worktree` from an agent. + - Never spawn >=2 isolation:worktree subagents in one message (the + pre-task-worktree-isolation-guard hook blocks the 2nd; this is the + upstream nudge). + tests: + - name: warns-on-worktree-add + input: + tool_input: + command: 'git worktree add .claude/worktrees/wt-x origin/develop' + expected_exit: 0 + expected_stderr_contains: 'git-worktree-add-discipline' + - name: warns-on-worktree-add-with-b-flag + input: + tool_input: + command: 'git worktree add -b feat/x .claude/worktrees/wt-x origin/develop' + expected_exit: 0 + expected_stderr_contains: 'git-worktree-add-discipline' + - name: allows-worktree-list + input: + tool_input: + command: 'git worktree list' + expected_exit: 0 + - name: allows-worktree-remove + input: + tool_input: + command: 'git worktree remove .claude/worktrees/wt-x' + expected_exit: 0 + - name: allows-non-worktree-git + input: + tool_input: + command: 'git status' + expected_exit: 0 diff --git a/references/agent-worktree-orchestration.md b/references/agent-worktree-orchestration.md new file mode 100644 index 0000000..e573e5b --- /dev/null +++ b/references/agent-worktree-orchestration.md @@ -0,0 +1,39 @@ +# Agent Worktree Orchestration — avoiding the sibling-recheckout collision + +When an orchestrator spawns parallel subagents in **one git repo**, they can silently corrupt each other's working tree. This doc is the safe pattern + the two enforcement layers that back it. + +## The failure mode + +Git worktrees all share **one object store and one set of branch refs**; a branch can be checked out in only one worktree at a time, and an agent's shell cwd **resets to the shared checkout between commands**. Two conditions defeat per-agent isolation: + +- **(a) Provisioning race.** Spawning ≥2 `isolation:worktree` agents in the SAME message races worktree provisioning — one agent fails to get a dedicated directory and reuses the parent's (or another agent's) worktree. +- **(b) cwd-reset + bare git.** A bare `git checkout -b …` runs against whatever worktree the cwd resolves to, switching THAT worktree's branch and dragging its uncommitted files along. + +Together they commingle two contexts' work in one directory. Once commits entangle (branch A's commit captures branch B's files), a `git revert` **cannot separate them** — the only recovery is a manual **forward-fix**. (This is a real incident, not hypothetical.) + +## The two rules + +1. **Never spawn ≥2 `isolation:worktree` subagents in one message.** Spawn one per message and wait for it to provision, OR use Rule 2. +2. **Pre-create a dedicated worktree per agent (preferred — eliminates the race):** + ```bash + git worktree add .claude/worktrees/wt- origin/ + ``` + Then spawn the agent WITHOUT `isolation:worktree`, and brief it to: + ```bash + MYWT="$(git rev-parse --show-toplevel)" # capture once + git -C "$MYWT" … # ALL git, every command + ``` + Never bare git, never `git checkout` of another branch, never `git worktree` from an agent. + +## Enforcement layers (this toolkit ships both) + +- **`hooks/pre-task-worktree-isolation-guard.sh`** — PreToolUse on the `Task`/`Agent` tool. Blocks (exit 2) a 2nd `isolation:worktree` spawn inside a short provisioning window (Rule 1). Single / staggered spawns pass. Fail-open; honors `CLAUDE_DISABLE_PLUGIN_HOOKS=1`. +- **`git-worktree-add-discipline`** (rule in `hooks/rules/rules.yaml`) — warn-only on `git worktree add`, the upstream nudge toward Rule 2 (dedicated worktree + `git -C`). + +## Recovery if a collision happens anyway + +1. **Commit immediately** in the affected agent's branch — anchoring the work to the ref makes it safe from the shared working dir. +2. **Forward-fix** the entangled state; do NOT `git revert` (it can't separate the commingled changes). +3. Verify branch + SHA (the ref is the truth, not the working tree). + +Memory refs: `reference_parallel_agent_worktree_collision`, `reference_worktree_rechecked_out_by_sibling`. From 2d26dd51f17df2d6e0e1478f861061fc0d988cf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Pe=C3=B1a?= Date: Tue, 23 Jun 2026 02:25:58 -0600 Subject: [PATCH 2/2] fix(hooks): drop TOCTOU stale-lock reclamation + validate the window (review #46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: the rm -rf reclamation between the staleness check and mkdir could delete a racing parallel process's freshly-created lock, letting both spawns pass. The lock dir is now NEVER deleted — mkdir is the sole atomic gate and staleness is decided by the timestamp (stale → allow + roll the window; fresh → block). P3: fall back to the default window if MNM_WORKTREE_GUARD_WINDOW is not a positive integer (avoids set -u arithmetic failure). Co-Authored-By: Claude Opus 4.8 --- hooks/pre-task-worktree-isolation-guard.sh | 32 ++++++++++++++-------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/hooks/pre-task-worktree-isolation-guard.sh b/hooks/pre-task-worktree-isolation-guard.sh index e9c4240..e444e83 100755 --- a/hooks/pre-task-worktree-isolation-guard.sh +++ b/hooks/pre-task-worktree-isolation-guard.sh @@ -43,6 +43,9 @@ HOOK_NAME="pre-task-worktree-isolation-guard.sh" # (pre-create + git -C, Rule 2) does NOT use isolation:worktree, so it never # trips this guard. WINDOW_SECONDS="${MNM_WORKTREE_GUARD_WINDOW:-90}" +# A malformed MNM_WORKTREE_GUARD_WINDOW would break the `(( … ))` arithmetic +# below under `set -u`; fall back to the default when it is not a positive int. +[[ "$WINDOW_SECONDS" =~ ^[1-9][0-9]*$ ]] || WINDOW_SECONDS=90 [[ "${CLAUDE_DISABLE_PLUGIN_HOOKS:-}" == "1" ]] && exit 0 command -v jq >/dev/null 2>&1 || exit 0 # fail-open: no jq @@ -64,27 +67,32 @@ LOCK_DIR="$LOCK_ROOT/${SESSION_ID}.lockd" now="$(date +%s 2>/dev/null || echo 0)" [[ "$now" =~ ^[0-9]+$ ]] || exit 0 # fail-open: no clock -# Reclaim a stale lock from a prior (already-provisioned) batch. -if [[ -d "$LOCK_DIR" ]]; then - last="$(cat "$LOCK_DIR/ts" 2>/dev/null || echo 0)" - [[ "$last" =~ ^[0-9]+$ ]] || last=0 - if (( now - last >= WINDOW_SECONDS )); then - rm -rf "$LOCK_DIR" 2>/dev/null || true - fi -fi - # Atomic acquire. `mkdir` is atomic across simultaneous processes, so when two # hooks fire at once (parallel tool calls in one assistant message) exactly one -# wins — a plain `[[ -f lock ]]` test would let BOTH through. +# wins — a plain `[[ -f lock ]]` test would let BOTH through. The lock dir is +# NEVER deleted here: staleness is decided by the timestamp below, not by +# existence, so there is no check-then-rm (TOCTOU) window in which a racing +# process's freshly-created lock could be removed. if mkdir "$LOCK_DIR" 2>/dev/null; then printf '%s' "$now" > "$LOCK_DIR/ts" 2>/dev/null || true - exit 0 # the single in-flight spawn + exit 0 # first / uncontended spawn fi -# Lock held within the window → this is a batched 2nd+ isolation:worktree spawn. +# Lock already exists — decide by AGE, never by deleting it: +# - STALE (>= window): the prior spawn already finished provisioning, so this +# is not a racing batch → allow, rolling the window forward by refreshing +# the timestamp. (Accepted edge: if a brand-new batch fires simultaneously +# while the lock is already stale, both may refresh-and-allow — an +# over-allow, never corruption; the load-bearing safety is the pre-create + +# git -C pattern this guard nudges toward.) +# - FRESH (< window): a batched 2nd+ spawn inside the window → block. held="$(cat "$LOCK_DIR/ts" 2>/dev/null || echo "$now")" [[ "$held" =~ ^[0-9]+$ ]] || held="$now" age=$(( now - held )) +if (( age >= WINDOW_SECONDS )); then + printf '%s' "$now" > "$LOCK_DIR/ts" 2>/dev/null || true # roll the window + exit 0 # stale lock → prior spawn done +fi cat >&2 <