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
|
@@ -10,6 +10,13 @@
|
|
|
10
10
|
* coverage in scheduler-autofix-select.test.cjs (node --test) exercises the
|
|
11
11
|
* same helpers in more granular detail.
|
|
12
12
|
*
|
|
13
|
+
* PRD 1129 added one sub-shape to case (d): "outcome 'plan' WITH a queue
|
|
14
|
+
* row is neither retried nor annotated" only holds while that queue row
|
|
15
|
+
* (the fix-plan child) is still alive. Once the child itself reaches a
|
|
16
|
+
* terminal, non-completed status, isFixPlanDead makes the parent eligible
|
|
17
|
+
* for exactly one further attempt (stamped autoFixReopened: true), after
|
|
18
|
+
* which it is permanently excluded and annotated again.
|
|
19
|
+
*
|
|
13
20
|
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-autofix-outcome.test.cjs
|
|
14
21
|
*/
|
|
15
22
|
|
|
@@ -17,7 +24,7 @@
|
|
|
17
24
|
|
|
18
25
|
import { test, expect } from 'vitest';
|
|
19
26
|
const {
|
|
20
|
-
selectAutoFixTargets, isExhaustedAutoFix, isPlanUnqueued, fixSlugFor,
|
|
27
|
+
selectAutoFixTargets, isExhaustedAutoFix, isPlanUnqueued, isFixPlanDead, fixSlugFor,
|
|
21
28
|
} = require('../scheduler.cjs');
|
|
22
29
|
|
|
23
30
|
const noSiblingOnDisk = () => false;
|
|
@@ -82,6 +89,71 @@ test("(d) outcome 'plan' with the fix slug present in the queue is neither retri
|
|
|
82
89
|
expect(isAnnotatable(job, queuedSlugs)).toBe(false);
|
|
83
90
|
});
|
|
84
91
|
|
|
92
|
+
// (e) dead fix-plan child: outcome 'plan' whose fix-plan row itself died —
|
|
93
|
+
// the parent is eligible again, exactly once.
|
|
94
|
+
test('(e) a dead fix-plan child makes the parent eligible for one further attempt', () => {
|
|
95
|
+
const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'plan', autoFixRetries: 0 });
|
|
96
|
+
const deadChild = { slug: fixSlugFor(job), status: 'needs_review', parallelGroup: job.parallelGroup };
|
|
97
|
+
const jobs = [job, deadChild];
|
|
98
|
+
expect(isFixPlanDead(job, jobs)).toBe(true);
|
|
99
|
+
expect(selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk }).some((j) => j.slug === job.slug)).toBe(true);
|
|
100
|
+
expect(isAnnotatable(job, new Set(jobs.map((j) => j.slug)))).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('(e) a failed, quarantined, or skipped fix-plan child also counts as dead', () => {
|
|
104
|
+
for (const status of ['failed', 'quarantined', 'skipped']) {
|
|
105
|
+
const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'plan', autoFixRetries: 0 });
|
|
106
|
+
const deadChild = { slug: fixSlugFor(job), status, parallelGroup: job.parallelGroup };
|
|
107
|
+
expect(isFixPlanDead(job, [job, deadChild])).toBe(true);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('(e) an already-reopened parent is annotated, never eligible again — even with another dead child', () => {
|
|
112
|
+
const job = makeJob({
|
|
113
|
+
autoFixAttempted: true, autoFixOutcome: 'plan', autoFixRetries: 1, autoFixReopened: true,
|
|
114
|
+
});
|
|
115
|
+
const deadChild = { slug: fixSlugFor(job), status: 'needs_review', parallelGroup: job.parallelGroup };
|
|
116
|
+
const jobs = [job, deadChild];
|
|
117
|
+
expect(isFixPlanDead(job, jobs)).toBe(false);
|
|
118
|
+
expect(selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk }).some((j) => j.slug === job.slug)).toBe(false);
|
|
119
|
+
expect(isExhaustedAutoFix(job)).toBe(true);
|
|
120
|
+
expect(isAnnotatable(job, new Set(jobs.map((j) => j.slug)))).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('(e) a reopened parent whose second attempt is still in flight (outcome cleared) is untouched — not re-eligible, not yet annotated', () => {
|
|
124
|
+
const job = makeJob({ autoFixAttempted: true, autoFixRetries: 1, autoFixReopened: true });
|
|
125
|
+
expect(isFixPlanDead(job, [job])).toBe(false);
|
|
126
|
+
expect(selectAutoFixTargets([job], { fixSlugExists: noSiblingOnDisk }).some((j) => j.slug === job.slug)).toBe(false);
|
|
127
|
+
expect(isExhaustedAutoFix(job)).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('(e) a fix-plan child that is still pending leaves the parent untouched', () => {
|
|
131
|
+
const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'plan', autoFixRetries: 0 });
|
|
132
|
+
const pendingChild = { slug: fixSlugFor(job), status: 'pending', parallelGroup: job.parallelGroup };
|
|
133
|
+
const jobs = [job, pendingChild];
|
|
134
|
+
expect(isFixPlanDead(job, jobs)).toBe(false);
|
|
135
|
+
expect(selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk }).some((j) => j.slug === job.slug)).toBe(false);
|
|
136
|
+
expect(isAnnotatable(job, new Set(jobs.map((j) => j.slug)))).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('(e) a fix-plan child that is still running leaves the parent untouched', () => {
|
|
140
|
+
const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'plan', autoFixRetries: 0 });
|
|
141
|
+
const runningChild = { slug: fixSlugFor(job), status: 'running', parallelGroup: job.parallelGroup };
|
|
142
|
+
const jobs = [job, runningChild];
|
|
143
|
+
expect(isFixPlanDead(job, jobs)).toBe(false);
|
|
144
|
+
expect(selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk }).some((j) => j.slug === job.slug)).toBe(false);
|
|
145
|
+
expect(isAnnotatable(job, new Set(jobs.map((j) => j.slug)))).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('(e) a fix-plan child that completed leaves the parent untouched (unchanged from today)', () => {
|
|
149
|
+
const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'plan', autoFixRetries: 0 });
|
|
150
|
+
const completedChild = { slug: fixSlugFor(job), status: 'completed', parallelGroup: job.parallelGroup };
|
|
151
|
+
const jobs = [job, completedChild];
|
|
152
|
+
expect(isFixPlanDead(job, jobs)).toBe(false);
|
|
153
|
+
expect(selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk }).some((j) => j.slug === job.slug)).toBe(false);
|
|
154
|
+
expect(isAnnotatable(job, new Set(jobs.map((j) => j.slug)))).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
85
157
|
// (invariant) every needs_review job is either an auto-fix target or
|
|
86
158
|
// annotatable (would receive a non-null verifierVerdict) — never both false.
|
|
87
159
|
test('invariant: fresh, retry-eligible, exhausted, and plan-unqueued jobs are always selected or annotatable', () => {
|
|
@@ -308,6 +308,23 @@ test('isFixPlanBeyondDepthCap: fix-plan at depth 1 is not capped, depth 2 is', (
|
|
|
308
308
|
assert.strictEqual(isFixPlanBeyondDepthCap('05-fix-foo', MAX_INVESTIGATION_DEPTH + 1), true);
|
|
309
309
|
});
|
|
310
310
|
|
|
311
|
+
// PRD 1131: classification is now provenance-aware (isFixPlan), not slug-only.
|
|
312
|
+
// A legacy row with no `isFixPlan` argument at all still falls back to the
|
|
313
|
+
// slug-only heuristic above (no re-classification of live history).
|
|
314
|
+
|
|
315
|
+
test('isFixPlanBeyondDepthCap: a scheduler-authored fix plan (isFixPlan:true) is still classified as one and capped at the same depth', () => {
|
|
316
|
+
assert.strictEqual(isFixPlanBeyondDepthCap('05-fix-foo', MAX_INVESTIGATION_DEPTH, true), false);
|
|
317
|
+
assert.strictEqual(isFixPlanBeyondDepthCap('05-fix-foo', MAX_INVESTIGATION_DEPTH + 1, true), true);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('isFixPlanBeyondDepthCap: a human-authored PRD (isFixPlan:false) whose slug merely matches "fix-" is never capped, even with no investigationDepth (PRD 1126 regression)', () => {
|
|
321
|
+
assert.strictEqual(isFixPlanBeyondDepthCap('126-fix-plan-death-reopens-parent', undefined, false), false);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test('isFixPlanBeyondDepthCap: a legacy row (isFixPlan argument omitted/undefined) with a fix- slug and no depth field is still treated as a fix plan via the slug fallback', () => {
|
|
325
|
+
assert.strictEqual(isFixPlanBeyondDepthCap('05-fix-foo', undefined, undefined), true);
|
|
326
|
+
});
|
|
327
|
+
|
|
311
328
|
test('healTargetForFix: depth-2 fix-of-a-fix slug resolves back to the depth-1 fix job (521-fix-recorder-export-footer-redesign)', () => {
|
|
312
329
|
// Depth-1 fix job: originally authored to fix job 521, itself in needs_review.
|
|
313
330
|
const depth1FixJob = {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-blocked-by-foreign-wip.test.cjs — validateForeignWipBlockClaim
|
|
3
|
+
* (the manifest-membership check that stops an executor from laundering a
|
|
4
|
+
* real regression into a BLOCKED_BY_FOREIGN_WIP verdict) and
|
|
5
|
+
* requeueForeignWipBlockedJobs (reconcile()'s auto-clear-and-resume pass).
|
|
6
|
+
*
|
|
7
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-blocked-by-foreign-wip.test.cjs
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
import { test, expect } from 'vitest';
|
|
13
|
+
const {
|
|
14
|
+
validateForeignWipBlockClaim,
|
|
15
|
+
requeueForeignWipBlockedJobs,
|
|
16
|
+
FOREIGN_WIP_BLOCK_STREAK_LIMIT,
|
|
17
|
+
} = require('../scheduler.cjs');
|
|
18
|
+
|
|
19
|
+
// ─── validateForeignWipBlockClaim ──────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
test('validateForeignWipBlockClaim: ok when every claimed path is in preRunDirtyPaths', () => {
|
|
22
|
+
const job = { preRunDirtyPaths: ['src/a.ts', 'src/b.ts'] };
|
|
23
|
+
const v = validateForeignWipBlockClaim(['src/a.ts'], job);
|
|
24
|
+
expect(v.ok).toBe(true);
|
|
25
|
+
expect(v.validPaths).toEqual(['src/a.ts']);
|
|
26
|
+
expect(v.invalidPaths).toEqual([]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('validateForeignWipBlockClaim: ok when every claimed path is in carriedPaths', () => {
|
|
30
|
+
const job = { carriedPaths: ['config/settings.json'] };
|
|
31
|
+
const v = validateForeignWipBlockClaim(['config/settings.json'], job);
|
|
32
|
+
expect(v.ok).toBe(true);
|
|
33
|
+
expect(v.validPaths).toEqual(['config/settings.json']);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('validateForeignWipBlockClaim: rejects an unlisted path — the launder-a-regression case', () => {
|
|
37
|
+
// The job's real, disclosed foreign-WIP manifest never mentioned this path
|
|
38
|
+
// — an executor naming it anyway must not get away with claiming a block.
|
|
39
|
+
const job = { preRunDirtyPaths: ['src/a.ts'] };
|
|
40
|
+
const v = validateForeignWipBlockClaim(['src/a.ts', 'src/my-own-broken-file.ts'], job);
|
|
41
|
+
expect(v.ok).toBe(false);
|
|
42
|
+
expect(v.validPaths).toEqual(['src/a.ts']);
|
|
43
|
+
expect(v.invalidPaths).toEqual(['src/my-own-broken-file.ts']);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('validateForeignWipBlockClaim: rejects a claim naming ONLY an unlisted path', () => {
|
|
47
|
+
const job = { preRunDirtyPaths: ['src/a.ts'] };
|
|
48
|
+
const v = validateForeignWipBlockClaim(['src/totally-unrelated.ts'], job);
|
|
49
|
+
expect(v.ok).toBe(false);
|
|
50
|
+
expect(v.invalidPaths).toEqual(['src/totally-unrelated.ts']);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('validateForeignWipBlockClaim: rejects an empty claim (no evidence at all)', () => {
|
|
54
|
+
const job = { preRunDirtyPaths: ['src/a.ts'] };
|
|
55
|
+
expect(validateForeignWipBlockClaim([], job).ok).toBe(false);
|
|
56
|
+
expect(validateForeignWipBlockClaim(null, job).ok).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('validateForeignWipBlockClaim: rejects against an empty/missing manifest', () => {
|
|
60
|
+
expect(validateForeignWipBlockClaim(['src/a.ts'], {}).ok).toBe(false);
|
|
61
|
+
expect(validateForeignWipBlockClaim(['src/a.ts'], null).ok).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ─── requeueForeignWipBlockedJobs ──────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
test('requeueForeignWipBlockedJobs: promotes skipped/blocked job to pending once its paths are clean', async () => {
|
|
67
|
+
const job = {
|
|
68
|
+
slug: '10-foo', status: 'skipped', cwd: '/repo', blockedByForeignWip: true,
|
|
69
|
+
foreignWipBlockedPaths: ['src/a.ts'], statusHistory: [],
|
|
70
|
+
};
|
|
71
|
+
await requeueForeignWipBlockedJobs([job], { getDirtyPaths: async () => [] });
|
|
72
|
+
expect(job.status).toBe('pending');
|
|
73
|
+
expect(job.blockedByForeignWip).toBeUndefined();
|
|
74
|
+
expect(job.foreignWipBlockedPaths).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('requeueForeignWipBlockedJobs: leaves the job parked while any blocked path is still dirty', async () => {
|
|
78
|
+
const job = {
|
|
79
|
+
slug: '10-foo', status: 'skipped', cwd: '/repo', blockedByForeignWip: true,
|
|
80
|
+
foreignWipBlockedPaths: ['src/a.ts', 'src/b.ts'], statusHistory: [],
|
|
81
|
+
};
|
|
82
|
+
await requeueForeignWipBlockedJobs([job], { getDirtyPaths: async () => ['src/a.ts'] });
|
|
83
|
+
expect(job.status).toBe('skipped');
|
|
84
|
+
expect(job.blockedByForeignWip).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('requeueForeignWipBlockedJobs: ignores jobs not in the blocked-skipped shape', async () => {
|
|
88
|
+
const skippedNotBlocked = { slug: '1', status: 'skipped', cwd: '/repo', statusHistory: [] };
|
|
89
|
+
const runningBlocked = { slug: '2', status: 'running', cwd: '/repo', blockedByForeignWip: true, foreignWipBlockedPaths: ['x'], statusHistory: [] };
|
|
90
|
+
const getDirtyPaths = async () => [];
|
|
91
|
+
await requeueForeignWipBlockedJobs([skippedNotBlocked, runningBlocked], { getDirtyPaths });
|
|
92
|
+
expect(skippedNotBlocked.status).toBe('skipped');
|
|
93
|
+
expect(runningBlocked.status).toBe('running');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('requeueForeignWipBlockedJobs: leaves the row alone when the cwd is not a git repo (getDirtyPaths → null)', async () => {
|
|
97
|
+
const job = {
|
|
98
|
+
slug: '10-foo', status: 'skipped', cwd: '/not-a-repo', blockedByForeignWip: true,
|
|
99
|
+
foreignWipBlockedPaths: ['src/a.ts'], statusHistory: [],
|
|
100
|
+
};
|
|
101
|
+
await requeueForeignWipBlockedJobs([job], { getDirtyPaths: async () => null });
|
|
102
|
+
expect(job.status).toBe('skipped');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('FOREIGN_WIP_BLOCK_STREAK_LIMIT is 3', () => {
|
|
106
|
+
expect(FOREIGN_WIP_BLOCK_STREAK_LIMIT).toBe(3);
|
|
107
|
+
});
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import { test, expect } from 'vitest';
|
|
26
26
|
const { commitGuardVerdict } = require('../scheduler.cjs');
|
|
27
|
+
const { resolveIsFixPlan } = require('../lib/fixPlanSlug.cjs');
|
|
27
28
|
|
|
28
29
|
test('commitGuardVerdict: legitimate no-op verdict (pass_no_commit_already_shipped) + concurrent dirt → no flag (655 incident)', () => {
|
|
29
30
|
const verifyResult = {
|
|
@@ -102,6 +103,25 @@ test('commitGuardVerdict: fix-plan job (523-fix-bounded-fix-plan-retry incident)
|
|
|
102
103
|
expect(verdict).toBeNull();
|
|
103
104
|
});
|
|
104
105
|
|
|
106
|
+
test('commitGuardVerdict: a zero-edit run of a human-authored PRD whose slug merely starts with "fix-" still yields silent_no_op (PRD 1131 — not exempted by name alone)', () => {
|
|
107
|
+
// Mirrors PRD 1126: authored via scheduler_create_prd (isFixPlan stamped
|
|
108
|
+
// false at reconcile time), slug happens to read "fix-...". Feeding the
|
|
109
|
+
// job's own persisted isFixPlan through resolveIsFixPlan (the same call
|
|
110
|
+
// spawnJob makes) must NOT exempt this zero-edit run from the guard.
|
|
111
|
+
const job = { slug: '126-fix-plan-death-reopens-parent', isFixPlan: false };
|
|
112
|
+
const verdict = commitGuardVerdict({
|
|
113
|
+
newlyDirty: [],
|
|
114
|
+
siblingRunning: false,
|
|
115
|
+
jobSelfCommitted: false,
|
|
116
|
+
legitimateNoOp: false,
|
|
117
|
+
isFixPlanJob: resolveIsFixPlan(job.slug, job.isFixPlan),
|
|
118
|
+
verifyResult: null,
|
|
119
|
+
});
|
|
120
|
+
expect(verdict).not.toBeNull();
|
|
121
|
+
expect(verdict.verdict).toBe('silent_no_op');
|
|
122
|
+
expect(verdict.downgradeTo).toBe('needs_review');
|
|
123
|
+
});
|
|
124
|
+
|
|
105
125
|
test('commitGuardVerdict: fix-plan job that DID leave real dirt behind is still flagged (isFixPlanJob only exempts the zero-edit case)', () => {
|
|
106
126
|
const verdict = commitGuardVerdict({
|
|
107
127
|
newlyDirty: ['src/main/someFeature.cjs'],
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-finalize-dispatch-guards.test.cjs — regression cover for the
|
|
3
|
+
* 2026-09-06 incident: PRD 1133 (reaper integration check) shipped correctly
|
|
4
|
+
* in commit 5de4134, but its finalize mutate's silent early-return dropped
|
|
5
|
+
* three subsequent no-op re-verifications of the same already-shipped slug
|
|
6
|
+
* with no audit trail, leaving the row stuck 'pending' forever and causing
|
|
7
|
+
* the dispatcher to re-fire it three more times.
|
|
8
|
+
*
|
|
9
|
+
* Covers the three pure decision helpers extracted for this fix:
|
|
10
|
+
* - evaluateFinalizeDrop: never silently discard a finalize (row missing,
|
|
11
|
+
* or row moved off 'running' by a concurrent cancel) — always says so,
|
|
12
|
+
* and still lets a genuinely-landed commit be stamped as a durable fact.
|
|
13
|
+
* - evaluateDispatchSidecarReconcile: refuse to re-dispatch a pending row
|
|
14
|
+
* whose newest run sidecar already shows a completed-equivalent outcome
|
|
15
|
+
* for THIS queueing episode.
|
|
16
|
+
* - isQueueRowRegression: detect (never repair) a running->pending change
|
|
17
|
+
* whose statusHistory got shorter — evidence of lost state, not a real
|
|
18
|
+
* transition.
|
|
19
|
+
*
|
|
20
|
+
* HOME is overridden to a tmp dir BEFORE requiring scheduler.cjs, matching
|
|
21
|
+
* scheduler-reap-dead-running-jobs.test.cjs's pattern — every path this
|
|
22
|
+
* module touches is baked into a top-level const from os.homedir() at
|
|
23
|
+
* require time.
|
|
24
|
+
*
|
|
25
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-finalize-dispatch-guards.test.cjs
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
'use strict';
|
|
29
|
+
|
|
30
|
+
const assert = require('node:assert/strict');
|
|
31
|
+
const fs = require('node:fs');
|
|
32
|
+
const os = require('node:os');
|
|
33
|
+
const path = require('node:path');
|
|
34
|
+
|
|
35
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'scheduler-finalize-dispatch-guards-test-'));
|
|
36
|
+
process.env.HOME = tmpHome;
|
|
37
|
+
|
|
38
|
+
const {
|
|
39
|
+
evaluateFinalizeDrop,
|
|
40
|
+
evaluateDispatchSidecarReconcile,
|
|
41
|
+
isQueueRowRegression,
|
|
42
|
+
} = require('../scheduler.cjs');
|
|
43
|
+
|
|
44
|
+
describe('evaluateFinalizeDrop', () => {
|
|
45
|
+
it('drops loudly (row-missing) with nothing to stamp when the row vanished from s.jobs', () => {
|
|
46
|
+
const decision = evaluateFinalizeDrop({
|
|
47
|
+
rowExists: false,
|
|
48
|
+
rowStatus: null,
|
|
49
|
+
rowRunId: null,
|
|
50
|
+
rowLandedCommit: null,
|
|
51
|
+
runId: 'run-2',
|
|
52
|
+
landedCommit: 'deadbeef',
|
|
53
|
+
});
|
|
54
|
+
assert.equal(decision.drop, true);
|
|
55
|
+
assert.equal(decision.reason, 'row-missing');
|
|
56
|
+
assert.equal(decision.stampLandedCommit, null);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('drops the status change (row-not-running) but stamps landedCommit when this run owns the row', () => {
|
|
60
|
+
const decision = evaluateFinalizeDrop({
|
|
61
|
+
rowExists: true,
|
|
62
|
+
rowStatus: 'failed', // e.g. remote.cancelJob already finalized it (PRD 1024)
|
|
63
|
+
rowRunId: 'run-2',
|
|
64
|
+
rowLandedCommit: null,
|
|
65
|
+
runId: 'run-2',
|
|
66
|
+
landedCommit: 'deadbeef',
|
|
67
|
+
});
|
|
68
|
+
assert.equal(decision.drop, true);
|
|
69
|
+
assert.equal(decision.reason, 'row-not-running');
|
|
70
|
+
assert.equal(decision.stampLandedCommit, 'deadbeef');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('stamps landedCommit even when the row carries a different runId, as long as it has none of its own', () => {
|
|
74
|
+
const decision = evaluateFinalizeDrop({
|
|
75
|
+
rowExists: true,
|
|
76
|
+
rowStatus: 'pending',
|
|
77
|
+
rowRunId: 'some-other-run',
|
|
78
|
+
rowLandedCommit: null,
|
|
79
|
+
runId: 'run-2',
|
|
80
|
+
landedCommit: 'deadbeef',
|
|
81
|
+
});
|
|
82
|
+
assert.equal(decision.drop, true);
|
|
83
|
+
assert.equal(decision.stampLandedCommit, 'deadbeef');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('never overwrites a row that already carries its own newer landedCommit from a different run', () => {
|
|
87
|
+
const decision = evaluateFinalizeDrop({
|
|
88
|
+
rowExists: true,
|
|
89
|
+
rowStatus: 'failed',
|
|
90
|
+
rowRunId: 'some-other-run',
|
|
91
|
+
rowLandedCommit: 'already-there',
|
|
92
|
+
runId: 'run-2',
|
|
93
|
+
landedCommit: 'deadbeef',
|
|
94
|
+
});
|
|
95
|
+
assert.equal(decision.drop, true);
|
|
96
|
+
assert.equal(decision.stampLandedCommit, null);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('never re-legalizes a cancelled (failed) row back to completed — the PRD-1024 protection', () => {
|
|
100
|
+
// The finalize call site never even reaches transitionJob for a dropped
|
|
101
|
+
// finalize; this test documents the contract evaluateFinalizeDrop enforces:
|
|
102
|
+
// drop:true means the caller MUST NOT change job.status.
|
|
103
|
+
const decision = evaluateFinalizeDrop({
|
|
104
|
+
rowExists: true,
|
|
105
|
+
rowStatus: 'failed',
|
|
106
|
+
rowRunId: 'run-2',
|
|
107
|
+
rowLandedCommit: null,
|
|
108
|
+
runId: 'run-2',
|
|
109
|
+
landedCommit: 'deadbeef',
|
|
110
|
+
});
|
|
111
|
+
assert.equal(decision.drop, true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('does not drop when the row is still running — the normal finalize path', () => {
|
|
115
|
+
const decision = evaluateFinalizeDrop({
|
|
116
|
+
rowExists: true,
|
|
117
|
+
rowStatus: 'running',
|
|
118
|
+
rowRunId: 'run-2',
|
|
119
|
+
rowLandedCommit: null,
|
|
120
|
+
runId: 'run-2',
|
|
121
|
+
landedCommit: 'deadbeef',
|
|
122
|
+
});
|
|
123
|
+
assert.equal(decision.drop, false);
|
|
124
|
+
assert.equal(decision.stampLandedCommit, null);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('evaluateDispatchSidecarReconcile', () => {
|
|
129
|
+
const baseRow = {
|
|
130
|
+
rowStatus: 'pending',
|
|
131
|
+
rowRunId: null,
|
|
132
|
+
statusHistory: [
|
|
133
|
+
{ from: 'running', to: 'pending', at: '2026-09-06T19:30:00.000Z' },
|
|
134
|
+
],
|
|
135
|
+
queuedAt: '2026-09-06T17:02:07.815Z',
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
it('skips dispatch when a prior run of this slug already completed after the last pending transition', () => {
|
|
139
|
+
const decision = evaluateDispatchSidecarReconcile({
|
|
140
|
+
...baseRow,
|
|
141
|
+
outcome: { status: 'completed', runId: '2026-09-06T19-26-54-681Z', finishedAt: '2026-09-06T19:35:00.000Z' },
|
|
142
|
+
});
|
|
143
|
+
assert.equal(decision.skip, true);
|
|
144
|
+
assert.equal(decision.runId, '2026-09-06T19-26-54-681Z');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('proceeds with dispatch when a deliberate human re-queue postdates the earlier completion', () => {
|
|
148
|
+
// The re-queue's own pending transition (19:30) is AFTER the completed
|
|
149
|
+
// sidecar's finishedAt (18:00) — an OLDER episode's completion, not this one.
|
|
150
|
+
const decision = evaluateDispatchSidecarReconcile({
|
|
151
|
+
...baseRow,
|
|
152
|
+
outcome: { status: 'completed', runId: 'old-run', finishedAt: '2026-09-06T18:00:00.000Z' },
|
|
153
|
+
});
|
|
154
|
+
assert.equal(decision.skip, false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('proceeds with dispatch when there is no sidecar at all (guard is a no-op)', () => {
|
|
158
|
+
const decision = evaluateDispatchSidecarReconcile({ ...baseRow, outcome: null });
|
|
159
|
+
assert.equal(decision.skip, false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('proceeds with dispatch when the sidecar belongs to this row\'s own current runId', () => {
|
|
163
|
+
const decision = evaluateDispatchSidecarReconcile({
|
|
164
|
+
...baseRow,
|
|
165
|
+
rowRunId: 'run-x',
|
|
166
|
+
outcome: { status: 'completed', runId: 'run-x', finishedAt: '2026-09-06T19:35:00.000Z' },
|
|
167
|
+
});
|
|
168
|
+
assert.equal(decision.skip, false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('proceeds with dispatch when the row is not pending (e.g. needs_review resume-recovery)', () => {
|
|
172
|
+
const decision = evaluateDispatchSidecarReconcile({
|
|
173
|
+
...baseRow,
|
|
174
|
+
rowStatus: 'needs_review',
|
|
175
|
+
outcome: { status: 'completed', runId: 'some-run', finishedAt: '2026-09-06T19:35:00.000Z' },
|
|
176
|
+
});
|
|
177
|
+
assert.equal(decision.skip, false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('proceeds with dispatch when the newest sidecar reports a failed (not completed-equivalent) outcome', () => {
|
|
181
|
+
const decision = evaluateDispatchSidecarReconcile({
|
|
182
|
+
...baseRow,
|
|
183
|
+
outcome: { status: 'failed', runId: 'some-run', finishedAt: '2026-09-06T19:35:00.000Z' },
|
|
184
|
+
});
|
|
185
|
+
assert.equal(decision.skip, false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('falls back to queuedAt when the row has never been reset (no pending statusHistory entry)', () => {
|
|
189
|
+
const decision = evaluateDispatchSidecarReconcile({
|
|
190
|
+
rowStatus: 'pending',
|
|
191
|
+
rowRunId: null,
|
|
192
|
+
statusHistory: [],
|
|
193
|
+
queuedAt: '2026-09-06T17:02:07.815Z',
|
|
194
|
+
outcome: { status: 'completed', runId: 'some-run', finishedAt: '2026-09-06T19:35:00.000Z' },
|
|
195
|
+
});
|
|
196
|
+
assert.equal(decision.skip, true);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('isQueueRowRegression', () => {
|
|
201
|
+
it('flags a running->pending change whose statusHistory got shorter', () => {
|
|
202
|
+
assert.equal(
|
|
203
|
+
isQueueRowRegression({ statusBefore: 'running', statusAfter: 'pending', historyLenBefore: 5, historyLenAfter: 3 }),
|
|
204
|
+
true,
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('does not flag a running->pending change with a normal appended (equal or longer) history', () => {
|
|
209
|
+
assert.equal(
|
|
210
|
+
isQueueRowRegression({ statusBefore: 'running', statusAfter: 'pending', historyLenBefore: 5, historyLenAfter: 6 }),
|
|
211
|
+
false,
|
|
212
|
+
);
|
|
213
|
+
assert.equal(
|
|
214
|
+
isQueueRowRegression({ statusBefore: 'running', statusAfter: 'pending', historyLenBefore: 20, historyLenAfter: 20 }),
|
|
215
|
+
false,
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('does not flag a shortened history on transitions other than running->pending', () => {
|
|
220
|
+
assert.equal(
|
|
221
|
+
isQueueRowRegression({ statusBefore: 'pending', statusAfter: 'completed', historyLenBefore: 5, historyLenAfter: 3 }),
|
|
222
|
+
false,
|
|
223
|
+
);
|
|
224
|
+
assert.equal(
|
|
225
|
+
isQueueRowRegression({ statusBefore: 'running', statusAfter: 'completed', historyLenBefore: 5, historyLenAfter: 3 }),
|
|
226
|
+
false,
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
});
|