claude-code-session-manager 0.75.0 → 0.75.2
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-SpzptNyq.js → AgentLibrary-s1jRGOji.js} +1 -1
- package/dist/assets/{DataModel-C4M9xVLC.js → DataModel-DNefcWbk.js} +1 -1
- package/dist/assets/{History-92FYrZKS.js → History-4sMk80yI.js} +1 -1
- package/dist/assets/{Hooks-DlnavO1l.js → Hooks-CNUVwH5P.js} +1 -1
- package/dist/assets/{HostBilko-CKI7jt1z.js → HostBilko-BhkyvIhw.js} +1 -1
- package/dist/assets/{Library-Brhxe7xa.js → Library-BpockanA.js} +1 -1
- package/dist/assets/{ListDetail-DmIweYTN.js → ListDetail-Cre7h3U0.js} +1 -1
- package/dist/assets/{MarkdownEditor-DA4xqWWi.js → MarkdownEditor-x-FzvaXm.js} +1 -1
- package/dist/assets/{McpServers-DWXQGzVc.js → McpServers-CXAcQLMB.js} +1 -1
- package/dist/assets/{Memory-HCx9Arao.js → Memory-CGBv1Rco.js} +1 -1
- package/dist/assets/{Panel-B20C6kN8.js → Panel-czEs21N2.js} +1 -1
- package/dist/assets/{Permissions-C4sPgLpA.js → Permissions-rGkLOpqI.js} +1 -1
- package/dist/assets/{Plugins-CaRenCBg.js → Plugins-BTmB_vgU.js} +2 -2
- package/dist/assets/{ProvenanceBadge-DfrXzTeh.js → ProvenanceBadge-dmCivDq5.js} +1 -1
- package/dist/assets/{SaveBar-BmpUx2Yh.js → SaveBar-D80TNkAx.js} +1 -1
- package/dist/assets/{Scheduler-xUD8pQ6q.js → Scheduler-C6wHSZP7.js} +3 -3
- package/dist/assets/{ScopeSwitcher-DVlKMck5.js → ScopeSwitcher-DsaiYTgl.js} +1 -1
- package/dist/assets/{Settings-C457mxOK.js → Settings-m2fyU8Q5.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-D3dDc5X7.js → SkillReferenceGraph-yS-lgBFU.js} +1 -1
- package/dist/assets/{Skills-BNDlbftC.js → Skills-DtQgCs9-.js} +1 -1
- package/dist/assets/{SystemPrompt-d-ykNRec.js → SystemPrompt-CLuVdoMA.js} +1 -1
- package/dist/assets/{TagLibrary-D3JWkQK-.js → TagLibrary-DQXoI752.js} +1 -1
- package/dist/assets/{TiptapBody-B1Ox6D6E.js → TiptapBody-BGKVXrvq.js} +1 -1
- package/dist/assets/{Toggle-CaYw6Lrj.js → Toggle-RFnEV71m.js} +1 -1
- package/dist/assets/{index-Dbo9R4e0.css → index-CMLnzdZC.css} +1 -1
- package/dist/assets/{index-CEeLxNCz.js → index-Ddrw7J7O.js} +4 -4
- package/dist/assets/{settingsSchema-DUkd45M5.js → settingsSchema-LwuJXKD1.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/scheduler-mcp-server.cjs +16 -1
- package/src/main/__tests__/prdAdminRoutes.test.cjs +100 -0
- package/src/main/__tests__/queueHistory.test.cjs +7 -0
- package/src/main/__tests__/scheduleJobSchema.test.cjs +1 -1
- package/src/main/__tests__/scheduleJobStatusDrift.test.cjs +1 -1
- package/src/main/__tests__/scheduleJobTransitions.test.cjs +24 -0
- package/src/main/__tests__/scheduler-autofix-outcome.test.cjs +116 -0
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +113 -7
- package/src/main/__tests__/scheduler-clear-queue-history.test.cjs +134 -0
- package/src/main/__tests__/scheduler-fix-plan-path.test.cjs +119 -0
- package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +5 -1
- package/src/main/ipcSchemas.cjs +10 -0
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +26 -0
- package/src/main/lib/prdAdminRoutes.cjs +12 -2
- package/src/main/lib/queueHistory.cjs +6 -4
- package/src/main/lib/scheduleJobSchema.cjs +1 -1
- package/src/main/lib/scheduleJobTransitions.cjs +19 -6
- package/src/main/lib/schedulerBatch.cjs +17 -1
- package/src/main/scheduler.cjs +224 -42
- package/src/preload/api.d.ts +1 -1
|
@@ -11,7 +11,7 @@ const assert = require('node:assert/strict');
|
|
|
11
11
|
const {
|
|
12
12
|
selectAutoFixTargets, isUnresolvableNeedsReview, isRescanCandidate,
|
|
13
13
|
healTargetForFix, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH,
|
|
14
|
-
isEligibleForImmediateAutoFix,
|
|
14
|
+
isEligibleForImmediateAutoFix, isExhaustedAutoFix, isPlanUnqueued, fixSlugFor,
|
|
15
15
|
} = require('../scheduler.cjs');
|
|
16
16
|
|
|
17
17
|
const noSiblingOnDisk = () => false;
|
|
@@ -34,8 +34,8 @@ test('selects a fresh needs_review job', () => {
|
|
|
34
34
|
assert.strictEqual(result[0].slug, '05-my-feature');
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
-
test('excludes job with autoFixAttempted: true', () => {
|
|
38
|
-
const jobs = [makeJob({ autoFixAttempted: true })];
|
|
37
|
+
test('excludes job with autoFixAttempted: true whose retry is already spent', () => {
|
|
38
|
+
const jobs = [makeJob({ autoFixAttempted: true, autoFixRetries: 1 })];
|
|
39
39
|
const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
|
|
40
40
|
assert.strictEqual(result.length, 0);
|
|
41
41
|
});
|
|
@@ -105,12 +105,112 @@ test('autoFixOutcome no-plan with retries=1 is exhausted (not selected)', () =>
|
|
|
105
105
|
assert.strictEqual(result.length, 0);
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
-
|
|
109
|
-
|
|
108
|
+
// ---- PRD: unstamped/error outcomes must retry once, not dead-end forever ----
|
|
109
|
+
|
|
110
|
+
test('autoFixAttempted true with NO outcome recorded (unstamped — spawn threw before onExit wired) is retry-eligible once', () => {
|
|
111
|
+
const jobs = [makeJob({ autoFixAttempted: true, autoFixRetries: 0 })];
|
|
112
|
+
const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
|
|
113
|
+
assert.strictEqual(result.length, 1);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('autoFixAttempted true with no outcome AND retries=1 is exhausted (not selected)', () => {
|
|
117
|
+
const jobs = [makeJob({ autoFixAttempted: true, autoFixRetries: 1 })];
|
|
110
118
|
const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
|
|
111
119
|
assert.strictEqual(result.length, 0);
|
|
112
120
|
});
|
|
113
121
|
|
|
122
|
+
test('autoFixOutcome error with retries=0 is retry-eligible', () => {
|
|
123
|
+
const jobs = [makeJob({ autoFixAttempted: true, autoFixOutcome: 'error', autoFixRetries: 0 })];
|
|
124
|
+
const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
|
|
125
|
+
assert.strictEqual(result.length, 1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('autoFixOutcome error with retries=1 is exhausted (not selected)', () => {
|
|
129
|
+
const jobs = [makeJob({ autoFixAttempted: true, autoFixOutcome: 'error', autoFixRetries: 1 })];
|
|
130
|
+
const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
|
|
131
|
+
assert.strictEqual(result.length, 0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('autoFixOutcome plan is NEVER retried, regardless of autoFixRetries', () => {
|
|
135
|
+
const jobs = [makeJob({ autoFixAttempted: true, autoFixOutcome: 'plan', autoFixRetries: 0 })];
|
|
136
|
+
const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
|
|
137
|
+
assert.strictEqual(result.length, 0);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ---- isExhaustedAutoFix / isPlanUnqueued (reverifyNeedsReview annotation) ----
|
|
141
|
+
|
|
142
|
+
test('isExhaustedAutoFix: no-plan outcome with retries spent → true', () => {
|
|
143
|
+
const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'no-plan', autoFixRetries: 1 });
|
|
144
|
+
assert.strictEqual(isExhaustedAutoFix(job), true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('isExhaustedAutoFix: error outcome with retries spent → true', () => {
|
|
148
|
+
const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'error', autoFixRetries: 1 });
|
|
149
|
+
assert.strictEqual(isExhaustedAutoFix(job), true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('isExhaustedAutoFix: unstamped outcome with retries spent → true', () => {
|
|
153
|
+
const job = makeJob({ autoFixAttempted: true, autoFixRetries: 1 });
|
|
154
|
+
assert.strictEqual(isExhaustedAutoFix(job), true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('isExhaustedAutoFix: retries not yet spent → false (still eligible for its one retry)', () => {
|
|
158
|
+
const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'no-plan', autoFixRetries: 0 });
|
|
159
|
+
assert.strictEqual(isExhaustedAutoFix(job), false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('isExhaustedAutoFix: plan outcome is never "exhausted" here — isPlanUnqueued\'s job instead', () => {
|
|
163
|
+
const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'plan', autoFixRetries: 1 });
|
|
164
|
+
assert.strictEqual(isExhaustedAutoFix(job), false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('isPlanUnqueued: plan outcome, fix slug absent from queue → true', () => {
|
|
168
|
+
const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'plan' });
|
|
169
|
+
assert.strictEqual(isPlanUnqueued(job, new Set()), true);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('isPlanUnqueued: plan outcome, fix slug present in queue → false', () => {
|
|
173
|
+
const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'plan' });
|
|
174
|
+
assert.strictEqual(isPlanUnqueued(job, new Set([fixSlugFor(job)])), false);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('isPlanUnqueued: no-plan outcome is never "plan unqueued"', () => {
|
|
178
|
+
const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'no-plan' });
|
|
179
|
+
assert.strictEqual(isPlanUnqueued(job, new Set()), false);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// ---- invariant: every needs_review job is either an auto-fix target, or
|
|
183
|
+
// classified by one of the annotation predicates (i.e. would receive a
|
|
184
|
+
// non-null verifierVerdict) — no job may fall through both. ----
|
|
185
|
+
|
|
186
|
+
test('invariant: attempted+no-outcome, retries spent → not a target, but IS annotatable', () => {
|
|
187
|
+
const job = makeJob({ autoFixAttempted: true, autoFixRetries: 1 });
|
|
188
|
+
const isTarget = selectAutoFixTargets([job], { fixSlugExists: noSiblingOnDisk }).length === 1;
|
|
189
|
+
const isAnnotatable = isExhaustedAutoFix(job) || isPlanUnqueued(job, new Set())
|
|
190
|
+
|| isUnresolvableNeedsReview(job, { hasRunDir: true });
|
|
191
|
+
assert.strictEqual(isTarget, false);
|
|
192
|
+
assert.strictEqual(isAnnotatable, true);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('invariant: plan outcome, no queue row → not a target, but IS annotatable (autofix_plan_unqueued)', () => {
|
|
196
|
+
const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'plan' });
|
|
197
|
+
const isTarget = selectAutoFixTargets([job], { fixSlugExists: noSiblingOnDisk }).length === 1;
|
|
198
|
+
const isAnnotatable = isExhaustedAutoFix(job) || isPlanUnqueued(job, new Set())
|
|
199
|
+
|| isUnresolvableNeedsReview(job, { hasRunDir: true });
|
|
200
|
+
assert.strictEqual(isTarget, false);
|
|
201
|
+
assert.strictEqual(isAnnotatable, true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('invariant: plan outcome WITH a queue row → not a target and NOT annotated (waiting on the fix-plan job itself)', () => {
|
|
205
|
+
const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'plan' });
|
|
206
|
+
const queuedSlugs = new Set([fixSlugFor(job)]);
|
|
207
|
+
const isTarget = selectAutoFixTargets([job], { fixSlugExists: noSiblingOnDisk }).length === 1;
|
|
208
|
+
const isAnnotatable = isExhaustedAutoFix(job) || isPlanUnqueued(job, queuedSlugs)
|
|
209
|
+
|| isUnresolvableNeedsReview(job, { hasRunDir: true });
|
|
210
|
+
assert.strictEqual(isTarget, false);
|
|
211
|
+
assert.strictEqual(isAnnotatable, false);
|
|
212
|
+
});
|
|
213
|
+
|
|
114
214
|
test('excludes when fix sibling exists on disk', () => {
|
|
115
215
|
const jobs = [makeJob()];
|
|
116
216
|
// fixSlug = '05-fix-my-feature'
|
|
@@ -224,12 +324,18 @@ test('isEligibleForImmediateAutoFix: fresh needs_review job (fresh from spawnJob
|
|
|
224
324
|
assert.strictEqual(result, true);
|
|
225
325
|
});
|
|
226
326
|
|
|
227
|
-
test('isEligibleForImmediateAutoFix: job already
|
|
228
|
-
const job = makeJob({ autoFixAttempted: true });
|
|
327
|
+
test('isEligibleForImmediateAutoFix: attempted job with retry already spent is excluded (prevents double-spawn with the periodic pass)', () => {
|
|
328
|
+
const job = makeJob({ autoFixAttempted: true, autoFixRetries: 1 });
|
|
229
329
|
const result = isEligibleForImmediateAutoFix(job, [job], noSiblingOnDisk);
|
|
230
330
|
assert.strictEqual(result, false);
|
|
231
331
|
});
|
|
232
332
|
|
|
333
|
+
test('isEligibleForImmediateAutoFix: attempted job with an unstamped outcome and its retry still unspent IS eligible (bounded once)', () => {
|
|
334
|
+
const job = makeJob({ autoFixAttempted: true, autoFixRetries: 0 });
|
|
335
|
+
const result = isEligibleForImmediateAutoFix(job, [job], noSiblingOnDisk);
|
|
336
|
+
assert.strictEqual(result, true);
|
|
337
|
+
});
|
|
338
|
+
|
|
233
339
|
test('isEligibleForImmediateAutoFix: fix sibling already on disk excludes the job', () => {
|
|
234
340
|
const job = makeJob();
|
|
235
341
|
// fixSlug = '05-fix-my-feature'
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-clear-queue-history.test.cjs — schedule:clear-queue must never
|
|
3
|
+
* drop a job row with zero audit trail. Before this PRD, the handler did
|
|
4
|
+
* `s.jobs = s.jobs.filter(...)` straight over every non-running victim with
|
|
5
|
+
* no history.jsonl write at all — a queue row (e.g. the 2026-08-30 sigma
|
|
6
|
+
* `788-pr-sweep-final-gate` incident) could vanish leaving no trace anywhere.
|
|
7
|
+
*
|
|
8
|
+
* Exercises `applyClearQueueVictims` (the core mutation extracted from the
|
|
9
|
+
* `schedule:clear-queue` IPC handler so it's testable without ipcMain/
|
|
10
|
+
* electron) directly against a synthetic state, then feeds its output
|
|
11
|
+
* through the REAL `queueHistory.appendHistory`. Job fixtures carry a real
|
|
12
|
+
* absolute `cwd` under a per-test mkdtemp dir (never the live operations
|
|
13
|
+
* root) because appendHistory routes each entry to its OWN project's
|
|
14
|
+
* `<cwd>/session-manager-operations/scheduler/state/history.jsonl` shard
|
|
15
|
+
* whenever the entry has a `cwd` — SM_HISTORY_PATH_OVERRIDE only affects
|
|
16
|
+
* cwd-less entries, and the AC here requires every entry to carry a cwd.
|
|
17
|
+
*
|
|
18
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-clear-queue-history.test.cjs
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
import { test, expect, beforeEach, afterEach } from 'vitest';
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
const os = require('node:os');
|
|
26
|
+
const path = require('node:path');
|
|
27
|
+
|
|
28
|
+
const queueHistory = require('../lib/queueHistory.cjs');
|
|
29
|
+
const { projectHistoryPath } = require('../lib/queueStore.cjs');
|
|
30
|
+
const scheduler = require('../scheduler.cjs');
|
|
31
|
+
|
|
32
|
+
let tmpCwd;
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-clear-queue-history-'));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
fs.rmSync(tmpCwd, { recursive: true, force: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function pendingJob(slug, overrides = {}) {
|
|
43
|
+
return { slug, cwd: tmpCwd, status: 'pending', runId: null, finishedAt: null, ...overrides };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function historyPath() {
|
|
47
|
+
return projectHistoryPath(tmpCwd);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readHistoryRows() {
|
|
51
|
+
const text = fs.readFileSync(historyPath(), 'utf8').trim();
|
|
52
|
+
return text ? text.split('\n').map((l) => JSON.parse(l)) : [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
test('applyClearQueueVictims transitions N pending jobs to skipped, drops them from state.jobs, and appendHistory writes N rows with source clear-queue', async () => {
|
|
56
|
+
const jobs = [pendingJob('101-a'), pendingJob('102-b'), pendingJob('103-c')];
|
|
57
|
+
const running = { slug: '104-running', cwd: tmpCwd, status: 'running', runId: 'run-104', finishedAt: null };
|
|
58
|
+
const state = { jobs: [...jobs, running] };
|
|
59
|
+
const victimSlugs = new Set(jobs.map((j) => j.slug));
|
|
60
|
+
const archiveNoteBySlug = new Map(jobs.map((j) => [j.slug, 'PRD file archived to /fake/archive/dir']));
|
|
61
|
+
|
|
62
|
+
const historyEntries = scheduler.applyClearQueueVictims(state, victimSlugs, archiveNoteBySlug, '/fake/archive/dir');
|
|
63
|
+
|
|
64
|
+
// Running job is untouched, exempt from clear-queue, and got no history row.
|
|
65
|
+
expect(state.jobs.map((j) => j.slug)).toEqual(['104-running']);
|
|
66
|
+
expect(historyEntries.map((e) => e.slug).sort()).toEqual(['101-a', '102-b', '103-c']);
|
|
67
|
+
for (const entry of historyEntries) {
|
|
68
|
+
expect(entry.status).toBe('skipped'); // NEVER 'completed' — a manually cleared job did not ship
|
|
69
|
+
expect(entry.cwd).toBe(tmpCwd);
|
|
70
|
+
expect(entry.finishedAt).toBeTruthy();
|
|
71
|
+
const last = entry.statusHistory[entry.statusHistory.length - 1];
|
|
72
|
+
expect(last.source).toBe('clear-queue');
|
|
73
|
+
expect(last.reason).toContain('/fake/archive/dir');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const { appended } = await queueHistory.appendHistory(historyEntries);
|
|
77
|
+
expect(appended).toBe(3);
|
|
78
|
+
const onDisk = readHistoryRows();
|
|
79
|
+
expect(onDisk.length).toBe(3);
|
|
80
|
+
expect(onDisk.every((e) => e.status === 'skipped')).toBe(true);
|
|
81
|
+
expect(onDisk.every((e) => e.statusHistory.at(-1).source === 'clear-queue')).toBe(true);
|
|
82
|
+
|
|
83
|
+
// queue.json (state.jobs, what writeQueue serializes) no longer lists them.
|
|
84
|
+
expect(state.jobs.some((j) => victimSlugs.has(j.slug))).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('running jobs are exempt from clear-queue and never appear in the returned history entries', () => {
|
|
88
|
+
const running = { slug: '201-running', cwd: tmpCwd, status: 'running', runId: 'run-201', finishedAt: null };
|
|
89
|
+
const state = { jobs: [running] };
|
|
90
|
+
const victimSlugs = new Set(); // schedule:clear-queue never includes running jobs as victims
|
|
91
|
+
|
|
92
|
+
const historyEntries = scheduler.applyClearQueueVictims(state, victimSlugs, new Map(), '/fake/archive/dir');
|
|
93
|
+
|
|
94
|
+
expect(historyEntries).toEqual([]);
|
|
95
|
+
expect(state.jobs).toEqual([running]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('a victim already terminal (completed) keeps its real status, not skipped, but still gets a clear-queue history row', () => {
|
|
99
|
+
const completed = { slug: '301-done', cwd: tmpCwd, status: 'completed', runId: 'run-301', finishedAt: '2026-08-29T00:00:00.000Z', statusHistory: [] };
|
|
100
|
+
const state = { jobs: [completed] };
|
|
101
|
+
const victimSlugs = new Set(['301-done']);
|
|
102
|
+
|
|
103
|
+
const historyEntries = scheduler.applyClearQueueVictims(state, victimSlugs, new Map(), '/fake/archive/dir');
|
|
104
|
+
|
|
105
|
+
expect(state.jobs).toEqual([]);
|
|
106
|
+
expect(historyEntries.length).toBe(1);
|
|
107
|
+
expect(historyEntries[0].status).toBe('completed'); // real outcome preserved, not overwritten to skipped
|
|
108
|
+
const last = historyEntries[0].statusHistory.at(-1);
|
|
109
|
+
expect(last.source).toBe('clear-queue');
|
|
110
|
+
expect(last.from).toBe('completed');
|
|
111
|
+
expect(last.to).toBe('completed');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('appendHistory dedupe: clearing an already-cleared queue twice does not double-write rows', async () => {
|
|
115
|
+
const jobs = [pendingJob('401-x'), pendingJob('402-y')];
|
|
116
|
+
const state = { jobs: [...jobs] };
|
|
117
|
+
const victimSlugs = new Set(jobs.map((j) => j.slug));
|
|
118
|
+
|
|
119
|
+
const historyEntries = scheduler.applyClearQueueVictims(state, victimSlugs, new Map(), '/fake/archive/dir');
|
|
120
|
+
expect(state.jobs).toEqual([]);
|
|
121
|
+
|
|
122
|
+
const r1 = await queueHistory.appendHistory(historyEntries);
|
|
123
|
+
expect(r1.appended).toBe(2);
|
|
124
|
+
|
|
125
|
+
// A crash-replay of the SAME batch through appendHistory (the dedupe
|
|
126
|
+
// queueHistory itself guards against) must not double-write — this is
|
|
127
|
+
// what protects a second clear-queue pass, or a retry after a crash
|
|
128
|
+
// between the mutate() and appendHistory calls, from duplicating rows.
|
|
129
|
+
const r2 = await queueHistory.appendHistory(historyEntries);
|
|
130
|
+
expect(r2.appended).toBe(0);
|
|
131
|
+
|
|
132
|
+
const onDisk = readHistoryRows();
|
|
133
|
+
expect(onDisk.length).toBe(2);
|
|
134
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-fix-plan-path.test.cjs — regression test for the self-heal
|
|
3
|
+
* dead-letter bug: spawnInvestigation used to write every auto-fix plan into
|
|
4
|
+
* the RETIRED flat `<cwd>/session-manager-operations/scheduler/prds/` dir
|
|
5
|
+
* (prdDirForCwd), which reconcile()'s consolidateAllFlatPrds sweeps BEFORE
|
|
6
|
+
* scanning for queue rows — so a freshly-authored fix plan (no queue row yet
|
|
7
|
+
* by definition) was archived into `prds-archived/` on the very next pass
|
|
8
|
+
* and never enqueued. Confirmed live in starry-night-ships: job
|
|
9
|
+
* 105-arena-perf-fences produced a valid fix plan that landed in
|
|
10
|
+
* prds-archived/ having never appeared in queue.json or history.jsonl.
|
|
11
|
+
*
|
|
12
|
+
* The fix: resolveFixPlanPath resolves the write dir from the ORIGINAL job's
|
|
13
|
+
* live PRD location (findPrdDir, via resolveVerifyPrdPath) — normally the
|
|
14
|
+
* originating Epic's own `epics/<epicId>/prds/`, which consolidateFlatPrds
|
|
15
|
+
* never touches — falling back to the flat dir only when the original can't
|
|
16
|
+
* be found live anywhere on disk.
|
|
17
|
+
*
|
|
18
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-fix-plan-path.test.cjs
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
import { test, expect, beforeAll, afterAll } from 'vitest';
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
const os = require('node:os');
|
|
26
|
+
const path = require('node:path');
|
|
27
|
+
|
|
28
|
+
let tmpHome;
|
|
29
|
+
let originalHome;
|
|
30
|
+
let resolveFixPlanPath;
|
|
31
|
+
let PRDS_DIR;
|
|
32
|
+
let consolidateFlatPrds;
|
|
33
|
+
|
|
34
|
+
beforeAll(() => {
|
|
35
|
+
originalHome = process.env.HOME;
|
|
36
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-fix-plan-path-'));
|
|
37
|
+
process.env.HOME = tmpHome;
|
|
38
|
+
|
|
39
|
+
// Load AFTER HOME is stubbed — scheduler.cjs snapshots os.homedir() into
|
|
40
|
+
// PRDS_DIR at module load, not lazily (see scheduler-find-prd-dir.test.cjs).
|
|
41
|
+
({ resolveFixPlanPath, PRDS_DIR } = require('../scheduler.cjs'));
|
|
42
|
+
({ consolidateFlatPrds } = require('../lib/prdMigration.cjs'));
|
|
43
|
+
|
|
44
|
+
if (!PRDS_DIR.startsWith(tmpHome)) {
|
|
45
|
+
throw new Error(`refusing to run: PRDS_DIR (${PRDS_DIR}) is not under the temp HOME (${tmpHome})`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// allProjectCwds()/activeProjectCwds() (consumed by findPrdDir's candidate
|
|
49
|
+
// search) discover project cwds by scanning ~/.claude/projects/*/*.jsonl
|
|
50
|
+
// for a `cwd` field — fake one project transcript pointing at the real
|
|
51
|
+
// on-disk cwd used below (mirrors scheduler-verify-prd-path.test.cjs).
|
|
52
|
+
const projectsDir = path.join(tmpHome, '.claude', 'projects');
|
|
53
|
+
const projDir = path.join(projectsDir, 'fake-fix-plan-project-slug');
|
|
54
|
+
fs.mkdirSync(projDir, { recursive: true });
|
|
55
|
+
fs.writeFileSync(
|
|
56
|
+
path.join(projDir, 'session.jsonl'),
|
|
57
|
+
`${JSON.stringify({ cwd: projectCwd() })}\n`,
|
|
58
|
+
'utf8',
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterAll(() => {
|
|
63
|
+
process.env.HOME = originalHome;
|
|
64
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
function projectCwd() {
|
|
68
|
+
return path.join(tmpHome, 'fix-plan-project-cwd');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
test('resolveFixPlanPath resolves the fix plan into the SAME Epic-scoped dir as the original PRD, and it survives consolidateFlatPrds', async () => {
|
|
72
|
+
const cwd = projectCwd();
|
|
73
|
+
const epicId = 'test-epic-arena-perf';
|
|
74
|
+
const slug = `105-arena-perf-fences-${process.pid}`;
|
|
75
|
+
const prdsDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'epics', epicId, 'prds');
|
|
76
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
77
|
+
fs.writeFileSync(path.join(prdsDir, `${slug}.md`), '---\ntitle: arena perf fences\n---\n\n# Goal\n\ntest\n', 'utf8');
|
|
78
|
+
|
|
79
|
+
const failedJob = { slug, cwd, parallelGroup: 5 };
|
|
80
|
+
const resolved = await resolveFixPlanPath(failedJob);
|
|
81
|
+
|
|
82
|
+
const expectedFixSlug = '05-fix-arena-perf-fences-' + process.pid;
|
|
83
|
+
expect(resolved.fixSlug).toBe(expectedFixSlug);
|
|
84
|
+
expect(resolved.livePrdDir).toBe(prdsDir);
|
|
85
|
+
expect(resolved.fixPath).toBe(path.join(prdsDir, `${expectedFixSlug}.md`));
|
|
86
|
+
|
|
87
|
+
// A fix plan written at the resolved path must survive a consolidateFlatPrds
|
|
88
|
+
// pass over the SAME project cwd — the sweep only ever touches the flat dir.
|
|
89
|
+
fs.writeFileSync(resolved.fixPath, `---\ntitle: Fix: arena perf fences\ncwd: ${cwd}\nparallelGroup: 5\nestimateMinutes: 30\n---\n\nfix body\n`, 'utf8');
|
|
90
|
+
await consolidateFlatPrds(cwd);
|
|
91
|
+
expect(fs.existsSync(resolved.fixPath)).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('resolveFixPlanPath falls back to the flat dir when the original PRD cannot be found live anywhere on disk', async () => {
|
|
95
|
+
const cwd = path.join(tmpHome, 'fix-plan-project-cwd-missing');
|
|
96
|
+
const slug = `999-nonexistent-${process.pid}`;
|
|
97
|
+
const failedJob = { slug, cwd, parallelGroup: 3 };
|
|
98
|
+
|
|
99
|
+
const resolved = await resolveFixPlanPath(failedJob);
|
|
100
|
+
|
|
101
|
+
expect(resolved.livePrdDir).toBe(null);
|
|
102
|
+
const flatDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'prds');
|
|
103
|
+
expect(resolved.fixPath).toBe(path.join(flatDir, `${resolved.fixSlug}.md`));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('the OLD flat-dir path (regression guard) does NOT survive consolidateFlatPrds — proves the bug this fix addresses', async () => {
|
|
107
|
+
const cwd = path.join(tmpHome, 'fix-plan-project-cwd-old-behavior');
|
|
108
|
+
const flatDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'prds');
|
|
109
|
+
fs.mkdirSync(flatDir, { recursive: true });
|
|
110
|
+
const oldFixPath = path.join(flatDir, '07-fix-old-behavior.md');
|
|
111
|
+
fs.writeFileSync(oldFixPath, `---\ntitle: Fix: old behavior\ncwd: ${cwd}\nparallelGroup: 7\nestimateMinutes: 30\n---\n\nfix body\n`, 'utf8');
|
|
112
|
+
|
|
113
|
+
// No queue.json at all — the file has no live queue row, exactly the
|
|
114
|
+
// freshly-authored-fix-plan situation the bug hit.
|
|
115
|
+
await consolidateFlatPrds(cwd);
|
|
116
|
+
|
|
117
|
+
expect(fs.existsSync(oldFixPath)).toBe(false);
|
|
118
|
+
expect(fs.existsSync(path.join(cwd, 'session-manager-operations', 'scheduler', 'prds-archived', '07-fix-old-behavior.md'))).toBe(true);
|
|
119
|
+
});
|
|
@@ -126,7 +126,11 @@ test("spawnJob's post-run handler retires a prd-missing skip as a plain completi
|
|
|
126
126
|
const state = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
|
|
127
127
|
const row = state.jobs.find((j) => j.slug === slug);
|
|
128
128
|
expect(row).toBeDefined();
|
|
129
|
-
|
|
129
|
+
// A 'prd-missing' skip means no executor ever ran — it must land on the
|
|
130
|
+
// distinct 'skipped' status, never 'completed' (the status a genuine
|
|
131
|
+
// successful run produces), so unrun work can never masquerade as done.
|
|
132
|
+
expect(row.status).toBe('skipped');
|
|
133
|
+
expect(row.status).not.toBe('completed');
|
|
130
134
|
expect(row.exitCode).toBe(0);
|
|
131
135
|
expect(row.error).toBeNull();
|
|
132
136
|
// No verify pass ran and no RCA feedback fired: a verified run always
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -369,6 +369,16 @@ const adminListPrdsQuery = z.object({
|
|
|
369
369
|
cwd: z.string().min(1).max(4096).optional(),
|
|
370
370
|
epicId: z.string().min(1).max(200).optional(),
|
|
371
371
|
status: z.string().min(1).max(40).optional(),
|
|
372
|
+
// Page size — default 100, hard max 500 so an agent can't accidentally
|
|
373
|
+
// re-request the full unbounded list (PRD: the 353KB/120s-timeout
|
|
374
|
+
// incident this pagination exists to fix). Query strings arrive as text,
|
|
375
|
+
// so these coerce before the int/range checks run.
|
|
376
|
+
limit: z.coerce.number().int().min(1).max(500).optional().default(100),
|
|
377
|
+
offset: z.coerce.number().int().min(0).optional().default(0),
|
|
378
|
+
// 'compact' (default) omits the secondary detail fields (parallelGroup,
|
|
379
|
+
// estimateMinutes, sourcePromptId, epicId, archivedStatus) that aren't
|
|
380
|
+
// needed to answer "what PRDs are there"; 'full' restores them.
|
|
381
|
+
fields: z.enum(['compact', 'full']).optional().default('compact'),
|
|
372
382
|
});
|
|
373
383
|
|
|
374
384
|
const adminGetPrdQuery = z.object({
|
|
@@ -93,6 +93,18 @@ test('a FAILED bare-named dep holds the dependent and reports an explicit reason
|
|
|
93
93
|
assert.match(reason, /874-nav-face-project-home <- leftnav-two-face-framework/);
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
+
test('a SKIPPED (never-ran) bare-named dep holds the dependent and reports an explicit reason', () => {
|
|
97
|
+
const jobs = [
|
|
98
|
+
job('873-leftnav-two-face-framework', 'skipped'),
|
|
99
|
+
job('874-nav-face-project-home', 'pending', { dependsOn: ['leftnav-two-face-framework'] }),
|
|
100
|
+
];
|
|
101
|
+
const { batch, reason } = pick(jobs);
|
|
102
|
+
assert.deepEqual(batch, []);
|
|
103
|
+
assert.match(reason, /depends-gate/);
|
|
104
|
+
assert.match(reason, /never-ran dependencies/);
|
|
105
|
+
assert.match(reason, /874-nav-face-project-home <- leftnav-two-face-framework/);
|
|
106
|
+
});
|
|
107
|
+
|
|
96
108
|
// ---------------------------------------------------------------------------
|
|
97
109
|
// Parallelism regression coverage.
|
|
98
110
|
//
|
|
@@ -209,6 +221,20 @@ test('hold record carries a failed dep status alongside the depends-gate reason'
|
|
|
209
221
|
]);
|
|
210
222
|
});
|
|
211
223
|
|
|
224
|
+
test('hold record carries a skipped dep status alongside the depends-gate reason', () => {
|
|
225
|
+
const jobs = [
|
|
226
|
+
job('985-foundation', 'skipped'),
|
|
227
|
+
job('986-dependent', 'pending', { dependsOn: ['foundation'] }),
|
|
228
|
+
];
|
|
229
|
+
const { batch, reason, holds } = pick(jobs, new Set(), 5);
|
|
230
|
+
assert.deepEqual(batch, []);
|
|
231
|
+
assert.match(reason, /depends-gate/);
|
|
232
|
+
assert.match(reason, /never-ran dependencies/);
|
|
233
|
+
assert.deepEqual(holds, [
|
|
234
|
+
{ slug: '986-dependent', dep: 'foundation', depStatus: 'skipped' },
|
|
235
|
+
]);
|
|
236
|
+
});
|
|
237
|
+
|
|
212
238
|
test('no holds when nothing is dependency-blocked', () => {
|
|
213
239
|
const jobs = [job('988-a', 'pending'), job('989-b', 'pending')];
|
|
214
240
|
const { batch, holds } = pick(jobs, new Set(), 5);
|
|
@@ -45,6 +45,16 @@ function parseJsonBody(raw) {
|
|
|
45
45
|
* scheduler.cjs's own registerAdminRoute(s) pattern.
|
|
46
46
|
*/
|
|
47
47
|
function registerAdminRoute(adminHttp, remote) {
|
|
48
|
+
// GET /admin/scheduler/prds — paginated PRD listing (PRD: 353KB/120s
|
|
49
|
+
// fix). Sort order is stable and explicit: slug ascending via
|
|
50
|
+
// String.localeCompare(..., { numeric: true }) (listPrdsInternal in
|
|
51
|
+
// scheduler.cjs), so offset paging can't skip or duplicate an entry
|
|
52
|
+
// between calls as long as the underlying PRD set is unchanged.
|
|
53
|
+
// `limit`/`offset` default to 100/0 (hard max limit 500); `total` is the
|
|
54
|
+
// filtered count BEFORE the page slice, and `hasMore` is derived from
|
|
55
|
+
// `total`, not from whether the page came back full. `fields=full` opts
|
|
56
|
+
// into the secondary detail fields (parallelGroup, estimateMinutes,
|
|
57
|
+
// sourcePromptId, epicId, archivedStatus) omitted by default.
|
|
48
58
|
adminHttp.registerRoute('GET', '/admin/scheduler/prds', async (req, res, query) => {
|
|
49
59
|
let input;
|
|
50
60
|
try {
|
|
@@ -53,8 +63,8 @@ function registerAdminRoute(adminHttp, remote) {
|
|
|
53
63
|
sendJson(res, 400, { ok: false, error: 'invalid query', details: e?.issues ?? e?.message });
|
|
54
64
|
return;
|
|
55
65
|
}
|
|
56
|
-
const prds = await remote.listPrds(input);
|
|
57
|
-
sendJson(res, 200, { ok: true, prds });
|
|
66
|
+
const { prds, total, limit, offset, hasMore } = await remote.listPrds(input);
|
|
67
|
+
sendJson(res, 200, { ok: true, prds, total, limit, offset, hasMore });
|
|
58
68
|
});
|
|
59
69
|
|
|
60
70
|
adminHttp.registerRoute('GET', '/admin/scheduler/prd', async (req, res, query) => {
|
|
@@ -62,9 +62,11 @@ function jobKey(job) {
|
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
64
|
* O(n) over `jobs`. Splits terminal jobs into `hot` (stays in queue.json)
|
|
65
|
-
* and `toArchive` (moves to history.jsonl). A completed/failed job
|
|
66
|
-
* only when ALL of:
|
|
67
|
-
* - status is 'completed' or '
|
|
65
|
+
* and `toArchive` (moves to history.jsonl). A completed/failed/skipped job
|
|
66
|
+
* archives only when ALL of:
|
|
67
|
+
* - status is 'completed', 'failed', or 'skipped' (skipped: a job whose
|
|
68
|
+
* PRD source vanished before dispatch — never ran, but still terminal,
|
|
69
|
+
* and must age out of the hot queue the same as any other finished row)
|
|
68
70
|
* - finishedAt parses and is older than opts.retentionMs (default
|
|
69
71
|
* HISTORY_RETENTION_MS)
|
|
70
72
|
* - it is not the healTargetForFix() parent of a pending/running fix-plan
|
|
@@ -90,7 +92,7 @@ function partitionJobs(jobs, nowMs, opts = {}) {
|
|
|
90
92
|
if (!j) continue;
|
|
91
93
|
const finishedMs = j.finishedAt ? Date.parse(j.finishedAt) : NaN;
|
|
92
94
|
const archivable =
|
|
93
|
-
(j.status === 'completed' || j.status === 'failed') &&
|
|
95
|
+
(j.status === 'completed' || j.status === 'failed' || j.status === 'skipped') &&
|
|
94
96
|
Number.isFinite(finishedMs) &&
|
|
95
97
|
(nowMs - finishedMs) > retentionMs &&
|
|
96
98
|
!protectedSlugs.has(j.slug);
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
const { z } = require('zod');
|
|
26
26
|
|
|
27
|
-
const JOB_STATUSES = ['pending', 'running', 'investigating', 'completed', 'failed', 'needs_review', 'quarantined'];
|
|
27
|
+
const JOB_STATUSES = ['pending', 'running', 'investigating', 'completed', 'skipped', 'failed', 'needs_review', 'quarantined'];
|
|
28
28
|
|
|
29
29
|
const ScheduleJobStatusSchema = z.enum(JOB_STATUSES);
|
|
30
30
|
|
|
@@ -44,7 +44,10 @@ const STATUS_HISTORY_CAP = 20;
|
|
|
44
44
|
* manual archive of an already-shipped PRD), pending->failed (admin
|
|
45
45
|
* cancelJob on a not-yet-started job)
|
|
46
46
|
* - running->completed|failed|needs_review (normal run outcomes, reaper),
|
|
47
|
-
* running->
|
|
47
|
+
* running->skipped (spawnJob:skip-archived's 'prd-missing' case — no
|
|
48
|
+
* executor ever ran; kept distinct from 'completed' so unrun work can't
|
|
49
|
+
* read as shipped), running->pending (halt/rate-limit reset,
|
|
50
|
+
* transient-failure retry)
|
|
48
51
|
* - investigating->failed|needs_review (restore prior status once the
|
|
49
52
|
* investigation probe exits), investigating->completed (defensive: the
|
|
50
53
|
* restored prior status could in principle be 'completed' if a caller
|
|
@@ -58,20 +61,30 @@ const STATUS_HISTORY_CAP = 20;
|
|
|
58
61
|
* - completed->pending (force-only reset, gated separately by
|
|
59
62
|
* resetJobFields' own guard — this table only says the edge is
|
|
60
63
|
* structurally legal, not that every caller may take it unconditionally)
|
|
64
|
+
* - skipped->pending (force-only reset, same resetJobFields guard as
|
|
65
|
+
* completed->pending — 'skipped' is otherwise terminal: no further
|
|
66
|
+
* transitions out)
|
|
61
67
|
* - quarantined->pending (reconcile()'s adopt path, PRD-authoring lockdown:
|
|
62
68
|
* a PRD discovered with no `createdVia` provenance stamp is queued
|
|
63
69
|
* 'quarantined' instead of 'pending'; the ONLY way out is the PRD being
|
|
64
70
|
* stamped via the update-prd API — reconcile() detects the stamp on its
|
|
65
71
|
* next pass and promotes the row)
|
|
72
|
+
* - pending|investigating|needs_review|quarantined -> skipped
|
|
73
|
+
* (schedule:clear-queue: a manual queue clear force-terminates any
|
|
74
|
+
* non-running victim row so it can leave a durable history.jsonl record
|
|
75
|
+
* instead of vanishing — same 'never ran, don't call it completed'
|
|
76
|
+
* rationale as running->skipped, just reachable from every pre-dispatch
|
|
77
|
+
* or unresolved status a queued row can be sitting in)
|
|
66
78
|
*/
|
|
67
79
|
const LEGAL_TRANSITIONS = {
|
|
68
|
-
pending: ['running', 'completed', 'failed'],
|
|
69
|
-
running: ['completed', 'failed', 'needs_review', 'pending'],
|
|
70
|
-
investigating: ['failed', 'needs_review', 'completed', 'pending'],
|
|
80
|
+
pending: ['running', 'completed', 'failed', 'skipped'],
|
|
81
|
+
running: ['completed', 'failed', 'needs_review', 'skipped', 'pending'],
|
|
82
|
+
investigating: ['failed', 'needs_review', 'completed', 'pending', 'skipped'],
|
|
71
83
|
failed: ['investigating', 'pending', 'completed'],
|
|
72
|
-
needs_review: ['investigating', 'pending', 'completed'],
|
|
84
|
+
needs_review: ['investigating', 'pending', 'completed', 'skipped'],
|
|
73
85
|
completed: ['pending'],
|
|
74
|
-
|
|
86
|
+
skipped: ['pending'],
|
|
87
|
+
quarantined: ['pending', 'skipped'],
|
|
75
88
|
};
|
|
76
89
|
|
|
77
90
|
let refusedTransitionCount = 0;
|
|
@@ -97,6 +97,12 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
|
|
|
97
97
|
|
|
98
98
|
const pending = [];
|
|
99
99
|
const heldByFailedDep = [];
|
|
100
|
+
// A dep that never ran (its PRD source vanished before dispatch — see
|
|
101
|
+
// scheduleJobSchema.cjs's 'skipped' status) is just as blocking as a
|
|
102
|
+
// failed one and deserves the same explicit reason, not a silent fall into
|
|
103
|
+
// the generic `holds` bucket — resetting won't help here (the source file
|
|
104
|
+
// is gone), so the guidance differs from the failed-dep case.
|
|
105
|
+
const heldBySkippedDep = [];
|
|
100
106
|
// Per-job hold records, surfaced to the UI so a `pending` row can say WHICH
|
|
101
107
|
// dep is holding it instead of leaving the reason in console.log.
|
|
102
108
|
const holds = [];
|
|
@@ -105,7 +111,9 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
|
|
|
105
111
|
if (!dep) { pending.push(j); continue; }
|
|
106
112
|
const depRows = rowsForDep(dep);
|
|
107
113
|
const failed = depRows.some((d) => d.status === 'failed');
|
|
114
|
+
const skipped = depRows.some((d) => d.status === 'skipped');
|
|
108
115
|
if (failed) heldByFailedDep.push({ job: j, dep });
|
|
116
|
+
else if (skipped) heldBySkippedDep.push({ job: j, dep });
|
|
109
117
|
holds.push({
|
|
110
118
|
slug: j.slug,
|
|
111
119
|
dep,
|
|
@@ -114,9 +122,17 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
|
|
|
114
122
|
// running/pending/needs_review dep — simply not eligible this tick.
|
|
115
123
|
}
|
|
116
124
|
if (pending.length === 0) {
|
|
125
|
+
const reasons = [];
|
|
117
126
|
if (heldByFailedDep.length > 0) {
|
|
118
127
|
const detail = heldByFailedDep.map(({ job, dep }) => `${job.slug} <- ${dep}`).join(', ');
|
|
119
|
-
|
|
128
|
+
reasons.push(`holding ${heldByFailedDep.length} job(s) behind failed dependencies [${detail}]. Reset or archive the dep to unblock.`);
|
|
129
|
+
}
|
|
130
|
+
if (heldBySkippedDep.length > 0) {
|
|
131
|
+
const detail = heldBySkippedDep.map(({ job, dep }) => `${job.slug} <- ${dep}`).join(', ');
|
|
132
|
+
reasons.push(`holding ${heldBySkippedDep.length} job(s) behind never-ran dependencies [${detail}]. The dep's PRD source is gone — author a fresh PRD for that work to unblock.`);
|
|
133
|
+
}
|
|
134
|
+
if (reasons.length > 0) {
|
|
135
|
+
const reason = `[scheduler] depends-gate [${projectCwd}]: ${reasons.join(' ')}`;
|
|
120
136
|
console.log(reason);
|
|
121
137
|
return { batch: [], reason, holds };
|
|
122
138
|
}
|