Closes #48. Makes the canonical worktree_branch_check fragment verify-only/fail-closed (exit 42, no git reset self-recovery), adds an orchestrator fail-closed collection rule and a cwd-drift guard at execute_waves entry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
5
.changeset/quick-quails-wake.md
Normal file
5
.changeset/quick-quails-wake.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 590
|
||||
---
|
||||
**Worktree base checks are now verify-only and fail-closed** — a GSD executor sub-agent no longer runs `git reset --hard` to self-correct a mismatched worktree base (which could fail silently under a `git reset --hard` deny rule and risk a wrong-base merge of unrelated files). On a base or HEAD-namespace mismatch the sub-agent now halts with `exit 42` and hands recovery to the orchestrator (the worktree lifecycle owner). The orchestrator also guards against cwd drift into an agent worktree at `execute_waves` entry.
|
||||
@@ -92,7 +92,7 @@ SDK query Module owning projection from project/workstream context to concrete `
|
||||
CJS Module owning worktree lifecycle safety policy for the GSD orchestration layer. Interface: `resolveWorktreeContext(cwd, deps) → WorktreeContext` (linked-worktree root mapping), `parseWorktreePorcelain(output) → WorktreeEntry[]` (porcelain parser, skips detached HEAD), `planWorktreePrune(repoRoot, opts, deps) → PrunePlan` (metadata-prune plan, never destructive by default), `executeWorktreePrunePlan(plan, deps) → PruneResult` (executes prune; degrades gracefully on git timeout), `listLinkedWorktreePaths(repoRoot, deps) → LinkedPathsResult`, `inspectWorktreeHealth(repoRoot, opts, deps) → HealthResult` (orphan + stale detection), `snapshotWorktreeInventory(repoRoot, opts, deps) → InventoryResult`, `planWorktreeWaveCleanup(repoRoot, manifest) → CleanupPlan` (manifest-scoped, fail-closed), `executeWorktreeWaveCleanupPlan(plan, deps) → CleanupResult`. Source of truth: `get-shit-done/bin/lib/worktree-safety.cjs`. Timeout path: all git subprocess calls are bounded; callers receive `ok:false, reason:'git_timed_out'` rather than a thrown exception. Test anchor: `tests/worktree-safety.test.cjs`.
|
||||
|
||||
### Worktree Lifecycle Module
|
||||
Workflow contract seam covering agent worktree lifecycle orchestration rules embedded in `get-shit-done/workflows/execute-phase.md`, `quick.md`, `execute-plan.md`, and `agents/gsd-executor.md`. Key invariants: `worktree_branch_check` uses `git reset --hard` (not `--soft`); HEAD attachment verified via `git symbolic-ref` before any reset; positive allow-list `^worktree-agent-*` enforced; `git update-ref` on protected refs is prohibited; cleanup is manifest-scoped (`WAVE_WORKTREE_MANIFEST`) not global-discovery-based; worktree spawning is sequential (one `run_in_background` at a time to avoid `config.lock` contention). Test anchor: `tests/worktree.test.cjs`.
|
||||
Workflow contract seam covering agent worktree lifecycle orchestration rules. The `worktree_branch_check` block lives in one canonical fragment (`get-shit-done/references/worktree-branch-check.md`) that `execute-phase.md`, `quick.md`, `diagnose-issues.md`, and `execute-plan.md` embed at dispatch. Key invariants: `worktree_branch_check` is **verify-only and fail-closed** — the orchestrator owns worktree lifecycle and base recovery, so the sub-agent holds no state-correction primitives; HEAD attachment verified via `git symbolic-ref`; positive allow-list `^worktree-agent-*` enforced; `git update-ref` on protected refs is prohibited; on base mismatch the sub-agent halts with `exit 42` and surfaces to the orchestrator (#48); the orchestrator runs a cwd-drift guard at `execute_waves` entry that resolves the worktree root and refuses drift into an agent worktree (#48); cleanup is manifest-scoped (`WAVE_WORKTREE_MANIFEST`) not global-discovery-based; worktree spawning is sequential (one `run_in_background` at a time to avoid `config.lock` contention). Test anchor: `tests/worktree.test.cjs`.
|
||||
|
||||
### Worktree Root Resolution Adapter Module
|
||||
Adapter Module owning linked-worktree root mapping and metadata-prune policy (`git worktree prune` non-destructive default) for planning/workstream callers.
|
||||
|
||||
@@ -299,7 +299,7 @@ Full roster at `get-shit-done/references/*.md`. References are shared knowledge
|
||||
| `scout-codebase.md` | Phase-type→codebase-map selection table for discuss-phase scout step (extracted via #2551). |
|
||||
| `revision-loop.md` | Plan revision iteration patterns. |
|
||||
| `universal-anti-patterns.md` | Universal anti-patterns to detect and avoid. |
|
||||
| `worktree-branch-check.md` | Canonical spawn-time worktree HEAD/base guard (worktree_branch_check): per-agent-branch assertion, protected-ref refusal (#2924), reset --hard base correction (#2015) — embedded into worktree sub-agent prompts at dispatch. |
|
||||
| `worktree-branch-check.md` | Canonical spawn-time worktree HEAD/base guard (worktree_branch_check): verify-only and fail-closed — per-agent-branch assertion, protected-ref refusal (#2924), and an exact-base assertion that halts with `exit 42` on mismatch so the orchestrator (worktree lifecycle owner) performs recovery (#48). Embedded into worktree sub-agent prompts at dispatch. |
|
||||
| `worktree-path-safety.md` | Worktree guard suite: HEAD assertion, cwd-drift sentinel (step 0a, #3097), and absolute-path guard (step 0b, #3099) — loaded into executor spawn prompts via `<execution_context>`. |
|
||||
| `artifact-types.md` | Planning artifact type definitions. |
|
||||
| `phase-argument-parsing.md` | Phase argument parsing conventions. |
|
||||
|
||||
@@ -1,35 +1,38 @@
|
||||
# Worktree branch check (spawn-time guard)
|
||||
|
||||
Canonical, fail-closed guard embedded into every worktree sub-agent prompt at dispatch.
|
||||
This is the single source of truth for the `worktree_branch_check` block — do not inline
|
||||
a copy elsewhere. History of coordinated edits: #2924, #2015, #3174, #48.
|
||||
Canonical, fail-closed, **verify-only** guard embedded into every worktree sub-agent
|
||||
prompt at dispatch. This is the single source of truth for the `worktree_branch_check`
|
||||
block — do not inline a copy elsewhere. History of coordinated edits: #2924, #2015, #3174, #48.
|
||||
|
||||
**Contract for orchestrators:** before dispatch, capture `EXPECTED_BASE=$(git rev-parse HEAD)`,
|
||||
then embed the block below into the sub-agent prompt verbatim, substituting `{EXPECTED_BASE}`
|
||||
with that captured SHA.
|
||||
with that captured SHA. The sub-agent only *verifies* and fails closed; the orchestrator
|
||||
(the worktree lifecycle owner) performs any base recovery — the sub-agent never rewrites a
|
||||
worktree it did not create (#48).
|
||||
|
||||
<worktree_branch_check>
|
||||
FIRST ACTION: HEAD assertion MUST run before any reset/checkout. Worktrees
|
||||
spawned by Claude Code's `isolation="worktree"` use the `worktree-agent-<id>`
|
||||
namespace. If HEAD is on a protected ref (main/master/develop/trunk/release/*)
|
||||
or detached, HALT — do NOT self-recover by force-rewinding via `git update-ref`,
|
||||
that destroys concurrent commits in multi-active scenarios (#2924). Only after
|
||||
the HEAD assertion passes is `git reset --hard` safe (#2015 — affects all platforms).
|
||||
FIRST ACTION: HEAD assertion MUST run before anything else, and this block is
|
||||
VERIFY-ONLY. Worktrees spawned by Claude Code's `isolation="worktree"` use the
|
||||
`worktree-agent-<id>` namespace. The orchestrator owns this worktree's lifecycle;
|
||||
a sub-agent MUST NOT hold state-correction primitives (hard-reset, update-ref,
|
||||
force-move, index-discard) on a worktree it did not create (#48, #2924). If ANY
|
||||
assertion below fails, HALT immediately — print the FATAL line, `exit 42`, and let
|
||||
the orchestrator (the lifecycle owner) decide recovery. Do NOT self-recover, do NOT
|
||||
commit.
|
||||
```bash
|
||||
HEAD_REF=$(git symbolic-ref --quiet HEAD || echo "DETACHED")
|
||||
ACTUAL_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
if [ "$HEAD_REF" = "DETACHED" ] || echo "$ACTUAL_BRANCH" | grep -Eq '^(main|master|develop|trunk|release/.*)$'; then
|
||||
echo "FATAL: worktree HEAD on '$ACTUAL_BRANCH' (expected worktree-agent-*); refusing to self-recover via 'git update-ref' (#2924)." >&2
|
||||
exit 1
|
||||
echo "FATAL: worktree HEAD on '$ACTUAL_BRANCH' (expected worktree-agent-*); refusing to commit or self-recover via 'git update-ref' (#2924)." >&2
|
||||
exit 42
|
||||
fi
|
||||
if ! echo "$ACTUAL_BRANCH" | grep -Eq '^worktree-agent-[A-Za-z0-9._/-]+$'; then
|
||||
echo "FATAL: worktree HEAD '$ACTUAL_BRANCH' is not in the worktree-agent-* namespace; refusing to commit (#2924)." >&2
|
||||
exit 1
|
||||
exit 42
|
||||
fi
|
||||
ACTUAL_BASE=$(git merge-base HEAD {EXPECTED_BASE})
|
||||
if [ "$ACTUAL_BASE" != "{EXPECTED_BASE}" ]; then
|
||||
git reset --hard {EXPECTED_BASE}
|
||||
[ "$(git rev-parse HEAD)" != "{EXPECTED_BASE}" ] && { echo "ERROR: could not correct worktree base"; exit 1; }
|
||||
if [ "$(git rev-parse HEAD)" != "{EXPECTED_BASE}" ]; then
|
||||
echo "FATAL: worktree base mismatch — HEAD is $(git rev-parse HEAD), expected {EXPECTED_BASE}. Orchestrator owns recovery; sub-agent refuses to rewrite the worktree (#48)." >&2
|
||||
exit 42
|
||||
fi
|
||||
```
|
||||
</worktree_branch_check>
|
||||
|
||||
@@ -416,6 +416,35 @@ CROSS_AI_TIMEOUT=$(gsd_run query config-get workflow.cross_ai_timeout 2>/dev/nul
|
||||
<step name="execute_waves">
|
||||
Execute each selected wave in sequence. Within a wave: parallel if `PARALLELIZATION=true`, sequential if `false`.
|
||||
|
||||
**Orchestrator cwd-drift guard (FIRST ACTION at execute_waves entry — #48):**
|
||||
|
||||
A prior `Agent(isolation="worktree")` dispatch can silently leave the orchestrator's
|
||||
cwd inside an agent worktree (or a subdirectory of one). Every subsequent
|
||||
orchestrator-side git call would then target the wrong tree — this is how a wrong-base
|
||||
merge nearly shipped ~1000 files. Resolve the *worktree root* (so a subdirectory cwd
|
||||
cannot skew the check) and refuse if it is an agent worktree. The discriminator is the
|
||||
per-agent branch namespace `worktree-agent-*`, NOT the `.claude/worktrees/` path: the
|
||||
orchestrator may itself be legitimately invoked from a feature worktree under
|
||||
`.claude/worktrees/`, so a path-substring refusal would break legitimate runs. Do NOT
|
||||
pin to `git worktree list`'s first entry — that is the main worktree, the wrong target
|
||||
when the orchestrator legitimately runs from a feature worktree.
|
||||
|
||||
```bash
|
||||
ORCHESTRATOR_WT=$(git rev-parse --show-toplevel 2>/dev/null) || {
|
||||
echo "FATAL: execute_waves entry is not inside a git worktree (#48)." >&2; exit 1; }
|
||||
ORCH_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
|
||||
if printf '%s' "$ORCH_BRANCH" | grep -Eq '^worktree-agent-'; then
|
||||
echo "FATAL: orchestrator cwd is inside an agent worktree (branch '$ORCH_BRANCH', root '$ORCHESTRATOR_WT') — refusing to execute waves (#48). A prior isolation=\"worktree\" dispatch drifted the cwd; re-run from the orchestrator's own worktree." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Pin to the worktree root; each later orchestrator-side block re-pins the same way
|
||||
# (see the #3174 cleanup guard). Treat $ORCHESTRATOR_WT as the canonical root for the
|
||||
# rest of the phase — prefer `git -C "$ORCHESTRATOR_WT"` for cross-step git calls,
|
||||
# since a bare `cd` does not persist across separate tool invocations.
|
||||
export ORCHESTRATOR_WT
|
||||
cd "$ORCHESTRATOR_WT" || { echo "FATAL: cannot cd to orchestrator worktree '$ORCHESTRATOR_WT' (#48)." >&2; exit 1; }
|
||||
```
|
||||
|
||||
**Stream-idle-timeout prevention — checkpoint heartbeats (#2410):**
|
||||
|
||||
Multi-plan phases can accumulate enough subagent context that the Claude API
|
||||
@@ -626,6 +655,8 @@ increases monotonically across waves. `{status}` is `complete` (success),
|
||||
|
||||
Immediately after each worktree `Agent()` spawn returns metadata, atomically append `{agent_id, worktree_path, branch, expected_base}` to `WAVE_WORKTREE_MANIFEST`. If any field is missing, stop and ask for recovery instead of scanning all agent worktrees.
|
||||
|
||||
> **ORCHESTRATOR FAIL-CLOSED RULE (#48):** `worktree_branch_check` is verify-only — an executor that hits a base/HEAD-namespace mismatch prints `FATAL:` and exits **42** instead of self-recovering. If any executor result reports a `FATAL:`/`exit 42` (or its commits never appear because it halted at the check), mark that plan **blocked**: do NOT merge or clean up its worktree (preserve it for inspection), do NOT count the wave as successful, and surface the mismatch with recovery guidance to the user. The orchestrator — the worktree lifecycle owner — performs any base correction (e.g. recreate the worktree on `{EXPECTED_BASE}`); the sub-agent never does. Never proceed past a halted executor on the assumption it succeeded.
|
||||
|
||||
> **ORCHESTRATOR RULE — CODEX RUNTIME**: After calling Agent() above to spawn executor agent(s), stop working on this task immediately. Do not read more files, edit code, or run tests related to this task while the subagent is active. Wait for the subagent to return its result. This prevents duplicate work, conflicting edits, and wasted context. Only resume when the subagent result is available.
|
||||
|
||||
**Sequential mode** (`USE_WORKTREES_FOR_PLAN` is `false` — either project-level `USE_WORKTREES=false`, or per-plan submodule intersection forced it false in step 2.5):
|
||||
|
||||
@@ -92,7 +92,7 @@ Otherwise: Apply checkpoint-based routing below.
|
||||
| Verify-only | B (segmented) | Segments between checkpoints. After none/human-verify → SUBAGENT. After decision/human-action → MAIN |
|
||||
| Decision | C (main) | Execute entirely in main context |
|
||||
|
||||
**Pattern A:** init_agent_tracking → capture `EXPECTED_BASE=$(git rev-parse HEAD)` → print `Spawning executor agent (runs in a subagent — no output until it returns, ~1–5 min; expected, not a freeze)` → spawn Agent(subagent_type="gsd-executor", model=executor_model) with prompt: execute plan at [path], autonomous, all tasks + SUMMARY + commit, follow deviation/auth rules, report: plan name, tasks, SUMMARY path, commit hash → track agent_id → wait → update tracking → report. **Include `isolation="worktree"` only if `workflow.use_worktrees` is not `false`** (read via `config-get workflow.use_worktrees`). **When using `isolation="worktree"`, embed the `<worktree_branch_check>` block from `get-shit-done/references/worktree-branch-check.md` into the prompt, substituting `{EXPECTED_BASE}` with the captured base SHA.** That guard asserts a per-agent branch before any reset/checkout, forbids `git update-ref` self-recovery (#2924), and hard-resets to `{EXPECTED_BASE}` to correct EnterWorktree basing branches from main (affects all platforms — #2015).
|
||||
**Pattern A:** init_agent_tracking → capture `EXPECTED_BASE=$(git rev-parse HEAD)` → print `Spawning executor agent (runs in a subagent — no output until it returns, ~1–5 min; expected, not a freeze)` → spawn Agent(subagent_type="gsd-executor", model=executor_model) with prompt: execute plan at [path], autonomous, all tasks + SUMMARY + commit, follow deviation/auth rules, report: plan name, tasks, SUMMARY path, commit hash → track agent_id → wait → update tracking → report. **Include `isolation="worktree"` only if `workflow.use_worktrees` is not `false`** (read via `config-get workflow.use_worktrees`). **When using `isolation="worktree"`, embed the `<worktree_branch_check>` block from `get-shit-done/references/worktree-branch-check.md` into the prompt, substituting `{EXPECTED_BASE}` with the captured base SHA.** That guard is **verify-only and fail-closed** (#48): it asserts a per-agent `worktree-agent-*` branch and the exact base, forbids `git update-ref` self-recovery (#2924), and on any mismatch prints `FATAL:` and `exit 42` so the orchestrator can recover — the sub-agent never rewrites a worktree it did not create. This supersedes the former self-recovery (#2015), whose destructive base rewrite could fail silently under a deny rule; the base-drift it addressed affects all platforms, and base correction is now the orchestrator's responsibility.
|
||||
|
||||
**Pattern B:** Execute segment-by-segment. Autonomous segments: spawn subagent for assigned tasks only (no SUMMARY/commit). Checkpoints: main context. After all segments: aggregate, create SUMMARY, commit. See segment_execution.
|
||||
|
||||
|
||||
@@ -22,11 +22,12 @@ describe('bug #3384: adjacent worktree data-loss guards', () => {
|
||||
assert.match(skipSet, /'worktree'/);
|
||||
});
|
||||
|
||||
test('diagnose-issues agents assert disposable worktree branch before reset --hard', () => {
|
||||
test('diagnose-issues references canonical fragment; fragment is verify-only and fails closed (#48)', () => {
|
||||
// diagnose-issues.md now references the canonical fragment rather than
|
||||
// inlining the block. Verify (a) it references the fragment and (b) the
|
||||
// fragment itself has the correct ordering: symbolic-ref/HEAD assertion and
|
||||
// ^worktree-agent- allow-list appear BEFORE git reset --hard.
|
||||
// ^worktree-agent- allow-list appear before any work, and (c) the fragment
|
||||
// is verify-only — no destructive self-recovery.
|
||||
const diagnoseSource = read('get-shit-done/workflows/diagnose-issues.md');
|
||||
assert.ok(
|
||||
diagnoseSource.includes('worktree-branch-check.md'),
|
||||
@@ -36,11 +37,12 @@ describe('bug #3384: adjacent worktree data-loss guards', () => {
|
||||
const fragmentSource = fs.readFileSync(WORKTREE_BRANCH_CHECK_FRAGMENT, 'utf8');
|
||||
const branchCheck = fragmentSource.indexOf('HEAD_REF=$(git symbolic-ref --quiet HEAD || echo');
|
||||
const namespaceCheck = fragmentSource.indexOf('^worktree-agent-');
|
||||
const reset = fragmentSource.indexOf('git reset --hard {EXPECTED_BASE}');
|
||||
|
||||
assert.ok(branchCheck > 0, 'canonical fragment must assert HEAD before repair');
|
||||
assert.ok(namespaceCheck > branchCheck, 'canonical fragment must require disposable worktree-agent branch before reset');
|
||||
assert.ok(reset > namespaceCheck, 'reset --hard must come only after branch namespace check in canonical fragment');
|
||||
assert.ok(branchCheck > 0, 'canonical fragment must assert HEAD before any work');
|
||||
assert.ok(namespaceCheck > branchCheck, 'canonical fragment must require disposable worktree-agent branch');
|
||||
// #48: verify-only — the destructive self-recovery is gone; the fragment fails closed instead.
|
||||
assert.ok(!fragmentSource.includes('git reset --hard {EXPECTED_BASE}'), 'canonical fragment must not self-recover via reset --hard — orchestrator owns recovery (#48)');
|
||||
assert.ok(fragmentSource.includes('exit 42'), 'canonical fragment must fail closed with exit 42 on base mismatch (#48)');
|
||||
});
|
||||
|
||||
test('remove-workspace fails closed when git worktree remove fails', () => {
|
||||
|
||||
@@ -146,21 +146,14 @@ describe('bug #2924: worktree HEAD attachment + destructive recovery', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('HEAD-attachment assertion runs BEFORE `git reset --hard`', () => {
|
||||
test('block is verify-only: HEAD assertion present, no git reset, fails closed (#48)', () => {
|
||||
const codeBlocks = extractFencedCodeBlocks(block);
|
||||
const allStatements = codeBlocks.flatMap(({ body }) => shellStatements(body));
|
||||
const symbolicRefIdx = findCommandIndex(allStatements, (cmd) =>
|
||||
cmd[0] === 'git' && cmd[1] === 'symbolic-ref' && cmd.includes('HEAD')
|
||||
);
|
||||
const resetHardIdx = findCommandIndex(allStatements, (cmd) =>
|
||||
cmd[0] === 'git' && cmd[1] === 'reset' && cmd.includes('--hard')
|
||||
);
|
||||
assert.notStrictEqual(symbolicRefIdx, -1, 'symbolic-ref check must exist');
|
||||
assert.notStrictEqual(resetHardIdx, -1, 'reset --hard must exist');
|
||||
assert.ok(
|
||||
symbolicRefIdx < resetHardIdx,
|
||||
'HEAD attachment assertion (symbolic-ref) must precede `git reset --hard` so a stale HEAD never moves a protected branch'
|
||||
);
|
||||
const symbolicRefIdx = findCommandIndex(allStatements, (cmd) => cmd[0] === 'git' && cmd[1] === 'symbolic-ref' && cmd.includes('HEAD'));
|
||||
const resetIdx = findCommandIndex(allStatements, (cmd) => cmd[0] === 'git' && cmd[1] === 'reset');
|
||||
assert.notStrictEqual(symbolicRefIdx, -1, 'symbolic-ref HEAD-attachment check must exist');
|
||||
assert.strictEqual(resetIdx, -1, 'fragment must be verify-only — no git reset self-recovery (#48)');
|
||||
assert.ok(/exit 42/.test(block), 'fragment must fail closed with exit 42 (#48)');
|
||||
});
|
||||
|
||||
test('block names protected branches that must NOT be the agent branch', () => {
|
||||
@@ -306,23 +299,19 @@ describe('bug #2924: worktree HEAD attachment + destructive recovery', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('HEAD assertion precedes `git reset --hard`', () => {
|
||||
// Use shell-statement ordering on the fenced code block to avoid false matches
|
||||
// from the preamble text that mentions `git reset --hard` for context.
|
||||
test('block is verify-only: HEAD assertion present, no git reset, fails closed (#48)', () => {
|
||||
// Verify-only contract: symbolic-ref exists, no git reset at all, fails closed with exit 42.
|
||||
const codeBlocks = extractFencedCodeBlocks(block);
|
||||
const allStatements = codeBlocks.flatMap(({ body }) => shellStatements(body));
|
||||
const symbolicRefIdx = findCommandIndex(allStatements, (cmd) =>
|
||||
cmd[0] === 'git' && cmd[1] === 'symbolic-ref' && cmd.includes('HEAD')
|
||||
);
|
||||
const resetHardIdx = findCommandIndex(allStatements, (cmd) =>
|
||||
cmd[0] === 'git' && cmd[1] === 'reset' && cmd.includes('--hard')
|
||||
);
|
||||
assert.notStrictEqual(symbolicRefIdx, -1, 'symbolic-ref check must exist');
|
||||
assert.notStrictEqual(resetHardIdx, -1, 'reset --hard must exist');
|
||||
assert.ok(
|
||||
symbolicRefIdx < resetHardIdx,
|
||||
'symbolic-ref HEAD assertion must appear before `git reset --hard` in quick.md worktree_branch_check'
|
||||
const resetIdx = findCommandIndex(allStatements, (cmd) =>
|
||||
cmd[0] === 'git' && cmd[1] === 'reset'
|
||||
);
|
||||
assert.notStrictEqual(symbolicRefIdx, -1, 'symbolic-ref HEAD-attachment check must exist');
|
||||
assert.strictEqual(resetIdx, -1, 'fragment must be verify-only — no git reset self-recovery (#48)');
|
||||
assert.ok(/exit 42/.test(block), 'fragment must fail closed with exit 42 (#48)');
|
||||
});
|
||||
|
||||
test('block forbids `git update-ref` self-recovery', () => {
|
||||
@@ -733,3 +722,49 @@ test('#3425: cleanup-tail snippet carries the same primary-worktree pin before r
|
||||
assert.match(content, /FATAL: cannot cd to primary worktree \$PRIMARY_WT/);
|
||||
assert.match(content, /# Cleanup-tail: remove residual agent worktrees after a cross-wave-dependency deviation\./);
|
||||
});
|
||||
|
||||
describe('bug #48: orchestrator cwd-drift guard at execute_waves entry', () => {
|
||||
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
|
||||
const stepStart = content.indexOf('<step name="execute_waves">');
|
||||
const nextStep = content.indexOf('<step ', stepStart + 1);
|
||||
const stepBody = content.slice(stepStart, nextStep === -1 ? undefined : nextStep);
|
||||
|
||||
test('execute_waves step exists', () => {
|
||||
assert.notStrictEqual(stepStart, -1, 'execute-phase.md must contain a <step name="execute_waves"> step');
|
||||
});
|
||||
|
||||
test('execute_waves contains a labelled cwd-drift guard (#48)', () => {
|
||||
assert.ok(stepBody.includes('cwd-drift guard') && /#48/.test(stepBody), 'execute_waves entry must contain a cwd-drift guard tagged #48');
|
||||
});
|
||||
|
||||
test('cwd-drift guard resolves the worktree root via git rev-parse --show-toplevel (#48)', () => {
|
||||
const g = stepBody.indexOf('cwd-drift guard');
|
||||
assert.notStrictEqual(g, -1);
|
||||
const region = stepBody.slice(g, g + 1600);
|
||||
assert.ok(/git rev-parse --show-toplevel/.test(region), 'cwd-drift guard must resolve the worktree ROOT via git rev-parse --show-toplevel (#48)');
|
||||
});
|
||||
|
||||
test('cwd-drift guard discriminates agent worktrees by branch namespace and fails closed (#48)', () => {
|
||||
const g = stepBody.indexOf('cwd-drift guard');
|
||||
assert.notStrictEqual(g, -1);
|
||||
const region = stepBody.slice(g, g + 1600);
|
||||
assert.ok(/worktree-agent-/.test(region), 'guard must use the worktree-agent-* branch namespace as the drift discriminator (#48)');
|
||||
assert.ok(/exit 1/.test(region), 'cwd-drift guard must fail closed with exit 1 on drift (#48)');
|
||||
});
|
||||
|
||||
test('cwd-drift guard does NOT blanket-refuse .claude/worktrees/ paths (#48)', () => {
|
||||
const g = stepBody.indexOf('cwd-drift guard');
|
||||
assert.notStrictEqual(g, -1);
|
||||
const region = stepBody.slice(g, g + 1600);
|
||||
assert.ok(!region.includes('*.claude/worktrees/*') && !region.includes('.claude/worktrees/*)'), 'guard must not blanket-refuse .claude/worktrees/ paths — would break legitimate worktree invocations (#48)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug #48: orchestrator fail-closed handling of verify-only halts', () => {
|
||||
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
|
||||
const withoutDispatchNote = content.replace(/<worktree_branch_check>[\s\S]*?<\/worktree_branch_check>/g, '');
|
||||
test('orchestrator documents a fail-closed rule for executor exit 42 / FATAL (#48)', () => {
|
||||
assert.ok(/exit 42|FATAL/.test(withoutDispatchNote), 'execute-phase.md must reference executor exit 42 / FATAL outside the dispatch note (#48)');
|
||||
assert.ok(/(blocked|do NOT merge|not merge)/i.test(withoutDispatchNote), 'execute-phase.md must document an orchestrator-side rule that an executor FATAL/exit 42 marks the plan blocked and is not merged (#48)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -139,8 +139,8 @@ describe('canonical worktree-branch-check fragment is the single source of truth
|
||||
assert.ok(blockMatch, 'worktree-branch-check.md must contain a <worktree_branch_check> block');
|
||||
});
|
||||
|
||||
test('fragment block contains reset --hard', () => {
|
||||
assert.ok(block.includes('reset --hard'), 'fragment block must use reset --hard');
|
||||
test('fragment block is verify-only (no git reset)', () => {
|
||||
assert.ok(!/git\s+reset/.test(block), 'fragment block must NOT run git reset — orchestrator owns base recovery (#48)');
|
||||
});
|
||||
|
||||
test('fragment block does NOT contain reset --soft', () => {
|
||||
@@ -176,8 +176,20 @@ describe('canonical worktree-branch-check fragment is the single source of truth
|
||||
assert.ok(block.includes('update-ref'), 'fragment block must reference update-ref prohibition');
|
||||
});
|
||||
|
||||
test('fragment block contains merge-base HEAD {EXPECTED_BASE}', () => {
|
||||
assert.ok(block.includes('merge-base HEAD {EXPECTED_BASE}'), 'fragment block must contain git merge-base HEAD {EXPECTED_BASE}');
|
||||
test('fragment block asserts exact base and fails closed with exit 42 (#48)', () => {
|
||||
assert.ok(block.includes('git rev-parse HEAD') && block.includes('{EXPECTED_BASE}'), 'fragment must assert HEAD equals {EXPECTED_BASE} exactly (#48)');
|
||||
assert.ok(/exit 42/.test(block), 'fragment must fail closed with exit 42 on mismatch (#48)');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #48: execute-plan mandate is verify-only ───────────────────────────────
|
||||
|
||||
describe('bug #48: execute-plan.md worktree mandate is verify-only', () => {
|
||||
test('execute-plan.md worktree mandate is verify-only (no reset --hard self-recovery) (#48)', () => {
|
||||
const content = fs.readFileSync(EXECUTE_PLAN_PATH, 'utf-8');
|
||||
assert.ok(content.includes('worktree-branch-check.md'), 'execute-plan.md must reference the canonical fragment');
|
||||
assert.ok(!/hard-reset/.test(content) && !/reset --hard/.test(content), 'execute-plan.md must not describe reset --hard self-recovery — verify-only per #48');
|
||||
assert.ok(/exit 42/.test(content), 'execute-plan.md mandate must specify fail-closed exit 42 (#48)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -214,7 +226,7 @@ function makeTempUpstreamRepo(prefix) {
|
||||
|
||||
// ─── #2015: reset --hard not --soft ─────────────────────────────────────────
|
||||
|
||||
describe('worktree_branch_check must use reset --hard not reset --soft (#2015)', () => {
|
||||
describe('verify-only: worktree_branch_check must NOT run git reset (#48, supersedes #2015)', () => {
|
||||
|
||||
test('execute-phase.md worktree_branch_check does not use reset --soft', () => {
|
||||
const fragmentContent = fs.readFileSync(WORKTREE_BRANCH_CHECK_FRAGMENT, 'utf-8');
|
||||
@@ -226,19 +238,19 @@ describe('worktree_branch_check must use reset --hard not reset --soft (#2015)',
|
||||
const block = blockMatch[1];
|
||||
assert.ok(
|
||||
!block.includes('reset --soft'),
|
||||
'worktree_branch_check must not use reset --soft (leaves working tree files unchanged). Use reset --hard instead.'
|
||||
'worktree_branch_check must not use reset --soft (leaves working tree files unchanged).'
|
||||
);
|
||||
});
|
||||
|
||||
test('execute-phase.md worktree_branch_check uses reset --hard for base correction', () => {
|
||||
test('verify-only: execute-phase.md worktree_branch_check must not run git reset at all (#48, supersedes #2015)', () => {
|
||||
const fragmentContent = fs.readFileSync(WORKTREE_BRANCH_CHECK_FRAGMENT, 'utf-8');
|
||||
const blockMatch = fragmentContent.match(/<worktree_branch_check>([\s\S]*?)<\/worktree_branch_check>/);
|
||||
assert.ok(blockMatch, 'worktree-branch-check.md must contain a <worktree_branch_check> block');
|
||||
|
||||
const block = blockMatch[1];
|
||||
assert.ok(
|
||||
block.includes('reset --hard'),
|
||||
'worktree_branch_check must use reset --hard to correctly reset both HEAD and working tree to the expected base'
|
||||
!/git\s+reset/.test(block),
|
||||
'worktree_branch_check must NOT run git reset — orchestrator owns base recovery (#48, supersedes #2015)'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -250,19 +262,19 @@ describe('worktree_branch_check must use reset --hard not reset --soft (#2015)',
|
||||
const block = blockMatch[1];
|
||||
assert.ok(
|
||||
!block.includes('reset --soft'),
|
||||
'quick.md worktree_branch_check must not use reset --soft. Use reset --hard instead.'
|
||||
'quick.md worktree_branch_check must not use reset --soft.'
|
||||
);
|
||||
});
|
||||
|
||||
test('quick.md worktree_branch_check uses reset --hard for base correction', () => {
|
||||
test('verify-only: quick.md worktree_branch_check must not run git reset at all (#48, supersedes #2015)', () => {
|
||||
const fragmentContent = fs.readFileSync(WORKTREE_BRANCH_CHECK_FRAGMENT, 'utf-8');
|
||||
const blockMatch = fragmentContent.match(/<worktree_branch_check>([\s\S]*?)<\/worktree_branch_check>/);
|
||||
assert.ok(blockMatch, 'worktree-branch-check.md must contain a <worktree_branch_check> block');
|
||||
|
||||
const block = blockMatch[1];
|
||||
assert.ok(
|
||||
block.includes('reset --hard'),
|
||||
'quick.md worktree_branch_check must use reset --hard to correctly reset both HEAD and working tree'
|
||||
!/git\s+reset/.test(block),
|
||||
'quick.md worktree_branch_check must NOT run git reset — orchestrator owns base recovery (#48, supersedes #2015)'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -340,7 +352,7 @@ describe('bug-2075: worktree deletion safeguards', () => {
|
||||
});
|
||||
|
||||
describe('Failure Mode A: worktree_branch_check audit across all worktree-spawning workflows', () => {
|
||||
test('execute-phase.md has worktree_branch_check block with --hard reset', () => {
|
||||
test('execute-phase.md has worktree_branch_check block (verify-only, no git reset) (#48)', () => {
|
||||
const executePhaseContent = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
executePhaseContent.includes('worktree-branch-check.md'),
|
||||
@@ -356,8 +368,8 @@ describe('bug-2075: worktree deletion safeguards', () => {
|
||||
|
||||
const block = blockMatch[1];
|
||||
assert.ok(
|
||||
block.includes('reset --hard'),
|
||||
'execute-phase.md worktree_branch_check must use git reset --hard (not --soft)'
|
||||
!/git\s+reset/.test(block),
|
||||
'execute-phase.md worktree_branch_check must NOT run git reset — verify-only per #48'
|
||||
);
|
||||
assert.ok(
|
||||
!block.includes('reset --soft'),
|
||||
@@ -365,7 +377,7 @@ describe('bug-2075: worktree deletion safeguards', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('quick.md has worktree_branch_check block with --hard reset', () => {
|
||||
test('quick.md has worktree_branch_check block (verify-only, no git reset) (#48)', () => {
|
||||
const quickContent = fs.readFileSync(QUICK_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
quickContent.includes('worktree-branch-check.md'),
|
||||
@@ -381,8 +393,8 @@ describe('bug-2075: worktree deletion safeguards', () => {
|
||||
|
||||
const block = blockMatch[1];
|
||||
assert.ok(
|
||||
block.includes('reset --hard'),
|
||||
'quick.md worktree_branch_check must use git reset --hard (not --soft)'
|
||||
!/git\s+reset/.test(block),
|
||||
'quick.md worktree_branch_check must NOT run git reset — verify-only per #48'
|
||||
);
|
||||
assert.ok(
|
||||
!block.includes('reset --soft'),
|
||||
@@ -402,8 +414,8 @@ describe('bug-2075: worktree deletion safeguards', () => {
|
||||
assert.ok(blockMatch, 'worktree-branch-check.md must contain a <worktree_branch_check> block');
|
||||
const block = blockMatch[1];
|
||||
assert.ok(
|
||||
block.includes('reset --hard'),
|
||||
'diagnose-issues.md worktree_branch_check must instruct agents to use git reset --hard'
|
||||
!/git\s+reset/.test(block),
|
||||
'diagnose-issues.md worktree_branch_check must NOT run git reset — verify-only per #48'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user