claude-code-session-manager 0.86.0 → 0.87.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-DyLWzZDf.js +3 -0
- package/dist/assets/{DataModel-Q4jhl24R.js → DataModel--mISIJ6h.js} +1 -1
- package/dist/assets/{History-Cj2FejEo.js → History-C2ahUXTg.js} +2 -2
- package/dist/assets/{Hooks-CaelQI6t.js → Hooks-BiC6oyR2.js} +3 -3
- package/dist/assets/{HostBilko--v7cMR8I.js → HostBilko-BPleEOld.js} +1 -1
- package/dist/assets/{Library-DgI9oCCZ.js → Library-Dc8Qst1R.js} +1 -1
- package/dist/assets/{ListDetail-DYUZN-x-.js → ListDetail-DIXh-OLX.js} +1 -1
- package/dist/assets/MarkdownEditor-C90bkLXK.js +1 -0
- package/dist/assets/{McpServers-ypCYURh3.js → McpServers-DqcbLOLZ.js} +2 -2
- package/dist/assets/{Memory-C2qYp-3M.js → Memory-CW62MXlh.js} +4 -4
- package/dist/assets/{Panel-Cj2kw-Zv.js → Panel-Bw1FhRuF.js} +1 -1
- package/dist/assets/Permissions-BcUC-5y8.js +3 -0
- package/dist/assets/{Plugins-C1Vj8_dU.js → Plugins-BnKx9flD.js} +2 -2
- package/dist/assets/{ProvenanceBadge-DczPNM5U.js → ProvenanceBadge-Bw5vNVPT.js} +1 -1
- package/dist/assets/{SaveBar-Cd_7U6Gb.js → SaveBar-CWr0O_w-.js} +1 -1
- package/dist/assets/Scheduler-DYdLuUqq.js +14 -0
- package/dist/assets/{ScopeSwitcher-DVSyI44-.js → ScopeSwitcher-CrBLbg8s.js} +1 -1
- package/dist/assets/Settings-DluB-vN1.js +3 -0
- package/dist/assets/{SkillReferenceGraph-CUv1_Q2c.js → SkillReferenceGraph-CHLSseay.js} +1 -1
- package/dist/assets/{Skills-C_YHkAy-.js → Skills-gNdo_HNK.js} +2 -2
- package/dist/assets/{SystemPrompt-B8R7T9xn.js → SystemPrompt-Cru05-Ia.js} +1 -1
- package/dist/assets/{TagLibrary-dj9YHWyy.js → TagLibrary-DNHY0xou.js} +1 -1
- package/dist/assets/{TiptapBody-DnSBUjHE.js → TiptapBody-I4lmbCgP.js} +1 -1
- package/dist/assets/{Toggle-CjV_BJn6.js → Toggle-bWMHjmRh.js} +1 -1
- package/dist/assets/{index-CDo9xBR9.css → index-DV3PorRY.css} +1 -1
- package/dist/assets/{index-CXFQIPhO.js → index-fc_JjdxL.js} +724 -724
- package/dist/assets/settingsSchema-BfhtZnGD.js +3 -0
- package/dist/index.html +2 -2
- package/package.json +14 -14
- package/plugins/CLAUDE.md +61 -0
- package/plugins/session-manager-dev/.claude-plugin/plugin.json +1 -1
- package/plugins/session-manager-dev/skills/builder/4-manual/SKILL.md +1 -1
- package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +1 -1
- package/scripts/scheduler-mcp-server.cjs +7 -0
- package/src/main/__tests__/agentModelResolve.test.cjs +100 -9
- package/src/main/__tests__/broadcastCoalescer.test.cjs +18 -0
- package/src/main/__tests__/epicMint.test.cjs +2 -2
- package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
- package/src/main/__tests__/needsReviewLedger.test.cjs +162 -0
- package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +3 -3
- package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +15 -1
- package/src/main/__tests__/prdCreateDisposition.test.cjs +201 -0
- package/src/main/__tests__/prdFrontmatterDisposition.test.cjs +125 -0
- package/src/main/__tests__/prdLocations.test.cjs +100 -2
- package/src/main/__tests__/prdLocationsArchived.test.cjs +43 -1
- package/src/main/__tests__/prdSetDisposition.test.cjs +222 -0
- package/src/main/__tests__/queue-health-verdict.test.cjs +170 -0
- package/src/main/__tests__/queue-starvation-per-project.test.cjs +14 -2
- package/src/main/__tests__/queueHistory.test.cjs +63 -0
- package/src/main/__tests__/reconcileTiming.test.cjs +135 -0
- package/src/main/__tests__/scheduleJobTransitions.test.cjs +101 -1
- package/src/main/__tests__/scheduler-boot-orphans.test.cjs +2 -2
- package/src/main/__tests__/scheduler-broadcast-reconcile.test.cjs +121 -0
- package/src/main/__tests__/scheduler-cross-project-batch.test.cjs +43 -0
- package/src/main/__tests__/scheduler-guard-verdict-autoresolve.test.cjs +344 -0
- package/src/main/__tests__/scheduler-job-budget.test.cjs +172 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +93 -3
- package/src/main/__tests__/scheduler-porcelain-rename.test.cjs +164 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +160 -0
- package/src/main/__tests__/scheduler-reaper-helpers-basics.test.cjs +87 -0
- package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +88 -0
- package/src/main/__tests__/scheduler-starve-escalation.test.cjs +14 -4
- package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +14 -0
- package/src/main/chatRunner.cjs +8 -5
- package/src/main/health.cjs +1 -1
- package/src/main/historyAggregator.cjs +5 -0
- package/src/main/index.cjs +95 -44
- package/src/main/ipcSchemas.cjs +47 -0
- package/src/main/lib/__tests__/active-sessions.test.cjs +251 -0
- package/src/main/lib/__tests__/bootSelfHeal.test.cjs +107 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +322 -43
- package/src/main/lib/__tests__/effectiveModelInfo.test.cjs +239 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +89 -0
- package/src/main/lib/__tests__/guardShims.test.cjs +151 -0
- package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +5 -5
- package/src/main/lib/__tests__/prdDisposition.test.cjs +224 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +179 -1
- package/src/main/lib/__tests__/usageCircuit.test.cjs +224 -0
- package/src/main/lib/__tests__/watchdog-helpers.test.cjs +312 -0
- package/src/main/lib/__tests__/watchdog-relaunch.test.cjs +193 -0
- package/{scripts → src/main}/lib/activeSessions.cjs +50 -4
- package/src/main/lib/agentModelResolve.cjs +65 -27
- package/src/main/lib/bootSelfHeal.cjs +88 -0
- package/src/main/lib/delegationReadiness.cjs +290 -225
- package/src/main/lib/effectiveModelInfo.cjs +333 -0
- package/src/main/lib/ephemeralCwd.cjs +1 -1
- package/src/main/lib/epicMint.cjs +3 -3
- package/src/main/lib/gitWorktree.cjs +42 -12
- package/src/main/lib/guardShims.cjs +156 -0
- package/src/main/lib/jobDirtFilter.cjs +7 -2
- package/src/main/lib/launchFailure.cjs +2 -1
- package/src/main/lib/mcpToolCatalog.cjs +4 -1
- package/src/main/lib/needsReviewLedger.cjs +205 -0
- package/src/main/lib/opsErrorLog.cjs +1 -1
- package/src/main/lib/opsOwnership.cjs +1 -1
- package/src/main/lib/prdCreate.cjs +56 -1
- package/src/main/lib/prdDisposition.cjs +199 -0
- package/src/main/lib/prdFrontmatter.cjs +8 -2
- package/src/main/lib/prdLocations.cjs +167 -45
- package/src/main/lib/projectHomeAdminRoutes.cjs +4 -4
- package/src/main/lib/projectPageSummarySchema.cjs +1 -1
- package/src/main/lib/projectRootResolve.cjs +1 -1
- package/src/main/lib/queueHistory.cjs +19 -1
- package/src/main/lib/queueStore.cjs +6 -1
- package/src/main/lib/reaperHelpers.cjs +181 -15
- package/src/main/lib/scheduleJobSchema.cjs +8 -0
- package/src/main/lib/scheduleJobTransitions.cjs +33 -0
- package/src/main/lib/schedulerConfig.cjs +24 -0
- package/src/main/lib/usageCircuit.cjs +159 -0
- package/{scripts → src/main}/lib/watchdogHelpers.cjs +1 -1
- package/src/main/scheduler/prdParser.cjs +13 -0
- package/src/main/scheduler.cjs +1285 -143
- package/src/main/templates/PRD_AUTHORING.md +50 -0
- package/src/main/templates/project-pages-catalog.json +1 -1
- package/src/main/usage.cjs +21 -3
- package/src/preload/api.d.ts +92 -1
- package/src/preload/index.cjs +10 -0
- package/web/README.md +41 -0
- package/{scripts/render-project-pages.cjs → web/project-pages/render.cjs} +4 -4
- package/{scripts/render-project-pages → web/project-pages/renderer}/dist/renderer.cjs +1 -1
- package/{scripts/validate-project-pages-summary.cjs → web/project-pages/validate-summary.cjs} +5 -5
- package/dist/assets/AgentLibrary-DTFL7y8G.js +0 -3
- package/dist/assets/MarkdownEditor-DCIubYWf.js +0 -1
- package/dist/assets/Permissions-BiYZNGYW.js +0 -3
- package/dist/assets/Scheduler-DcLBiJBq.js +0 -14
- package/dist/assets/Settings-Cv-pRyms.js +0 -3
- package/dist/assets/settingsSchema-BJVciriw.js +0 -3
- /package/{scripts/project-pages-logic → web/project-pages/logic}/dist/logic.cjs +0 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prdSetDisposition.test.cjs — scheduler wave-disposition PRD:
|
|
3
|
+
* scheduler.remote.setPrdDisposition (backing the renderer's
|
|
4
|
+
* `schedule:set-prd-disposition` IPC action) is how a human changes an
|
|
5
|
+
* already-written PRD's disposition from the Scheduler UI — promoting an
|
|
6
|
+
* appended wave to its own head, or re-attaching a head behind another
|
|
7
|
+
* chain. Validates via prdDisposition.cjs's computeDispositionRewrite
|
|
8
|
+
* (cycle-safety, running/completed rows untouched) before delegating the
|
|
9
|
+
* actual write to the SAME remote.updatePrd every other PRD edit path uses.
|
|
10
|
+
*
|
|
11
|
+
* Mirrors prdUpdateDependsOn.test.cjs's HOME-isolation fixture.
|
|
12
|
+
*
|
|
13
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/prdSetDisposition.test.cjs
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
import { test, expect, beforeAll, afterAll } from 'vitest';
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const os = require('node:os');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
|
|
23
|
+
let tmpHome;
|
|
24
|
+
let originalHome;
|
|
25
|
+
let scheduler;
|
|
26
|
+
let queueStore;
|
|
27
|
+
let config;
|
|
28
|
+
|
|
29
|
+
beforeAll(() => {
|
|
30
|
+
originalHome = process.env.HOME;
|
|
31
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-set-disposition-home-'));
|
|
32
|
+
process.env.HOME = tmpHome;
|
|
33
|
+
|
|
34
|
+
scheduler = require('../scheduler.cjs');
|
|
35
|
+
queueStore = require('../lib/queueStore.cjs');
|
|
36
|
+
config = require('../config.cjs');
|
|
37
|
+
|
|
38
|
+
if (!scheduler.PRDS_DIR.startsWith(tmpHome)) {
|
|
39
|
+
throw new Error(`refusing to run: PRDS_DIR (${scheduler.PRDS_DIR}) is not under the temp HOME (${tmpHome})`);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterAll(() => {
|
|
44
|
+
process.env.HOME = originalHome;
|
|
45
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
function registerActiveProject(cwd) {
|
|
49
|
+
const projectsDir = path.join(tmpHome, '.claude', 'projects');
|
|
50
|
+
const slugDir = path.join(projectsDir, `fake-project-slug-${path.basename(cwd)}`);
|
|
51
|
+
fs.mkdirSync(slugDir, { recursive: true });
|
|
52
|
+
fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function makeFixtureProject(prefix) {
|
|
56
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
57
|
+
registerActiveProject(cwd);
|
|
58
|
+
config.addAllowedRoot(cwd);
|
|
59
|
+
const opsRoot = path.join(cwd, 'session-manager-operations');
|
|
60
|
+
const prdsDir = path.join(opsRoot, 'scheduler', 'epics', 'test-epic-1', 'prds');
|
|
61
|
+
const stateDir = path.join(opsRoot, 'scheduler', 'state');
|
|
62
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
63
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
64
|
+
fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs: [] }, null, 2), 'utf8');
|
|
65
|
+
queueStore.bustCwdCache();
|
|
66
|
+
return { cwd, prdsDir, stateDir };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function writePrd(prdsDir, filename, cwd, extraFrontmatter = '') {
|
|
70
|
+
fs.writeFileSync(
|
|
71
|
+
path.join(prdsDir, filename),
|
|
72
|
+
`---\ntitle: ${filename}\ncwd: ${cwd}\nestimateMinutes: 10\ncreatedVia: scheduler-api\n${extraFrontmatter}---\n\n# Goal\nDo the thing.\n`,
|
|
73
|
+
'utf8',
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Seeds a queue.json row for `slug` with the given status — setPrdDisposition
|
|
78
|
+
* (like updatePrd) reads job status off the live queue, not the PRD file. */
|
|
79
|
+
function seedQueueJob(stateDir, slug, status) {
|
|
80
|
+
fs.writeFileSync(
|
|
81
|
+
path.join(stateDir, 'queue.json'),
|
|
82
|
+
JSON.stringify({ jobs: [{ slug, status, title: slug, cwd: '' }] }, null, 2),
|
|
83
|
+
'utf8',
|
|
84
|
+
);
|
|
85
|
+
queueStore.bustCwdCache();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
test('append sets the expected edges', async () => {
|
|
89
|
+
const { cwd, prdsDir } = makeFixtureProject('sm-set-disposition-append-');
|
|
90
|
+
try {
|
|
91
|
+
writePrd(prdsDir, '3-wave1-tail.md', cwd);
|
|
92
|
+
writePrd(prdsDir, '4-wave2-root.md', cwd);
|
|
93
|
+
|
|
94
|
+
const result = await scheduler.remote.setPrdDisposition({
|
|
95
|
+
slug: '4-wave2-root',
|
|
96
|
+
cwd,
|
|
97
|
+
disposition: 'append',
|
|
98
|
+
dependsOn: ['3-wave1-tail'],
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(result.ok).toBe(true);
|
|
102
|
+
const written = fs.readFileSync(path.join(prdsDir, '4-wave2-root.md'), 'utf8');
|
|
103
|
+
expect(written).toMatch(/^dependsOn: \[3-wave1-tail\]$/m);
|
|
104
|
+
expect(written).toMatch(/^disposition: append$/m);
|
|
105
|
+
} finally {
|
|
106
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('new-head sets none', async () => {
|
|
111
|
+
const { cwd, prdsDir } = makeFixtureProject('sm-set-disposition-new-head-');
|
|
112
|
+
try {
|
|
113
|
+
writePrd(prdsDir, '3-wave1-tail.md', cwd);
|
|
114
|
+
writePrd(prdsDir, '4-wave2-root.md', cwd, 'dependsOn: [wave1-tail]\ndisposition: append\n');
|
|
115
|
+
|
|
116
|
+
const result = await scheduler.remote.setPrdDisposition({
|
|
117
|
+
slug: '4-wave2-root',
|
|
118
|
+
cwd,
|
|
119
|
+
disposition: 'new-head',
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(result.ok).toBe(true);
|
|
123
|
+
const written = fs.readFileSync(path.join(prdsDir, '4-wave2-root.md'), 'utf8');
|
|
124
|
+
expect(written).not.toContain('dependsOn');
|
|
125
|
+
expect(written).toMatch(/^disposition: new-head$/m);
|
|
126
|
+
} finally {
|
|
127
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('promoting a wave to a head removes exactly its root edges', async () => {
|
|
132
|
+
const { cwd, prdsDir } = makeFixtureProject('sm-set-disposition-promote-');
|
|
133
|
+
try {
|
|
134
|
+
writePrd(prdsDir, '3-wave1-tail.md', cwd);
|
|
135
|
+
writePrd(prdsDir, '5-other-head.md', cwd);
|
|
136
|
+
writePrd(prdsDir, '4-wave2-root.md', cwd, 'dependsOn: [wave1-tail]\ndisposition: append\n');
|
|
137
|
+
|
|
138
|
+
const result = await scheduler.remote.setPrdDisposition({
|
|
139
|
+
slug: '4-wave2-root',
|
|
140
|
+
cwd,
|
|
141
|
+
disposition: 'new-head',
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(result.ok).toBe(true);
|
|
145
|
+
const promoted = fs.readFileSync(path.join(prdsDir, '4-wave2-root.md'), 'utf8');
|
|
146
|
+
expect(promoted).not.toContain('dependsOn');
|
|
147
|
+
// The unrelated sibling head is untouched by this call.
|
|
148
|
+
const other = fs.readFileSync(path.join(prdsDir, '5-other-head.md'), 'utf8');
|
|
149
|
+
expect(other).not.toContain('dependsOn');
|
|
150
|
+
} finally {
|
|
151
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('a change that would create a cycle is rejected, and nothing is written', async () => {
|
|
156
|
+
const { cwd, prdsDir } = makeFixtureProject('sm-set-disposition-cycle-');
|
|
157
|
+
try {
|
|
158
|
+
writePrd(prdsDir, '3-a.md', cwd, 'dependsOn: [b]\n');
|
|
159
|
+
writePrd(prdsDir, '4-b.md', cwd);
|
|
160
|
+
const before = fs.readFileSync(path.join(prdsDir, '4-b.md'), 'utf8');
|
|
161
|
+
|
|
162
|
+
const result = await scheduler.remote.setPrdDisposition({
|
|
163
|
+
slug: '4-b',
|
|
164
|
+
cwd,
|
|
165
|
+
disposition: 'append',
|
|
166
|
+
dependsOn: ['3-a'],
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(result.ok).toBe(false);
|
|
170
|
+
expect(result.error).toMatch(/cycle/i);
|
|
171
|
+
const after = fs.readFileSync(path.join(prdsDir, '4-b.md'), 'utf8');
|
|
172
|
+
expect(after).toBe(before);
|
|
173
|
+
} finally {
|
|
174
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('running rows are untouched', async () => {
|
|
179
|
+
const { cwd, prdsDir, stateDir } = makeFixtureProject('sm-set-disposition-running-');
|
|
180
|
+
try {
|
|
181
|
+
writePrd(prdsDir, '3-wave1-tail.md', cwd);
|
|
182
|
+
writePrd(prdsDir, '4-wave2-root.md', cwd, 'dependsOn: [wave1-tail]\ndisposition: append\n');
|
|
183
|
+
seedQueueJob(stateDir, '4-wave2-root', 'running');
|
|
184
|
+
const before = fs.readFileSync(path.join(prdsDir, '4-wave2-root.md'), 'utf8');
|
|
185
|
+
|
|
186
|
+
const result = await scheduler.remote.setPrdDisposition({
|
|
187
|
+
slug: '4-wave2-root',
|
|
188
|
+
cwd,
|
|
189
|
+
disposition: 'new-head',
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
expect(result.ok).toBe(false);
|
|
193
|
+
expect(result.error).toMatch(/running/i);
|
|
194
|
+
const after = fs.readFileSync(path.join(prdsDir, '4-wave2-root.md'), 'utf8');
|
|
195
|
+
expect(after).toBe(before);
|
|
196
|
+
} finally {
|
|
197
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('completed rows are untouched', async () => {
|
|
202
|
+
const { cwd, prdsDir, stateDir } = makeFixtureProject('sm-set-disposition-completed-');
|
|
203
|
+
try {
|
|
204
|
+
writePrd(prdsDir, '3-wave1-tail.md', cwd);
|
|
205
|
+
writePrd(prdsDir, '4-wave2-root.md', cwd, 'dependsOn: [wave1-tail]\ndisposition: append\n');
|
|
206
|
+
seedQueueJob(stateDir, '4-wave2-root', 'completed');
|
|
207
|
+
const before = fs.readFileSync(path.join(prdsDir, '4-wave2-root.md'), 'utf8');
|
|
208
|
+
|
|
209
|
+
const result = await scheduler.remote.setPrdDisposition({
|
|
210
|
+
slug: '4-wave2-root',
|
|
211
|
+
cwd,
|
|
212
|
+
disposition: 'new-head',
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
expect(result.ok).toBe(false);
|
|
216
|
+
expect(result.error).toMatch(/completed/i);
|
|
217
|
+
const after = fs.readFileSync(path.join(prdsDir, '4-wave2-root.md'), 'utf8');
|
|
218
|
+
expect(after).toBe(before);
|
|
219
|
+
} finally {
|
|
220
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
221
|
+
}
|
|
222
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* queue-health-verdict.test.cjs — classifyQueueHealth is the Scheduler
|
|
3
|
+
* page's queue-health header: the single honest read of WHY a queue looks
|
|
4
|
+
* stale (saturated slots / blocked dependencies / genuinely idle / the
|
|
5
|
+
* dispatch driver itself stalled / a decision to pause / an open launch
|
|
6
|
+
* circuit breaker), reusing classifyQueueStarvation so the header can never
|
|
7
|
+
* disagree with the starvation watchdog's own read of the same state.
|
|
8
|
+
*
|
|
9
|
+
* Run: timeout 180 npx vitest run src/main/__tests__/queue-health-verdict.test.cjs
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
import { test, afterAll } from 'vitest';
|
|
15
|
+
const assert = require('node:assert/strict');
|
|
16
|
+
const fs = require('node:fs');
|
|
17
|
+
const os = require('node:os');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
|
|
20
|
+
// HOME before require: every state path is baked from os.homedir() at load.
|
|
21
|
+
const originalHome = process.env.HOME;
|
|
22
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'queue-health-test-'));
|
|
23
|
+
process.env.HOME = tmpHome;
|
|
24
|
+
fs.mkdirSync(path.join(tmpHome, '.claude', 'session-manager'), { recursive: true });
|
|
25
|
+
fs.mkdirSync(path.join(tmpHome, '.claude', 'projects'), { recursive: true });
|
|
26
|
+
|
|
27
|
+
const { classifyQueueHealth, QUEUE_STARVATION_MS } = require('../scheduler.cjs');
|
|
28
|
+
|
|
29
|
+
afterAll(() => {
|
|
30
|
+
process.env.HOME = originalHome;
|
|
31
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const CWD = '/home/bilko/Projects/starry-night-ships';
|
|
35
|
+
const NOW = Date.parse('2026-09-13T20:00:00.000Z');
|
|
36
|
+
const LONG = QUEUE_STARVATION_MS + 60_000;
|
|
37
|
+
|
|
38
|
+
test('saturated: jobs running, zero free slots', () => {
|
|
39
|
+
const jobs = [
|
|
40
|
+
{ slug: '1-a', cwd: CWD, status: 'running', startedAt: new Date(NOW - LONG).toISOString(), dependsOn: [] },
|
|
41
|
+
{ slug: '2-b', cwd: CWD, status: 'pending', dependsOn: [] },
|
|
42
|
+
];
|
|
43
|
+
const v = classifyQueueHealth({
|
|
44
|
+
jobs, paused: null, launchBlocks: {}, runningSet: new Set(['1-a']),
|
|
45
|
+
freeSlots: 0, totalSlots: 4, lastDispatchAttemptAtMs: NOW, now: NOW, cwd: CWD,
|
|
46
|
+
});
|
|
47
|
+
assert.equal(v.kind, 'saturated');
|
|
48
|
+
assert.equal(v.runningCount, 1);
|
|
49
|
+
assert.equal(v.totalSlots, 4);
|
|
50
|
+
// Dispatchable/blocked counts stay real numbers even in a kind whose
|
|
51
|
+
// headline cause is slot saturation, not dependency shape — the header
|
|
52
|
+
// must still be able to show them alongside the verdict.
|
|
53
|
+
assert.equal(v.pending, 1);
|
|
54
|
+
assert.equal(v.dispatchable, 1);
|
|
55
|
+
assert.deepEqual(v.blockedChains, []);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('blocked: every pending row terminates in a failed dependency', () => {
|
|
59
|
+
const jobs = [
|
|
60
|
+
{ slug: '1-a', cwd: CWD, status: 'failed', dependsOn: [] },
|
|
61
|
+
{ slug: '2-b', cwd: CWD, status: 'pending', dependsOn: ['1-a'] },
|
|
62
|
+
];
|
|
63
|
+
const v = classifyQueueHealth({
|
|
64
|
+
jobs, paused: null, launchBlocks: {}, runningSet: new Set(),
|
|
65
|
+
freeSlots: 4, totalSlots: 4, lastDispatchAttemptAtMs: NOW - 5_000, now: NOW, cwd: CWD,
|
|
66
|
+
});
|
|
67
|
+
assert.equal(v.kind, 'blocked');
|
|
68
|
+
assert.equal(v.pending, 1);
|
|
69
|
+
assert.equal(v.dispatchable, 0);
|
|
70
|
+
assert.equal(v.blockedChains.length, 1);
|
|
71
|
+
assert.deepEqual(v.blockedChains[0].blockedBy, ['1-a']);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('idle: nothing pending', () => {
|
|
75
|
+
const jobs = [
|
|
76
|
+
{ slug: '1-a', cwd: CWD, status: 'completed', dependsOn: [] },
|
|
77
|
+
];
|
|
78
|
+
const v = classifyQueueHealth({
|
|
79
|
+
jobs, paused: null, launchBlocks: {}, runningSet: new Set(),
|
|
80
|
+
freeSlots: 4, totalSlots: 4, lastDispatchAttemptAtMs: NOW, now: NOW, cwd: CWD,
|
|
81
|
+
});
|
|
82
|
+
assert.equal(v.kind, 'idle');
|
|
83
|
+
assert.equal(v.pending, 0);
|
|
84
|
+
assert.equal(v.dispatchable, 0);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('stalled: dispatchable work, nothing running, driver idle past threshold', () => {
|
|
88
|
+
const jobs = [
|
|
89
|
+
{ slug: '1-a', cwd: CWD, status: 'pending', dependsOn: [] },
|
|
90
|
+
];
|
|
91
|
+
const v = classifyQueueHealth({
|
|
92
|
+
jobs, paused: null, launchBlocks: {}, runningSet: new Set(),
|
|
93
|
+
freeSlots: 4, totalSlots: 4, lastDispatchAttemptAtMs: NOW - LONG, now: NOW, cwd: CWD,
|
|
94
|
+
});
|
|
95
|
+
assert.equal(v.kind, 'stalled');
|
|
96
|
+
assert.equal(v.dispatchable, 1);
|
|
97
|
+
assert.ok(v.idleMs >= QUEUE_STARVATION_MS);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('running: dispatchable work, nothing running yet, but driver has not been idle long enough to call it a stall', () => {
|
|
101
|
+
const jobs = [
|
|
102
|
+
{ slug: '1-a', cwd: CWD, status: 'pending', dependsOn: [] },
|
|
103
|
+
];
|
|
104
|
+
const v = classifyQueueHealth({
|
|
105
|
+
jobs, paused: null, launchBlocks: {}, runningSet: new Set(),
|
|
106
|
+
freeSlots: 4, totalSlots: 4, lastDispatchAttemptAtMs: NOW - 5_000, now: NOW, cwd: CWD,
|
|
107
|
+
});
|
|
108
|
+
assert.equal(v.kind, 'running');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('paused: a decision, not a stall — takes priority over everything else', () => {
|
|
112
|
+
const jobs = [
|
|
113
|
+
{ slug: '1-a', cwd: CWD, status: 'pending', dependsOn: [] },
|
|
114
|
+
];
|
|
115
|
+
const v = classifyQueueHealth({
|
|
116
|
+
jobs, paused: { reason: 'manual', since: NOW }, launchBlocks: {}, runningSet: new Set(),
|
|
117
|
+
freeSlots: 0, totalSlots: 4, lastDispatchAttemptAtMs: NOW - LONG, now: NOW, cwd: CWD,
|
|
118
|
+
});
|
|
119
|
+
assert.equal(v.kind, 'paused');
|
|
120
|
+
assert.equal(v.reason, 'manual');
|
|
121
|
+
assert.equal(v.pending, 1);
|
|
122
|
+
assert.equal(v.dispatchable, 1);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('launch-blocked: an open circuit breaker for a persona a pending row actually uses', () => {
|
|
126
|
+
const jobs = [
|
|
127
|
+
{ slug: '1-a', cwd: CWD, status: 'pending', dependsOn: [], agentType: 'dev-lead' },
|
|
128
|
+
];
|
|
129
|
+
const launchBlocks = {
|
|
130
|
+
'dev-lead': { kind: 'model_config_rejected', attempts: 3, hint: 'update the CLI', until: null },
|
|
131
|
+
};
|
|
132
|
+
const v = classifyQueueHealth({
|
|
133
|
+
jobs, paused: null, launchBlocks, runningSet: new Set(),
|
|
134
|
+
freeSlots: 4, totalSlots: 4, lastDispatchAttemptAtMs: NOW - LONG, now: NOW, cwd: CWD,
|
|
135
|
+
});
|
|
136
|
+
assert.equal(v.kind, 'launch-blocked');
|
|
137
|
+
assert.equal(v.agentType, 'dev-lead');
|
|
138
|
+
assert.equal(v.pending, 1);
|
|
139
|
+
assert.equal(v.dispatchable, 1);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('launch-blocked is scoped: a breaker for a persona nothing pending here uses does not fire', () => {
|
|
143
|
+
const jobs = [
|
|
144
|
+
{ slug: '1-a', cwd: CWD, status: 'pending', dependsOn: [], agentType: 'dev-lead' },
|
|
145
|
+
];
|
|
146
|
+
const launchBlocks = {
|
|
147
|
+
'architect': { kind: 'model_config_rejected', attempts: 3, hint: 'update the CLI', until: null },
|
|
148
|
+
};
|
|
149
|
+
const v = classifyQueueHealth({
|
|
150
|
+
jobs, paused: null, launchBlocks, runningSet: new Set(),
|
|
151
|
+
freeSlots: 4, totalSlots: 4, lastDispatchAttemptAtMs: NOW - 5_000, now: NOW, cwd: CWD,
|
|
152
|
+
});
|
|
153
|
+
assert.notEqual(v.kind, 'launch-blocked');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('needsReviewCount and cwd scoping: another project\'s rows never leak into this scope\'s counts', () => {
|
|
157
|
+
const other = '/home/bilko/Projects/session-manager';
|
|
158
|
+
const jobs = [
|
|
159
|
+
{ slug: '1-a', cwd: CWD, status: 'needs_review', dependsOn: [] },
|
|
160
|
+
{ slug: '2-b', cwd: CWD, status: 'pending', dependsOn: [] },
|
|
161
|
+
{ slug: '3-c', cwd: other, status: 'pending', dependsOn: [] },
|
|
162
|
+
{ slug: '4-d', cwd: other, status: 'needs_review', dependsOn: [] },
|
|
163
|
+
];
|
|
164
|
+
const v = classifyQueueHealth({
|
|
165
|
+
jobs, paused: null, launchBlocks: {}, runningSet: new Set(),
|
|
166
|
+
freeSlots: 4, totalSlots: 4, lastDispatchAttemptAtMs: NOW - 5_000, now: NOW, cwd: CWD,
|
|
167
|
+
});
|
|
168
|
+
assert.equal(v.pending, 1);
|
|
169
|
+
assert.equal(v.needsReviewCount, 1);
|
|
170
|
+
});
|
|
@@ -16,14 +16,21 @@
|
|
|
16
16
|
|
|
17
17
|
'use strict';
|
|
18
18
|
|
|
19
|
-
import { test } from 'vitest';
|
|
19
|
+
import { test, afterAll } from 'vitest';
|
|
20
20
|
const assert = require('node:assert/strict');
|
|
21
21
|
const fs = require('node:fs');
|
|
22
22
|
const os = require('node:os');
|
|
23
23
|
const path = require('node:path');
|
|
24
24
|
|
|
25
25
|
// HOME before require: every state path is baked from os.homedir() at load.
|
|
26
|
-
|
|
26
|
+
// beforeAll runs AFTER module evaluation, so this assignment must stay at
|
|
27
|
+
// top level — see pollLoop-dispatch-on-failure.test.cjs:40-56 for the
|
|
28
|
+
// beforeAll/afterAll pattern this restore mirrors.
|
|
29
|
+
const originalHome = process.env.HOME;
|
|
30
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'starve-per-project-test-'));
|
|
31
|
+
process.env.HOME = tmpHome;
|
|
32
|
+
fs.mkdirSync(path.join(tmpHome, '.claude', 'session-manager'), { recursive: true });
|
|
33
|
+
fs.mkdirSync(path.join(tmpHome, '.claude', 'projects'), { recursive: true });
|
|
27
34
|
|
|
28
35
|
const {
|
|
29
36
|
classifyQueueStarvation,
|
|
@@ -31,6 +38,11 @@ const {
|
|
|
31
38
|
QUEUE_STARVATION_MS,
|
|
32
39
|
} = require('../scheduler.cjs');
|
|
33
40
|
|
|
41
|
+
afterAll(() => {
|
|
42
|
+
process.env.HOME = originalHome;
|
|
43
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
34
46
|
const PROJECT_A = '/home/bilko/Projects/starry-night-ships';
|
|
35
47
|
const PROJECT_B = '/home/bilko/Projects/session-manager';
|
|
36
48
|
const NOW = Date.parse('2026-09-12T20:00:00.000Z');
|
|
@@ -163,6 +163,25 @@ test('appendHistory dedupes by slug+runId against what is already on disk', asyn
|
|
|
163
163
|
expect(read.length).toBe(1);
|
|
164
164
|
});
|
|
165
165
|
|
|
166
|
+
test('appendHistory: a needs_review ledger line and a terminal row sharing slug+runId do NOT dedupe against each other', async () => {
|
|
167
|
+
// An entry and its resolution line (needsReviewLedger.cjs) deliberately
|
|
168
|
+
// reference the SAME runId as each other — and a slug's terminal archive
|
|
169
|
+
// row can later reuse that same runId too (same run, different `kind`).
|
|
170
|
+
// jobKey must bucket by kind or the second/third write looks like a
|
|
171
|
+
// crash-replay of the first and silently never lands.
|
|
172
|
+
const entryLine = { kind: 'needs_review_entry', slug: 'ledger-dup', runId: 'shared-run', at: new Date(NOW).toISOString(), reason: 'uncommitted_changes', source: 'spawnJob:finalize' };
|
|
173
|
+
const resolutionLine = { kind: 'needs_review_resolution', slug: 'ledger-dup', runId: 'shared-run', at: new Date(NOW).toISOString(), resolvedTo: 'completed', ladderRung: 'reverify', source: 'reverifyNeedsReview:heal', dwellMs: 1000 };
|
|
174
|
+
const terminalLine = old({ slug: 'ledger-dup', runId: 'shared-run' });
|
|
175
|
+
const r1 = await queueHistory.appendHistory([entryLine]);
|
|
176
|
+
const r2 = await queueHistory.appendHistory([resolutionLine]);
|
|
177
|
+
const r3 = await queueHistory.appendHistory([terminalLine]);
|
|
178
|
+
expect(r1.appended).toBe(1);
|
|
179
|
+
expect(r2.appended).toBe(1);
|
|
180
|
+
expect(r3.appended).toBe(1);
|
|
181
|
+
const read = await queueHistory.readHistory({ limit: 10 });
|
|
182
|
+
expect(read.filter((j) => j.slug === 'ledger-dup')).toHaveLength(3);
|
|
183
|
+
});
|
|
184
|
+
|
|
166
185
|
test('readHistory respects limit and returns [] when file is absent', async () => {
|
|
167
186
|
const empty = await queueHistory.readHistory({ limit: 5 });
|
|
168
187
|
expect(empty).toEqual([]);
|
|
@@ -220,6 +239,18 @@ test('selectHistoryJobs: default historyEntries=[] preserves old call shape', ()
|
|
|
220
239
|
expect(merged.map((j) => j.slug)).toEqual(['01-fresh']);
|
|
221
240
|
});
|
|
222
241
|
|
|
242
|
+
test('selectHistoryJobs: needs_review ledger lines (kind !== "terminal") are excluded from the merged result', () => {
|
|
243
|
+
// The History view renders ScheduleJob-shaped rows (status, exitCode, ...)
|
|
244
|
+
// — a needs_review_entry/needs_review_resolution line (needsReviewLedger.cjs)
|
|
245
|
+
// has neither, and must not slip through as a statusless row in that table.
|
|
246
|
+
const { selectHistoryJobs } = require('../scheduler.cjs');
|
|
247
|
+
const terminalEntry = old({ slug: 'hist-1', runId: 'rhist1' });
|
|
248
|
+
const ledgerEntry = { kind: 'needs_review_entry', slug: 'ledger-1', runId: 'rledger1', at: new Date(NOW).toISOString(), reason: 'uncommitted_changes', source: 'spawnJob:finalize' };
|
|
249
|
+
const ledgerResolution = { kind: 'needs_review_resolution', slug: 'ledger-1', runId: 'rledger1', at: new Date(NOW).toISOString(), resolvedTo: 'completed', ladderRung: 'reverify', source: 'reverifyNeedsReview:heal', dwellMs: 500 };
|
|
250
|
+
const merged = selectHistoryJobs([], 10, [terminalEntry, ledgerEntry, ledgerResolution]);
|
|
251
|
+
expect(merged.map((j) => j.slug)).toEqual(['hist-1']);
|
|
252
|
+
});
|
|
253
|
+
|
|
223
254
|
// ---------- historyTerminalBySlug ----------
|
|
224
255
|
//
|
|
225
256
|
// Guards against scheduler.cjs's reconcile() resurrecting an already-
|
|
@@ -258,6 +289,22 @@ test('historyTerminalBySlug: cache invalidates after a new appendHistory call (m
|
|
|
258
289
|
expect(second.has('cache-2')).toBe(true);
|
|
259
290
|
});
|
|
260
291
|
|
|
292
|
+
test('historyTerminalBySlug: ignores needs_review ledger lines, even a LATER one for an already-terminal slug', async () => {
|
|
293
|
+
// Chronological order matters here: the terminal row lands first, and a
|
|
294
|
+
// needs_review_entry for the SAME slug (a later re-run parking again)
|
|
295
|
+
// lands after it. Without the kind guard, last-line-wins would replace
|
|
296
|
+
// the true 'completed' record with a status-less ledger row.
|
|
297
|
+
const terminal = old({ slug: 'mixed-1', status: 'completed', runId: 'rterm' });
|
|
298
|
+
await queueHistory.appendHistory([terminal]);
|
|
299
|
+
await queueHistory.appendHistory([{
|
|
300
|
+
kind: 'needs_review_entry', slug: 'mixed-1', runId: 'rlater', cwd: null,
|
|
301
|
+
at: new Date(NOW).toISOString(), reason: 'uncommitted_changes', source: 'spawnJob:finalize',
|
|
302
|
+
}]);
|
|
303
|
+
|
|
304
|
+
const map = await queueHistory.historyTerminalBySlug();
|
|
305
|
+
expect(map.get('mixed-1')).toEqual({ status: 'completed', finishedAt: terminal.finishedAt, landedCommit: null });
|
|
306
|
+
});
|
|
307
|
+
|
|
261
308
|
// ---------- completedSlugsForCwd (PRD 1122) ----------
|
|
262
309
|
//
|
|
263
310
|
// Feeds scheduler.cjs's computeDepHistorySatisfaction: a dependsOn slug with
|
|
@@ -290,3 +337,19 @@ test('completedSlugsForCwd: returns an empty Set when the project has no history
|
|
|
290
337
|
const slugs = await queueHistory.completedSlugsForCwd(freshCwd);
|
|
291
338
|
expect(slugs.size).toBe(0);
|
|
292
339
|
});
|
|
340
|
+
|
|
341
|
+
test('completedSlugsForCwd: ignores needs_review ledger lines even when they are the newest line for a completed slug', async () => {
|
|
342
|
+
const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-1122-ledger-'));
|
|
343
|
+
await queueHistory.appendHistory([
|
|
344
|
+
old({ slug: '900-base', status: 'completed', runId: 'ra', cwd: projectCwd }),
|
|
345
|
+
]);
|
|
346
|
+
// A later needs_review park for a NEW run of the same slug must not blank
|
|
347
|
+
// out the earlier 'completed' record read by completedSlugsForCwd.
|
|
348
|
+
await queueHistory.appendHistory([{
|
|
349
|
+
kind: 'needs_review_entry', slug: '900-base', runId: 'rb', cwd: projectCwd,
|
|
350
|
+
at: new Date(NOW).toISOString(), reason: 'uncommitted_changes', source: 'spawnJob:finalize',
|
|
351
|
+
}]);
|
|
352
|
+
|
|
353
|
+
const slugs = await queueHistory.completedSlugsForCwd(projectCwd);
|
|
354
|
+
expect(slugs.has('900-base')).toBe(true);
|
|
355
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* reconcileTiming.test.cjs — proves reconcile() (src/main/scheduler.cjs)
|
|
3
|
+
* records a per-phase timing breakdown and emits exactly one warn-level
|
|
4
|
+
* `logs.writeLine` when a pass exceeds RECONCILE_SLOW_PASS_MS
|
|
5
|
+
* (src/main/lib/schedulerConfig.cjs), and stays silent on a normal-speed
|
|
6
|
+
* pass. Before this, diagnosing a slow reconcile pass (the
|
|
7
|
+
* "schedule.state timed out after 5000ms" toast) required hand-timing the
|
|
8
|
+
* filesystem scans from a throwaway script.
|
|
9
|
+
*
|
|
10
|
+
* HOME-isolated (mirrors reconcileFlatPrdSweep.test.cjs): stub HOME to a
|
|
11
|
+
* mkdtemp dir before requiring scheduler.cjs, and register the fixture
|
|
12
|
+
* project as "active" via a fake ~/.claude/projects transcript — reconcile's
|
|
13
|
+
* PRD discovery (prdLocations.cjs) only scans cwds it can find that way.
|
|
14
|
+
*
|
|
15
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/reconcileTiming.test.cjs
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
import { test, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
|
|
21
|
+
const fs = require('node:fs');
|
|
22
|
+
const os = require('node:os');
|
|
23
|
+
const path = require('node:path');
|
|
24
|
+
|
|
25
|
+
let tmpHome;
|
|
26
|
+
let originalHome;
|
|
27
|
+
let scheduler;
|
|
28
|
+
let queueHistory;
|
|
29
|
+
let logs;
|
|
30
|
+
let RECONCILE_SLOW_PASS_MS;
|
|
31
|
+
|
|
32
|
+
beforeAll(() => {
|
|
33
|
+
originalHome = process.env.HOME;
|
|
34
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-reconcile-timing-home-'));
|
|
35
|
+
process.env.HOME = tmpHome;
|
|
36
|
+
|
|
37
|
+
scheduler = require('../scheduler.cjs');
|
|
38
|
+
queueHistory = require('../lib/queueHistory.cjs');
|
|
39
|
+
logs = require('../logs.cjs');
|
|
40
|
+
({ RECONCILE_SLOW_PASS_MS } = require('../lib/schedulerConfig.cjs'));
|
|
41
|
+
|
|
42
|
+
if (!scheduler.PRDS_DIR.startsWith(tmpHome)) {
|
|
43
|
+
throw new Error(`refusing to run: PRDS_DIR (${scheduler.PRDS_DIR}) is not under the temp HOME (${tmpHome})`);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterAll(() => {
|
|
48
|
+
process.env.HOME = originalHome;
|
|
49
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
vi.restoreAllMocks();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
function registerActiveProject(cwd) {
|
|
57
|
+
const projectsDir = path.join(tmpHome, '.claude', 'projects');
|
|
58
|
+
const slugDir = path.join(projectsDir, `fake-project-slug-${path.basename(cwd)}`);
|
|
59
|
+
fs.mkdirSync(slugDir, { recursive: true });
|
|
60
|
+
fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function makeFixtureProject(prefix) {
|
|
64
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
65
|
+
registerActiveProject(cwd);
|
|
66
|
+
return cwd;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function writeCanonicalPrd(cwd, epicId, slug) {
|
|
70
|
+
const epicPrdsDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'epics', epicId, 'prds');
|
|
71
|
+
fs.mkdirSync(epicPrdsDir, { recursive: true });
|
|
72
|
+
fs.writeFileSync(
|
|
73
|
+
path.join(epicPrdsDir, `${slug}.md`),
|
|
74
|
+
`---\ntitle: Timing fixture\ncwd: ${cwd}\nestimateMinutes: 10\nsourcePromptId: ${epicId}\ncreatedVia: scheduler-api\nissuedAt: 2026-08-07T00:00:00.000Z\n---\n\n# Goal\n\nSomething.\n`,
|
|
75
|
+
'utf8',
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function warnCallsMatching(spy) {
|
|
80
|
+
return spy.mock.calls
|
|
81
|
+
.map(([payload]) => payload)
|
|
82
|
+
.filter((payload) => payload?.level === 'warn' && payload?.scope === 'scheduler' && /reconcile\(\) pass took/.test(payload?.message ?? ''));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
test('a normal-speed reconcile() pass logs nothing', async () => {
|
|
86
|
+
const cwd = makeFixtureProject('sm-reconcile-timing-fast-proj-');
|
|
87
|
+
writeCanonicalPrd(cwd, 'epic-fast', '1600-fast');
|
|
88
|
+
|
|
89
|
+
const writeLineSpy = vi.spyOn(logs, 'writeLine');
|
|
90
|
+
const state = { jobs: [], invalidJobs: [], paused: null };
|
|
91
|
+
await scheduler.reconcile(state);
|
|
92
|
+
|
|
93
|
+
expect(warnCallsMatching(writeLineSpy)).toHaveLength(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('a slow reconcile() pass emits exactly one warn with the per-phase breakdown', async () => {
|
|
97
|
+
const cwd = makeFixtureProject('sm-reconcile-timing-slow-proj-');
|
|
98
|
+
writeCanonicalPrd(cwd, 'epic-slow', '1601-slow');
|
|
99
|
+
|
|
100
|
+
const realHistoryTerminalBySlug = queueHistory.historyTerminalBySlug.bind(queueHistory);
|
|
101
|
+
vi.spyOn(queueHistory, 'historyTerminalBySlug').mockImplementation(async (...args) => {
|
|
102
|
+
await new Promise((resolve) => { setTimeout(resolve, RECONCILE_SLOW_PASS_MS + 200); });
|
|
103
|
+
return realHistoryTerminalBySlug(...args);
|
|
104
|
+
});
|
|
105
|
+
const writeLineSpy = vi.spyOn(logs, 'writeLine');
|
|
106
|
+
|
|
107
|
+
const state = { jobs: [], invalidJobs: [], paused: null };
|
|
108
|
+
await scheduler.reconcile(state);
|
|
109
|
+
|
|
110
|
+
const warnCalls = warnCallsMatching(writeLineSpy);
|
|
111
|
+
expect(warnCalls).toHaveLength(1);
|
|
112
|
+
const { meta } = warnCalls[0];
|
|
113
|
+
expect(meta.totalMs).toBeGreaterThan(RECONCILE_SLOW_PASS_MS);
|
|
114
|
+
expect(meta.phaseMs).toHaveProperty('flatPrdSweep');
|
|
115
|
+
expect(meta.phaseMs).toHaveProperty('prdDirResolve');
|
|
116
|
+
expect(meta.phaseMs).toHaveProperty('parseLoop');
|
|
117
|
+
expect(meta.phaseMs).toHaveProperty('historyLookup');
|
|
118
|
+
expect(meta.phaseMs).toHaveProperty('queueWrite');
|
|
119
|
+
expect(meta.phaseMs.historyLookup).toBeGreaterThanOrEqual(RECONCILE_SLOW_PASS_MS);
|
|
120
|
+
expect(typeof meta.prdFileCount).toBe('number');
|
|
121
|
+
expect(typeof meta.resolvedDirCount).toBe('number');
|
|
122
|
+
expect(meta.prdFileCount).toBeGreaterThanOrEqual(1);
|
|
123
|
+
}, 15_000);
|
|
124
|
+
|
|
125
|
+
test('reconcile() surfaces its own error and does not swallow it while timing', async () => {
|
|
126
|
+
vi.spyOn(queueHistory, 'partitionJobs').mockImplementation(() => {
|
|
127
|
+
throw new Error('boom: injected failure for timing-swallow test');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const cwd = makeFixtureProject('sm-reconcile-timing-throw-proj-');
|
|
131
|
+
writeCanonicalPrd(cwd, 'epic-throw', '1602-throw');
|
|
132
|
+
|
|
133
|
+
const state = { jobs: [], invalidJobs: [], paused: null };
|
|
134
|
+
await expect(scheduler.reconcile(state)).rejects.toThrow('boom: injected failure for timing-swallow test');
|
|
135
|
+
});
|