Files
msd-core/tests/quick-batch-dispatch.test.cjs
Tom Boucher 515191f07d feat(#3677): quick-batch hardening and acceptance (#4240)
* 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>
2026-09-03 09:47:22 -04:00

422 lines
18 KiB
JavaScript

'use strict';
/**
* quick-batch-dispatch.test.cjs — Behavioral tests for the quick-batch
* dispatch decision core (#3676, Phase 4 of epic #3344 / ADR-1239
* "Quick-batch binding").
*
* Module: gsd-core/bin/lib/quick-batch-dispatch.cjs
* (compiled from src/quick-batch-dispatch.cts)
*
* Design doc: `.gsd/phase/feat-3676-quick-batch-command-workflow/40-design.md`
* Test matrix: `.gsd/phase/feat-3676-quick-batch-command-workflow/50-test-matrix.md`
* This file covers matrix rows: 5,7,8,9,10,13,14,15 (args), 3,4,6,7,8,9,10,12
* (effective concurrency), 24,32,33 (merge order), 27,39 (spawn backpressure),
* 30,31 (verification routing), 28,34,35,36 (merge routing), 26 (cleanup entry).
* Property rows 51-53 live in quick-batch-dispatch.property.test.cjs.
*
* This module is PURE — no filesystem or lock I/O — so every test here runs
* without a temp project directory.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const {
parseQuickBatchArgs,
computeEffectiveConcurrency,
computeMergeOrder,
computeSpawnPlan,
routeVerificationOutcome,
routeMergeOutcome,
buildCleanupManifestEntry,
filterAlreadyExecuted,
} = require('../gsd-core/bin/lib/quick-batch-dispatch.cjs');
// ─── parseQuickBatchArgs (rows 5,7-10,13-15) ────────────────────────────────
describe('quick-batch-dispatch: parseQuickBatchArgs', () => {
test('row 10: --jobs omitted defaults to auto', () => {
const result = parseQuickBatchArgs([]);
assert.equal(result.ok, true);
assert.deepEqual(result.value, { jobs: 'auto', validate: false, research: false, resume: null });
});
test('--jobs auto parses explicitly', () => {
const result = parseQuickBatchArgs(['--jobs', 'auto']);
assert.equal(result.ok, true);
assert.equal(result.value.jobs, 'auto');
});
test('--jobs N (positive integer) parses to a number', () => {
const result = parseQuickBatchArgs(['--jobs', '4']);
assert.equal(result.ok, true);
assert.equal(result.value.jobs, 4);
});
test('boundary: --jobs 1 (limit) is accepted', () => {
const result = parseQuickBatchArgs(['--jobs', '1']);
assert.equal(result.ok, true);
assert.equal(result.value.jobs, 1);
});
test('row 9 (hostile): --jobs 0 rejected before dispatch', () => {
const result = parseQuickBatchArgs(['--jobs', '0']);
assert.equal(result.ok, false);
});
test('row 9 (hostile): --jobs -1 rejected before dispatch', () => {
const result = parseQuickBatchArgs(['--jobs', '-1']);
assert.equal(result.ok, false);
});
test('row 9 (hostile): --jobs abc (non-numeric) rejected before dispatch', () => {
const result = parseQuickBatchArgs(['--jobs', 'abc']);
assert.equal(result.ok, false);
});
test('--jobs with a missing value is rejected', () => {
const result = parseQuickBatchArgs(['--jobs']);
assert.equal(result.ok, false);
});
test('--validate and --research set their respective flags', () => {
const result = parseQuickBatchArgs(['--validate', '--research']);
assert.equal(result.ok, true);
assert.equal(result.value.validate, true);
assert.equal(result.value.research, true);
});
test('row 16: --resume <batch-id> is parsed', () => {
const result = parseQuickBatchArgs(['--resume', '260101-abc']);
assert.equal(result.ok, true);
assert.equal(result.value.resume, '260101-abc');
});
test('--resume with a missing value is rejected', () => {
const result = parseQuickBatchArgs(['--resume']);
assert.equal(result.ok, false);
});
test('row 7: --discuss is rejected before any dispatch', () => {
const result = parseQuickBatchArgs(['--discuss']);
assert.equal(result.ok, false);
});
test('row 8: --full is rejected before any dispatch', () => {
const result = parseQuickBatchArgs(['--full']);
assert.equal(result.ok, false);
});
test('row 15 (hostile): --discuss --validate is still rejected — presence alone is sufficient', () => {
const result = parseQuickBatchArgs(['--discuss', '--validate']);
assert.equal(result.ok, false);
});
test('row 15 (hostile): --full mixed with otherwise-valid flags is still rejected', () => {
const result = parseQuickBatchArgs(['--jobs', '4', '--full', '--research']);
assert.equal(result.ok, false);
});
});
// ─── computeEffectiveConcurrency (rows 3,4,6,7,8,9,10,12) ───────────────────
describe('quick-batch-dispatch: computeEffectiveConcurrency', () => {
test('row 3: --jobs auto uses capacity alone', () => {
const n = computeEffectiveConcurrency({ jobs: 'auto', taskCount: 25, capacity: 4, isolation: 'harness-worktree', mutating: true });
assert.equal(n, 4);
});
test('row 7: --jobs 10 with 3 tasks and capacity 4 -> min(3,10,4) = 3', () => {
const n = computeEffectiveConcurrency({ jobs: 10, taskCount: 3, capacity: 4, isolation: 'harness-worktree', mutating: true });
assert.equal(n, 3);
});
test('row 8: --jobs 2 with 8 tasks and capacity 4 -> min(8,2,4) = 2', () => {
const n = computeEffectiveConcurrency({ jobs: 2, taskCount: 8, capacity: 4, isolation: 'harness-worktree', mutating: true });
assert.equal(n, 2);
});
test('boundary: --jobs equal to capacity and task count (limit) never exceeds either', () => {
const n = computeEffectiveConcurrency({ jobs: 4, taskCount: 4, capacity: 4, isolation: 'harness-worktree', mutating: true });
assert.equal(n, 4);
});
test('boundary: --jobs one more than task count (limit+1) still bounded by task count', () => {
const n = computeEffectiveConcurrency({ jobs: 5, taskCount: 4, capacity: 10, isolation: 'harness-worktree', mutating: true });
assert.equal(n, 4);
});
test('boundary: --jobs one less than task count (limit-1) is honored', () => {
const n = computeEffectiveConcurrency({ jobs: 3, taskCount: 4, capacity: 10, isolation: 'harness-worktree', mutating: true });
assert.equal(n, 3);
});
test('row 6: isolation none forces a MUTATING wave to concurrency 1 regardless of --jobs/capacity', () => {
const n = computeEffectiveConcurrency({ jobs: 4, taskCount: 4, capacity: 4, isolation: 'none', mutating: true });
assert.equal(n, 1);
});
test('row 6: isolation none forces --jobs auto down to 1 for a mutating wave too', () => {
const n = computeEffectiveConcurrency({ jobs: 'auto', taskCount: 4, capacity: 4, isolation: 'none', mutating: true });
assert.equal(n, 1);
});
test('row 12: isolation none does NOT cap a non-mutating (research-only) wave', () => {
const n = computeEffectiveConcurrency({ jobs: 4, taskCount: 4, capacity: 4, isolation: 'none', mutating: false });
assert.equal(n, 4, 'research-only stage is not subject to the isolation=none cap');
});
test('a non-none isolation never triggers the cap regardless of mutating', () => {
const n = computeEffectiveConcurrency({ jobs: 4, taskCount: 4, capacity: 4, isolation: 'harness-worktree', mutating: true });
assert.equal(n, 4);
});
});
// ─── computeMergeOrder (row 24; matrix rows 32-33) ──────────────────────────
describe('quick-batch-dispatch: computeMergeOrder — deterministic wave order, never completion order', () => {
test('row 32: all items ready simultaneously merge in the original wave order', () => {
const order = computeMergeOrder(['x', 'y', 'z'], new Set(['x', 'y', 'z']));
assert.deepEqual(order, ['x', 'y', 'z']);
});
test('row 33: item 2 finishing before item 1 does not reorder the merge — item 2 waits', () => {
// Only 'y' (wave position 2) is ready; 'x' (position 1) is not yet.
const order = computeMergeOrder(['x', 'y', 'z'], new Set(['y']));
assert.deepEqual(order, [], 'y must wait for x, which precedes it in wave order');
});
test('partial readiness returns only the contiguous ready PREFIX', () => {
const order = computeMergeOrder(['x', 'y', 'z'], new Set(['x', 'y']));
assert.deepEqual(order, ['x', 'y'], 'z is not ready yet, so it is excluded even though x and y are');
});
test('empty wave order returns empty', () => {
assert.deepEqual(computeMergeOrder([], new Set(['x'])), []);
});
test('nothing ready returns empty', () => {
assert.deepEqual(computeMergeOrder(['x', 'y'], new Set()), []);
});
});
// ─── computeSpawnPlan (row 27; matrix row 39) ───────────────────────────────
describe('quick-batch-dispatch: computeSpawnPlan — backpressure never increases fan-out', () => {
test('row 39: spawns up to remaining capacity, backpressures the rest to pending', () => {
const result = computeSpawnPlan({ eligibleIds: ['a', 'b', 'c'], capacity: 2, currentInFlight: 0 });
assert.deepEqual(result.spawn, ['a', 'b']);
assert.deepEqual(result.pending, ['c']);
});
test('boundary: eligible count exactly at capacity (limit) spawns everything', () => {
const result = computeSpawnPlan({ eligibleIds: ['a', 'b'], capacity: 2, currentInFlight: 0 });
assert.deepEqual(result.spawn, ['a', 'b']);
assert.deepEqual(result.pending, []);
});
test('boundary: eligible count one over capacity (limit+1) backpressures exactly one', () => {
const result = computeSpawnPlan({ eligibleIds: ['a', 'b', 'c'], capacity: 2, currentInFlight: 0 });
assert.equal(result.spawn.length, 2);
assert.equal(result.pending.length, 1);
});
test('boundary: eligible count one under capacity (limit-1) spawns everything, no backpressure', () => {
const result = computeSpawnPlan({ eligibleIds: ['a'], capacity: 2, currentInFlight: 0 });
assert.deepEqual(result.spawn, ['a']);
assert.deepEqual(result.pending, []);
});
test('row 27: a refused id returns to pending — never counted against capacity, never spawned', () => {
const result = computeSpawnPlan({ eligibleIds: ['a', 'b', 'c'], capacity: 3, currentInFlight: 0, refused: ['b'] });
assert.deepEqual(result.spawn, ['a', 'c']);
assert.deepEqual(result.pending, ['b']);
});
test('currentInFlight reduces available capacity for new spawns', () => {
const result = computeSpawnPlan({ eligibleIds: ['a', 'b'], capacity: 2, currentInFlight: 1 });
assert.deepEqual(result.spawn, ['a']);
assert.deepEqual(result.pending, ['b']);
});
test('currentInFlight already at or over capacity spawns nothing (never negative fan-out)', () => {
const result = computeSpawnPlan({ eligibleIds: ['a', 'b'], capacity: 2, currentInFlight: 5 });
assert.deepEqual(result.spawn, []);
assert.deepEqual(result.pending, ['a', 'b']);
});
test('accepts a Set for refused, same as an array', () => {
const result = computeSpawnPlan({ eligibleIds: ['a', 'b'], capacity: 2, currentInFlight: 0, refused: new Set(['a']) });
assert.deepEqual(result.spawn, ['b']);
assert.deepEqual(result.pending, ['a']);
});
});
// ─── routeVerificationOutcome (rows 30,31) ──────────────────────────────────
describe('quick-batch-dispatch: routeVerificationOutcome', () => {
test('passed routes to complete', () => {
assert.deepEqual(routeVerificationOutcome('passed'), { action: 'complete' });
});
test('row 30: human_needed routes to a terminal human_needed action — never complete', () => {
const routing = routeVerificationOutcome('human_needed');
assert.deepEqual(routing, { action: 'human_needed' });
assert.notEqual(routing.action, 'complete');
});
test('row 31: gaps_found routes to fail, with a failure reason — no rollback signal, no retry signal', () => {
const routing = routeVerificationOutcome('gaps_found');
assert.equal(routing.action, 'fail');
assert.match(routing.failureReason, /gaps_found/);
});
});
// ─── routeMergeOutcome (rows 28,34-35,36) ───────────────────────────────────
describe('quick-batch-dispatch: routeMergeOutcome', () => {
test('row 36: merged routes to complete', () => {
assert.deepEqual(routeMergeOutcome({ kind: 'merged' }), { action: 'complete' });
});
test('row 34: merge_failed routes to fail and preserves the worktree', () => {
const routing = routeMergeOutcome({ kind: 'merge_failed', detail: 'conflict in src/x.ts' });
assert.equal(routing.action, 'fail');
assert.equal(routing.preserveWorktree, true);
assert.match(routing.failureReason, /merge_failed/);
});
test('row 28/35: scope_violation (undeclared deletion) routes to fail and preserves the worktree', () => {
const routing = routeMergeOutcome({ kind: 'scope_violation', detail: 'undeclared deletion: src/y.ts' });
assert.equal(routing.action, 'fail');
assert.equal(routing.preserveWorktree, true);
assert.match(routing.failureReason, /scope_violation/);
});
test('detail is optional — a bare kind still routes correctly', () => {
const routing = routeMergeOutcome({ kind: 'merge_failed' });
assert.equal(routing.action, 'fail');
assert.equal(routing.failureReason, 'merge_failed');
});
});
// ─── buildCleanupManifestEntry (row 26, Open Question 2) ────────────────────
describe('quick-batch-dispatch: buildCleanupManifestEntry — sourced FRESH from the plan, never BATCH.json', () => {
test('row 26: derives files_modified/declared_deletions from the plan frontmatter', () => {
const planContent = [
'---',
'files_modified:',
' - src/a.ts',
' - src/b.ts',
'files_deleted:',
' - src/old.ts',
'---',
'',
'<objective>Do the thing</objective>',
].join('\n');
const entry = buildCleanupManifestEntry({
agentId: 'agent-1',
worktreePath: '/tmp/wt-1',
branch: 'gsd/quick-batch/260101-abc',
expectedBase: 'main',
planContent,
});
assert.equal(entry.agent_id, 'agent-1');
assert.equal(entry.worktree_path, '/tmp/wt-1');
assert.equal(entry.branch, 'gsd/quick-batch/260101-abc');
assert.equal(entry.expected_base, 'main');
assert.deepEqual(entry.files_modified, ['src/a.ts', 'src/b.ts']);
assert.deepEqual(entry.declared_deletions, ['src/old.ts']);
});
test('a plan declaring no files_modified/files_deleted produces empty arrays (never undefined, never guessed)', () => {
const entry = buildCleanupManifestEntry({
agentId: null,
worktreePath: '/tmp/wt-2',
branch: 'gsd/quick-batch/260101-def',
expectedBase: 'main',
planContent: '<objective>Do another thing</objective>',
});
assert.deepEqual(entry.files_modified, []);
assert.deepEqual(entry.declared_deletions, []);
});
test('allowedBases is passed through only when supplied', () => {
const withBases = buildCleanupManifestEntry({
agentId: null,
worktreePath: '/tmp/wt-3',
branch: 'gsd/quick-batch/260101-ghi',
expectedBase: 'main',
allowedBases: ['main', 'next'],
planContent: '',
});
assert.deepEqual(withBases.allowed_bases, ['main', 'next']);
const withoutBases = buildCleanupManifestEntry({
agentId: null,
worktreePath: '/tmp/wt-4',
branch: 'gsd/quick-batch/260101-jkl',
expectedBase: 'main',
planContent: '',
});
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']);
});
});