* chore(#3677): checkpoint design artifacts (gitignored, dev-only) * test(#3677): add failing regression test for the crash-window duplicate-dispatch gap (RED) Independently re-traces resume-mode.md/planner-wave.md/worktree-dispatch.md/ merge-wave.md and src/quick-batch.cts's resumeBatch (lines 894-899) and confirms the prior research pass's Open Question 1: a coordinator crash between Step 6 (executor commits, SUMMARY.md written) and Step 7 (merge) leaves BATCH.json at "pending" with no STATE.md row yet (only written in Step 9), so --resume's eligibility re-derivation would dispatch a second executor into a new worktree for the same item, orphaning the first. This test asserts worktree-dispatch.md's Step 6 excludes an item whose SUMMARY.md already exists from the spawn set, mirroring planner-wave.md's existing PLAN.md-existence check one layer earlier. Fails against the current worktree-dispatch.md, which has no such guard. See .gsd/phase/feat-3677-quick-batch-hardening-acceptance/40-design.md §1 for the full trace and fix-location rationale. * fix(#3677): guard worktree-dispatch.md against re-dispatching an already-executed item (GREEN) worktree-dispatch.md's Step 6 re-derives eligibility every dispatch round via the same quick-batch resume call resume-mode.md uses, but had no check for "did this item already finish executing" the way planner-wave.md already checks "did this item already get planned" (PLAN.md existence) before re-planning. A coordinator crash between Step 6 (executor commits, SUMMARY.md written) and Step 7 (merge) left the item eligible for a second dispatch on --resume, orphaning the first worktree's real, already- committed work and silently losing it once the second executor's SUMMARY.md write clobbered the first at the same item_dir path. Adds a SUMMARY.md-existence exclusion before spawn-plan is computed, symmetric to planner-wave.md's PLAN.md check. The excluded item is not lost: merge-wave.md's own mergeable-wave criterion (status=pending, SUMMARY.md on disk, not yet merged) already picks it up independently of this eligible/spawn list. Workflow-prose-only fix — touches no already-merged/reviewed .cts module. See .gsd/phase/feat-3677-quick-batch-hardening-acceptance/40-design.md §1 for the fix-location rationale (why not resumeBatch itself). * test(#3677): add real-git coverage for worktree-ownership tampering, scope drift, and submodules Closes the three coverage gaps identified in 40-design.md §2/§3 (#3677, epic #3344 Phase 5's own AC bullets: "arbitrary-worktree ownership attempts", "scope drift", "submodules"): - Arbitrary-worktree ownership tampering: a manifest entry naming a non-agent branch is silently dropped at normalization before any git subprocess runs; a manifest entry naming a plausible agent-branch that was never actually created by this repo's own worktree.create (a genuinely foreign repo/branch) is blocked via base_mismatch. Both leave the foreign location and repoRoot's HEAD provably untouched. - Advisory scope drift: a committed path outside declared files_modified still merges successfully (advisory, never blocking) while surfacing a scope_out_of_declared warning naming the drifted path; an exact declared-scope match produces zero warnings (boundary case). - Real .gitmodules submodule integration: a repo containing a real local git submodule merges cleanly through executeWorktreeWaveCleanupPlan for an unrelated plan; a real gitlink pointer bump (declared) merges cleanly with the superproject tree reflecting the new pinned commit; an undeclared bump is advisory-only and surfaces a scope warning naming vendor/sub, same as any other undeclared modification. No src/*.cts changes — all three gaps were coverage-only; the underlying primitives already behaved correctly (independently verified against real git subprocess output before writing each assertion). * docs(#3677): document how to diagnose a preserved quick-batch worktree Extends the one-sentence "worktree is preserved (never deleted)" mention into a concrete diagnosis procedure: where the preserved directory is, how to read the executor's real commits/diff against the plan's declared files_modified, how to read the item's own SUMMARY.md independent of merge outcome, how to manually merge-and-clean-up or discard, and how to re-run --resume afterward. Also documents that a SUMMARY.md-written-but-still- pending item (the crash-window case fixed in this same PR) needs no manual intervention — --resume routes it straight to the merge step. * chore(#3677): checkpoint final acceptance-evidence mapping (gitignored, dev-only) * fix(#3677): make crash-window duplicate-dispatch guard behaviorally provable and durably recoverable Orthogonal review (Spec finding): the crash-window regression test added earlier this phase only asserted readStep('worktree-dispatch.md') + regex matches against the markdown prose — proving the DOCUMENTATION says the right thing, never that the runtime condition (pending status + on-disk SUMMARY.md + absent STATE row) is actually handled correctly. #3677's own "Alternatives considered" explicitly rejects "document recovery without fault injection" for exactly this reason. Extracts the filtering decision into a pure, independently testable function, filterAlreadyExecuted(eligibleIds, executedIds) in src/quick-batch-dispatch.cts, wired to a new `quick-batch filter-executed` CLI verb (src/quick-batch-command-router.cts) — the same pure-decision- then-CLI-wired pattern computeSpawnPlan/computeMergeOrder already establish. worktree-dispatch.md now calls this verb explicitly instead of only describing the decision in prose. A genuine fixture-based test in tests/quick-batch.test.cjs constructs a REAL BATCH.json (createBatch), writes a REAL SUMMARY.md on disk at the item's real item_dir, calls the REAL resumeBatch, and proves both that resumeBatch alone still reports the item eligible AND that filterAlreadyExecuted (fed a real filesystem check) correctly excludes it. The prior prose-assertion tests are kept — they now prove the workflow markdown is correctly WIRED to the verb — but are no longer the only proof. Self-discovered defect while building that fixture (fixed inline, not deferred): tracing merge-wave.md against /gsd:quick's own prior art (QUICK_WORKTREE_MANIFEST=$(mktemp ...), quick.md:415) showed $QUICK_BATCH_WORKTREE_MANIFEST is a fresh PER-PROCESS temp file. A resumed coordinator correctly does not re-dispatch an already-executed item (this fix), but nothing durably recorded that item's worktree_path/branch/base either — Step 7 in the resumed process would have had no data to build its cleanup-wave entry from. Adds dispatched_worktree/dispatched_branch/ dispatched_base to QuickBatchItem (src/quick-batch.cts) — deliberately NOT a reuse of the pre-existing `worktree` field, whose loadBatch validation requires the path to exist on disk (verified empirically: reusing it made the batch permanently unloadable the moment a legitimately-merged worktree was removed). worktree-dispatch.md persists the triple once a worktree is created; merge-wave.md falls back to it when the ephemeral manifest lacks an entry, clears it after a successful merge, and fails closed rather than guessing if no record exists anywhere. See .gsd/phase/feat-3677-quick-batch-hardening-acceptance/40-design.md §9.1 and §9.3 for the full trace, empirical verification notes, and rejected alternatives (reusing `worktree` directly). * test(#3677): prove the arbitrary-worktree-ownership boundary against two real sibling worktrees Orthogonal review (Security finding): the two existing ownership-tampering tests didn't test ownership — one was trivially rejected by WORKTREE_AGENT_BRANCH_RE's shape check before any git call (proves branch- NAME filtering, not ownership), the other pointed at a wholly separate, never-linked foreign repo, so merge-base failed immediately because the branch didn't exist as a ref at all. Neither exercised the real scenario: a manifest entry whose worktree_path/branch are swapped to point at a DIFFERENT, GENUINELY-REGISTERED sibling worktree of the SAME repoRoot, with a branch name passing the shape check and a base in allowed_bases. Investigated executeWorktreeWaveCleanupPlan (src/worktree-safety.cts) directly: this is NOT a reachable gap. Git enforces branch-per-worktree uniqueness, so a swapped-in entry.branch can only match worktree_path's ACTUAL checked-out branch if it names that sibling's own real, uniquely- generated branch name — which manifest tampering confined to one batch's own record has no way to know (branch names are agent-<quick_id>[-<timestamp>]-shaped, and quick_id allocation is collision-checked GLOBALLY across every existing quick task and batch, not merely within one batch). Adds a stronger test that empirically proves this: two REAL, concurrently- alive sibling worktrees of the same repo (both via real `git worktree add`, both WORKTREE_AGENT_BRANCH_RE-passing, both sharing one merge-base), with worktree_path/branch swapped between them in both directions. Both attempts are blocked via branch_mismatch; both real worktrees, their branches, and one sibling's real uncommitted-to-main commit survive completely untouched. Supplements (does not replace) the original two tests, which still prove distinct, real boundaries. See .gsd/phase/feat-3677-quick-batch-hardening-acceptance/40-design.md §9.2 for the full trace, including the one explicitly-documented (not fixed) trust boundary this investigation surfaced: the primitive defends against fabricated data, not a caller bug that misattributes a real-but-wrong item's own triple to a different item. * chore(#3677): checkpoint design-doc addendum for review pass 2 findings (gitignored, dev-only) * docs(#3677): add changeset for PR 4240 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,7 +32,7 @@ const path = require('path');
|
||||
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const { executeWorktreeWaveCleanupPlan } = require('../gsd-core/bin/lib/worktree-safety.cjs');
|
||||
const { executeWorktreeWaveCleanupPlan, planWorktreeWaveCleanup } = require('../gsd-core/bin/lib/worktree-safety.cjs');
|
||||
const { routeMergeOutcome } = require('../gsd-core/bin/lib/quick-batch-dispatch.cjs');
|
||||
|
||||
const SUBPROCESS_TIMEOUT_MS = 30_000;
|
||||
@@ -199,3 +199,459 @@ describe('quick-batch merge routing — real undeclared-deletion detection (row
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #3677 Phase 5 (epic #3344) — closes the three coverage gaps identified in
|
||||
* `.gsd/phase/feat-3677-quick-batch-hardening-acceptance/40-design.md` §2/§3:
|
||||
* arbitrary-worktree ownership tampering (security AC), advisory scope drift
|
||||
* (scheduling AC), and a real-git submodule integration test (scheduling AC).
|
||||
* Same real-git-fixture discipline as the two describe blocks above — no new
|
||||
* mocks, no source-grep, real subprocess `git`.
|
||||
*/
|
||||
|
||||
describe('quick-batch merge routing — arbitrary-worktree ownership tampering (Security AC)', () => {
|
||||
test('a manifest entry naming a non-agent branch (e.g. the repo\'s own primary branch) is silently dropped at normalization and NEVER reaches git execution', () => {
|
||||
const tmpBase = createTempDir('qb-ownership-tamper-');
|
||||
try {
|
||||
const repoDir = path.join(tmpBase, 'repo');
|
||||
initRepo(repoDir);
|
||||
const headBefore = git(['rev-parse', 'HEAD'], repoDir).trim();
|
||||
|
||||
// A tampered/corrupt manifest naming the repo's OWN primary branch as
|
||||
// if it were a batch-owned worktree entry — the branch name fails
|
||||
// WORKTREE_AGENT_BRANCH_RE (`^((worktree-)?agent-|worktree-wf_)...`),
|
||||
// so it must never reach a git subprocess at all.
|
||||
const tamperedManifest = {
|
||||
worktrees: [{
|
||||
agent_id: 'tampered',
|
||||
worktree_path: repoDir,
|
||||
branch: 'main',
|
||||
expected_base: headBefore,
|
||||
}],
|
||||
};
|
||||
|
||||
const plan = planWorktreeWaveCleanup(repoDir, tamperedManifest);
|
||||
assert.equal(plan.ok, false, `a non-agent branch name must be rejected before a plan is built, got: ${JSON.stringify(plan)}`);
|
||||
assert.equal(plan.reason, 'empty_manifest');
|
||||
assert.equal(plan.entries.length, 0);
|
||||
|
||||
const result = executeWorktreeWaveCleanupPlan(plan);
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.entries.length, 0, 'no entry may reach git execution for a rejected manifest');
|
||||
|
||||
// repoRoot must be provably untouched — same HEAD, no merge commit.
|
||||
const headAfter = git(['rev-parse', 'HEAD'], repoDir).trim();
|
||||
assert.equal(headAfter, headBefore, 'repo HEAD must not move when the only manifest entry was rejected at normalization');
|
||||
} finally {
|
||||
cleanup(tmpBase);
|
||||
}
|
||||
});
|
||||
|
||||
test('a manifest entry naming a plausible agent-branch that was never actually created by this repo\'s own worktree.create is blocked (base_mismatch), never merged', () => {
|
||||
const tmpBase = createTempDir('qb-ownership-foreign-');
|
||||
try {
|
||||
const repoDir = path.join(tmpBase, 'repo');
|
||||
const foreignDir = path.join(tmpBase, 'foreign');
|
||||
initRepo(repoDir);
|
||||
const headBefore = git(['rev-parse', 'HEAD'], repoDir).trim();
|
||||
|
||||
// A completely separate repository — never created via `git worktree
|
||||
// add` against repoDir — whose HEAD branch happens to be named
|
||||
// plausibly (passes WORKTREE_AGENT_BRANCH_RE). This is the "arbitrary"
|
||||
// ownership-tampering case: the manifest ENTRY looks legitimate, but
|
||||
// nothing about worktree_path proves it is actually repoDir's own
|
||||
// worktree.
|
||||
fs.mkdirSync(foreignDir, { recursive: true });
|
||||
git(['init'], foreignDir);
|
||||
git(['config', 'user.email', 'test@test.com'], foreignDir);
|
||||
git(['config', 'user.name', 'Test'], foreignDir);
|
||||
git(['config', 'commit.gpgsign', 'false'], foreignDir);
|
||||
fs.writeFileSync(path.join(foreignDir, 'secret.txt'), 'not part of this batch\n');
|
||||
git(['add', '-A'], foreignDir);
|
||||
git(['commit', '-m', 'foreign repo initial commit'], foreignDir);
|
||||
git(['checkout', '-b', 'agent-hostile-1'], foreignDir);
|
||||
|
||||
const plan = {
|
||||
ok: true,
|
||||
repoRoot: repoDir,
|
||||
action: 'cleanup_wave',
|
||||
discovery: 'manifest',
|
||||
entries: [{
|
||||
agent_id: 'hostile',
|
||||
worktree_path: foreignDir,
|
||||
branch: 'agent-hostile-1',
|
||||
expected_base: headBefore,
|
||||
allowed_bases: [headBefore],
|
||||
}],
|
||||
};
|
||||
|
||||
const result = executeWorktreeWaveCleanupPlan(plan);
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.entries[0].status, 'blocked', `expected a blocked entry for a foreign worktree_path, got: ${JSON.stringify(result.entries[0])}`);
|
||||
// repoDir has no local ref named `agent-hostile-1` (it was only ever
|
||||
// created in the FOREIGN repo) — merge-base against it fails closed.
|
||||
assert.equal(result.entries[0].reason, 'base_mismatch');
|
||||
|
||||
// Neither side was touched: the foreign repo's secret file is intact
|
||||
// and untouched by any merge/remove, and repoDir's HEAD never moved.
|
||||
assert.ok(fs.existsSync(path.join(foreignDir, 'secret.txt')), 'foreign repo must be left completely alone');
|
||||
const headAfter = git(['rev-parse', 'HEAD'], repoDir).trim();
|
||||
assert.equal(headAfter, headBefore, 'repoDir HEAD must not move for a blocked foreign entry');
|
||||
} finally {
|
||||
cleanup(tmpBase);
|
||||
}
|
||||
});
|
||||
|
||||
// #3677 review pass 2 (Security finding): the two tests above prove
|
||||
// branch-shape rejection and a wholly-foreign, never-registered repo are
|
||||
// both handled — but neither exercises the scenario "arbitrary-worktree
|
||||
// ownership" actually names: a manifest entry whose worktree_path/branch
|
||||
// are SWAPPED to point at a DIFFERENT, GENUINELY-REGISTERED sibling
|
||||
// worktree of the SAME repoRoot (a concurrent batch's own agent worktree,
|
||||
// or a stale worktree from a prior crashed run), with a branch name that
|
||||
// passes WORKTREE_AGENT_BRANCH_RE's shape check and a base that is
|
||||
// legitimately in allowed_bases.
|
||||
//
|
||||
// Investigation conclusion (see
|
||||
// `.gsd/phase/feat-3677-quick-batch-hardening-acceptance/40-design.md`
|
||||
// §1's companion note): this is NOT a reachable gap in
|
||||
// `executeWorktreeWaveCleanupPlan` itself. Git enforces branch-per-
|
||||
// worktree uniqueness — the SAME branch cannot be checked out in two
|
||||
// worktrees of one repo at once — so `worktree_path`'s ACTUAL checked-out
|
||||
// branch (`git -C worktree_path rev-parse --abbrev-ref HEAD`,
|
||||
// `src/worktree-safety.cts:1047`) can only equal a swapped-in
|
||||
// `entry.branch` if that `entry.branch` is the SIBLING's own real,
|
||||
// uniquely-generated branch name — which manifest tampering confined to
|
||||
// ONE batch's own record has no way to know (branch names are
|
||||
// `agent-<quick_id>[-<timestamp>]`-shaped, and `quick_id` allocation is
|
||||
// collision-checked GLOBALLY across every existing quick task and batch,
|
||||
// `src/quick-batch.cts` `collectExistingBatchQuickIds`). This test proves
|
||||
// the boundary directly against TWO real, concurrently-alive sibling
|
||||
// worktrees of the SAME repo, both created via real `git worktree add`,
|
||||
// both with `WORKTREE_AGENT_BRANCH_RE`-passing names, both sharing the
|
||||
// SAME merge-base — so base/branch-shape checks ALONE could not
|
||||
// distinguish them if the primitive were naive; `branch_mismatch` is what
|
||||
// actually does.
|
||||
test('a manifest entry with one sibling worktree\'s real PATH but the OTHER sibling\'s real BRANCH name is blocked (branch_mismatch); both real, concurrently-alive worktrees survive untouched', () => {
|
||||
const tmpBase = createTempDir('qb-ownership-sibling-swap-');
|
||||
try {
|
||||
const repoDir = path.join(tmpBase, 'repo');
|
||||
const wt1Dir = path.join(tmpBase, 'wt-item1');
|
||||
const wt2Dir = path.join(tmpBase, 'wt-item2');
|
||||
const branch1 = 'agent-item1';
|
||||
const branch2 = 'agent-item2';
|
||||
|
||||
initRepo(repoDir);
|
||||
const headBefore = git(['rev-parse', 'HEAD'], repoDir).trim();
|
||||
|
||||
// TWO real, concurrently-alive sibling worktrees of the SAME repo —
|
||||
// e.g. two items in the same batch dispatch round, or one item's
|
||||
// worktree from THIS batch and a stale one left by a prior crashed
|
||||
// run. Both branch off the SAME base commit.
|
||||
addWorktree(repoDir, wt1Dir, branch1);
|
||||
addWorktree(repoDir, wt2Dir, branch2);
|
||||
fs.writeFileSync(path.join(wt2Dir, 'item2-own-work.txt'), 'item 2 real, uncommitted-to-main work\n');
|
||||
git(['add', '-A'], wt2Dir);
|
||||
git(['commit', '-m', 'item2: real work'], wt2Dir);
|
||||
const item2CommitBefore = git(['rev-parse', 'HEAD'], wt2Dir).trim();
|
||||
|
||||
// Tampered/corrupted entry: intends item1's cleanup (branch1,
|
||||
// item1's own base), but worktree_path has been swapped to point at
|
||||
// item2's REAL, currently-in-use worktree.
|
||||
const swappedPathPlan = {
|
||||
ok: true,
|
||||
repoRoot: repoDir,
|
||||
action: 'cleanup_wave',
|
||||
discovery: 'manifest',
|
||||
entries: [{
|
||||
agent_id: 'agent-item1',
|
||||
worktree_path: wt2Dir,
|
||||
branch: branch1,
|
||||
expected_base: headBefore,
|
||||
allowed_bases: [headBefore],
|
||||
}],
|
||||
};
|
||||
const swappedPathResult = executeWorktreeWaveCleanupPlan(swappedPathPlan);
|
||||
assert.equal(swappedPathResult.ok, false);
|
||||
assert.equal(swappedPathResult.entries[0].status, 'blocked', `expected a blocked entry for a path/branch swap, got: ${JSON.stringify(swappedPathResult.entries[0])}`);
|
||||
assert.equal(swappedPathResult.entries[0].reason, 'branch_mismatch', 'wt2Dir is really checked out on branch2, not branch1 — the swap cannot pass the branch check');
|
||||
|
||||
// The reverse swap is blocked the same way: item2's real path is
|
||||
// untouched here too, so re-verify with wt1Dir/branch2 sharing the
|
||||
// SAME base commit (no distinguishing signal except the branch
|
||||
// check itself).
|
||||
const reverseSwapPlan = {
|
||||
ok: true,
|
||||
repoRoot: repoDir,
|
||||
action: 'cleanup_wave',
|
||||
discovery: 'manifest',
|
||||
entries: [{
|
||||
agent_id: 'agent-item2',
|
||||
worktree_path: wt1Dir,
|
||||
branch: branch2,
|
||||
expected_base: headBefore,
|
||||
allowed_bases: [headBefore],
|
||||
}],
|
||||
};
|
||||
const reverseSwapResult = executeWorktreeWaveCleanupPlan(reverseSwapPlan);
|
||||
assert.equal(reverseSwapResult.entries[0].status, 'blocked');
|
||||
assert.equal(reverseSwapResult.entries[0].reason, 'branch_mismatch');
|
||||
|
||||
// Both real, concurrently-alive sibling worktrees survive completely
|
||||
// untouched by either tampered attempt — no merge landed, no
|
||||
// worktree/branch removed, item2's real work is exactly as it was.
|
||||
const headAfter = git(['rev-parse', 'HEAD'], repoDir).trim();
|
||||
assert.equal(headAfter, headBefore, 'repoDir HEAD must not move for either blocked swap attempt');
|
||||
assert.ok(fs.existsSync(wt1Dir), 'sibling worktree 1 must survive untouched');
|
||||
assert.ok(fs.existsSync(wt2Dir), 'sibling worktree 2 must survive untouched');
|
||||
assert.equal(git(['rev-parse', '--abbrev-ref', 'HEAD'], wt1Dir).trim(), branch1, 'worktree 1 must still be on its own real branch, never repointed');
|
||||
assert.equal(git(['rev-parse', '--abbrev-ref', 'HEAD'], wt2Dir).trim(), branch2, 'worktree 2 must still be on its own real branch, never repointed');
|
||||
assert.equal(git(['rev-parse', 'HEAD'], wt2Dir).trim(), item2CommitBefore, 'item 2\'s real, uncommitted-to-main work must be exactly as it was — never merged, never lost, never attributed to item1');
|
||||
} finally {
|
||||
cleanup(tmpBase);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('quick-batch merge routing — advisory scope drift merges but warns (Scheduling AC)', () => {
|
||||
test('a committed path outside declared files_modified merges successfully with an advisory scope_out_of_declared warning, never blocked', () => {
|
||||
const tmpBase = createTempDir('qb-scope-drift-');
|
||||
try {
|
||||
const repoDir = path.join(tmpBase, 'repo');
|
||||
const wtDir = path.join(tmpBase, 'wt-drift');
|
||||
const branchName = 'worktree-agent-drift';
|
||||
|
||||
initRepo(repoDir);
|
||||
addWorktree(repoDir, wtDir, branchName);
|
||||
const baseCommit = git(['merge-base', 'HEAD', branchName], repoDir).trim();
|
||||
|
||||
// The plan declared ONLY declared.txt, but the executor's real commit
|
||||
// touches declared.txt AND an undeclared drifted.txt.
|
||||
fs.writeFileSync(path.join(wtDir, 'declared.txt'), 'in scope\n');
|
||||
fs.writeFileSync(path.join(wtDir, 'drifted.txt'), 'NOT declared\n');
|
||||
git(['add', '-A'], wtDir);
|
||||
git(['commit', '-m', 'touch declared.txt and drifted.txt'], wtDir);
|
||||
|
||||
const plan = {
|
||||
ok: true,
|
||||
repoRoot: repoDir,
|
||||
action: 'cleanup_wave',
|
||||
discovery: 'manifest',
|
||||
entries: [{
|
||||
agent_id: 'drift1',
|
||||
worktree_path: wtDir,
|
||||
branch: branchName,
|
||||
expected_base: baseCommit,
|
||||
files_modified: ['declared.txt'],
|
||||
}],
|
||||
};
|
||||
|
||||
const result = executeWorktreeWaveCleanupPlan(plan);
|
||||
|
||||
// Advisory only: the merge still SUCCEEDS despite the drift.
|
||||
assert.equal(result.entries[0].status, 'merged_removed', `scope drift must be advisory, not blocking — got: ${JSON.stringify(result.entries[0])}`);
|
||||
assert.ok(!fs.existsSync(wtDir), 'worktree removed after a successful (advisory-only) merge');
|
||||
assert.ok(fs.existsSync(path.join(repoDir, 'drifted.txt')), 'the undeclared file is still merged in, not rejected');
|
||||
|
||||
// But the drift is surfaced, not silently swallowed.
|
||||
assert.equal(result.warnings.length, 1);
|
||||
assert.equal(result.warnings[0].code, 'scope_out_of_declared');
|
||||
assert.equal(result.warnings[0].path, 'drifted.txt');
|
||||
assert.equal(result.warnings[0].branch, branchName);
|
||||
|
||||
const routing = routeMergeOutcome({ kind: 'merged' });
|
||||
assert.equal(routing.action, 'complete', 'a scope-drift warning must not change merge routing to a failure');
|
||||
} finally {
|
||||
cleanup(tmpBase);
|
||||
}
|
||||
});
|
||||
|
||||
test('a fully-declared commit (files_modified matches exactly) merges with zero warnings — boundary against the drift case above', () => {
|
||||
const tmpBase = createTempDir('qb-scope-nodrift-');
|
||||
try {
|
||||
const repoDir = path.join(tmpBase, 'repo');
|
||||
const wtDir = path.join(tmpBase, 'wt-nodrift');
|
||||
const branchName = 'worktree-agent-nodrift';
|
||||
|
||||
initRepo(repoDir);
|
||||
addWorktree(repoDir, wtDir, branchName);
|
||||
const baseCommit = git(['merge-base', 'HEAD', branchName], repoDir).trim();
|
||||
|
||||
fs.writeFileSync(path.join(wtDir, 'declared.txt'), 'in scope\n');
|
||||
git(['add', '-A'], wtDir);
|
||||
git(['commit', '-m', 'touch only declared.txt'], wtDir);
|
||||
|
||||
const plan = {
|
||||
ok: true,
|
||||
repoRoot: repoDir,
|
||||
action: 'cleanup_wave',
|
||||
discovery: 'manifest',
|
||||
entries: [{
|
||||
agent_id: 'nodrift1',
|
||||
worktree_path: wtDir,
|
||||
branch: branchName,
|
||||
expected_base: baseCommit,
|
||||
files_modified: ['declared.txt'],
|
||||
}],
|
||||
};
|
||||
|
||||
const result = executeWorktreeWaveCleanupPlan(plan);
|
||||
assert.equal(result.entries[0].status, 'merged_removed');
|
||||
assert.equal(result.warnings.length, 0, 'an exact declared-scope match must produce no advisory warnings');
|
||||
} finally {
|
||||
cleanup(tmpBase);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('quick-batch merge routing — real .gitmodules submodule integration (Scheduling AC)', () => {
|
||||
/**
|
||||
* Builds `<repoDir>/vendor/sub` as a REAL git submodule (local file://
|
||||
* remote, no network) pinned at `pinnedCommit`, committed on repoDir's
|
||||
* primary branch. Returns the two submodule commits so a test can bump
|
||||
* between them inside a worktree branch — the real "submodule-touch"
|
||||
* scenario #3344's AC names.
|
||||
*/
|
||||
function buildRepoWithSubmodule(tmpBase) {
|
||||
const subDir = path.join(tmpBase, 'subsrc');
|
||||
fs.mkdirSync(subDir, { recursive: true });
|
||||
git(['init'], subDir);
|
||||
git(['config', 'user.email', 'test@test.com'], subDir);
|
||||
git(['config', 'user.name', 'Test'], subDir);
|
||||
git(['config', 'commit.gpgsign', 'false'], subDir);
|
||||
fs.writeFileSync(path.join(subDir, 'f.txt'), 'hello\n');
|
||||
git(['add', '-A'], subDir);
|
||||
git(['commit', '-m', 'sub commit 1'], subDir);
|
||||
const sub1 = git(['rev-parse', 'HEAD'], subDir).trim();
|
||||
fs.appendFileSync(path.join(subDir, 'f.txt'), 'world\n');
|
||||
git(['add', '-A'], subDir);
|
||||
git(['commit', '-m', 'sub commit 2'], subDir);
|
||||
const sub2 = git(['rev-parse', 'HEAD'], subDir).trim();
|
||||
|
||||
const repoDir = path.join(tmpBase, 'repo');
|
||||
initRepo(repoDir);
|
||||
git(['-c', 'protocol.file.allow=always', 'submodule', 'add', subDir, 'vendor/sub'], repoDir);
|
||||
// Pin the just-added submodule checkout to sub1 so the worktree branch
|
||||
// below has a REAL pointer bump (sub1 -> sub2) to commit, not a no-op.
|
||||
git(['checkout', sub1], path.join(repoDir, 'vendor', 'sub'));
|
||||
git(['add', 'vendor/sub', '.gitmodules'], repoDir);
|
||||
git(['commit', '-m', 'add submodule pinned at sub1'], repoDir);
|
||||
|
||||
return { repoDir, sub1, sub2 };
|
||||
}
|
||||
|
||||
test('a repo with .gitmodules and a plan that never touches the submodule merges cleanly through the cleanup primitive', () => {
|
||||
const tmpBase = createTempDir('qb-submodule-untouched-');
|
||||
try {
|
||||
const { repoDir } = buildRepoWithSubmodule(tmpBase);
|
||||
const wtDir = path.join(tmpBase, 'wt-unrelated');
|
||||
const branchName = 'worktree-agent-sub-unrelated';
|
||||
addWorktree(repoDir, wtDir, branchName);
|
||||
const baseCommit = git(['merge-base', 'HEAD', branchName], repoDir).trim();
|
||||
|
||||
fs.writeFileSync(path.join(wtDir, 'unrelated.txt'), 'nothing to do with the submodule\n');
|
||||
git(['add', '-A'], wtDir);
|
||||
git(['commit', '-m', 'touch unrelated.txt only'], wtDir);
|
||||
|
||||
const plan = {
|
||||
ok: true,
|
||||
repoRoot: repoDir,
|
||||
action: 'cleanup_wave',
|
||||
discovery: 'manifest',
|
||||
entries: [{
|
||||
agent_id: 'sub-unrelated',
|
||||
worktree_path: wtDir,
|
||||
branch: branchName,
|
||||
expected_base: baseCommit,
|
||||
files_modified: ['unrelated.txt'],
|
||||
}],
|
||||
};
|
||||
|
||||
const result = executeWorktreeWaveCleanupPlan(plan);
|
||||
assert.equal(result.entries[0].status, 'merged_removed', `a .gitmodules-bearing repo must not break an unrelated merge — got: ${JSON.stringify(result.entries[0])}`);
|
||||
assert.equal(result.warnings.length, 0);
|
||||
} finally {
|
||||
cleanup(tmpBase);
|
||||
}
|
||||
});
|
||||
|
||||
test('a real submodule pointer bump (sub1 -> sub2) merges cleanly and the superproject tree reflects the new pinned commit', () => {
|
||||
const tmpBase = createTempDir('qb-submodule-bump-');
|
||||
try {
|
||||
const { repoDir, sub2 } = buildRepoWithSubmodule(tmpBase);
|
||||
const wtDir = path.join(tmpBase, 'wt-bump');
|
||||
const branchName = 'worktree-agent-sub-bump';
|
||||
addWorktree(repoDir, wtDir, branchName);
|
||||
const baseCommit = git(['merge-base', 'HEAD', branchName], repoDir).trim();
|
||||
|
||||
git(['-c', 'protocol.file.allow=always', 'submodule', 'update', '--init'], wtDir);
|
||||
git(['checkout', sub2], path.join(wtDir, 'vendor', 'sub'));
|
||||
git(['add', 'vendor/sub'], wtDir);
|
||||
git(['commit', '-m', 'bump vendor/sub to sub2'], wtDir);
|
||||
|
||||
const plan = {
|
||||
ok: true,
|
||||
repoRoot: repoDir,
|
||||
action: 'cleanup_wave',
|
||||
discovery: 'manifest',
|
||||
entries: [{
|
||||
agent_id: 'sub-bump',
|
||||
worktree_path: wtDir,
|
||||
branch: branchName,
|
||||
expected_base: baseCommit,
|
||||
files_modified: ['vendor/sub'],
|
||||
}],
|
||||
};
|
||||
|
||||
const result = executeWorktreeWaveCleanupPlan(plan);
|
||||
assert.equal(result.entries[0].status, 'merged_removed', `a real gitlink pointer bump must merge like any other file change — got: ${JSON.stringify(result.entries[0])}`);
|
||||
assert.equal(result.warnings.length, 0, 'declaring vendor/sub in files_modified must suppress the scope-drift advisory for this exact bump');
|
||||
|
||||
// The superproject's own tree now points at the new submodule commit —
|
||||
// the concrete, diagnosable outcome of a "submodule-touch" merge.
|
||||
const treeEntry = git(['ls-tree', 'HEAD', 'vendor/sub'], repoDir).trim();
|
||||
assert.match(treeEntry, /^160000 commit /, 'vendor/sub must remain a gitlink (mode 160000), never mis-parsed as a regular file');
|
||||
assert.match(treeEntry, new RegExp(sub2), 'the merged tree must point at the bumped submodule commit');
|
||||
} finally {
|
||||
cleanup(tmpBase);
|
||||
}
|
||||
});
|
||||
|
||||
test('a submodule pointer bump NOT declared in files_modified still merges (advisory, not blocking) but surfaces a scope warning naming vendor/sub', () => {
|
||||
const tmpBase = createTempDir('qb-submodule-undeclared-');
|
||||
try {
|
||||
const { repoDir, sub2 } = buildRepoWithSubmodule(tmpBase);
|
||||
const wtDir = path.join(tmpBase, 'wt-bump-undeclared');
|
||||
const branchName = 'worktree-agent-sub-bump-undeclared';
|
||||
addWorktree(repoDir, wtDir, branchName);
|
||||
const baseCommit = git(['merge-base', 'HEAD', branchName], repoDir).trim();
|
||||
|
||||
git(['-c', 'protocol.file.allow=always', 'submodule', 'update', '--init'], wtDir);
|
||||
git(['checkout', sub2], path.join(wtDir, 'vendor', 'sub'));
|
||||
git(['add', 'vendor/sub'], wtDir);
|
||||
git(['commit', '-m', 'bump vendor/sub to sub2, undeclared'], wtDir);
|
||||
|
||||
const plan = {
|
||||
ok: true,
|
||||
repoRoot: repoDir,
|
||||
action: 'cleanup_wave',
|
||||
discovery: 'manifest',
|
||||
entries: [{
|
||||
agent_id: 'sub-bump-undeclared',
|
||||
worktree_path: wtDir,
|
||||
branch: branchName,
|
||||
expected_base: baseCommit,
|
||||
files_modified: ['README.md'],
|
||||
}],
|
||||
};
|
||||
|
||||
const result = executeWorktreeWaveCleanupPlan(plan);
|
||||
assert.equal(result.entries[0].status, 'merged_removed', 'an undeclared submodule touch is advisory-only, same as any other undeclared modification');
|
||||
assert.equal(result.warnings.length, 1);
|
||||
assert.equal(result.warnings[0].code, 'scope_out_of_declared');
|
||||
assert.equal(result.warnings[0].path, 'vendor/sub');
|
||||
} finally {
|
||||
cleanup(tmpBase);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -271,6 +271,72 @@ describe('quick-batch workflow: submodule fail-loud commit-time guard (rows 36,4
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Crash-window duplicate-dispatch guard (#3677, epic #3344 Phase 5) ──────
|
||||
|
||||
describe('quick-batch workflow: worktree-dispatch.md never re-dispatches an item that already finished executing (crash-window guard)', () => {
|
||||
test('Step 6 drops any quick_id whose SUMMARY.md already exists before computing spawn-plan, mirroring planner-wave.md\'s PLAN.md-existence check', () => {
|
||||
const content = readStep('worktree-dispatch.md');
|
||||
assert.match(content, /Crash-window guard/i, 'worktree-dispatch.md must document the crash-window duplicate-dispatch guard');
|
||||
assert.match(content, /NEVER re-dispatch it into a second worktree/, 'the guard must explicitly forbid re-dispatch');
|
||||
assert.match(content, /\$\{item_dir\}\/\$\{quick_id\}-SUMMARY\.md/, 'the guard must check the same on-disk SUMMARY.md path merge-wave.md already uses');
|
||||
});
|
||||
|
||||
test('the guard is positioned before spawn-plan is computed, not after (a post-hoc check cannot prevent the duplicate dispatch)', () => {
|
||||
const content = readStep('worktree-dispatch.md');
|
||||
const lines = splitLines(content);
|
||||
const guardIdx = lines.findIndex((l) => /Crash-window guard/i.test(l));
|
||||
const spawnPlanIdx = lines.findIndex((l) => l.includes('quick-batch spawn-plan'));
|
||||
assert.ok(guardIdx !== -1, 'crash-window guard section must exist');
|
||||
assert.ok(spawnPlanIdx !== -1, 'spawn-plan call must exist');
|
||||
assert.ok(guardIdx < spawnPlanIdx, 'the crash-window guard must appear BEFORE the spawn-plan call, or it cannot prevent this round\'s duplicate dispatch');
|
||||
});
|
||||
|
||||
test('the guard explains the item is not lost — merge-wave.md\'s own SUMMARY.md-on-disk criterion still picks it up', () => {
|
||||
const content = readStep('worktree-dispatch.md');
|
||||
assert.match(content, /merge-wave\.md/, 'the guard must point at merge-wave.md as the item\'s recovery path');
|
||||
assert.match(content, /not remove it from the batch/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Durable worktree-recovery persistence (#3677) ──────────────────────────
|
||||
//
|
||||
// The ephemeral $QUICK_BATCH_WORKTREE_MANIFEST does not survive a
|
||||
// coordinator crash/restart (fresh mktemp file every process) — Step 6 must
|
||||
// durably persist worktree_path/branch/expected_base onto BATCH.json so a
|
||||
// RESUMED process's Step 7 can recover them for an item the crash-window
|
||||
// guard correctly did not re-dispatch.
|
||||
|
||||
describe('quick-batch workflow: durable worktree-recovery persistence (#3677)', () => {
|
||||
test('worktree-dispatch.md persists dispatchedWorktree/dispatchedBranch/dispatchedBase via quick-batch update right after recording the ephemeral manifest entry', () => {
|
||||
const content = readStep('worktree-dispatch.md');
|
||||
assert.match(content, /Durable worktree-recovery persistence/i);
|
||||
assert.match(content, /dispatchedWorktree/);
|
||||
assert.match(content, /dispatchedBranch/);
|
||||
assert.match(content, /dispatchedBase/);
|
||||
assert.match(content, /quick-batch update --batch/);
|
||||
});
|
||||
|
||||
test('merge-wave.md falls back to the durable triple when the ephemeral manifest has no entry for a crash-recovered item', () => {
|
||||
const content = readStep('merge-wave.md');
|
||||
assert.match(content, /Durable fallback/i);
|
||||
assert.match(content, /dispatched_worktree/);
|
||||
assert.match(content, /dispatched_branch/);
|
||||
assert.match(content, /dispatched_base/);
|
||||
});
|
||||
|
||||
test('merge-wave.md clears the durable triple back to null after a successful merge_removed (the worktree no longer exists on disk)', () => {
|
||||
const content = readStep('merge-wave.md');
|
||||
assert.match(content, /Clear the\s*\n?\s*durable worktree-recovery fields now/i);
|
||||
assert.match(content, /"dispatchedWorktree":null,"dispatchedBranch":null,"dispatchedBase":null/);
|
||||
});
|
||||
|
||||
test('merge-wave.md fails closed (never guesses) when an item has no worktree record anywhere', () => {
|
||||
const content = readStep('merge-wave.md');
|
||||
assert.match(content, /still `null`/);
|
||||
assert.match(content, /missing durable worktree/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── planner-quick-batch mode (rows 13-15) ──────────────────────────────────
|
||||
|
||||
describe('quick-batch planner mode: agents/gsd-planner.md extension (rows 13-15)', () => {
|
||||
|
||||
@@ -227,6 +227,48 @@ describe('quick-batch-command-router: argument shaping (mocked modules)', () =>
|
||||
assert.deepEqual(calls[0], { eligibleIds: ['a', 'b', 'c'], capacity: 2, currentInFlight: 0, refused: ['b'] });
|
||||
});
|
||||
|
||||
// #3677: crash-window duplicate-dispatch guard CLI verb.
|
||||
test('filter-executed forwards eligible/executed to filterAlreadyExecuted', () => {
|
||||
const calls = [];
|
||||
routeQuickBatchCommand({
|
||||
args: ['quick-batch', 'filter-executed', '--eligible', '["a","b","c"]', '--executed', '["b"]'],
|
||||
cwd: '/tmp/proj',
|
||||
raw: true,
|
||||
error: (msg) => { throw new Error(`unexpected error: ${msg}`); },
|
||||
_quickBatch: {},
|
||||
_quickBatchDispatch: {
|
||||
filterAlreadyExecuted: (eligibleIds, executedIds) => { calls.push({ eligibleIds, executedIds }); return { spawnEligible: [], alreadyExecuted: [] }; },
|
||||
},
|
||||
});
|
||||
assert.deepEqual(calls[0], { eligibleIds: ['a', 'b', 'c'], executedIds: ['b'] });
|
||||
});
|
||||
|
||||
test('filter-executed rejects a missing --eligible', () => {
|
||||
let message = null;
|
||||
routeQuickBatchCommand({
|
||||
args: ['quick-batch', 'filter-executed', '--executed', '["b"]'],
|
||||
cwd: '/tmp/proj',
|
||||
raw: true,
|
||||
error: (msg) => { message = msg; },
|
||||
_quickBatch: {},
|
||||
_quickBatchDispatch: {},
|
||||
});
|
||||
assert.match(message, /--eligible/);
|
||||
});
|
||||
|
||||
test('filter-executed rejects a missing --executed', () => {
|
||||
let message = null;
|
||||
routeQuickBatchCommand({
|
||||
args: ['quick-batch', 'filter-executed', '--eligible', '["a"]'],
|
||||
cwd: '/tmp/proj',
|
||||
raw: true,
|
||||
error: (msg) => { message = msg; },
|
||||
_quickBatch: {},
|
||||
_quickBatchDispatch: {},
|
||||
});
|
||||
assert.match(message, /--executed/);
|
||||
});
|
||||
|
||||
test('verification-routing rejects an invalid --status', () => {
|
||||
let message = null;
|
||||
routeQuickBatchCommand({
|
||||
|
||||
@@ -30,6 +30,7 @@ const {
|
||||
routeVerificationOutcome,
|
||||
routeMergeOutcome,
|
||||
buildCleanupManifestEntry,
|
||||
filterAlreadyExecuted,
|
||||
} = require('../gsd-core/bin/lib/quick-batch-dispatch.cjs');
|
||||
|
||||
// ─── parseQuickBatchArgs (rows 5,7-10,13-15) ────────────────────────────────
|
||||
@@ -365,3 +366,56 @@ describe('quick-batch-dispatch: buildCleanupManifestEntry — sourced FRESH from
|
||||
assert.equal('allowed_bases' in withoutBases, false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── filterAlreadyExecuted — crash-window duplicate-dispatch guard (#3677) ──
|
||||
//
|
||||
// Pure-decision unit tests (this module performs no filesystem I/O — the
|
||||
// caller determines `executedIds` via its own check). A REAL fixture
|
||||
// combining this function with an actual on-disk SUMMARY.md and a real
|
||||
// resumeBatch call lives in tests/quick-batch.test.cjs (crosses
|
||||
// quick-batch.cjs + quick-batch-dispatch.cjs, so it belongs with the
|
||||
// filesystem-backed suite, not this pure one).
|
||||
|
||||
describe('quick-batch-dispatch: filterAlreadyExecuted', () => {
|
||||
test('an id present in executedIds is excluded from spawnEligible and reported in alreadyExecuted', () => {
|
||||
const result = filterAlreadyExecuted(['a', 'b', 'c'], ['b']);
|
||||
assert.deepEqual(result.spawnEligible, ['a', 'c']);
|
||||
assert.deepEqual(result.alreadyExecuted, ['b']);
|
||||
});
|
||||
|
||||
test('boundary: empty executedIds — every eligible id is spawnEligible, alreadyExecuted is empty', () => {
|
||||
const result = filterAlreadyExecuted(['a', 'b'], []);
|
||||
assert.deepEqual(result.spawnEligible, ['a', 'b']);
|
||||
assert.deepEqual(result.alreadyExecuted, []);
|
||||
});
|
||||
|
||||
test('boundary: every eligible id already executed — spawnEligible is empty, nothing is silently dropped', () => {
|
||||
const result = filterAlreadyExecuted(['a', 'b'], ['a', 'b']);
|
||||
assert.deepEqual(result.spawnEligible, []);
|
||||
assert.deepEqual(result.alreadyExecuted, ['a', 'b']);
|
||||
});
|
||||
|
||||
test('boundary: empty eligibleIds — both outputs empty regardless of executedIds', () => {
|
||||
const result = filterAlreadyExecuted([], ['x', 'y']);
|
||||
assert.deepEqual(result.spawnEligible, []);
|
||||
assert.deepEqual(result.alreadyExecuted, []);
|
||||
});
|
||||
|
||||
test('order-preserving: spawnEligible/alreadyExecuted each keep eligibleIds\' original relative order', () => {
|
||||
const result = filterAlreadyExecuted(['c', 'a', 'b', 'd'], ['a', 'd']);
|
||||
assert.deepEqual(result.spawnEligible, ['c', 'b']);
|
||||
assert.deepEqual(result.alreadyExecuted, ['a', 'd']);
|
||||
});
|
||||
|
||||
test('accepts a Set for executedIds, not only an array (same convention as computeSpawnPlan\'s refused param)', () => {
|
||||
const result = filterAlreadyExecuted(['a', 'b'], new Set(['a']));
|
||||
assert.deepEqual(result.spawnEligible, ['b']);
|
||||
assert.deepEqual(result.alreadyExecuted, ['a']);
|
||||
});
|
||||
|
||||
test('an id in executedIds that is NOT in eligibleIds is ignored — never invented into either output', () => {
|
||||
const result = filterAlreadyExecuted(['a'], ['a', 'phantom-id']);
|
||||
assert.deepEqual(result.spawnEligible, []);
|
||||
assert.deepEqual(result.alreadyExecuted, ['a']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,14 @@ const { appendQuickTaskRow } = require('../gsd-core/bin/lib/markdown-table.cjs')
|
||||
const { makeFakeClock } = require('./helpers/clock.cjs');
|
||||
const { runGsdTools, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// #3677: crash-window duplicate-dispatch guard — combines resumeBatch (this
|
||||
// module) with filterAlreadyExecuted (the pure decision extracted into
|
||||
// quick-batch-dispatch.cts) and a REAL on-disk SUMMARY.md, the same three
|
||||
// pieces worktree-dispatch.md's Step 6 wires together at runtime.
|
||||
const { filterAlreadyExecuted } = require('../gsd-core/bin/lib/quick-batch-dispatch.cjs');
|
||||
const { generateSlugInternal } = require('../gsd-core/bin/lib/core-utils.cjs');
|
||||
const { planningPaths } = require('../gsd-core/bin/lib/planning-workspace.cjs');
|
||||
|
||||
// ─── Shared fixtures ────────────────────────────────────────────────────────────
|
||||
|
||||
function mkTmpProject() {
|
||||
@@ -738,6 +746,118 @@ describe('quick-batch: exactly-once STATE completion', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── crash-window duplicate-dispatch guard (#3677, epic #3344 Phase 5) ─────
|
||||
//
|
||||
// A DIFFERENT crash window than row 31 above: row 31 crashes AFTER the
|
||||
// STATE.md row is written (Step 9) but before BATCH.json records `complete`
|
||||
// — resumeBatch's own hasQuickTaskRow detection covers that one directly.
|
||||
// This one crashes EARLIER — after Step 6 (executor committed, SUMMARY.md
|
||||
// written) but before Step 7 (merge), so NO STATE.md row exists yet.
|
||||
// resumeBatch alone cannot detect this; worktree-dispatch.md's Step 6 must
|
||||
// additionally check for an on-disk SUMMARY.md via filterAlreadyExecuted.
|
||||
// Real fixture (real BATCH.json via createBatch, real SUMMARY.md file on
|
||||
// disk, real resumeBatch call), not a prose/structural proxy — see
|
||||
// `.gsd/phase/feat-3677-quick-batch-hardening-acceptance/40-design.md` §1.
|
||||
|
||||
describe('quick-batch: crash-window duplicate-dispatch guard (#3677) — SUMMARY.md written but BATCH.json still pending', () => {
|
||||
test('resumeBatch alone still reports the crashed item eligible; filterAlreadyExecuted (fed a REAL on-disk SUMMARY.md check) excludes it from spawnEligible', () => {
|
||||
const dir = mkTmpProject();
|
||||
writeState(dir, stateWithQuickTasksSection());
|
||||
try {
|
||||
const created = createBatch(dir, [{ description: 'crashed item' }, { description: 'clean item' }]);
|
||||
assert.equal(created.ok, true);
|
||||
const [crashed, clean] = created.value.manifest.items;
|
||||
|
||||
// Simulate the real Step-6-to-Step-7 crash window: the executor
|
||||
// finished (a real commit, in the real design; here a real SUMMARY.md
|
||||
// file is what matters) but the coordinator crashed before Step 7's
|
||||
// merge. BATCH.json is untouched — still `pending` — and no STATE.md
|
||||
// row exists (that is written only in Step 9, well after this point).
|
||||
const slug = generateSlugInternal(crashed.description);
|
||||
const itemDir = path.join(planningPaths(dir).quick, `${crashed.quick_id}-${slug}`);
|
||||
fs.mkdirSync(itemDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(itemDir, `${crashed.quick_id}-SUMMARY.md`),
|
||||
'---\nstatus: complete\n---\n\n# Summary\n\nDid the thing.\n',
|
||||
);
|
||||
|
||||
const preResume = loadBatch(dir, created.value.batchId);
|
||||
assert.equal(
|
||||
preResume.value.items.find((it) => it.quick_id === crashed.quick_id).status,
|
||||
'pending',
|
||||
'BATCH.json is untouched by the crash — still pending',
|
||||
);
|
||||
|
||||
// --resume re-derives eligibility exactly as worktree-dispatch.md's
|
||||
// Step 6 substep 1 does. BOTH items come back eligible: resumeBatch's
|
||||
// own crash-window detection (hasQuickTaskRow) only fires on a
|
||||
// STATE.md row, which does not exist yet for the crashed item.
|
||||
const resumed = resumeBatch(dir, created.value.batchId);
|
||||
assert.equal(resumed.ok, true);
|
||||
assert.deepEqual(
|
||||
resumed.value.eligible.slice().sort(),
|
||||
[clean.quick_id, crashed.quick_id].sort(),
|
||||
'resumeBatch alone does not know the crashed item already finished executing',
|
||||
);
|
||||
|
||||
// worktree-dispatch.md's own crash-window guard: a REAL filesystem
|
||||
// check for each eligible id's SUMMARY.md (same item_dir derivation
|
||||
// via generateSlugInternal every other step uses), fed into the pure
|
||||
// filterAlreadyExecuted decision.
|
||||
const executedIds = resumed.value.eligible.filter((quickId) => {
|
||||
const item = resumed.value.manifest.items.find((it) => it.quick_id === quickId);
|
||||
const itemSlug = generateSlugInternal(item.description);
|
||||
const summaryPath = path.join(planningPaths(dir).quick, `${quickId}-${itemSlug}`, `${quickId}-SUMMARY.md`);
|
||||
return fs.existsSync(summaryPath);
|
||||
});
|
||||
assert.deepEqual(executedIds, [crashed.quick_id], 'only the crashed item has a real on-disk SUMMARY.md');
|
||||
|
||||
const filtered = filterAlreadyExecuted(resumed.value.eligible, executedIds);
|
||||
assert.deepEqual(filtered.spawnEligible, [clean.quick_id], 'the crashed item must NOT be re-dispatched into a second worktree');
|
||||
assert.deepEqual(filtered.alreadyExecuted, [crashed.quick_id]);
|
||||
|
||||
// The crashed item is not lost — it is exactly the shape
|
||||
// merge-wave.md's OWN independent criterion (status=pending,
|
||||
// SUMMARY.md on disk, not yet merged) already expects to find.
|
||||
assert.equal(fs.existsSync(path.join(itemDir, `${crashed.quick_id}-SUMMARY.md`)), true);
|
||||
assert.equal(
|
||||
loadBatch(dir, created.value.batchId).value.items.find((it) => it.quick_id === crashed.quick_id).status,
|
||||
'pending',
|
||||
'filterAlreadyExecuted performs no BATCH.json write — merge-wave.md still finds it pending with an on-disk SUMMARY.md',
|
||||
);
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('an item with NO on-disk SUMMARY.md is unaffected — the guard only excludes genuinely already-executed items', () => {
|
||||
const dir = mkTmpProject();
|
||||
writeState(dir, stateWithQuickTasksSection());
|
||||
try {
|
||||
const created = createBatch(dir, [{ description: 'never started' }]);
|
||||
assert.equal(created.ok, true);
|
||||
const [item] = created.value.manifest.items;
|
||||
|
||||
const resumed = resumeBatch(dir, created.value.batchId);
|
||||
assert.deepEqual(resumed.value.eligible, [item.quick_id]);
|
||||
|
||||
// No SUMMARY.md written anywhere — the filesystem check must find nothing.
|
||||
const executedIds = resumed.value.eligible.filter((quickId) => {
|
||||
const it = resumed.value.manifest.items.find((x) => x.quick_id === quickId);
|
||||
const s = generateSlugInternal(it.description);
|
||||
return fs.existsSync(path.join(planningPaths(dir).quick, `${quickId}-${s}`, `${quickId}-SUMMARY.md`));
|
||||
});
|
||||
assert.deepEqual(executedIds, []);
|
||||
|
||||
const filtered = filterAlreadyExecuted(resumed.value.eligible, executedIds);
|
||||
assert.deepEqual(filtered.spawnEligible, [item.quick_id], 'an item that never started must still be dispatched normally');
|
||||
assert.deepEqual(filtered.alreadyExecuted, []);
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 34-35: Independence + regression ────────────────────────────────────────────
|
||||
|
||||
describe('quick-batch: independence from scanQuickTasks, regression on existing quick paths', () => {
|
||||
@@ -1071,6 +1191,114 @@ describe('quick-batch: updateBatchItems — basic mutation + persistence', () =>
|
||||
});
|
||||
});
|
||||
|
||||
// #3677: durable worktree-recovery fields (dispatched_worktree/branch/base)
|
||||
// — the crash-window duplicate-dispatch guard's fallback for a RESUMED
|
||||
// coordinator process whose own ephemeral $QUICK_BATCH_WORKTREE_MANIFEST
|
||||
// (a fresh mktemp file every process) has no entry for an item dispatched
|
||||
// by a PRIOR, now-dead process. Deliberately separate fields from the
|
||||
// pre-existing `worktree` (which requires the path to exist on disk via
|
||||
// loadBatch's validation) — these three carry NO existence check, because
|
||||
// their entire purpose is to stay readable (and clearable) after a
|
||||
// legitimate post-merge worktree removal.
|
||||
describe('quick-batch: updateBatchItems — durable worktree-recovery fields (#3677)', () => {
|
||||
test('persists dispatchedWorktree/dispatchedBranch/dispatchedBase and a fresh loadBatch (simulating a new coordinator process) reads them back', () => {
|
||||
const dir = mkTmpProject();
|
||||
try {
|
||||
const created = createBatch(dir, [{ description: 'a' }, { description: 'b' }]);
|
||||
assert.equal(created.ok, true);
|
||||
const [itemA, itemB] = created.value.manifest.items;
|
||||
assert.equal(itemA.dispatched_worktree, null, 'null until Step 6 actually dispatches');
|
||||
assert.equal(itemA.dispatched_branch, null);
|
||||
assert.equal(itemA.dispatched_base, null);
|
||||
|
||||
const wtDir = path.join(os.tmpdir(), `qb-dispatched-wt-${itemA.quick_id}`);
|
||||
fs.mkdirSync(wtDir, { recursive: true });
|
||||
try {
|
||||
const result = updateBatchItems(dir, created.value.batchId, [
|
||||
{ quickId: itemA.quick_id, dispatchedWorktree: wtDir, dispatchedBranch: `agent-${itemA.quick_id}`, dispatchedBase: 'deadbeef' },
|
||||
]);
|
||||
assert.equal(result.ok, true, result.ok ? '' : result.reason);
|
||||
|
||||
// A FRESH loadBatch call — standing in for a brand-new coordinator
|
||||
// process (its own ephemeral worktree manifest would be empty) —
|
||||
// must still recover the durable triple.
|
||||
const reloaded = loadBatch(dir, created.value.batchId);
|
||||
assert.equal(reloaded.ok, true);
|
||||
const reloadedA = reloaded.value.items.find((it) => it.quick_id === itemA.quick_id);
|
||||
assert.equal(reloadedA.dispatched_worktree, wtDir);
|
||||
assert.equal(reloadedA.dispatched_branch, `agent-${itemA.quick_id}`);
|
||||
assert.equal(reloadedA.dispatched_base, 'deadbeef');
|
||||
|
||||
// The untouched sibling item is unaffected.
|
||||
const reloadedB = reloaded.value.items.find((it) => it.quick_id === itemB.quick_id);
|
||||
assert.equal(reloadedB.dispatched_worktree, null);
|
||||
} finally {
|
||||
cleanup(wtDir);
|
||||
}
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('clearing the triple (post-merge) succeeds and remains loadable even though the worktree path no longer exists on disk', () => {
|
||||
const dir = mkTmpProject();
|
||||
try {
|
||||
const created = createBatch(dir, [{ description: 'a' }]);
|
||||
assert.equal(created.ok, true);
|
||||
const [itemA] = created.value.manifest.items;
|
||||
|
||||
const wtDir = path.join(os.tmpdir(), `qb-dispatched-wt-clear-${itemA.quick_id}`);
|
||||
fs.mkdirSync(wtDir, { recursive: true });
|
||||
const set = updateBatchItems(dir, created.value.batchId, [
|
||||
{ quickId: itemA.quick_id, dispatchedWorktree: wtDir, dispatchedBranch: `agent-${itemA.quick_id}`, dispatchedBase: 'deadbeef' },
|
||||
]);
|
||||
assert.equal(set.ok, true);
|
||||
|
||||
// Merge-wave.md's own post-merge step: the real worktree is removed
|
||||
// (git worktree remove, out of scope here), THEN the durable triple
|
||||
// is cleared.
|
||||
cleanup(wtDir);
|
||||
const cleared = updateBatchItems(dir, created.value.batchId, [
|
||||
{ quickId: itemA.quick_id, dispatchedWorktree: null, dispatchedBranch: null, dispatchedBase: null },
|
||||
]);
|
||||
assert.equal(cleared.ok, true, 'clearing must succeed even though the path no longer exists on disk — this is exactly why these fields are NOT the pre-existing `worktree` field');
|
||||
|
||||
const reloaded = loadBatch(dir, created.value.batchId);
|
||||
assert.equal(reloaded.ok, true, 'a SUBSEQUENT loadBatch must not fail on the now-null dispatched_worktree field');
|
||||
const reloadedA = reloaded.value.items.find((it) => it.quick_id === itemA.quick_id);
|
||||
assert.equal(reloadedA.dispatched_worktree, null);
|
||||
assert.equal(reloadedA.dispatched_branch, null);
|
||||
assert.equal(reloadedA.dispatched_base, null);
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects a non-string, non-null dispatchedBranch without persisting anything (schema validation)', () => {
|
||||
const dir = mkTmpProject();
|
||||
try {
|
||||
const created = createBatch(dir, [{ description: 'a' }]);
|
||||
assert.equal(created.ok, true);
|
||||
|
||||
// updateBatchItems itself performs no shape check on these fields
|
||||
// (same "opaque identifier" convention as `commit`) — the schema
|
||||
// guard fires on the NEXT loadBatch, exactly like every other
|
||||
// malformed field in this manifest. Simulate a corrupt write directly
|
||||
// to prove loadBatch's validateBatchSchema catches it.
|
||||
const manifestPath = path.join(dir, '.planning', 'quick-batches', created.value.batchId, 'BATCH.json');
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
||||
manifest.items[0].dispatched_branch = 12345;
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
||||
|
||||
const reloaded = loadBatch(dir, created.value.batchId);
|
||||
assert.equal(reloaded.ok, false);
|
||||
assert.match(reloaded.reason, /dispatched_branch/);
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('quick-batch: updateBatchItems — fails closed, never persists on a bad update', () => {
|
||||
test('rejects an unknown quickId without persisting anything', () => {
|
||||
const dir = mkTmpProject();
|
||||
|
||||
Reference in New Issue
Block a user