claude-code-session-manager 0.79.0 → 0.81.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{AgentLibrary-COtVRqBR.js → AgentLibrary-psZYVM2w.js} +1 -1
- package/dist/assets/{DataModel-CSEKw_OR.js → DataModel-BMied5pg.js} +1 -1
- package/dist/assets/{History-CHHovrAO.js → History-BFC0oaKc.js} +1 -1
- package/dist/assets/{Hooks-BZU6C3x6.js → Hooks-CTLfO9G8.js} +1 -1
- package/dist/assets/{HostBilko-CqTUoq37.js → HostBilko-D4I0Cpwn.js} +1 -1
- package/dist/assets/{Library-BtxdyTLz.js → Library-BTzS8KsS.js} +1 -1
- package/dist/assets/{ListDetail-qZc7Zm-6.js → ListDetail-CuRmT008.js} +1 -1
- package/dist/assets/{MarkdownEditor-BHe_4fJR.js → MarkdownEditor-BgQGtvWo.js} +1 -1
- package/dist/assets/{McpServers-7Z98HLNo.js → McpServers-DiVQUK57.js} +1 -1
- package/dist/assets/{Memory-CR72KoyP.js → Memory-DSu15JmL.js} +1 -1
- package/dist/assets/{Panel-pL6H3dpQ.js → Panel-CD5wxGSR.js} +1 -1
- package/dist/assets/{Permissions-CWSWjyXM.js → Permissions-C_tAE6yJ.js} +1 -1
- package/dist/assets/{Plugins-CN6lX2lt.js → Plugins-kwI7W-eK.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BXSXwIsk.js → ProvenanceBadge-CQceOgsH.js} +1 -1
- package/dist/assets/{SaveBar-BlB5TGpR.js → SaveBar-BDk5e3Pp.js} +1 -1
- package/dist/assets/{Scheduler-DRciWUmR.js → Scheduler-DRnvEYzv.js} +7 -7
- package/dist/assets/{ScopeSwitcher-kFrXtjpr.js → ScopeSwitcher-CeifaOlq.js} +1 -1
- package/dist/assets/{Settings-BXuyf4lJ.js → Settings-BWQ1Utop.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-Dfacb0PE.js → SkillReferenceGraph-CW6e1SW1.js} +1 -1
- package/dist/assets/{Skills-CHqcpiyt.js → Skills-BLwFB0E4.js} +1 -1
- package/dist/assets/{SystemPrompt-fxXm0BZr.js → SystemPrompt-DC4ZTArJ.js} +1 -1
- package/dist/assets/{TagLibrary-DOz65ZTz.js → TagLibrary-pMeGfjUb.js} +1 -1
- package/dist/assets/{TiptapBody-D0bWx_9o.js → TiptapBody-BHFid2pZ.js} +1 -1
- package/dist/assets/{Toggle-C9jBwGSx.js → Toggle-D9eYoZh4.js} +1 -1
- package/dist/assets/{index-DPYa6jbM.js → index-CuyM9vAP.js} +5 -5
- package/dist/assets/{settingsSchema-BTPw1bR3.js → settingsSchema-DrxC67uZ.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/builder/3-publish/SKILL.md +10 -0
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -0
- package/src/main/__tests__/computeDepHistorySatisfaction.test.cjs +66 -0
- package/src/main/__tests__/prdCreate.test.cjs +133 -8
- package/src/main/__tests__/prdFrontmatterDependsOn.test.cjs +136 -0
- package/src/main/__tests__/prdUpdateDependsOn.test.cjs +160 -0
- package/src/main/__tests__/queueHistory.test.cjs +33 -0
- package/src/main/__tests__/rcaReport.test.cjs +24 -0
- package/src/main/__tests__/runVerify-blocked-by-foreign-wip.test.cjs +58 -0
- package/src/main/__tests__/runVerify-policy-denial.test.cjs +89 -0
- package/src/main/__tests__/scheduleJobTransitions.test.cjs +1 -0
- package/src/main/__tests__/scheduler-already-satisfied-on-main.test.cjs +105 -0
- package/src/main/__tests__/scheduler-autofix-outcome.test.cjs +73 -1
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +17 -0
- package/src/main/__tests__/scheduler-blocked-by-foreign-wip.test.cjs +107 -0
- package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +20 -0
- package/src/main/__tests__/scheduler-finalize-dispatch-guards.test.cjs +229 -0
- package/src/main/__tests__/scheduler-leftover-quarantine.test.cjs +199 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +141 -1
- package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +222 -0
- package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +81 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +205 -2
- package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +51 -0
- package/src/main/__tests__/scheduler-resume-recovery.test.cjs +254 -0
- package/src/main/__tests__/schedulerBatchRootBlocker.test.cjs +117 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -2
- package/src/main/ipcSchemas.cjs +15 -1
- package/src/main/lib/__tests__/branchSweep.test.cjs +164 -0
- package/src/main/lib/__tests__/fixtures/204-mercury-steam-horse.log.txt +13 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +129 -10
- package/src/main/lib/__tests__/landedSinceRun.test.cjs +61 -1
- package/src/main/lib/__tests__/rateLimitWindow.test.cjs +88 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +120 -1
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +59 -7
- package/src/main/lib/branchSweep.cjs +127 -0
- package/src/main/lib/depSlugResolve.cjs +72 -0
- package/src/main/lib/epicWorktreeMerge.cjs +3 -3
- package/src/main/lib/epicWorktreeMint.cjs +17 -5
- package/src/main/lib/fixPlanSlug.cjs +62 -0
- package/src/main/lib/gitWorktree.cjs +117 -12
- package/src/main/lib/landedSinceRun.cjs +41 -1
- package/src/main/lib/mcpToolCatalog.cjs +4 -1
- package/src/main/lib/prdCreate.cjs +84 -5
- package/src/main/lib/prdFrontmatter.cjs +56 -8
- package/src/main/lib/queueHistory.cjs +50 -5
- package/src/main/lib/rateLimitWindow.cjs +62 -0
- package/src/main/lib/rcaReport.cjs +18 -3
- package/src/main/lib/reaperHelpers.cjs +169 -3
- package/src/main/lib/scheduleJobTransitions.cjs +28 -5
- package/src/main/lib/schedulerBatch.cjs +181 -23
- package/src/main/runVerify.cjs +71 -3
- package/src/main/scheduler/prdParser.cjs +7 -0
- package/src/main/scheduler.cjs +1554 -71
- package/src/preload/api.d.ts +8 -0
|
@@ -223,7 +223,11 @@ test('reapDeadRunningJobs salvages a delta-scoped in-place patch (PRD 1098) when
|
|
|
223
223
|
|
|
224
224
|
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
225
225
|
const row = jobs.find((j) => j.slug === 'vanished-inplace');
|
|
226
|
-
|
|
226
|
+
// PRD 1133: a successful-looking reap that left everything uncommitted
|
|
227
|
+
// (no branch, HEAD never moved) must never read as 'completed' — this is
|
|
228
|
+
// exactly the dangerous shape the reaper now proves against.
|
|
229
|
+
assert.equal(row.status, 'needs_review');
|
|
230
|
+
assert.equal(row.verifierVerdict, 'reaped_without_integration');
|
|
227
231
|
assert.ok(row.salvagePatch, 'a salvage patch must be recorded on the row');
|
|
228
232
|
assert.ok(fs.existsSync(row.salvagePatch));
|
|
229
233
|
const patch = fs.readFileSync(row.salvagePatch, 'utf8');
|
|
@@ -332,8 +336,207 @@ test('reapDeadRunningJobs skips in-place salvage (no whole-tree dump) when the r
|
|
|
332
336
|
|
|
333
337
|
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
334
338
|
const row = jobs.find((j) => j.slug === 'vanished-no-baseline');
|
|
335
|
-
|
|
339
|
+
// PRD 1133: no commit landed (HEAD never moved) — must not read as 'completed'
|
|
340
|
+
// just because the log's own result event looked clean.
|
|
341
|
+
assert.equal(row.status, 'needs_review');
|
|
342
|
+
assert.equal(row.verifierVerdict, 'reaped_without_integration');
|
|
336
343
|
assert.equal(row.salvagePatch, undefined, 'must skip salvage entirely rather than ever dumping the whole tree');
|
|
337
344
|
assert.equal(row.leftoverPaths, undefined, 'no baseline means no safe attribution — must not guess');
|
|
338
345
|
assert.equal(row.leftoverCount, undefined);
|
|
339
346
|
});
|
|
347
|
+
|
|
348
|
+
// ---------- PRD 1133: reaper must prove integration before 'completed' ----------
|
|
349
|
+
|
|
350
|
+
test('reapDeadRunningJobs: a worktree job shaped like PRD 1118 (branch un-integrated, landedCommit absent) comes out needs_review, naming the branch, and never merges it', async () => {
|
|
351
|
+
const projectCwd = path.join(tmpHome, 'i-project-1118-shape');
|
|
352
|
+
initRepo(projectCwd);
|
|
353
|
+
registerActiveProject(projectCwd);
|
|
354
|
+
|
|
355
|
+
const headBefore = git(['rev-parse', 'HEAD'], projectCwd).trim();
|
|
356
|
+
const slug = '1118-pause-until-the-binding-rate-limit-window';
|
|
357
|
+
const branch = `sm-job/${slug}`;
|
|
358
|
+
// Simulate the job's own worktree branch: one real commit, never merged
|
|
359
|
+
// back into the main tree (exactly the live 1118 shape — "one real commit,
|
|
360
|
+
// +301", stranded on sm-job/1118-... with landedCommit undefined).
|
|
361
|
+
git(['checkout', '-b', branch], projectCwd);
|
|
362
|
+
fs.writeFileSync(path.join(projectCwd, 'rateLimitWindow.cjs'), 'module.exports = {};\n', 'utf8');
|
|
363
|
+
git(['add', '-A'], projectCwd);
|
|
364
|
+
git(['commit', '-q', '-m', 'add rateLimitWindow.cjs'], projectCwd);
|
|
365
|
+
// Back to the original tip (detached is fine — reap only reads guardCwd's
|
|
366
|
+
// current HEAD, it never needs a named branch checked out).
|
|
367
|
+
git(['checkout', headBefore], projectCwd);
|
|
368
|
+
|
|
369
|
+
const runId = `run-${slug}`;
|
|
370
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
371
|
+
{
|
|
372
|
+
slug,
|
|
373
|
+
status: 'running',
|
|
374
|
+
cwd: projectCwd,
|
|
375
|
+
runId,
|
|
376
|
+
runtime: { pid: 999999 }, // guaranteed-dead pid
|
|
377
|
+
guardHeadBefore: headBefore,
|
|
378
|
+
startedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
379
|
+
},
|
|
380
|
+
]);
|
|
381
|
+
writeRunLog(runId, slug, ['{"type":"result","subtype":"success","result":"done","is_error":false}']);
|
|
382
|
+
|
|
383
|
+
bustCwdCache();
|
|
384
|
+
await reapDeadRunningJobs();
|
|
385
|
+
|
|
386
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
387
|
+
const row = jobs.find((j) => j.slug === slug);
|
|
388
|
+
assert.equal(row.status, 'needs_review', 'a successful-looking result event must not be enough on its own');
|
|
389
|
+
assert.equal(row.verifierVerdict, 'reaped_without_integration');
|
|
390
|
+
assert.match(row.error, new RegExp(branch.replace('/', '\\/')), 'the parked reason must name the branch that still holds the work');
|
|
391
|
+
assert.equal(row.landedCommit, undefined, 'nothing was actually merged, so landedCommit must stay unset');
|
|
392
|
+
|
|
393
|
+
// The branch itself must survive untouched — this reap must never attempt
|
|
394
|
+
// (let alone perform) the merge.
|
|
395
|
+
const branchStillExists = git(['rev-parse', '--verify', branch], projectCwd).trim();
|
|
396
|
+
assert.ok(branchStillExists, 'the un-integrated branch must be preserved, not merged or deleted, by a mere reap');
|
|
397
|
+
assert.equal(git(['rev-parse', 'HEAD'], projectCwd).trim(), headBefore, 'guardCwd HEAD must not have moved — no merge was performed');
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test('reapDeadRunningJobs: a worktree job whose branch is already fully integrated into HEAD comes out completed, with landedCommit stamped', async () => {
|
|
401
|
+
const projectCwd = path.join(tmpHome, 'j-project-real-success');
|
|
402
|
+
initRepo(projectCwd);
|
|
403
|
+
registerActiveProject(projectCwd);
|
|
404
|
+
|
|
405
|
+
const headBefore = git(['rev-parse', 'HEAD'], projectCwd).trim();
|
|
406
|
+
const slug = 'real-successful-worktree-run';
|
|
407
|
+
const branch = `sm-job/${slug}`;
|
|
408
|
+
git(['checkout', '-b', branch], projectCwd);
|
|
409
|
+
fs.writeFileSync(path.join(projectCwd, 'feature.txt'), 'shipped\n', 'utf8');
|
|
410
|
+
git(['add', '-A'], projectCwd);
|
|
411
|
+
git(['commit', '-q', '-m', 'ship feature'], projectCwd);
|
|
412
|
+
git(['checkout', headBefore], projectCwd); // back to the pre-branch tip (detached)
|
|
413
|
+
// Simulate a normal completion pass having already integrated the branch
|
|
414
|
+
// (integrateJobBranch's own fast-forward path) BEFORE the process vanished
|
|
415
|
+
// between that merge and the queue write landing.
|
|
416
|
+
git(['merge', '--ff-only', branch], projectCwd);
|
|
417
|
+
const headAfterMerge = git(['rev-parse', 'HEAD'], projectCwd).trim();
|
|
418
|
+
|
|
419
|
+
const runId = `run-${slug}`;
|
|
420
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
421
|
+
{
|
|
422
|
+
slug,
|
|
423
|
+
status: 'running',
|
|
424
|
+
cwd: projectCwd,
|
|
425
|
+
runId,
|
|
426
|
+
runtime: { pid: 999999 },
|
|
427
|
+
guardHeadBefore: headBefore,
|
|
428
|
+
startedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
429
|
+
},
|
|
430
|
+
]);
|
|
431
|
+
writeRunLog(runId, slug, ['{"type":"result","subtype":"success","result":"done","is_error":false}']);
|
|
432
|
+
|
|
433
|
+
bustCwdCache();
|
|
434
|
+
await reapDeadRunningJobs();
|
|
435
|
+
|
|
436
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
437
|
+
const row = jobs.find((j) => j.slug === slug);
|
|
438
|
+
assert.equal(row.status, 'completed', 'the branch is already an ancestor of HEAD — the work genuinely landed');
|
|
439
|
+
assert.equal(row.verifierVerdict, undefined);
|
|
440
|
+
assert.equal(row.landedCommit, headAfterMerge, 'HEAD advanced during the run window — landedCommit must be stamped');
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test('reapDeadRunningJobs: a worktree job whose branch never diverged from its base (legitimate no-op) still completes, not punished', async () => {
|
|
444
|
+
const projectCwd = path.join(tmpHome, 'k-project-noop-worktree');
|
|
445
|
+
initRepo(projectCwd);
|
|
446
|
+
registerActiveProject(projectCwd);
|
|
447
|
+
|
|
448
|
+
const headBefore = git(['rev-parse', 'HEAD'], projectCwd).trim();
|
|
449
|
+
const slug = 'noop-worktree-job';
|
|
450
|
+
const branch = `sm-job/${slug}`;
|
|
451
|
+
// Branch created but nothing ever committed on it — a genuine no-op run.
|
|
452
|
+
git(['branch', branch], projectCwd);
|
|
453
|
+
|
|
454
|
+
const runId = `run-${slug}`;
|
|
455
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
456
|
+
{
|
|
457
|
+
slug,
|
|
458
|
+
status: 'running',
|
|
459
|
+
cwd: projectCwd,
|
|
460
|
+
runId,
|
|
461
|
+
runtime: { pid: 999999 },
|
|
462
|
+
guardHeadBefore: headBefore,
|
|
463
|
+
startedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
464
|
+
},
|
|
465
|
+
]);
|
|
466
|
+
writeRunLog(runId, slug, ['{"type":"result","subtype":"success","result":"done","is_error":false}']);
|
|
467
|
+
|
|
468
|
+
bustCwdCache();
|
|
469
|
+
await reapDeadRunningJobs();
|
|
470
|
+
|
|
471
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
472
|
+
const row = jobs.find((j) => j.slug === slug);
|
|
473
|
+
assert.equal(row.status, 'completed', 'a branch with no new commits is a legitimate no-op, not a finish-protocol violation');
|
|
474
|
+
assert.equal(row.verifierVerdict, undefined);
|
|
475
|
+
assert.equal(row.landedCommit, undefined, 'HEAD never moved — nothing to stamp');
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test('reapDeadRunningJobs: an in-place run in a git repo whose HEAD genuinely advanced during the run window completes, with landedCommit stamped', async () => {
|
|
479
|
+
const projectCwd = path.join(tmpHome, 'l-project-inplace-success');
|
|
480
|
+
initRepo(projectCwd);
|
|
481
|
+
registerActiveProject(projectCwd);
|
|
482
|
+
|
|
483
|
+
const headBefore = git(['rev-parse', 'HEAD'], projectCwd).trim();
|
|
484
|
+
const startedAt = new Date(Date.now() - 60_000).toISOString();
|
|
485
|
+
fs.writeFileSync(path.join(projectCwd, 'shipped.txt'), 'done\n', 'utf8');
|
|
486
|
+
git(['add', '-A'], projectCwd);
|
|
487
|
+
git(['commit', '-q', '-m', 'in-place job commit'], projectCwd);
|
|
488
|
+
const headAfter = git(['rev-parse', 'HEAD'], projectCwd).trim();
|
|
489
|
+
|
|
490
|
+
const slug = 'inplace-job-real-commit';
|
|
491
|
+
const runId = `run-${slug}`;
|
|
492
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
493
|
+
{
|
|
494
|
+
slug,
|
|
495
|
+
status: 'running',
|
|
496
|
+
cwd: projectCwd,
|
|
497
|
+
runId,
|
|
498
|
+
runtime: { pid: 999999 },
|
|
499
|
+
guardHeadBefore: headBefore,
|
|
500
|
+
startedAt,
|
|
501
|
+
},
|
|
502
|
+
]);
|
|
503
|
+
writeRunLog(runId, slug, ['{"type":"result","subtype":"success","result":"done","is_error":false}']);
|
|
504
|
+
|
|
505
|
+
bustCwdCache();
|
|
506
|
+
await reapDeadRunningJobs();
|
|
507
|
+
|
|
508
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
509
|
+
const row = jobs.find((j) => j.slug === slug);
|
|
510
|
+
assert.equal(row.status, 'completed');
|
|
511
|
+
assert.equal(row.verifierVerdict, undefined);
|
|
512
|
+
assert.equal(row.landedCommit, headAfter);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test('reapDeadRunningJobs: a non-git cwd skips the integration check entirely, preserving today\'s behaviour', async () => {
|
|
516
|
+
const projectCwd = path.join(tmpHome, 'm-project-non-git');
|
|
517
|
+
fs.mkdirSync(projectCwd, { recursive: true });
|
|
518
|
+
registerActiveProject(projectCwd);
|
|
519
|
+
|
|
520
|
+
const slug = 'non-git-cwd-job';
|
|
521
|
+
const runId = `run-${slug}`;
|
|
522
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
523
|
+
{
|
|
524
|
+
slug,
|
|
525
|
+
status: 'running',
|
|
526
|
+
cwd: projectCwd,
|
|
527
|
+
runId,
|
|
528
|
+
runtime: { pid: 999999 },
|
|
529
|
+
// No git repo at all — the integration/HEAD-advance check must be
|
|
530
|
+
// skipped, never treated as a failure to prove landing.
|
|
531
|
+
},
|
|
532
|
+
]);
|
|
533
|
+
writeRunLog(runId, slug, ['{"type":"result","subtype":"success","result":"done","is_error":false}']);
|
|
534
|
+
|
|
535
|
+
bustCwdCache();
|
|
536
|
+
await reapDeadRunningJobs();
|
|
537
|
+
|
|
538
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
539
|
+
const row = jobs.find((j) => j.slug === slug);
|
|
540
|
+
assert.equal(row.status, 'completed', 'a non-git cwd must keep today\'s behaviour — the check is meaningless there');
|
|
541
|
+
assert.equal(row.verifierVerdict, undefined);
|
|
542
|
+
});
|
|
@@ -146,6 +146,57 @@ test('reconcile() exempts a fix-plan PRD (NN-fix-*) from quarantine even with no
|
|
|
146
146
|
}
|
|
147
147
|
});
|
|
148
148
|
|
|
149
|
+
test('reconcile() does NOT stamp investigationDepth for a createdVia-stamped PRD whose slug merely starts with "fix-" (PRD 1126/1131 regression)', async () => {
|
|
150
|
+
const { cwd, prdsDir, stateDir } = makeFixtureProject('sm-quarantine-fake-fixplan-');
|
|
151
|
+
try {
|
|
152
|
+
// Mirrors PRD 1126: authored via scheduler_create_prd (createdVia stamped),
|
|
153
|
+
// slug happens to kebab-case into "fix-..." — must NOT be treated as a
|
|
154
|
+
// genuine scheduler-authored fix plan just because the name matches.
|
|
155
|
+
fs.writeFileSync(
|
|
156
|
+
path.join(prdsDir, '9005-fix-plan-death-reopens-parent.md'),
|
|
157
|
+
`---\ntitle: Fix plan death reopens parent\ncwd: ${cwd}\nestimateMinutes: 15\ncreatedVia: scheduler-api\nissuedAt: 2026-08-07T00:00:00.000Z\n---\n\n# Goal\nDo the thing.\n`,
|
|
158
|
+
'utf8',
|
|
159
|
+
);
|
|
160
|
+
fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs: [] }, null, 2), 'utf8');
|
|
161
|
+
|
|
162
|
+
queueStore.bustCwdCache();
|
|
163
|
+
const state = { jobs: [], invalidJobs: [], paused: null };
|
|
164
|
+
await scheduler.reconcile(state);
|
|
165
|
+
|
|
166
|
+
const row = state.jobs.find((j) => j.slug === '9005-fix-plan-death-reopens-parent');
|
|
167
|
+
expect(row).toBeDefined();
|
|
168
|
+
expect(row.status).toBe('pending');
|
|
169
|
+
expect(row.isFixPlan).toBe(false);
|
|
170
|
+
expect(row.investigationDepth).toBeUndefined();
|
|
171
|
+
} finally {
|
|
172
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('reconcile() stamps isFixPlan + investigationDepth for a genuine spawnInvestigation-authored fix plan (explicit isFixPlan:true, no createdVia)', async () => {
|
|
177
|
+
const { cwd, prdsDir, stateDir } = makeFixtureProject('sm-quarantine-genuine-fixplan-');
|
|
178
|
+
try {
|
|
179
|
+
fs.writeFileSync(
|
|
180
|
+
path.join(prdsDir, '9006-fix-something.md'),
|
|
181
|
+
`---\ntitle: Fix something\ncwd: ${cwd}\nestimateMinutes: 15\nisFixPlan: true\n---\n\n# Goal\nHeal it.\n`,
|
|
182
|
+
'utf8',
|
|
183
|
+
);
|
|
184
|
+
fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs: [] }, null, 2), 'utf8');
|
|
185
|
+
|
|
186
|
+
queueStore.bustCwdCache();
|
|
187
|
+
const state = { jobs: [], invalidJobs: [], paused: null };
|
|
188
|
+
await scheduler.reconcile(state);
|
|
189
|
+
|
|
190
|
+
const row = state.jobs.find((j) => j.slug === '9006-fix-something');
|
|
191
|
+
expect(row).toBeDefined();
|
|
192
|
+
expect(row.status).toBe('pending');
|
|
193
|
+
expect(row.isFixPlan).toBe(true);
|
|
194
|
+
expect(row.investigationDepth).toBe(2);
|
|
195
|
+
} finally {
|
|
196
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
149
200
|
test('reconcile() adopts a quarantined row to pending once its PRD file is stamped', async () => {
|
|
150
201
|
const { cwd, prdsDir, stateDir } = makeFixtureProject('sm-quarantine-adopt-');
|
|
151
202
|
try {
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-resume-recovery.test.cjs — PRD 1111: resume-first recovery for a
|
|
3
|
+
* job parked in needs_review with verdict 'uncommitted_changes'. Before this
|
|
4
|
+
* feature, every such job went straight to spawnInvestigation (a cold-read
|
|
5
|
+
* fix-plan PRD authored by a FRESH session with no memory of the run it's
|
|
6
|
+
* diagnosing). This suite covers selectResumeRecoveryTarget's eligibility
|
|
7
|
+
* table, the --resume vs --session-id argv split (buildClaudeSpawnArgs), the
|
|
8
|
+
* preamble builder, the one-attempt bound, the SM_RESUME_RECOVERY_DISABLE
|
|
9
|
+
* kill-switch, and the two skip-gates (spawnInvestigation, selectAutoFixTargets).
|
|
10
|
+
*
|
|
11
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-resume-recovery.test.cjs
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
import { test, expect, afterEach } from 'vitest';
|
|
17
|
+
const {
|
|
18
|
+
selectResumeRecoveryTarget,
|
|
19
|
+
buildResumeRecoveryPreamble,
|
|
20
|
+
buildClaudeSpawnArgs,
|
|
21
|
+
selectAutoFixTargets,
|
|
22
|
+
spawnInvestigation,
|
|
23
|
+
} = require('../scheduler.cjs');
|
|
24
|
+
|
|
25
|
+
const savedDisable = process.env.SM_RESUME_RECOVERY_DISABLE;
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
if (savedDisable === undefined) delete process.env.SM_RESUME_RECOVERY_DISABLE;
|
|
28
|
+
else process.env.SM_RESUME_RECOVERY_DISABLE = savedDisable;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function baseJob(overrides = {}) {
|
|
32
|
+
return {
|
|
33
|
+
slug: '900-example',
|
|
34
|
+
status: 'needs_review',
|
|
35
|
+
verifierVerdict: 'uncommitted_changes',
|
|
36
|
+
sessionId: 'sess-abc-123',
|
|
37
|
+
uncommittedPaths: ['src/foo.js', 'src/bar.js'],
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------- selectResumeRecoveryTarget eligibility table ----------
|
|
43
|
+
|
|
44
|
+
test('selectResumeRecoveryTarget: eligible shape returns a resume decision', () => {
|
|
45
|
+
const target = selectResumeRecoveryTarget(baseJob());
|
|
46
|
+
expect(target).toEqual({
|
|
47
|
+
slug: '900-example',
|
|
48
|
+
sessionId: 'sess-abc-123',
|
|
49
|
+
dirtyPaths: ['src/foo.js', 'src/bar.js'],
|
|
50
|
+
salvagePatch: null,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('selectResumeRecoveryTarget: carries salvagePatch through when present on the row', () => {
|
|
55
|
+
const target = selectResumeRecoveryTarget(baseJob({ salvagePatch: '/tmp/runs/x/900-example.uncommitted.patch' }));
|
|
56
|
+
expect(target.salvagePatch).toBe('/tmp/runs/x/900-example.uncommitted.patch');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const notNeedsReviewStatuses = ['pending', 'running', 'investigating', 'completed', 'failed', 'skipped'];
|
|
60
|
+
for (const status of notNeedsReviewStatuses) {
|
|
61
|
+
test(`selectResumeRecoveryTarget: status=${status} → null`, () => {
|
|
62
|
+
expect(selectResumeRecoveryTarget(baseJob({ status }))).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const nonRecoverableVerdicts = [
|
|
67
|
+
'silent_no_op',
|
|
68
|
+
'pass_no_commit_already_shipped',
|
|
69
|
+
'pass_no_commit_prior_run_verified',
|
|
70
|
+
'transcript_errors',
|
|
71
|
+
'deps_unmet',
|
|
72
|
+
'halt',
|
|
73
|
+
'verify_unavailable',
|
|
74
|
+
'worktree_integration_failed',
|
|
75
|
+
'shared_tree_reverted',
|
|
76
|
+
undefined,
|
|
77
|
+
];
|
|
78
|
+
for (const verdict of nonRecoverableVerdicts) {
|
|
79
|
+
test(`selectResumeRecoveryTarget: verdict=${JSON.stringify(verdict)} → null`, () => {
|
|
80
|
+
expect(selectResumeRecoveryTarget(baseJob({ verifierVerdict: verdict }))).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
test('selectResumeRecoveryTarget: missing sessionId → null', () => {
|
|
85
|
+
expect(selectResumeRecoveryTarget(baseJob({ sessionId: undefined }))).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('selectResumeRecoveryTarget: empty-string sessionId → null', () => {
|
|
89
|
+
expect(selectResumeRecoveryTarget(baseJob({ sessionId: '' }))).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('selectResumeRecoveryTarget: non-string sessionId → null', () => {
|
|
93
|
+
expect(selectResumeRecoveryTarget(baseJob({ sessionId: 12345 }))).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('selectResumeRecoveryTarget: resumeRecoveryAttempted === true → null (bounded to one attempt)', () => {
|
|
97
|
+
expect(selectResumeRecoveryTarget(baseJob({ resumeRecoveryAttempted: true }))).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('selectResumeRecoveryTarget: resumeRecoveryAttempted absent/false → still eligible', () => {
|
|
101
|
+
expect(selectResumeRecoveryTarget(baseJob({ resumeRecoveryAttempted: false }))).not.toBeNull();
|
|
102
|
+
expect(selectResumeRecoveryTarget(baseJob())).not.toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('selectResumeRecoveryTarget: no recorded dirty paths → null', () => {
|
|
106
|
+
expect(selectResumeRecoveryTarget(baseJob({ uncommittedPaths: [] }))).toBeNull();
|
|
107
|
+
expect(selectResumeRecoveryTarget(baseJob({ uncommittedPaths: undefined }))).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('selectResumeRecoveryTarget: null/undefined job → null', () => {
|
|
111
|
+
expect(selectResumeRecoveryTarget(null)).toBeNull();
|
|
112
|
+
expect(selectResumeRecoveryTarget(undefined)).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('selectResumeRecoveryTarget: SM_RESUME_RECOVERY_DISABLE=1 → null even for an otherwise-eligible job', () => {
|
|
116
|
+
process.env.SM_RESUME_RECOVERY_DISABLE = '1';
|
|
117
|
+
expect(selectResumeRecoveryTarget(baseJob())).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ---------- --resume vs --session-id argv (buildClaudeSpawnArgs) ----------
|
|
121
|
+
|
|
122
|
+
test('buildClaudeSpawnArgs: resume=true passes --resume <sessionId>, never --session-id', () => {
|
|
123
|
+
const args = buildClaudeSpawnArgs({ prompt: 'preamble text', model: 'sonnet', sessionId: 'sess-abc-123', resume: true });
|
|
124
|
+
expect(args).toContain('--resume');
|
|
125
|
+
expect(args[args.indexOf('--resume') + 1]).toBe('sess-abc-123');
|
|
126
|
+
expect(args).not.toContain('--session-id');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('buildClaudeSpawnArgs: resume=false passes --session-id <sessionId>, never --resume', () => {
|
|
130
|
+
const args = buildClaudeSpawnArgs({ prompt: 'prd body', model: 'sonnet', sessionId: 'sess-fresh-456', resume: false });
|
|
131
|
+
expect(args).toContain('--session-id');
|
|
132
|
+
expect(args[args.indexOf('--session-id') + 1]).toBe('sess-fresh-456');
|
|
133
|
+
expect(args).not.toContain('--resume');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('buildClaudeSpawnArgs: --model is always explicitly pinned, both modes', () => {
|
|
137
|
+
for (const resume of [true, false]) {
|
|
138
|
+
const args = buildClaudeSpawnArgs({ prompt: 'x', model: 'sonnet', sessionId: 'sid', resume });
|
|
139
|
+
const modelIdx = args.indexOf('--model');
|
|
140
|
+
expect(modelIdx).toBeGreaterThanOrEqual(0);
|
|
141
|
+
expect(args[modelIdx + 1]).toBe('sonnet');
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ---------- preamble content (buildResumeRecoveryPreamble) ----------
|
|
146
|
+
|
|
147
|
+
test('buildResumeRecoveryPreamble: contains every recorded dirty path', () => {
|
|
148
|
+
const dirtyPaths = ['src/main/scheduler.cjs', 'session-manager-operations/scheduler/prds/900-example.md'];
|
|
149
|
+
const preamble = buildResumeRecoveryPreamble({ dirtyPaths, salvagePatch: null });
|
|
150
|
+
for (const p of dirtyPaths) {
|
|
151
|
+
expect(preamble).toContain(p);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('buildResumeRecoveryPreamble: does NOT re-embed a PRD body passed in as an unrelated string', () => {
|
|
156
|
+
const prdBody = '# Goal\nSome long PRD body that must never appear in a resume preamble.\n## Acceptance criteria\n- [ ] thing';
|
|
157
|
+
const preamble = buildResumeRecoveryPreamble({ dirtyPaths: ['a.js'], salvagePatch: null });
|
|
158
|
+
expect(preamble).not.toContain(prdBody);
|
|
159
|
+
expect(preamble).not.toContain('Acceptance criteria');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('buildResumeRecoveryPreamble: instructs verifying paths on disk, foreground gate, and commit', () => {
|
|
163
|
+
const preamble = buildResumeRecoveryPreamble({ dirtyPaths: ['a.js'], salvagePatch: null });
|
|
164
|
+
expect(preamble).toMatch(/verify/i);
|
|
165
|
+
expect(preamble).toMatch(/FOREGROUND/);
|
|
166
|
+
expect(preamble).toMatch(/commit/i);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('buildResumeRecoveryPreamble: mentions the salvage patch path when present', () => {
|
|
170
|
+
const preamble = buildResumeRecoveryPreamble({ dirtyPaths: ['a.js'], salvagePatch: '/tmp/runs/x/900-example.uncommitted.patch' });
|
|
171
|
+
expect(preamble).toContain('/tmp/runs/x/900-example.uncommitted.patch');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ---------- selectAutoFixTargets skips resume-eligible jobs ----------
|
|
175
|
+
|
|
176
|
+
test('selectAutoFixTargets: excludes a job selectResumeRecoveryTarget accepts', () => {
|
|
177
|
+
const job = baseJob({ runId: 'run-1' });
|
|
178
|
+
const targets = selectAutoFixTargets([job], {
|
|
179
|
+
fixSlugExists: () => false,
|
|
180
|
+
resolveJobRunId: () => job.runId,
|
|
181
|
+
});
|
|
182
|
+
expect(targets).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('selectAutoFixTargets: still selects a needs_review job that is NOT resume-eligible (different verdict)', () => {
|
|
186
|
+
const job = baseJob({ runId: 'run-1', verifierVerdict: 'transcript_errors' });
|
|
187
|
+
const targets = selectAutoFixTargets([job], {
|
|
188
|
+
fixSlugExists: () => false,
|
|
189
|
+
resolveJobRunId: () => job.runId,
|
|
190
|
+
});
|
|
191
|
+
expect(targets.map((t) => t.slug)).toEqual([job.slug]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('selectAutoFixTargets: a resume-recovery-exhausted job (resumeRecoveryAttempted=true) falls through to normal auto-fix eligibility', () => {
|
|
195
|
+
const job = baseJob({ runId: 'run-1', resumeRecoveryAttempted: true });
|
|
196
|
+
const targets = selectAutoFixTargets([job], {
|
|
197
|
+
fixSlugExists: () => false,
|
|
198
|
+
resolveJobRunId: () => job.runId,
|
|
199
|
+
});
|
|
200
|
+
expect(targets.map((t) => t.slug)).toEqual([job.slug]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// ---------- resume-then-fail: never a dead end ----------
|
|
204
|
+
|
|
205
|
+
test('resume-then-fail: a job whose one resume attempt is spent falls through to the normal fix-plan path, not a dead end', () => {
|
|
206
|
+
// spawnResumeRecovery reuses spawnJob wholesale, so a resume run that
|
|
207
|
+
// itself re-parks in needs_review runs through the SAME finalize mutate as
|
|
208
|
+
// any other run — the only difference is resumeRecoveryAttempted is now
|
|
209
|
+
// true on the row (stamped atomically with the 'running' transition
|
|
210
|
+
// before the resume child ever spawned).
|
|
211
|
+
const jobAfterFailedResume = baseJob({ runId: 'run-2', resumeRecoveryAttempted: true });
|
|
212
|
+
// A second resume is refused...
|
|
213
|
+
expect(selectResumeRecoveryTarget(jobAfterFailedResume)).toBeNull();
|
|
214
|
+
// ...but the job is NOT stranded: it is offered for a fix-plan exactly
|
|
215
|
+
// like any other needs_review job, via the same gate spawnInvestigation's
|
|
216
|
+
// periodic caller (reverifyNeedsReview) consults.
|
|
217
|
+
const targets = selectAutoFixTargets([jobAfterFailedResume], {
|
|
218
|
+
fixSlugExists: () => false,
|
|
219
|
+
resolveJobRunId: () => jobAfterFailedResume.runId,
|
|
220
|
+
});
|
|
221
|
+
expect(targets.map((t) => t.slug)).toEqual([jobAfterFailedResume.slug]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ---------- spawnInvestigation's own resume-first guard ----------
|
|
225
|
+
|
|
226
|
+
test('spawnInvestigation: a resume-eligible job is skipped before any fix-plan work (returns deferred:false immediately)', async () => {
|
|
227
|
+
const job = baseJob();
|
|
228
|
+
// A resume-eligible job trips the guard before spawnInvestigation ever
|
|
229
|
+
// touches the filesystem for runDir/investigationDepth lookups, so a
|
|
230
|
+
// nonexistent runDir is safe to pass here — proves the guard runs first.
|
|
231
|
+
const result = await spawnInvestigation(job, '/nonexistent/run/dir/for/this/test');
|
|
232
|
+
expect(result).toEqual({ deferred: false });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('spawnInvestigation: resume-recovery-exhausted job (resumeRecoveryAttempted=true) is NOT skipped by the resume guard', async () => {
|
|
236
|
+
// slug matches the fix-plan pattern + investigationDepth beyond the cap so
|
|
237
|
+
// the NEXT gate (isFixPlanBeyondDepthCap) trips deterministically and
|
|
238
|
+
// returns before any fs/spawn work — proving the resume guard was passed
|
|
239
|
+
// through, not that this particular job happens to be safe to fully run.
|
|
240
|
+
const job = baseJob({ slug: '01-fix-example', resumeRecoveryAttempted: true, investigationDepth: 99 });
|
|
241
|
+
const result = await spawnInvestigation(job, '/nonexistent/run/dir/for/this/test');
|
|
242
|
+
expect(result).toEqual({ deferred: false });
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('SM_RESUME_RECOVERY_DISABLE=1: spawnInvestigation is NOT skipped by the resume guard for an otherwise-eligible job', async () => {
|
|
246
|
+
process.env.SM_RESUME_RECOVERY_DISABLE = '1';
|
|
247
|
+
const job = baseJob({ slug: '01-fix-example', investigationDepth: 99 });
|
|
248
|
+
// With the kill-switch on, selectResumeRecoveryTarget returns null, so the
|
|
249
|
+
// resume guard never fires; the job falls through to the NEXT gate
|
|
250
|
+
// (isFixPlanBeyondDepthCap, tripped here via investigationDepth: 99) —
|
|
251
|
+
// proving today's pre-feature behaviour is restored exactly.
|
|
252
|
+
const result = await spawnInvestigation(job, '/nonexistent/run/dir/for/this/test');
|
|
253
|
+
expect(result).toEqual({ deferred: false });
|
|
254
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// PRD 1123 — a single row parked in `needs_review` (or `failed`/`skipped`)
|
|
4
|
+
// several dependsOn hops down used to read as generic starvation: the picker
|
|
5
|
+
// only ever named a held job's IMMEDIATE dep, which is very often just
|
|
6
|
+
// another `pending` row, not the actual cause. These tests pin the root-
|
|
7
|
+
// blocker walk: the reason must name the transitive ancestor that is itself
|
|
8
|
+
// not blocked by anything, plus the count of jobs held behind it and its
|
|
9
|
+
// status — and must terminate (not hang) on a dependsOn cycle.
|
|
10
|
+
//
|
|
11
|
+
// Run: timeout 120 npx vitest run src/main/__tests__/schedulerBatchRootBlocker.test.cjs
|
|
12
|
+
|
|
13
|
+
const assert = require('node:assert/strict');
|
|
14
|
+
const { pickForProject, DEFAULT_PROJECT_CWD } = require('../lib/schedulerBatch.cjs');
|
|
15
|
+
|
|
16
|
+
const CWD = DEFAULT_PROJECT_CWD;
|
|
17
|
+
|
|
18
|
+
function job(slug, status, extra = {}) {
|
|
19
|
+
return { slug, status, cwd: CWD, dependsOn: [], ...extra };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test('a 3-deep chain rooted at a needs_review row names the ROOT, not the middle link', () => {
|
|
23
|
+
const jobs = [
|
|
24
|
+
job('100-root', 'needs_review'),
|
|
25
|
+
job('101-middle', 'pending', { dependsOn: ['100-root'] }),
|
|
26
|
+
job('102-leaf', 'pending', { dependsOn: ['101-middle'] }),
|
|
27
|
+
];
|
|
28
|
+
const { batch, reason, holds } = pickForProject(jobs, new Set(), 3);
|
|
29
|
+
assert.deepEqual(batch, []);
|
|
30
|
+
assert.match(reason, /depends-gate/);
|
|
31
|
+
// Names the root, not the immediate (middle) dep as the cause.
|
|
32
|
+
assert.match(reason, /root blocker 100-root/);
|
|
33
|
+
assert.match(reason, /needs_review/);
|
|
34
|
+
assert.match(reason, /is a QUESTION awaiting a human/);
|
|
35
|
+
// Both downstream jobs are counted as held behind the root.
|
|
36
|
+
assert.match(reason, /holding 2 job\(s\)/);
|
|
37
|
+
assert.match(reason, /101-middle/);
|
|
38
|
+
assert.match(reason, /102-leaf/);
|
|
39
|
+
|
|
40
|
+
const leafHold = holds.find((h) => h.slug === '102-leaf');
|
|
41
|
+
assert.equal(leafHold.dep, '101-middle'); // immediate dep still recorded
|
|
42
|
+
assert.equal(leafHold.rootSlug, '100-root'); // but the row also knows the root
|
|
43
|
+
assert.equal(leafHold.rootStatus, 'needs_review');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('the same 3-deep chain rooted at a FAILED row names the root, not the middle link', () => {
|
|
47
|
+
const jobs = [
|
|
48
|
+
job('200-root', 'failed'),
|
|
49
|
+
job('201-middle', 'pending', { dependsOn: ['200-root'] }),
|
|
50
|
+
job('202-leaf', 'pending', { dependsOn: ['201-middle'] }),
|
|
51
|
+
];
|
|
52
|
+
const { batch, reason, holds } = pickForProject(jobs, new Set(), 3);
|
|
53
|
+
assert.deepEqual(batch, []);
|
|
54
|
+
assert.match(reason, /root blocker 200-root/);
|
|
55
|
+
assert.match(reason, /\(failed\)/);
|
|
56
|
+
assert.match(reason, /holding 1 job\(s\)/); // only 202-leaf: 201-middle is caught by the shallow failed-dep bucket
|
|
57
|
+
assert.match(reason, /Reset or archive 200-root/);
|
|
58
|
+
|
|
59
|
+
const leafHold = holds.find((h) => h.slug === '202-leaf');
|
|
60
|
+
assert.equal(leafHold.rootSlug, '200-root');
|
|
61
|
+
assert.equal(leafHold.rootStatus, 'failed');
|
|
62
|
+
const middleHold = holds.find((h) => h.slug === '201-middle');
|
|
63
|
+
assert.equal(middleHold.rootSlug, '200-root');
|
|
64
|
+
assert.equal(middleHold.rootStatus, 'failed');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('a 2-node dependsOn cycle is reported as its own reason, not a hang', () => {
|
|
68
|
+
const jobs = [
|
|
69
|
+
job('300-a', 'pending', { dependsOn: ['301-b'] }),
|
|
70
|
+
job('301-b', 'pending', { dependsOn: ['300-a'] }),
|
|
71
|
+
];
|
|
72
|
+
const start = Date.now();
|
|
73
|
+
const { batch, reason } = pickForProject(jobs, new Set(), 3);
|
|
74
|
+
assert.ok(Date.now() - start < 5000, 'must terminate quickly, not hang');
|
|
75
|
+
assert.deepEqual(batch, []);
|
|
76
|
+
assert.match(reason, /CYCLE/);
|
|
77
|
+
assert.match(reason, /300-a/);
|
|
78
|
+
assert.match(reason, /301-b/);
|
|
79
|
+
// Reported exactly once, not once per member.
|
|
80
|
+
assert.equal((reason.match(/CYCLE/g) || []).length, 1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('no false stall reported when the gate is not the reason the batch is empty (slots exhausted)', () => {
|
|
84
|
+
const jobs = [job('400-a', 'pending'), job('401-b', 'pending')];
|
|
85
|
+
const { batch, reason } = pickForProject(jobs, new Set(), 0);
|
|
86
|
+
assert.deepEqual(batch, []);
|
|
87
|
+
assert.match(reason, /no slots free/);
|
|
88
|
+
assert.doesNotMatch(reason, /root blocker/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('no false stall reported when the gate is not the reason the batch is empty (project cap)', () => {
|
|
92
|
+
const prevCap = process.env.SM_PROJECT_JOB_CAP;
|
|
93
|
+
process.env.SM_PROJECT_JOB_CAP = '1';
|
|
94
|
+
try {
|
|
95
|
+
const jobs = [
|
|
96
|
+
job('402-running', 'running'),
|
|
97
|
+
job('403-pending', 'pending'),
|
|
98
|
+
];
|
|
99
|
+
const { batch, reason } = pickForProject(jobs, new Set(['402-running']), 3);
|
|
100
|
+
assert.deepEqual(batch, []);
|
|
101
|
+
assert.match(reason, /project-cap/);
|
|
102
|
+
assert.doesNotMatch(reason, /root blocker/);
|
|
103
|
+
} finally {
|
|
104
|
+
if (prevCap === undefined) delete process.env.SM_PROJECT_JOB_CAP;
|
|
105
|
+
else process.env.SM_PROJECT_JOB_CAP = prevCap;
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('a running/pending-rooted chain reports no stall reason (expected to clear on its own)', () => {
|
|
110
|
+
const jobs = [
|
|
111
|
+
job('500-root', 'running'),
|
|
112
|
+
job('501-dependent', 'pending', { dependsOn: ['500-root'] }),
|
|
113
|
+
];
|
|
114
|
+
const { batch, reason } = pickForProject(jobs, new Set(['500-root']), 3);
|
|
115
|
+
assert.deepEqual(batch, []);
|
|
116
|
+
assert.equal(reason, null);
|
|
117
|
+
});
|