claude-code-session-manager 0.75.1 → 0.75.3
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-CGplUKxY.js → AgentLibrary-CzQqcObq.js} +1 -1
- package/dist/assets/{DataModel-DQ15KxVb.js → DataModel-Bj_WlLz8.js} +1 -1
- package/dist/assets/{History-v2TSC_6A.js → History-DnSi_OHm.js} +1 -1
- package/dist/assets/{Hooks-Co7s6BNB.js → Hooks-0BB0dp3S.js} +1 -1
- package/dist/assets/{HostBilko-DJj8FWPK.js → HostBilko-DHpwwsLQ.js} +1 -1
- package/dist/assets/{Library-DQaC9VQ1.js → Library-CaJVqVvi.js} +1 -1
- package/dist/assets/{ListDetail-BwFRfAZm.js → ListDetail-C1W2HmC2.js} +1 -1
- package/dist/assets/{MarkdownEditor-C155gNpc.js → MarkdownEditor-5Ob9FW3z.js} +1 -1
- package/dist/assets/{McpServers-BmLgE_gS.js → McpServers-JxCSfm1S.js} +1 -1
- package/dist/assets/{Memory-ISwNcS5j.js → Memory-BDeqlqwH.js} +1 -1
- package/dist/assets/{Panel-1adjEpj-.js → Panel-Dh9ZHuEj.js} +1 -1
- package/dist/assets/{Permissions-FrCz5cGa.js → Permissions-DXy-CbEY.js} +1 -1
- package/dist/assets/{Plugins-zUssKp_b.js → Plugins-_n1Iuc8T.js} +2 -2
- package/dist/assets/{ProvenanceBadge-CAXqA_HX.js → ProvenanceBadge-BP_evfxE.js} +1 -1
- package/dist/assets/{SaveBar-D9cqms3Y.js → SaveBar-D-gCUx4n.js} +1 -1
- package/dist/assets/{Scheduler-dNNc9S11.js → Scheduler-Bpd4OGju.js} +3 -3
- package/dist/assets/{ScopeSwitcher-1wBTne7S.js → ScopeSwitcher-CAWzM6RI.js} +1 -1
- package/dist/assets/{Settings-BT_expIg.js → Settings-DRRozLyT.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-CVccq4Cu.js → SkillReferenceGraph-DGHDWlz4.js} +1 -1
- package/dist/assets/{Skills-xFpRY0ld.js → Skills-D8L66eiX.js} +1 -1
- package/dist/assets/{SystemPrompt-CEk_71th.js → SystemPrompt-CYtUsonD.js} +1 -1
- package/dist/assets/{TagLibrary-C2zhihQG.js → TagLibrary-E5CLeuVk.js} +1 -1
- package/dist/assets/{TiptapBody-CQKcHKaD.js → TiptapBody-B2hRgbPE.js} +1 -1
- package/dist/assets/{Toggle-CQ-IBb8m.js → Toggle-BTwsbxam.js} +1 -1
- package/dist/assets/{index-Dbo9R4e0.css → index-CMLnzdZC.css} +1 -1
- package/dist/assets/{index-BgbV3sKX.js → index-DijufvkJ.js} +4 -4
- package/dist/assets/{settingsSchema-D6ccSKqm.js → settingsSchema-D6wzxAi6.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/lib/activeSessions.cjs +35 -3
- 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-clear-queue-history.test.cjs +134 -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__/opsRootAbsoluteCwd.test.cjs +51 -0
- package/src/main/lib/__tests__/opsRootNestedWrite.test.cjs +51 -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/queueStore.cjs +42 -6
- 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 +89 -16
- package/src/preload/api.d.ts +1 -1
|
@@ -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
|
+
});
|
|
@@ -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({
|
|
@@ -124,3 +124,54 @@ test('the guards compose: no relative cwd from a transcript can reach an ops-roo
|
|
|
124
124
|
// And even if one somehow did, the write path itself refuses it.
|
|
125
125
|
expect(() => queueStore.projectStateDir('src/renderer/state')).toThrow(/absolute path/);
|
|
126
126
|
});
|
|
127
|
+
|
|
128
|
+
// ─── the second incident: an ABSOLUTE cwd INSIDE an ops root ────────────────
|
|
129
|
+
//
|
|
130
|
+
// Reported from starry-night-ships on 2026-08-30: 14 stray
|
|
131
|
+
// `.../session-manager-operations/scheduler/state/queue.json` stubs, each
|
|
132
|
+
// nested under another ops directory (`scheduler/prds/`, `prompt-sessions/`,
|
|
133
|
+
// `scheduler/epics/<id>/prds/`), every one holding `{"jobs": []}` and mtimes
|
|
134
|
+
// spanning 8 days. The 2026-08-13 absolute-path guard above could not catch
|
|
135
|
+
// them: these cwds ARE absolute and DO exist. They came from transcripts
|
|
136
|
+
// where an agent had `cd`'d into the artifact directory it was writing, so
|
|
137
|
+
// the last transcript row recorded that subdirectory as the session cwd.
|
|
138
|
+
|
|
139
|
+
test('a transcript cwd inside an ops root is normalized to the project root, not taken literally', async () => {
|
|
140
|
+
const project = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-opsroot-nested-'));
|
|
141
|
+
tmpDirs.push(project);
|
|
142
|
+
const prdDir = path.join(project, 'session-manager-operations', 'scheduler', 'epics', 'e-1', 'prds');
|
|
143
|
+
fs.mkdirSync(prdDir, { recursive: true });
|
|
144
|
+
const projectsDir = await mkProjectsDir({ nested: prdDir });
|
|
145
|
+
|
|
146
|
+
const cwds = activeProjectCwds(Infinity, { projectsDir });
|
|
147
|
+
expect(cwds).toContain(project);
|
|
148
|
+
expect(cwds).not.toContain(prdDir);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('normalization keeps the active-project signal rather than dropping the row', () => {
|
|
152
|
+
const { projectRootOf } = require('../../../../scripts/lib/activeSessions.cjs');
|
|
153
|
+
expect(projectRootOf('/p/session-manager-operations/prompt-sessions')).toBe('/p');
|
|
154
|
+
expect(projectRootOf('/p/session-manager-operations')).toBe('/p');
|
|
155
|
+
expect(projectRootOf('/p/session-manager-operations/scheduler/state')).toBe('/p');
|
|
156
|
+
// Already-nested strays truncate at the FIRST segment — the inner one is
|
|
157
|
+
// itself the bug, never a project.
|
|
158
|
+
expect(projectRootOf('/p/session-manager-operations/x/session-manager-operations/y')).toBe('/p');
|
|
159
|
+
// A plain project cwd is untouched.
|
|
160
|
+
expect(projectRootOf('/p/src/main')).toBe('/p/src/main');
|
|
161
|
+
// A directory that merely CONTAINS the name as a substring is not a segment.
|
|
162
|
+
expect(projectRootOf('/p/session-manager-operations-archive')).toBe('/p/session-manager-operations-archive');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('projectStateDir fails closed on an ops-internal cwd, naming the offending value', () => {
|
|
166
|
+
expect(() => queueStore.projectStateDir('/p/session-manager-operations/scheduler/prds'))
|
|
167
|
+
.toThrow(/must be a project root/);
|
|
168
|
+
expect(() => queueStore.projectStateDir('/p/session-manager-operations/scheduler/prds'))
|
|
169
|
+
.toThrow(/scheduler\/prds/);
|
|
170
|
+
// The guard is segment-exact, not a substring match.
|
|
171
|
+
expect(queueStore.projectStateDir('/p/session-manager-operations-archive'))
|
|
172
|
+
.toBe(path.join('/p/session-manager-operations-archive', 'session-manager-operations', 'scheduler', 'state'));
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// The writeSplit half of this guard lives in opsRootNestedWrite.test.cjs —
|
|
176
|
+
// it must override HOME before requiring queueStore (writeSplit persists
|
|
177
|
+
// machine state to a homedir path), which cannot be done from this file.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opsRootNestedWrite.test.cjs — writeSplit must never materialize a second
|
|
3
|
+
* ops root beneath the first.
|
|
4
|
+
*
|
|
5
|
+
* Companion to opsRootAbsoluteCwd.test.cjs; separate file because HOME must
|
|
6
|
+
* be overridden BEFORE requiring queueStore — MACHINE_STATE_PATH is baked
|
|
7
|
+
* into a top-level const from os.homedir() at require time, and writeSplit
|
|
8
|
+
* writes it on every call. (Learned the hard way: running this assertion in
|
|
9
|
+
* the shared file overwrote the real ~/.claude/session-manager/
|
|
10
|
+
* scheduler-machine.json, dropping the user's firePolicy and concurrencyCap.)
|
|
11
|
+
*
|
|
12
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/opsRootNestedWrite.test.cjs
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
import { test, expect, afterEach } from 'vitest';
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const fsp = require('node:fs/promises');
|
|
20
|
+
const os = require('node:os');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
|
|
23
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-opsroot-home-'));
|
|
24
|
+
process.env.HOME = tmpHome;
|
|
25
|
+
|
|
26
|
+
const queueStore = require('../queueStore.cjs');
|
|
27
|
+
|
|
28
|
+
const tmpDirs = [tmpHome];
|
|
29
|
+
afterEach(async () => {
|
|
30
|
+
while (tmpDirs.length > 1) {
|
|
31
|
+
await fsp.rm(tmpDirs.pop(), { recursive: true, force: true }).catch(() => {});
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('one ops-internal cwd in a write batch is skipped without losing the other projects', async () => {
|
|
36
|
+
const good = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-opsroot-good-'));
|
|
37
|
+
tmpDirs.push(good);
|
|
38
|
+
const bad = path.join(good, 'session-manager-operations', 'scheduler', 'epics', 'e-1', 'prds');
|
|
39
|
+
fs.mkdirSync(bad, { recursive: true });
|
|
40
|
+
|
|
41
|
+
await queueStore.writeSplit({
|
|
42
|
+
config: {},
|
|
43
|
+
sourceCwds: [bad, good],
|
|
44
|
+
jobs: [{ slug: 'keeper', cwd: good }, { slug: 'stray', cwd: bad }],
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const written = JSON.parse(fs.readFileSync(queueStore.projectQueuePath(good), 'utf8'));
|
|
48
|
+
expect(written.jobs.map((j) => j.slug)).toEqual(['keeper']);
|
|
49
|
+
// The doubled-suffix stub the starry-night-ships report was about.
|
|
50
|
+
expect(fs.existsSync(path.join(bad, 'session-manager-operations'))).toBe(false);
|
|
51
|
+
});
|
|
@@ -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);
|
|
@@ -38,7 +38,8 @@ const { ScheduleJobSchema } = require('./scheduleJobSchema.cjs');
|
|
|
38
38
|
|
|
39
39
|
const MACHINE_STATE_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'scheduler-machine.json');
|
|
40
40
|
const LEGACY_QUEUE_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'queue.json');
|
|
41
|
-
const
|
|
41
|
+
const OPS_DIRNAME = 'session-manager-operations';
|
|
42
|
+
const STATE_SUBPATH = [OPS_DIRNAME, 'scheduler', 'state'];
|
|
42
43
|
|
|
43
44
|
/**
|
|
44
45
|
* A project's `session-manager-operations/scheduler/state/` directory.
|
|
@@ -60,6 +61,20 @@ function projectStateDir(cwd) {
|
|
|
60
61
|
if (!path.isAbsolute(cwd)) {
|
|
61
62
|
throw new Error(`projectStateDir: cwd must be an absolute path, got "${cwd}"`);
|
|
62
63
|
}
|
|
64
|
+
// ...and it must not itself sit INSIDE an ops root. Absolute-and-existing
|
|
65
|
+
// was not enough: a transcript records an agent's `cd` into a PRD folder,
|
|
66
|
+
// activeSessions hands that subdirectory over as a project, and this join
|
|
67
|
+
// materializes a second ops root under the first — the doubled
|
|
68
|
+
// `.../prds/session-manager-operations/scheduler/state/queue.json` stubs
|
|
69
|
+
// found in starry-night-ships on 2026-08-30 (14 of them, 8 days' worth).
|
|
70
|
+
// activeSessions.projectRootOf normalizes such a cwd away upstream; this is
|
|
71
|
+
// the fail-closed backstop for every other caller. A queue file below an
|
|
72
|
+
// ops root is always a bug, never a project.
|
|
73
|
+
if (cwd.split(path.sep).includes(OPS_DIRNAME)) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`projectStateDir: cwd must be a project root, not a path inside ${OPS_DIRNAME}/, got "${cwd}"`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
63
78
|
return path.join(cwd, ...STATE_SUBPATH);
|
|
64
79
|
}
|
|
65
80
|
|
|
@@ -71,6 +86,22 @@ function projectHistoryPath(cwd) {
|
|
|
71
86
|
return path.join(projectStateDir(cwd), 'history.jsonl');
|
|
72
87
|
}
|
|
73
88
|
|
|
89
|
+
/**
|
|
90
|
+
* queuePathOrSkip(cwd, context) → the shard path, or null when `cwd` is not a
|
|
91
|
+
* usable project root. projectStateDir fails closed on a relative or
|
|
92
|
+
* ops-internal cwd; a single such job row (or a stale cached cwd) must not
|
|
93
|
+
* abort a whole read or write of every OTHER project's queue, so callers that
|
|
94
|
+
* loop over many cwds log-and-skip instead of throwing.
|
|
95
|
+
*/
|
|
96
|
+
function queuePathOrSkip(cwd, context) {
|
|
97
|
+
try {
|
|
98
|
+
return projectQueuePath(cwd);
|
|
99
|
+
} catch (e) {
|
|
100
|
+
console.error(`[queueStore] ${context}: skipping invalid project cwd — ${e?.message}`);
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
74
105
|
function writeJsonAtomicSync(file, value) {
|
|
75
106
|
assertOpsWrite(file, 'scheduler');
|
|
76
107
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
@@ -183,7 +214,8 @@ function readMergedSync(opts) {
|
|
|
183
214
|
}
|
|
184
215
|
}
|
|
185
216
|
for (const cwd of stateCwds(opts)) {
|
|
186
|
-
const file =
|
|
217
|
+
const file = queuePathOrSkip(cwd, 'read');
|
|
218
|
+
if (!file) continue;
|
|
187
219
|
try {
|
|
188
220
|
const { jobs, invalid } = shapeJobs(fs.readFileSync(file, 'utf8'), file);
|
|
189
221
|
out.jobs.push(...jobs);
|
|
@@ -212,7 +244,8 @@ async function readMerged(opts) {
|
|
|
212
244
|
}
|
|
213
245
|
}
|
|
214
246
|
for (const cwd of stateCwds(opts)) {
|
|
215
|
-
const file =
|
|
247
|
+
const file = queuePathOrSkip(cwd, 'read');
|
|
248
|
+
if (!file) continue;
|
|
216
249
|
try {
|
|
217
250
|
const { jobs, invalid } = shapeJobs(await fsp.readFile(file, 'utf8'), file);
|
|
218
251
|
out.jobs.push(...jobs);
|
|
@@ -261,12 +294,14 @@ async function writeSplit(state, defaultCwd) {
|
|
|
261
294
|
byCwd.get(cwd).push(job);
|
|
262
295
|
}
|
|
263
296
|
for (const [cwd, jobs] of byCwd) {
|
|
297
|
+
const file = queuePathOrSkip(cwd, 'writeSplit');
|
|
298
|
+
if (!file) continue;
|
|
264
299
|
try {
|
|
265
|
-
await writeJsonAtomic(
|
|
300
|
+
await writeJsonAtomic(file, { jobs });
|
|
266
301
|
} catch (e) {
|
|
267
302
|
// A single unwritable project (deleted repo dir, permissions) must not
|
|
268
303
|
// lose every other project's write.
|
|
269
|
-
console.error(`[queueStore] failed to write ${
|
|
304
|
+
console.error(`[queueStore] failed to write ${file}: ${e?.message}`);
|
|
270
305
|
}
|
|
271
306
|
}
|
|
272
307
|
bustCwdCache();
|
|
@@ -316,7 +351,8 @@ async function migrateLegacyGlobalQueue(defaultCwd) {
|
|
|
316
351
|
}
|
|
317
352
|
let moved = 0;
|
|
318
353
|
for (const [cwd, jobs] of byCwd) {
|
|
319
|
-
const file =
|
|
354
|
+
const file = queuePathOrSkip(cwd, 'legacy split');
|
|
355
|
+
if (!file) continue;
|
|
320
356
|
let existing = [];
|
|
321
357
|
try { existing = shapeJobs(await fsp.readFile(file, 'utf8'), file).jobs; } catch { /* fresh shard */ }
|
|
322
358
|
const have = new Set(existing.map((j) => 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
|
}
|