claude-code-session-manager 0.85.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-DRH-Ty20.js → DataModel--mISIJ6h.js} +1 -1
- package/dist/assets/{History-CfRhT1Im.js → History-C2ahUXTg.js} +2 -2
- package/dist/assets/{Hooks-wEmh_U6c.js → Hooks-BiC6oyR2.js} +3 -3
- package/dist/assets/{HostBilko-D_t7Rbi7.js → HostBilko-BPleEOld.js} +1 -1
- package/dist/assets/{Library-CpArQ-OJ.js → Library-Dc8Qst1R.js} +1 -1
- package/dist/assets/{ListDetail-pjaKYs84.js → ListDetail-DIXh-OLX.js} +1 -1
- package/dist/assets/MarkdownEditor-C90bkLXK.js +1 -0
- package/dist/assets/{McpServers-ftqaV3kn.js → McpServers-DqcbLOLZ.js} +2 -2
- package/dist/assets/{Memory-ChMWkNd0.js → Memory-CW62MXlh.js} +4 -4
- package/dist/assets/{Panel-D9Kr40Ai.js → Panel-Bw1FhRuF.js} +1 -1
- package/dist/assets/Permissions-BcUC-5y8.js +3 -0
- package/dist/assets/{Plugins-BtChISho.js → Plugins-BnKx9flD.js} +2 -2
- package/dist/assets/{ProvenanceBadge-DBA5EcYy.js → ProvenanceBadge-Bw5vNVPT.js} +1 -1
- package/dist/assets/{SaveBar-I0_dWNTX.js → SaveBar-CWr0O_w-.js} +1 -1
- package/dist/assets/Scheduler-DYdLuUqq.js +14 -0
- package/dist/assets/{ScopeSwitcher-5GTEveb2.js → ScopeSwitcher-CrBLbg8s.js} +1 -1
- package/dist/assets/Settings-DluB-vN1.js +3 -0
- package/dist/assets/{SkillReferenceGraph-DNBFGrYE.js → SkillReferenceGraph-CHLSseay.js} +1 -1
- package/dist/assets/{Skills-DJB6-bBM.js → Skills-gNdo_HNK.js} +2 -2
- package/dist/assets/{SystemPrompt-BiDDrJUA.js → SystemPrompt-Cru05-Ia.js} +1 -1
- package/dist/assets/{TagLibrary-_Wrevtop.js → TagLibrary-DNHY0xou.js} +1 -1
- package/dist/assets/{TiptapBody-OWWXdLRy.js → TiptapBody-I4lmbCgP.js} +1 -1
- package/dist/assets/{Toggle-B122N0HL.js → Toggle-bWMHjmRh.js} +1 -1
- package/dist/assets/{index-CDo9xBR9.css → index-DV3PorRY.css} +1 -1
- package/dist/assets/{index-DhvuQL4C.js → index-fc_JjdxL.js} +724 -724
- package/dist/assets/settingsSchema-BfhtZnGD.js +3 -0
- package/dist/index.html +2 -2
- package/package.json +15 -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__/health-starve-escalation.test.cjs +94 -0
- package/src/main/__tests__/loadGateDetailTick.test.cjs +31 -0
- package/src/main/__tests__/machineProfile.test.cjs +19 -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__/pty-session-open-telemetry.test.cjs +96 -0
- package/src/main/__tests__/queue-health-verdict.test.cjs +170 -0
- package/src/main/__tests__/queue-starvation-per-project.test.cjs +147 -0
- 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-failed-autoreset.test.cjs +121 -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-needs-review-autoresolve.test.cjs +189 -0
- package/src/main/__tests__/scheduler-no-dead-end-status.test.cjs +152 -0
- package/src/main/__tests__/scheduler-porcelain-rename.test.cjs +164 -0
- package/src/main/__tests__/scheduler-quarantine-autoresolve.test.cjs +165 -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 +154 -0
- package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +14 -0
- package/src/main/__tests__/telemetryClient.test.cjs +75 -5
- package/src/main/__tests__/telemetrySettings.test.cjs +32 -0
- package/src/main/chatRunner.cjs +8 -5
- package/src/main/health.cjs +76 -3
- 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__/loadGate.test.cjs +103 -2
- 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__/telemetryBoot.test.cjs +11 -0
- 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/loadGate.cjs +23 -1
- package/src/main/lib/machineProfile.cjs +15 -0
- 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/schedulerBatch.cjs +12 -1
- package/src/main/lib/schedulerConfig.cjs +37 -0
- package/src/main/lib/telemetryBoot.cjs +11 -8
- package/src/main/lib/telemetryClient.cjs +44 -2
- package/src/main/lib/telemetrySettings.cjs +20 -3
- package/src/main/lib/usageCircuit.cjs +159 -0
- package/{scripts → src/main}/lib/watchdogHelpers.cjs +1 -1
- package/src/main/pty.cjs +9 -0
- package/src/main/scheduler/prdParser.cjs +13 -0
- package/src/main/scheduler.cjs +1869 -193
- 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-Bkv-HcP1.js +0 -3
- package/dist/assets/MarkdownEditor-Xc141kjj.js +0 -1
- package/dist/assets/Permissions-DKoNVgzj.js +0 -3
- package/dist/assets/Scheduler-CbES7MC8.js +0 -14
- package/dist/assets/Settings-BX3FElXk.js +0 -3
- package/dist/assets/settingsSchema-sGoCTd7J.js +0 -3
- /package/{scripts/project-pages-logic → web/project-pages/logic}/dist/logic.cjs +0 -0
|
@@ -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
|
+
});
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
'use strict';
|
|
8
8
|
|
|
9
|
-
import { test, expect } from 'vitest';
|
|
9
|
+
import { test, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
10
10
|
const fs = require('node:fs');
|
|
11
11
|
const crypto = require('node:crypto');
|
|
12
12
|
const {
|
|
@@ -17,6 +17,7 @@ const {
|
|
|
17
17
|
_resetRefusedTransitionCountForTests,
|
|
18
18
|
} = require('../lib/scheduleJobTransitions.cjs');
|
|
19
19
|
const auditLog = require('../lib/auditLog.cjs');
|
|
20
|
+
const queueHistory = require('../lib/queueHistory.cjs');
|
|
20
21
|
|
|
21
22
|
function uniqueSlug(label) {
|
|
22
23
|
return `test-${label}-${crypto.randomUUID().slice(0, 8)}`;
|
|
@@ -175,3 +176,102 @@ test('_resetRefusedTransitionCountForTests resets the counter', () => {
|
|
|
175
176
|
_resetRefusedTransitionCountForTests();
|
|
176
177
|
expect(getRefusedTransitionCount()).toBe(0);
|
|
177
178
|
});
|
|
179
|
+
|
|
180
|
+
// ---------- needs_review ledger (PRD: needs_review durability) ----------
|
|
181
|
+
//
|
|
182
|
+
// transitionJob is the ONE place every needs_review entry/resolution is
|
|
183
|
+
// decided (this file's own header comment) — these tests exercise that
|
|
184
|
+
// hook directly rather than going through scheduler.cjs's many call sites.
|
|
185
|
+
// queueHistory.appendHistory is mocked so these tests never touch disk or
|
|
186
|
+
// race its own fire-and-forget promise; queueHistory.test.cjs already
|
|
187
|
+
// covers appendHistory's real I/O.
|
|
188
|
+
|
|
189
|
+
let appendHistorySpy;
|
|
190
|
+
|
|
191
|
+
beforeEach(() => {
|
|
192
|
+
appendHistorySpy = vi.spyOn(queueHistory, 'appendHistory').mockResolvedValue({ appended: 1 });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
afterEach(() => {
|
|
196
|
+
appendHistorySpy.mockRestore();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('needs_review entry: transitioning INTO needs_review appends a kind:needs_review_entry ledger line', () => {
|
|
200
|
+
const slug = uniqueSlug('nr-entry');
|
|
201
|
+
const job = { slug, cwd: '/tmp/proj', status: 'running', runId: 'run-abc', verifierVerdict: 'uncommitted_changes' };
|
|
202
|
+
|
|
203
|
+
transitionJob(job, 'needs_review', { reason: 'finish protocol incomplete', source: 'spawnJob:finalize' });
|
|
204
|
+
|
|
205
|
+
expect(appendHistorySpy).toHaveBeenCalledTimes(1);
|
|
206
|
+
const [lines] = appendHistorySpy.mock.calls[0];
|
|
207
|
+
expect(lines).toHaveLength(1);
|
|
208
|
+
expect(lines[0]).toMatchObject({
|
|
209
|
+
kind: 'needs_review_entry',
|
|
210
|
+
slug,
|
|
211
|
+
cwd: '/tmp/proj',
|
|
212
|
+
runId: 'run-abc',
|
|
213
|
+
reason: 'uncommitted_changes',
|
|
214
|
+
source: 'spawnJob:finalize',
|
|
215
|
+
});
|
|
216
|
+
expect(typeof lines[0].at).toBe('string');
|
|
217
|
+
// Episode bookkeeping stamped on the job so a later resolution can
|
|
218
|
+
// reference this SAME runId even if job.runId is reassigned meanwhile.
|
|
219
|
+
expect(job.needsReviewEntryRunId).toBe('run-abc');
|
|
220
|
+
expect(job.needsReviewEnteredAt).toBe(lines[0].at);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('needs_review resolution: transitioning OUT of needs_review appends a paired line referencing the ENTRY runId, with dwellMs', () => {
|
|
224
|
+
const slug = uniqueSlug('nr-resolve');
|
|
225
|
+
const job = { slug, cwd: '/tmp/proj', status: 'running', runId: 'run-1' };
|
|
226
|
+
|
|
227
|
+
transitionJob(job, 'needs_review', { reason: 'park', source: 'spawnJob:finalize' });
|
|
228
|
+
const enteredAt = job.needsReviewEnteredAt;
|
|
229
|
+
|
|
230
|
+
// A resume-recovery re-dispatch (or any later attempt) mints a FRESH runId
|
|
231
|
+
// before this episode resolves — the resolution must still reference the
|
|
232
|
+
// ENTRY's runId, not whatever the job carries now.
|
|
233
|
+
job.runId = 'run-2';
|
|
234
|
+
transitionJob(job, 'completed', { reason: 'boot reverify: stale needs_review healed', source: 'reverifyNeedsReview:heal' });
|
|
235
|
+
|
|
236
|
+
expect(appendHistorySpy).toHaveBeenCalledTimes(2);
|
|
237
|
+
const [resolutionLines] = appendHistorySpy.mock.calls[1];
|
|
238
|
+
expect(resolutionLines).toHaveLength(1);
|
|
239
|
+
const resolution = resolutionLines[0];
|
|
240
|
+
expect(resolution.kind).toBe('needs_review_resolution');
|
|
241
|
+
expect(resolution.runId).toBe('run-1'); // the ENTRY's runId
|
|
242
|
+
expect(resolution.resolvedTo).toBe('completed');
|
|
243
|
+
expect(resolution.ladderRung).toBe('reverify');
|
|
244
|
+
expect(typeof resolution.dwellMs).toBe('number');
|
|
245
|
+
expect(resolution.dwellMs).toBeGreaterThanOrEqual(0);
|
|
246
|
+
expect(Date.parse(enteredAt) + resolution.dwellMs).toBe(Date.parse(resolution.at));
|
|
247
|
+
|
|
248
|
+
// Episode bookkeeping cleared once resolved.
|
|
249
|
+
expect(job.needsReviewEntryRunId).toBeUndefined();
|
|
250
|
+
expect(job.needsReviewEnteredAt).toBeUndefined();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('a slug that parks twice produces two independent entry/resolution pairs, not one merged episode', () => {
|
|
254
|
+
const { reduceNeedsReviewLedger } = require('../lib/needsReviewLedger.cjs');
|
|
255
|
+
const slug = uniqueSlug('nr-twice');
|
|
256
|
+
const job = { slug, cwd: '/tmp/proj', status: 'running', runId: 'run-A' };
|
|
257
|
+
|
|
258
|
+
transitionJob(job, 'needs_review', { reason: 'park 1', source: 'spawnJob:finalize' });
|
|
259
|
+
transitionJob(job, 'pending', { reason: 'manual reset', source: 'ipc:schedule:reset-job' });
|
|
260
|
+
transitionJob(job, 'running', { reason: 'redispatched', source: 'spawnJob:dispatch' });
|
|
261
|
+
job.runId = 'run-B';
|
|
262
|
+
transitionJob(job, 'needs_review', { reason: 'park 2', source: 'spawnJob:finalize' });
|
|
263
|
+
transitionJob(job, 'completed', { reason: 'healed', source: 'reverifyNeedsReview:heal' });
|
|
264
|
+
|
|
265
|
+
const ledgerLines = appendHistorySpy.mock.calls.map((call) => call[0][0]);
|
|
266
|
+
const entries = ledgerLines.filter((l) => l.kind === 'needs_review_entry');
|
|
267
|
+
const resolutions = ledgerLines.filter((l) => l.kind === 'needs_review_resolution');
|
|
268
|
+
expect(entries).toHaveLength(2);
|
|
269
|
+
expect(resolutions).toHaveLength(2);
|
|
270
|
+
expect(entries.map((l) => l.runId).sort()).toEqual(['run-A', 'run-B']);
|
|
271
|
+
expect(resolutions.map((l) => l.runId).sort()).toEqual(['run-A', 'run-B']);
|
|
272
|
+
|
|
273
|
+
const rollup = reduceNeedsReviewLedger(ledgerLines);
|
|
274
|
+
expect(rollup.unresolvedCount).toBe(0);
|
|
275
|
+
expect(rollup.byLadderRung['manual-reset']).toBe(1);
|
|
276
|
+
expect(rollup.byLadderRung['reverify']).toBe(1);
|
|
277
|
+
});
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* scheduler-boot-orphans.test.cjs — boot-time reconciliation of 'running' jobs
|
|
3
3
|
* left behind by an app crash/restart (PRD 686: consolidated in from the
|
|
4
4
|
* external watchdog's reconcileQueueOffline(), which is now deleted from
|
|
5
|
-
*
|
|
5
|
+
* src/main/lib/watchdogHelpers.cjs).
|
|
6
6
|
*
|
|
7
7
|
* Covers the two behaviors most likely to drift in the move:
|
|
8
8
|
* - a still-alive orphaned pid must be DEFERRED, never classified from its
|
|
@@ -101,7 +101,7 @@ test('feedbackSweepDue gates on the tick interval, not every tick', () => {
|
|
|
101
101
|
});
|
|
102
102
|
|
|
103
103
|
test('scheduler.cjs reuses watchdogHelpers.sweep verbatim (no forked copy)', () => {
|
|
104
|
-
const { sweep } = require('
|
|
104
|
+
const { sweep } = require('../lib/watchdogHelpers.cjs');
|
|
105
105
|
assert.equal(sweepFeedback, sweep, 'scheduler.cjs must import the same sweep function, not reimplement it');
|
|
106
106
|
});
|
|
107
107
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-broadcast-reconcile.test.cjs — broadcast() used to do its own bare
|
|
3
|
+
* `readQueue() -> reconcile(state) -> writeQueue(state)` BEFORE ever touching
|
|
4
|
+
* the broadcastCoalescer, so a burst of N broadcast() calls inside one
|
|
5
|
+
* BROADCAST_COALESCE_MS window cost N full reconcile passes + N queue writes
|
|
6
|
+
* to produce ONE coalesced payload — and did so outside mutate()'s lock, so a
|
|
7
|
+
* concurrent mutation's update could be lost. The fix moves the
|
|
8
|
+
* reconcile+write pair inside the coalescer's `getPayload`, run through
|
|
9
|
+
* `mutate()`, so N broadcast() calls in one window cost exactly one reconcile
|
|
10
|
+
* and one write.
|
|
11
|
+
*
|
|
12
|
+
* `reconcile` is a bare local call inside getPayload's mutate() callback, so
|
|
13
|
+
* — matching this file's existing testable-seam convention (module.exports.
|
|
14
|
+
* stashList / evaluateSharedTreeGuard / committedInWindow) — it is invoked as
|
|
15
|
+
* `module.exports.reconcile(state)` specifically so tests can spy on it here.
|
|
16
|
+
* `queueStore.readMerged`/`writeSplit` are genuine cross-module property
|
|
17
|
+
* calls already, so they're spied on directly with no source change needed.
|
|
18
|
+
*
|
|
19
|
+
* HOME-isolated (mirrors reconcileFlatPrdSweep.test.cjs): stub HOME to a
|
|
20
|
+
* mkdtemp dir before requiring scheduler.cjs so `ensureDirs()` (called by the
|
|
21
|
+
* real `writeQueue()` wrapper before the now-mocked `queueStore.writeSplit`)
|
|
22
|
+
* never touches the real machine.
|
|
23
|
+
*
|
|
24
|
+
* Run: timeout 60 npx vitest run src/main/__tests__/scheduler-broadcast-reconcile.test.cjs
|
|
25
|
+
*/
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
import { test, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
|
|
29
|
+
const fs = require('node:fs');
|
|
30
|
+
const os = require('node:os');
|
|
31
|
+
const path = require('node:path');
|
|
32
|
+
|
|
33
|
+
let tmpHome;
|
|
34
|
+
let originalHome;
|
|
35
|
+
let scheduler;
|
|
36
|
+
let queueStore;
|
|
37
|
+
|
|
38
|
+
beforeAll(() => {
|
|
39
|
+
originalHome = process.env.HOME;
|
|
40
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-broadcast-reconcile-home-'));
|
|
41
|
+
process.env.HOME = tmpHome;
|
|
42
|
+
|
|
43
|
+
scheduler = require('../scheduler.cjs');
|
|
44
|
+
queueStore = require('../lib/queueStore.cjs');
|
|
45
|
+
|
|
46
|
+
if (!scheduler.PRDS_DIR.startsWith(tmpHome)) {
|
|
47
|
+
throw new Error(`refusing to run: PRDS_DIR (${scheduler.PRDS_DIR}) is not under the temp HOME (${tmpHome})`);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterAll(() => {
|
|
52
|
+
process.env.HOME = originalHome;
|
|
53
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
function freshState() {
|
|
57
|
+
return { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function fakeWindow() {
|
|
61
|
+
return {
|
|
62
|
+
isDestroyed: () => false,
|
|
63
|
+
webContents: { isDestroyed: () => false, isCrashed: () => false, send: vi.fn() },
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function wait(ms) {
|
|
68
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
vi.restoreAllMocks();
|
|
73
|
+
scheduler.attachWindow(null);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('10 broadcast() calls inside one coalesce window trigger exactly one reconcile and one queue write', async () => {
|
|
77
|
+
vi.spyOn(queueStore, 'readMerged').mockImplementation(async () => freshState());
|
|
78
|
+
const writeSpy = vi.spyOn(queueStore, 'writeSplit').mockResolvedValue(undefined);
|
|
79
|
+
const reconcileSpy = vi.spyOn(scheduler, 'reconcile').mockResolvedValue(undefined);
|
|
80
|
+
|
|
81
|
+
for (let i = 0; i < 10; i += 1) scheduler.broadcast();
|
|
82
|
+
|
|
83
|
+
// BROADCAST_COALESCE_MS is 200ms; give the trailing-edge timer room to fire.
|
|
84
|
+
await wait(400);
|
|
85
|
+
|
|
86
|
+
expect(reconcileSpy).toHaveBeenCalledTimes(1);
|
|
87
|
+
expect(writeSpy).toHaveBeenCalledTimes(1);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('a window-less (flush) broadcast() still reconciles, even with no window attached', async () => {
|
|
91
|
+
scheduler.attachWindow(null);
|
|
92
|
+
vi.spyOn(queueStore, 'readMerged').mockImplementation(async () => freshState());
|
|
93
|
+
const writeSpy = vi.spyOn(queueStore, 'writeSplit').mockResolvedValue(undefined);
|
|
94
|
+
const reconcileSpy = vi.spyOn(scheduler, 'reconcile').mockResolvedValue(undefined);
|
|
95
|
+
|
|
96
|
+
await scheduler.broadcast({ flush: true });
|
|
97
|
+
|
|
98
|
+
expect(reconcileSpy).toHaveBeenCalledTimes(1);
|
|
99
|
+
expect(writeSpy).toHaveBeenCalledTimes(1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('a reconcile() that throws does not wedge the coalescer — the next broadcast() still sends', async () => {
|
|
103
|
+
const win = fakeWindow();
|
|
104
|
+
scheduler.attachWindow(win);
|
|
105
|
+
vi.spyOn(queueStore, 'readMerged').mockImplementation(async () => freshState());
|
|
106
|
+
vi.spyOn(queueStore, 'writeSplit').mockResolvedValue(undefined);
|
|
107
|
+
const reconcileSpy = vi.spyOn(scheduler, 'reconcile')
|
|
108
|
+
.mockRejectedValueOnce(new Error('boom'))
|
|
109
|
+
.mockResolvedValue(undefined);
|
|
110
|
+
|
|
111
|
+
scheduler.broadcast();
|
|
112
|
+
await wait(400);
|
|
113
|
+
expect(win.webContents.send).not.toHaveBeenCalled();
|
|
114
|
+
|
|
115
|
+
scheduler.broadcast();
|
|
116
|
+
await wait(400);
|
|
117
|
+
|
|
118
|
+
expect(reconcileSpy).toHaveBeenCalledTimes(2);
|
|
119
|
+
expect(win.webContents.send).toHaveBeenCalledTimes(1);
|
|
120
|
+
expect(win.webContents.send).toHaveBeenCalledWith('schedule:state', expect.any(Object));
|
|
121
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Ported from the retired top-level test-root directory (a node:test suite,
|
|
4
|
+
// never runnable by this repo's vitest-only setup — see CLAUDE.md's Commands
|
|
5
|
+
// section). Pins the two basic cross-project pickNextBatch contracts that
|
|
6
|
+
// were asserted there but not literally exercised elsewhere: N independent
|
|
7
|
+
// projects each with a single pending job all fire together when the global
|
|
8
|
+
// slot pool is generous, and a global cap BELOW the total pending count
|
|
9
|
+
// across projects admits exactly that many, not more.
|
|
10
|
+
//
|
|
11
|
+
// Run: timeout 120 npx vitest run src/main/__tests__/scheduler-cross-project-batch.test.cjs
|
|
12
|
+
|
|
13
|
+
const assert = require('node:assert/strict');
|
|
14
|
+
const { pickNextBatch } = require('../lib/schedulerBatch.cjs');
|
|
15
|
+
|
|
16
|
+
function job(slug, status, cwd, parallelGroup) {
|
|
17
|
+
return { slug, status, cwd, parallelGroup, dependsOn: [] };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test('3 independent projects, 1 pending each, generous cap (6) → all 3 fire', () => {
|
|
21
|
+
const jobs = [
|
|
22
|
+
job('a-job', 'pending', '/a', 5),
|
|
23
|
+
job('b-job', 'pending', '/b', 5),
|
|
24
|
+
job('c-job', 'pending', '/c', 5),
|
|
25
|
+
];
|
|
26
|
+
const { batch, reason } = pickNextBatch(jobs, new Set(), 6);
|
|
27
|
+
assert.equal(batch.length, 3);
|
|
28
|
+
assert.equal(reason, null);
|
|
29
|
+
const slugs = new Set(batch.map((j) => j.slug));
|
|
30
|
+
assert.ok(slugs.has('a-job'));
|
|
31
|
+
assert.ok(slugs.has('b-job'));
|
|
32
|
+
assert.ok(slugs.has('c-job'));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('3 independent projects, 1 pending each, cap 2 (below total pending) → exactly 2 fire', () => {
|
|
36
|
+
const jobs = [
|
|
37
|
+
job('a-job', 'pending', '/a', 5),
|
|
38
|
+
job('b-job', 'pending', '/b', 5),
|
|
39
|
+
job('c-job', 'pending', '/c', 5),
|
|
40
|
+
];
|
|
41
|
+
const { batch } = pickNextBatch(jobs, new Set(), 2);
|
|
42
|
+
assert.equal(batch.length, 2);
|
|
43
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-failed-autoreset.test.cjs
|
|
3
|
+
*
|
|
4
|
+
* Covers selectFailedAutoResetTargets (the bounded failed -> pending
|
|
5
|
+
* auto-reset selector, PRD 1151) and failedAutoResetDisabled (its kill
|
|
6
|
+
* switch) in isolation. selectFailedAutoResetTargets is pure — no IO, no
|
|
7
|
+
* `require` inside the function — so unlike findStuckFailedJobs' own test
|
|
8
|
+
* file, there is no run-log fixture to set up here.
|
|
9
|
+
*
|
|
10
|
+
* HOME is overridden to a tmp dir BEFORE requiring scheduler.cjs — see
|
|
11
|
+
* scheduler-reap-dead-running-jobs.test.cjs's comment for why.
|
|
12
|
+
*
|
|
13
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-failed-autoreset.test.cjs
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const assert = require('node:assert/strict');
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const os = require('node:os');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
|
|
23
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'failed-autoreset-test-'));
|
|
24
|
+
process.env.HOME = tmpHome;
|
|
25
|
+
|
|
26
|
+
const {
|
|
27
|
+
selectFailedAutoResetTargets,
|
|
28
|
+
FAILED_AUTORESET_CAP,
|
|
29
|
+
failedAutoResetDisabled,
|
|
30
|
+
} = require('../scheduler.cjs');
|
|
31
|
+
|
|
32
|
+
const MIN_MS = 60_000;
|
|
33
|
+
const THRESHOLD_MS = 10 * MIN_MS;
|
|
34
|
+
|
|
35
|
+
test('a fresh failed row (under threshold) is not selected', () => {
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
const job = {
|
|
38
|
+
slug: 'fresh-failure',
|
|
39
|
+
status: 'failed',
|
|
40
|
+
statusHistory: [{ to: 'failed', at: new Date(now - 2 * MIN_MS).toISOString() }],
|
|
41
|
+
};
|
|
42
|
+
assert.equal(selectFailedAutoResetTargets([job], now, THRESHOLD_MS).length, 0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('a row past threshold is selected, with its current attempt count', () => {
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
const job = {
|
|
48
|
+
slug: 'ripe-for-reset',
|
|
49
|
+
cwd: '/home/user/project',
|
|
50
|
+
status: 'failed',
|
|
51
|
+
failedAutoResetAttempts: 1,
|
|
52
|
+
statusHistory: [{ to: 'failed', at: new Date(now - 15 * MIN_MS).toISOString() }],
|
|
53
|
+
};
|
|
54
|
+
const found = selectFailedAutoResetTargets([job], now, THRESHOLD_MS);
|
|
55
|
+
assert.equal(found.length, 1);
|
|
56
|
+
assert.equal(found[0].slug, 'ripe-for-reset');
|
|
57
|
+
assert.equal(found[0].cwd, '/home/user/project');
|
|
58
|
+
assert.equal(found[0].attempts, 1);
|
|
59
|
+
assert.ok(found[0].ageMs >= 15 * MIN_MS - 1000);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('a row at the cap (failedAutoResetAttempts: 3) is not selected', () => {
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
const job = {
|
|
65
|
+
slug: 'cap-exhausted',
|
|
66
|
+
status: 'failed',
|
|
67
|
+
failedAutoResetAttempts: FAILED_AUTORESET_CAP,
|
|
68
|
+
statusHistory: [{ to: 'failed', at: new Date(now - 15 * MIN_MS).toISOString() }],
|
|
69
|
+
};
|
|
70
|
+
assert.equal(selectFailedAutoResetTargets([job], now, THRESHOLD_MS).length, 0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('a non-failed status is never selected', () => {
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
const job = {
|
|
76
|
+
slug: 'needs-review-row',
|
|
77
|
+
status: 'needs_review',
|
|
78
|
+
statusHistory: [{ to: 'failed', at: new Date(now - 15 * MIN_MS).toISOString() }],
|
|
79
|
+
};
|
|
80
|
+
assert.equal(selectFailedAutoResetTargets([job], now, THRESHOLD_MS).length, 0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('the newest failed entry gates the threshold, not the first one', () => {
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
const job = {
|
|
86
|
+
slug: 'refailed-recently',
|
|
87
|
+
status: 'failed',
|
|
88
|
+
failedAutoResetAttempts: 1,
|
|
89
|
+
statusHistory: [
|
|
90
|
+
{ to: 'failed', at: new Date(now - 2 * 24 * 60 * MIN_MS).toISOString() },
|
|
91
|
+
{ to: 'pending', at: new Date(now - 23 * 60 * MIN_MS).toISOString() },
|
|
92
|
+
{ to: 'failed', at: new Date(now - 2 * MIN_MS).toISOString() },
|
|
93
|
+
],
|
|
94
|
+
};
|
|
95
|
+
assert.equal(selectFailedAutoResetTargets([job], now, THRESHOLD_MS).length, 0, 'the old failed entry must not be used to gate this row');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('a row whose newest failed entry came from spawnJob:fail-dirty is never selected — that source is a deliberate do-not-auto-requeue decision', () => {
|
|
99
|
+
const now = Date.now();
|
|
100
|
+
const job = {
|
|
101
|
+
slug: 'dirty-worktree',
|
|
102
|
+
status: 'failed',
|
|
103
|
+
statusHistory: [
|
|
104
|
+
{ to: 'failed', at: new Date(now - 15 * MIN_MS).toISOString(), source: 'spawnJob:fail-dirty' },
|
|
105
|
+
],
|
|
106
|
+
};
|
|
107
|
+
assert.equal(selectFailedAutoResetTargets([job], now, THRESHOLD_MS).length, 0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('failedAutoResetDisabled reflects SM_FAILED_AUTORESET_DISABLE', () => {
|
|
111
|
+
const saved = process.env.SM_FAILED_AUTORESET_DISABLE;
|
|
112
|
+
try {
|
|
113
|
+
delete process.env.SM_FAILED_AUTORESET_DISABLE;
|
|
114
|
+
assert.equal(failedAutoResetDisabled(), false);
|
|
115
|
+
process.env.SM_FAILED_AUTORESET_DISABLE = '1';
|
|
116
|
+
assert.equal(failedAutoResetDisabled(), true);
|
|
117
|
+
} finally {
|
|
118
|
+
if (saved === undefined) delete process.env.SM_FAILED_AUTORESET_DISABLE;
|
|
119
|
+
else process.env.SM_FAILED_AUTORESET_DISABLE = saved;
|
|
120
|
+
}
|
|
121
|
+
});
|