claude-code-session-manager 0.83.0 → 0.85.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-B-OM-4gk.js → AgentLibrary-Bkv-HcP1.js} +1 -1
- package/dist/assets/{DataModel-BC6ppcBr.js → DataModel-DRH-Ty20.js} +1 -1
- package/dist/assets/{History-D3Qw2BWn.js → History-CfRhT1Im.js} +2 -2
- package/dist/assets/{Hooks-OxBQ0nIc.js → Hooks-wEmh_U6c.js} +1 -1
- package/dist/assets/{HostBilko-BNFC6bNZ.js → HostBilko-D_t7Rbi7.js} +1 -1
- package/dist/assets/{Library-DAMyrUtY.js → Library-CpArQ-OJ.js} +1 -1
- package/dist/assets/{ListDetail-BwywrpvE.js → ListDetail-pjaKYs84.js} +1 -1
- package/dist/assets/{MarkdownEditor-HEwjN1bp.js → MarkdownEditor-Xc141kjj.js} +1 -1
- package/dist/assets/{McpServers-D1VZF7Ix.js → McpServers-ftqaV3kn.js} +1 -1
- package/dist/assets/{Memory-CgYJ_QqD.js → Memory-ChMWkNd0.js} +6 -6
- package/dist/assets/{Panel-dTNWC59Q.js → Panel-D9Kr40Ai.js} +1 -1
- package/dist/assets/{Permissions-qI_NKi1b.js → Permissions-DKoNVgzj.js} +1 -1
- package/dist/assets/{Plugins-DZ0_CHiF.js → Plugins-BtChISho.js} +2 -2
- package/dist/assets/{ProvenanceBadge-B2i_uxCu.js → ProvenanceBadge-DBA5EcYy.js} +1 -1
- package/dist/assets/{SaveBar-4tNMCjUd.js → SaveBar-I0_dWNTX.js} +1 -1
- package/dist/assets/{Scheduler-CUen18Gc.js → Scheduler-CbES7MC8.js} +7 -7
- package/dist/assets/{ScopeSwitcher-Co3tkLTT.js → ScopeSwitcher-5GTEveb2.js} +1 -1
- package/dist/assets/Settings-BX3FElXk.js +3 -0
- package/dist/assets/{SkillReferenceGraph-BKcHtMPc.js → SkillReferenceGraph-DNBFGrYE.js} +1 -1
- package/dist/assets/{Skills-DHb4INtF.js → Skills-DJB6-bBM.js} +2 -2
- package/dist/assets/SystemPrompt-BiDDrJUA.js +1 -0
- package/dist/assets/{TagLibrary-xLP5dJvh.js → TagLibrary-_Wrevtop.js} +1 -1
- package/dist/assets/{TiptapBody-T-ULTjK4.js → TiptapBody-OWWXdLRy.js} +1 -1
- package/dist/assets/{Toggle-B-5M2hGe.js → Toggle-B122N0HL.js} +1 -1
- package/dist/assets/{index-CYhdtisq.css → index-CDo9xBR9.css} +1 -1
- package/dist/assets/{index-CzAxC432.js → index-DhvuQL4C.js} +316 -314
- package/dist/assets/{settingsSchema-C_nsemcF.js → settingsSchema-sGoCTd7J.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +4 -1
- package/scripts/hooks/guard-destructive-git.cjs +514 -0
- package/scripts/hooks/guard-inline-implementation.cjs +219 -0
- package/scripts/hooks/guard-prd-writes.cjs +200 -0
- package/src/main/__tests__/epicMintTelemetryTap.test.cjs +64 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
- package/src/main/__tests__/health-queue-dispatch.test.cjs +84 -0
- package/src/main/__tests__/health-usage-poller.test.cjs +97 -0
- package/src/main/__tests__/health-worktree-cap-blocked.test.cjs +65 -0
- package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +149 -0
- package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +120 -0
- package/src/main/__tests__/promptSessionTranscript.test.cjs +0 -0
- package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +143 -0
- package/src/main/__tests__/rateLimitPollerStreak.test.cjs +79 -0
- package/src/main/__tests__/scheduleJobTransitionsTelemetryTap.test.cjs +72 -0
- package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +74 -0
- package/src/main/__tests__/scheduler-job-overrun.test.cjs +58 -0
- package/src/main/__tests__/scheduler-notify-originating-tab-transcript.test.cjs +1 -0
- package/src/main/__tests__/scheduler-periodic-reverify-guard.test.cjs +134 -2
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +33 -0
- package/src/main/__tests__/scheduler-stuck-failed-escalation.test.cjs +136 -0
- package/src/main/__tests__/telemetryClient.test.cjs +242 -1
- package/src/main/__tests__/telemetryContract.test.cjs +930 -0
- package/src/main/crashDiagnostics.cjs +29 -1
- package/src/main/health.cjs +197 -3
- package/src/main/index.cjs +65 -6
- package/src/main/ipcSchemas.cjs +19 -2
- package/src/main/lib/__tests__/crashTelemetry.test.cjs +103 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +302 -4
- package/src/main/lib/__tests__/fixtures/scheduler-machine.json.corrupt-1789147548 +34 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +413 -1
- package/src/main/lib/__tests__/jobWorktreeBootLive.test.cjs +71 -0
- package/src/main/lib/__tests__/queueStoreAtomicWrite.test.cjs +88 -0
- package/src/main/lib/__tests__/queueStoreMachineStateRecovery.test.cjs +123 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +58 -0
- package/src/main/lib/__tests__/telemetryBacklog.test.cjs +626 -0
- package/src/main/lib/__tests__/telemetryBoot.test.cjs +123 -0
- package/src/main/lib/__tests__/telemetryConsent.test.cjs +136 -0
- package/src/main/lib/__tests__/telemetryCounters.test.cjs +57 -0
- package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +95 -0
- package/src/main/lib/crashTelemetry.cjs +37 -0
- package/src/main/lib/delegationReadiness.cjs +119 -1
- package/src/main/lib/epicMint.cjs +2 -0
- package/src/main/lib/gitWorktree.cjs +427 -17
- package/src/main/lib/jobWorktree.cjs +2 -1
- package/src/main/lib/jobWorktreeBootLive.cjs +51 -0
- package/src/main/lib/jobWorktreeTerminalOrphanLive.cjs +68 -0
- package/src/main/lib/opsErrorLog.cjs +78 -25
- package/src/main/lib/queueStore.cjs +241 -22
- package/src/main/lib/reaperHelpers.cjs +23 -1
- package/src/main/lib/scheduleJobSchema.cjs +7 -0
- package/src/main/lib/scheduleJobTransitions.cjs +12 -0
- package/src/main/lib/telemetryBacklog.cjs +601 -0
- package/src/main/lib/telemetryBoot.cjs +76 -0
- package/src/main/lib/telemetryClient.cjs +150 -5
- package/src/main/lib/telemetryConsent.cjs +34 -0
- package/src/main/lib/telemetryCounters.cjs +45 -0
- package/src/main/promptSessionTranscript.cjs +0 -0
- package/src/main/pty.cjs +2 -0
- package/src/main/scheduler.cjs +481 -33
- package/src/preload/api.d.ts +84 -4
- package/src/preload/index.cjs +9 -0
- package/dist/assets/Settings-DXVgKLUx.js +0 -3
- package/dist/assets/SystemPrompt-BjqFOHSk.js +0 -1
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduleJobTransitionsTelemetryTap.test.cjs — transitionJob() is the ONE
|
|
3
|
+
* chokepoint every job status change flows through (scheduleJobTransitions.
|
|
4
|
+
* cjs's own header); this asserts it fires the 'scheduler.job.finish'
|
|
5
|
+
* counter exactly once when a run genuinely finishes (running -> a terminal
|
|
6
|
+
* status), and not on other legal edges (retries, admin resets).
|
|
7
|
+
*
|
|
8
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduleJobTransitionsTelemetryTap.test.cjs
|
|
9
|
+
*/
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
import { test, expect, afterEach, beforeEach } from 'vitest';
|
|
13
|
+
|
|
14
|
+
const countersPath = require.resolve('../lib/telemetryCounters.cjs');
|
|
15
|
+
const transitionsPath = require.resolve('../lib/scheduleJobTransitions.cjs');
|
|
16
|
+
|
|
17
|
+
let calls;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
calls = [];
|
|
21
|
+
require.cache[countersPath] = {
|
|
22
|
+
id: countersPath,
|
|
23
|
+
filename: countersPath,
|
|
24
|
+
loaded: true,
|
|
25
|
+
exports: { trackSchedulerJobFinish: (props) => calls.push(props) },
|
|
26
|
+
};
|
|
27
|
+
delete require.cache[transitionsPath];
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
delete require.cache[countersPath];
|
|
32
|
+
delete require.cache[transitionsPath];
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function freshJob(status) {
|
|
36
|
+
return { slug: 'test-slug', status, cwd: '/tmp/whatever' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test('running -> completed fires scheduler.job.finish once with { status: "completed" }', () => {
|
|
40
|
+
const { transitionJob } = require('../lib/scheduleJobTransitions.cjs');
|
|
41
|
+
const job = freshJob('running');
|
|
42
|
+
expect(transitionJob(job, 'completed', { reason: 'run succeeded', source: 'test' })).toBe(true);
|
|
43
|
+
expect(calls).toEqual([{ status: 'completed' }]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('running -> failed fires scheduler.job.finish once with { status: "failed" }', () => {
|
|
47
|
+
const { transitionJob } = require('../lib/scheduleJobTransitions.cjs');
|
|
48
|
+
const job = freshJob('running');
|
|
49
|
+
transitionJob(job, 'failed', { reason: 'run failed', source: 'test' });
|
|
50
|
+
expect(calls).toEqual([{ status: 'failed' }]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('running -> pending (retry) does NOT fire scheduler.job.finish', () => {
|
|
54
|
+
const { transitionJob } = require('../lib/scheduleJobTransitions.cjs');
|
|
55
|
+
const job = freshJob('running');
|
|
56
|
+
transitionJob(job, 'pending', { reason: 'transient retry', source: 'test' });
|
|
57
|
+
expect(calls).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('pending -> running (dispatch) does NOT fire scheduler.job.finish', () => {
|
|
61
|
+
const { transitionJob } = require('../lib/scheduleJobTransitions.cjs');
|
|
62
|
+
const job = freshJob('pending');
|
|
63
|
+
transitionJob(job, 'running', { reason: 'dispatch', source: 'test' });
|
|
64
|
+
expect(calls).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('an illegal (refused) transition does NOT fire scheduler.job.finish', () => {
|
|
68
|
+
const { transitionJob } = require('../lib/scheduleJobTransitions.cjs');
|
|
69
|
+
const job = freshJob('completed');
|
|
70
|
+
expect(transitionJob(job, 'running', { reason: 'bogus', source: 'test' })).toBe(false);
|
|
71
|
+
expect(calls).toEqual([]);
|
|
72
|
+
});
|
|
@@ -201,6 +201,80 @@ function writeNoopClaudeStub() {
|
|
|
201
201
|
return stubPath;
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
// Stub `claude` binary that blocks until a `go` marker file appears in its
|
|
205
|
+
// cwd (polled), THEN emits a success result and exits 0 — gives the test a
|
|
206
|
+
// window to read the queue row's intermediate dispatchPhase before the run
|
|
207
|
+
// finalizes and deletes it.
|
|
208
|
+
function writeGatedClaudeStub() {
|
|
209
|
+
const stubPath = path.join(os.tmpdir(), `sm-claude-stub-gated-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
|
|
210
|
+
const body = `
|
|
211
|
+
const fs = require('fs');
|
|
212
|
+
const path = require('path');
|
|
213
|
+
const { execFileSync } = require('child_process');
|
|
214
|
+
const goFile = path.join(process.cwd(), 'go.marker');
|
|
215
|
+
const deadline = Date.now() + 10_000;
|
|
216
|
+
while (!fs.existsSync(goFile) && Date.now() < deadline) {
|
|
217
|
+
try { execFileSync('sleep', ['0.02']); } catch {}
|
|
218
|
+
}
|
|
219
|
+
process.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', result: 'ok', is_error: false }) + '\\n');
|
|
220
|
+
process.exit(0);
|
|
221
|
+
`;
|
|
222
|
+
fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
|
|
223
|
+
return stubPath;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
test('dispatchPhase breadcrumb advances to "spawned" mid-run and is gone after finalize', async () => {
|
|
227
|
+
const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-inplace-salvage-project-'));
|
|
228
|
+
initRepo(projectCwd);
|
|
229
|
+
registerActiveProject(projectCwd);
|
|
230
|
+
|
|
231
|
+
const slug = `1163-test-dispatchphase-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
232
|
+
const prdsDir = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'prds');
|
|
233
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
234
|
+
fs.writeFileSync(path.join(prdsDir, `${slug}.md`), 'Do a thing, gated on a marker file.', 'utf8');
|
|
235
|
+
|
|
236
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
237
|
+
{ slug, status: 'pending', cwd: projectCwd },
|
|
238
|
+
]);
|
|
239
|
+
|
|
240
|
+
process.env.SM_CLAUDE_BIN = writeGatedClaudeStub();
|
|
241
|
+
|
|
242
|
+
const runId = `run-${slug}`;
|
|
243
|
+
const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
|
|
244
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
245
|
+
|
|
246
|
+
const readRow = () => JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs.find((j) => j.slug === slug);
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
const spawnPromise = spawnJob({ slug, cwd: projectCwd }, runId, runDir, projectCwd);
|
|
250
|
+
|
|
251
|
+
// Poll the row until dispatchPhase reaches 'spawned' (the onPid mutate),
|
|
252
|
+
// proving the breadcrumb advanced through the dispatch region while the
|
|
253
|
+
// stub is deliberately blocked mid-run.
|
|
254
|
+
const pollDeadline = Date.now() + 8_000;
|
|
255
|
+
let row = readRow();
|
|
256
|
+
while (row?.dispatchPhase !== 'spawned' && Date.now() < pollDeadline) {
|
|
257
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
258
|
+
row = readRow();
|
|
259
|
+
}
|
|
260
|
+
expect(row?.dispatchPhase).toBe('spawned');
|
|
261
|
+
expect(typeof row.dispatchPhaseAt).toBe('string');
|
|
262
|
+
expect(Number.isNaN(Date.parse(row.dispatchPhaseAt))).toBe(false);
|
|
263
|
+
|
|
264
|
+
// Let the gated stub finish.
|
|
265
|
+
fs.writeFileSync(path.join(projectCwd, 'go.marker'), 'go\n', 'utf8');
|
|
266
|
+
await spawnPromise;
|
|
267
|
+
|
|
268
|
+
const finalRow = readRow();
|
|
269
|
+
expect(finalRow.exitCode).toBe(0);
|
|
270
|
+
expect(finalRow.dispatchPhase).toBeUndefined();
|
|
271
|
+
expect(finalRow.dispatchPhaseAt).toBeUndefined();
|
|
272
|
+
} finally {
|
|
273
|
+
fs.rmSync(projectCwd, { recursive: true, force: true });
|
|
274
|
+
fs.rmSync(runDir, { recursive: true, force: true });
|
|
275
|
+
}
|
|
276
|
+
}, 30_000);
|
|
277
|
+
|
|
204
278
|
test('a job whose tree is dirty only from pre-existing baseline WIP (human/sibling), and which itself dirties/commits nothing, gets no leftover attribution', async () => {
|
|
205
279
|
const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-inplace-salvage-project-'));
|
|
206
280
|
initRepo(projectCwd);
|
|
@@ -23,6 +23,7 @@ const {
|
|
|
23
23
|
findOverrunningJobs,
|
|
24
24
|
JOB_OVERRUN_FACTOR,
|
|
25
25
|
JOB_OVERRUN_FLOOR_MS,
|
|
26
|
+
resetJobFields,
|
|
26
27
|
} = require('../scheduler.cjs');
|
|
27
28
|
|
|
28
29
|
const NOW = Date.parse('2026-08-08T12:00:00.000Z');
|
|
@@ -115,3 +116,60 @@ test('the shipped defaults are the documented ones', () => {
|
|
|
115
116
|
assert.strictEqual(JOB_OVERRUN_FACTOR, 3);
|
|
116
117
|
assert.strictEqual(JOB_OVERRUN_FLOOR_MS, 45 * 60_000);
|
|
117
118
|
});
|
|
119
|
+
|
|
120
|
+
// The escalation loop in scheduler.cjs (~8700) stamps job.overrun straight
|
|
121
|
+
// from findOverrunningJobs' own return shape — these tests exercise that
|
|
122
|
+
// exact shape against the live starry-night-ships incident fixture
|
|
123
|
+
// (estimateMinutes: 22, ranMs: 6379556, ratio: 4.83) rather than duplicating
|
|
124
|
+
// the threshold math the escalation loop itself must not recompute.
|
|
125
|
+
|
|
126
|
+
test('the live incident fixture (4.9x over a 22m estimate) is stamped', () => {
|
|
127
|
+
const startedAt = new Date(NOW - 6379556).toISOString();
|
|
128
|
+
const jobs = [
|
|
129
|
+
{ slug: '231-saturn-record-and-docs', cwd: '/starry', status: 'running', estimateMinutes: 22, startedAt },
|
|
130
|
+
];
|
|
131
|
+
const over = findOverrunningJobs(jobs, NOW);
|
|
132
|
+
assert.strictEqual(over.length, 1);
|
|
133
|
+
const [hit] = over;
|
|
134
|
+
assert.ok(hit.ratio >= 4.8 && hit.ratio <= 4.9, `expected ~4.83x, got ${hit.ratio}`);
|
|
135
|
+
|
|
136
|
+
// Mirror the escalation loop's own stamp assignment (job.overrun = {...}) —
|
|
137
|
+
// same fields the ScheduleJobLite renderer type now carries.
|
|
138
|
+
const job = jobs[0];
|
|
139
|
+
job.overrun = { ratio: hit.ratio, ranMs: hit.ranMs, estimateMinutes: hit.estimateMinutes, at: new Date(NOW).toISOString() };
|
|
140
|
+
assert.strictEqual(job.overrun.estimateMinutes, 22);
|
|
141
|
+
assert.strictEqual(job.overrun.ranMs, 6379556);
|
|
142
|
+
assert.ok(job.overrun.ratio >= 4.8 && job.overrun.ratio <= 4.9);
|
|
143
|
+
|
|
144
|
+
// Idempotent re-escalation: a later sweep overwrites in place, never appends.
|
|
145
|
+
const laterNow = NOW + 10 * 60_000;
|
|
146
|
+
const laterOver = findOverrunningJobs(jobs, laterNow)[0];
|
|
147
|
+
job.overrun = {
|
|
148
|
+
ratio: laterOver.ratio, ranMs: laterOver.ranMs, estimateMinutes: laterOver.estimateMinutes, at: new Date(laterNow).toISOString(),
|
|
149
|
+
};
|
|
150
|
+
assert.strictEqual(typeof job.overrun, 'object');
|
|
151
|
+
assert.ok(job.overrun.ranMs > hit.ranMs, 'ranMs should have advanced on re-escalation, not duplicated');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('a job with no usable estimate is never in the escalation list, so it is never stamped', () => {
|
|
155
|
+
const jobs = [
|
|
156
|
+
{ slug: 'no-est', cwd: '/p1', status: 'running', startedAt: agoMin(300) },
|
|
157
|
+
];
|
|
158
|
+
assert.deepStrictEqual(findOverrunningJobs(jobs, NOW), []);
|
|
159
|
+
assert.strictEqual(jobs[0].overrun, undefined);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('resetJobFields clears a stamped overrun badge — this run\'s outcome, not durable across a reset', () => {
|
|
163
|
+
const job = {
|
|
164
|
+
slug: '231-saturn-record-and-docs',
|
|
165
|
+
status: 'running',
|
|
166
|
+
statusHistory: [],
|
|
167
|
+
runId: 'r1',
|
|
168
|
+
startedAt: agoMin(180),
|
|
169
|
+
overrun: { ratio: 4.83, ranMs: 6379556, estimateMinutes: 22, at: new Date(NOW).toISOString() },
|
|
170
|
+
};
|
|
171
|
+
const ok = resetJobFields(job, 'reset for test');
|
|
172
|
+
assert.strictEqual(ok, true);
|
|
173
|
+
assert.strictEqual(job.status, 'pending');
|
|
174
|
+
assert.strictEqual('overrun' in job, false);
|
|
175
|
+
});
|
|
@@ -32,6 +32,7 @@ test('appends the job result text to the transcript store when sourcePromptId is
|
|
|
32
32
|
expect(appendTranscriptTurn).toHaveBeenCalledWith('/some/cwd', 'psess-abc', {
|
|
33
33
|
role: 'assistant',
|
|
34
34
|
text: 'the real agent result text',
|
|
35
|
+
eventId: 'prd-result:863-transcript:run-1',
|
|
35
36
|
});
|
|
36
37
|
});
|
|
37
38
|
|
|
@@ -13,6 +13,18 @@
|
|
|
13
13
|
*
|
|
14
14
|
* These tests pin the guard to isRescanCandidate so the two cannot drift again.
|
|
15
15
|
*
|
|
16
|
+
* Reopened 2026-09-12 through a different door (starry-night-ships
|
|
17
|
+
* 231-saturn-record-and-docs / 243-neptune-kurama-mode): the guard also gates
|
|
18
|
+
* reverifyNeedsReview's auto-fix loop, not just its re-verification arm, but
|
|
19
|
+
* only checked isRescanCandidate — so a needs_review row whose mechanical
|
|
20
|
+
* recovery already ran and failed (verdict 'worktree_integration_failed',
|
|
21
|
+
* mechanicalRecoveryAttempted: true — not itself a RESCANNABLE_VERDICTS
|
|
22
|
+
* member) never got a chance at the next rung (a fix-plan investigation via
|
|
23
|
+
* selectAutoFixTargets). The guard is now widened to OR in every live target
|
|
24
|
+
* of the recovery ladder the periodic pass actually drives
|
|
25
|
+
* (selectMechanicalRecoveryTarget / selectResumeRecoveryTarget /
|
|
26
|
+
* selectAutoFixTargets).
|
|
27
|
+
*
|
|
16
28
|
* HOME is overridden to a tmp dir BEFORE requiring scheduler.cjs — see
|
|
17
29
|
* scheduler-reap-dead-running-jobs.test.cjs's comment for why.
|
|
18
30
|
*
|
|
@@ -29,7 +41,12 @@ const path = require('node:path');
|
|
|
29
41
|
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'reverify-guard-test-'));
|
|
30
42
|
process.env.HOME = tmpHome;
|
|
31
43
|
|
|
32
|
-
const {
|
|
44
|
+
const {
|
|
45
|
+
shouldRunPeriodicReverify,
|
|
46
|
+
isRescanCandidate,
|
|
47
|
+
selectAutoFixTargets,
|
|
48
|
+
selectMechanicalRecoveryTarget,
|
|
49
|
+
} = require('../scheduler.cjs');
|
|
33
50
|
|
|
34
51
|
function writeRunLog(runId, slug, lines) {
|
|
35
52
|
const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
|
|
@@ -70,12 +87,44 @@ test('needs_review with a rescannable verdict still fires; a non-rescannable ver
|
|
|
70
87
|
]),
|
|
71
88
|
true,
|
|
72
89
|
);
|
|
90
|
+
// A verdict outside RESCANNABLE_VERDICTS is not itself enough to suppress
|
|
91
|
+
// the pass: with no sessionId, this row also fails selectResumeRecoveryTarget's
|
|
92
|
+
// eligibility check, so it falls through as a genuine selectAutoFixTargets
|
|
93
|
+
// candidate (a fix-plan investigation, not a rescan) — and the widened guard
|
|
94
|
+
// must fire for that too.
|
|
73
95
|
assert.equal(
|
|
74
96
|
shouldRunPeriodicReverify([
|
|
75
97
|
{ slug: '300-nr', status: 'needs_review', runId: 'run-guard-3', verifierVerdict: 'uncommitted_changes' },
|
|
76
98
|
]),
|
|
77
|
-
|
|
99
|
+
true,
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('a needs_review row with no rescan/recovery/autofix eligibility at all does not fire the pass', () => {
|
|
104
|
+
// Genuinely nothing to do: already has a fix-plan outcome recorded as
|
|
105
|
+
// 'plan' (never retried by selectAutoFixTargets — see its autoFixOutcome
|
|
106
|
+
// exclusion), so selectAutoFixTargets excludes it regardless of
|
|
107
|
+
// fixSlugExists; not a rescan candidate (RESCANNABLE_VERDICTS); not
|
|
108
|
+
// resume/mechanical eligible either.
|
|
109
|
+
writeRunLog('run-guard-3b', '301-nr', ['[scheduler] starting 301-nr']);
|
|
110
|
+
const jobs = [
|
|
111
|
+
{
|
|
112
|
+
slug: '301-nr',
|
|
113
|
+
status: 'needs_review',
|
|
114
|
+
runId: 'run-guard-3b',
|
|
115
|
+
verifierVerdict: 'uncommitted_changes',
|
|
116
|
+
autoFixAttempted: true,
|
|
117
|
+
autoFixOutcome: 'plan',
|
|
118
|
+
},
|
|
119
|
+
];
|
|
120
|
+
assert.equal(isRescanCandidate(jobs[0]), false);
|
|
121
|
+
assert.equal(selectMechanicalRecoveryTarget(jobs[0]), null);
|
|
122
|
+
assert.equal(
|
|
123
|
+
selectAutoFixTargets(jobs, { fixSlugExists: () => false }).length,
|
|
124
|
+
0,
|
|
125
|
+
'sanity: selectAutoFixTargets itself must exclude this row (cheap-guard stub matches production: fixSlugExists always false)',
|
|
78
126
|
);
|
|
127
|
+
assert.equal(shouldRunPeriodicReverify(jobs), false);
|
|
79
128
|
});
|
|
80
129
|
|
|
81
130
|
test('a queue with nothing rescannable, or a non-array, does not fire the pass', () => {
|
|
@@ -83,3 +132,86 @@ test('a queue with nothing rescannable, or a non-array, does not fire the pass',
|
|
|
83
132
|
assert.equal(shouldRunPeriodicReverify([]), false);
|
|
84
133
|
assert.equal(shouldRunPeriodicReverify(undefined), false);
|
|
85
134
|
});
|
|
135
|
+
|
|
136
|
+
// Live reproduction fixture (verbatim, verdict from the machine 2026-09-12):
|
|
137
|
+
// starry-night-ships 231-saturn-record-and-docs, needs_review,
|
|
138
|
+
// worktree_integration_failed, mechanical recovery already spent.
|
|
139
|
+
function liveFixtureRow(overrides = {}) {
|
|
140
|
+
return {
|
|
141
|
+
slug: '231-saturn-record-and-docs',
|
|
142
|
+
cwd: '/home/bilko/Projects/starry-night-ships',
|
|
143
|
+
status: 'needs_review',
|
|
144
|
+
verifierVerdict: 'worktree_integration_failed',
|
|
145
|
+
mechanicalRecoveryAttempted: true,
|
|
146
|
+
autoFixAttempted: undefined,
|
|
147
|
+
runId: '2026-09-11T23-58-26-007Z',
|
|
148
|
+
...overrides,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
test('live fixture: mechanical recovery spent, autofix never attempted — guard now fires (was false)', () => {
|
|
153
|
+
writeRunLog(liveFixtureRow().runId, liveFixtureRow().slug, ['[scheduler] starting 231-saturn-record-and-docs']);
|
|
154
|
+
const jobs = [liveFixtureRow()];
|
|
155
|
+
assert.equal(isRescanCandidate(jobs[0]), false, 'worktree_integration_failed is deliberately NOT in RESCANNABLE_VERDICTS');
|
|
156
|
+
assert.equal(selectMechanicalRecoveryTarget(jobs[0]), null, 'mechanical recovery is spent (mechanicalRecoveryAttempted: true)');
|
|
157
|
+
assert.equal(shouldRunPeriodicReverify(jobs), true);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('live fixture is authored as a selectAutoFixTargets candidate once mechanical recovery is spent', () => {
|
|
161
|
+
const jobs = [liveFixtureRow()];
|
|
162
|
+
const targets = selectAutoFixTargets(jobs, { fixSlugExists: () => false });
|
|
163
|
+
assert.deepEqual(targets.map((t) => t.slug), ['231-saturn-record-and-docs']);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('a row whose mechanical recovery is still PENDING is excluded from selectAutoFixTargets (both rungs never fire together)', () => {
|
|
167
|
+
const jobs = [liveFixtureRow({ mechanicalRecoveryAttempted: undefined })];
|
|
168
|
+
assert.notEqual(selectMechanicalRecoveryTarget(jobs[0]), null, 'still eligible for its one mechanical retry');
|
|
169
|
+
const targets = selectAutoFixTargets(jobs, { fixSlugExists: () => false });
|
|
170
|
+
assert.deepEqual(targets, [], 'a mechanical-recovery-pending row must not also become a fix-plan target');
|
|
171
|
+
// The guard still fires for this row — via the mechanical-recovery rung, not autofix.
|
|
172
|
+
assert.equal(shouldRunPeriodicReverify(jobs), true);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('one-attempt caps are unchanged by the widened guard', () => {
|
|
176
|
+
// mechanicalRecoveryAttempted still permits exactly one retry: once true, selectMechanicalRecoveryTarget is spent forever.
|
|
177
|
+
assert.equal(selectMechanicalRecoveryTarget(liveFixtureRow({ mechanicalRecoveryAttempted: true })), null);
|
|
178
|
+
assert.notEqual(selectMechanicalRecoveryTarget(liveFixtureRow({ mechanicalRecoveryAttempted: undefined })), null);
|
|
179
|
+
// autoFixRetries < 1 still bounds the fix-plan retry inside selectAutoFixTargets.
|
|
180
|
+
const exhausted = [liveFixtureRow({ autoFixAttempted: true, autoFixOutcome: 'error', autoFixRetries: 1 })];
|
|
181
|
+
assert.deepEqual(selectAutoFixTargets(exhausted, { fixSlugExists: () => false }), [], 'exhausted retry budget must stay excluded');
|
|
182
|
+
const withBudget = [liveFixtureRow({ autoFixAttempted: true, autoFixOutcome: 'error', autoFixRetries: 0 })];
|
|
183
|
+
assert.equal(selectAutoFixTargets(withBudget, { fixSlugExists: () => false }).length, 1, 'one bounded retry is still available');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('kill-switches still fully disable their respective paths', () => {
|
|
187
|
+
const jobs = [liveFixtureRow()];
|
|
188
|
+
const prevAutofix = process.env.SM_AUTOFIX_DISABLE;
|
|
189
|
+
const prevMechanical = process.env.SM_MECHANICAL_RECOVERY_DISABLE;
|
|
190
|
+
try {
|
|
191
|
+
// SM_REVERIFY_PERIODIC_DISABLE is enforced by the interval callback around
|
|
192
|
+
// shouldRunPeriodicReverify (scheduler.cjs's rescheduleInterval body), not
|
|
193
|
+
// inside the guard itself — the guard stays a pure predicate over jobs.
|
|
194
|
+
assert.equal(process.env.SM_REVERIFY_PERIODIC_DISABLE, undefined, 'sanity: not set in this test process');
|
|
195
|
+
|
|
196
|
+
// SM_MECHANICAL_RECOVERY_DISABLE=1 makes selectMechanicalRecoveryTarget
|
|
197
|
+
// always return null, which is exactly what the widened guard consults.
|
|
198
|
+
process.env.SM_MECHANICAL_RECOVERY_DISABLE = '1';
|
|
199
|
+
const pendingMechanicalJob = [liveFixtureRow({ mechanicalRecoveryAttempted: undefined })];
|
|
200
|
+
assert.equal(selectMechanicalRecoveryTarget(pendingMechanicalJob[0]), null);
|
|
201
|
+
delete process.env.SM_MECHANICAL_RECOVERY_DISABLE;
|
|
202
|
+
|
|
203
|
+
// SM_AUTOFIX_DISABLE gates reverifyNeedsReview's own auto-fix dispatch
|
|
204
|
+
// loop (scheduler.cjs ~8207), not selectAutoFixTargets/the guard — the
|
|
205
|
+
// guard's job is only to decide whether the pass should run at all, and
|
|
206
|
+
// it must still fire so the disabled loop's own no-op is reached (rather
|
|
207
|
+
// than the periodic pass never running and other reverify semantics,
|
|
208
|
+
// e.g. rescan candidates elsewhere in the same tick, being starved too).
|
|
209
|
+
process.env.SM_AUTOFIX_DISABLE = '1';
|
|
210
|
+
assert.equal(shouldRunPeriodicReverify(jobs), true);
|
|
211
|
+
} finally {
|
|
212
|
+
if (prevAutofix === undefined) delete process.env.SM_AUTOFIX_DISABLE;
|
|
213
|
+
else process.env.SM_AUTOFIX_DISABLE = prevAutofix;
|
|
214
|
+
if (prevMechanical === undefined) delete process.env.SM_MECHANICAL_RECOVERY_DISABLE;
|
|
215
|
+
else process.env.SM_MECHANICAL_RECOVERY_DISABLE = prevMechanical;
|
|
216
|
+
}
|
|
217
|
+
});
|
|
@@ -148,6 +148,39 @@ test('reapDeadRunningJobs reaps a pidless row older than PIDLESS_SPAWN_GRACE_MS
|
|
|
148
148
|
assert.ok(pidlessEvent, 'reaping a pidless row must leave an audit trace');
|
|
149
149
|
});
|
|
150
150
|
|
|
151
|
+
test('reapDeadRunningJobs clears runId on a pidless reap whose run dir holds only a sibling slug\'s files', async () => {
|
|
152
|
+
const projectCwd = path.join(tmpHome, 'c2-project-batch-sibling');
|
|
153
|
+
fs.mkdirSync(projectCwd, { recursive: true });
|
|
154
|
+
registerActiveProject(projectCwd);
|
|
155
|
+
|
|
156
|
+
const staleStartedAt = new Date(Date.now() - PIDLESS_SPAWN_GRACE_MS - 60_000).toISOString();
|
|
157
|
+
// Both jobs were dispatched into the SAME batch runId dir (pickRunDir's
|
|
158
|
+
// header: "tickQueue hands ONE shared batch dir to every spawnJob in the
|
|
159
|
+
// batch"). 'sibling-that-ran' actually spawned and wrote its own log;
|
|
160
|
+
// 'never-spawned' never got a pid and never wrote anything of its own.
|
|
161
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
162
|
+
{
|
|
163
|
+
slug: 'zzq8712-pidless-batch-row',
|
|
164
|
+
status: 'running',
|
|
165
|
+
cwd: projectCwd,
|
|
166
|
+
runId: 'run-shared-batch',
|
|
167
|
+
startedAt: staleStartedAt,
|
|
168
|
+
// no runtime key at all — the spawn never got far enough to record one
|
|
169
|
+
},
|
|
170
|
+
]);
|
|
171
|
+
// Only the sibling's log exists in the shared batch dir.
|
|
172
|
+
writeRunLog('run-shared-batch', 'zzq8712-sibling-that-ran', [
|
|
173
|
+
'{"type":"result","subtype":"success","result":"done","is_error":false}',
|
|
174
|
+
]);
|
|
175
|
+
|
|
176
|
+
await reapDeadRunningJobs();
|
|
177
|
+
|
|
178
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
179
|
+
const row = jobs.find((j) => j.slug === 'zzq8712-pidless-batch-row');
|
|
180
|
+
assert.equal(row.status, 'failed');
|
|
181
|
+
assert.equal(row.runId, null, 'a runId whose dir holds no artifact for this slug must not survive the reap');
|
|
182
|
+
});
|
|
183
|
+
|
|
151
184
|
test('reapDeadRunningJobs leaves a pidless row alone while it is still within the grace window', async () => {
|
|
152
185
|
const projectCwd = path.join(tmpHome, 'd-project');
|
|
153
186
|
fs.mkdirSync(projectCwd, { recursive: true });
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-stuck-failed-escalation.test.cjs
|
|
3
|
+
*
|
|
4
|
+
* `failed` is a fully terminal state for every automated recovery path in
|
|
5
|
+
* scheduler.cjs (selectResumeRecoveryTarget/selectAutoFixTargets require
|
|
6
|
+
* needs_review, reapDeadRunningJobs only ever writes running → failed,
|
|
7
|
+
* reconcile-repair's to-pending is for structurally invalid rows) — only a
|
|
8
|
+
* human's scheduler_reset_job ever takes failed → pending. Job
|
|
9
|
+
* 4056-outcome-stats sat `failed` for five days with no operator signal
|
|
10
|
+
* (reported 2026-09-10, social-signals-trader), even once the periodic
|
|
11
|
+
* reverify guard fix (shouldRunPeriodicReverify, commit f4125f8) made the
|
|
12
|
+
* pass actually fire on it — reverifyNeedsReview's failed branch can only
|
|
13
|
+
* annotate looksDone, never resolve a failed row.
|
|
14
|
+
*
|
|
15
|
+
* These tests cover findStuckFailedJobs (the escalation candidate finder) and
|
|
16
|
+
* stuckFailedEscalationDisabled (the kill switch gate) in isolation.
|
|
17
|
+
*
|
|
18
|
+
* HOME is overridden to a tmp dir BEFORE requiring scheduler.cjs — see
|
|
19
|
+
* scheduler-reap-dead-running-jobs.test.cjs's comment for why.
|
|
20
|
+
*
|
|
21
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-stuck-failed-escalation.test.cjs
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const assert = require('node:assert/strict');
|
|
27
|
+
const fs = require('node:fs');
|
|
28
|
+
const os = require('node:os');
|
|
29
|
+
const path = require('node:path');
|
|
30
|
+
|
|
31
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'stuck-failed-escalation-test-'));
|
|
32
|
+
process.env.HOME = tmpHome;
|
|
33
|
+
|
|
34
|
+
const {
|
|
35
|
+
findStuckFailedJobs,
|
|
36
|
+
STUCK_FAILED_ESCALATE_MS,
|
|
37
|
+
stuckFailedEscalationDisabled,
|
|
38
|
+
isRescanCandidate,
|
|
39
|
+
} = require('../scheduler.cjs');
|
|
40
|
+
|
|
41
|
+
function writeRunLog(runId, slug, lines) {
|
|
42
|
+
const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
|
|
43
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
44
|
+
fs.writeFileSync(path.join(runDir, `${slug}.log`), lines.join('\n') + '\n');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DAY_MS = 24 * 60 * 60_000;
|
|
48
|
+
|
|
49
|
+
test('a failed rescan-candidate older than the threshold is reported exactly once', () => {
|
|
50
|
+
writeRunLog('run-4056', '4056-outcome-stats', ['[scheduler] starting 4056-outcome-stats']); // no result event
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
const job = {
|
|
53
|
+
slug: '4056-outcome-stats',
|
|
54
|
+
status: 'failed',
|
|
55
|
+
cwd: '/home/user/social-signals-trader',
|
|
56
|
+
runId: 'run-4056',
|
|
57
|
+
statusHistory: [{ to: 'failed', at: new Date(now - 5 * DAY_MS).toISOString() }],
|
|
58
|
+
};
|
|
59
|
+
assert.equal(isRescanCandidate(job), true, 'fixture must be a genuine rescan candidate');
|
|
60
|
+
|
|
61
|
+
const found = findStuckFailedJobs([job], now, STUCK_FAILED_ESCALATE_MS);
|
|
62
|
+
assert.equal(found.length, 1);
|
|
63
|
+
assert.equal(found[0].slug, '4056-outcome-stats');
|
|
64
|
+
assert.equal(found[0].cwd, '/home/user/social-signals-trader');
|
|
65
|
+
assert.ok(found[0].ageMs >= 5 * DAY_MS - 1000);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('a second pass over the same row (after the caller stamps stuckFailedNotified) produces no second notification', () => {
|
|
69
|
+
writeRunLog('run-4056b', 'repeat-offender', []);
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
const job = {
|
|
72
|
+
slug: 'repeat-offender',
|
|
73
|
+
status: 'failed',
|
|
74
|
+
runId: 'run-4056b',
|
|
75
|
+
statusHistory: [{ to: 'failed', at: new Date(now - 2 * DAY_MS).toISOString() }],
|
|
76
|
+
};
|
|
77
|
+
assert.equal(findStuckFailedJobs([job], now, STUCK_FAILED_ESCALATE_MS).length, 1);
|
|
78
|
+
|
|
79
|
+
// Simulate the caller stamping the row after the first notification.
|
|
80
|
+
job.stuckFailedNotified = true;
|
|
81
|
+
assert.equal(findStuckFailedJobs([job], now, STUCK_FAILED_ESCALATE_MS).length, 0, 'idempotency flag must suppress re-notification');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('a failed row younger than the threshold is not reported', () => {
|
|
85
|
+
writeRunLog('run-fresh', 'fresh-failure', []);
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
const job = {
|
|
88
|
+
slug: 'fresh-failure',
|
|
89
|
+
status: 'failed',
|
|
90
|
+
runId: 'run-fresh',
|
|
91
|
+
statusHistory: [{ to: 'failed', at: new Date(now - 60_000).toISOString() }],
|
|
92
|
+
};
|
|
93
|
+
assert.equal(findStuckFailedJobs([job], now, STUCK_FAILED_ESCALATE_MS).length, 0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('a failed row with a real result event (genuine red gate, not a rescan candidate) is never reported, however old', () => {
|
|
97
|
+
writeRunLog('run-genuine-red', 'genuine-red', [
|
|
98
|
+
JSON.stringify({ type: 'result', subtype: 'success', is_error: true }),
|
|
99
|
+
]);
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
const job = {
|
|
102
|
+
slug: 'genuine-red',
|
|
103
|
+
status: 'failed',
|
|
104
|
+
runId: 'run-genuine-red',
|
|
105
|
+
statusHistory: [{ to: 'failed', at: new Date(now - 10 * DAY_MS).toISOString() }],
|
|
106
|
+
};
|
|
107
|
+
assert.equal(isRescanCandidate(job), false);
|
|
108
|
+
assert.equal(findStuckFailedJobs([job], now, STUCK_FAILED_ESCALATE_MS).length, 0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('a non-failed row, or a failed row with no recoverable failed timestamp, is skipped rather than guessed at', () => {
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
assert.equal(findStuckFailedJobs([{ slug: 'a', status: 'needs_review' }], now, STUCK_FAILED_ESCALATE_MS).length, 0);
|
|
114
|
+
writeRunLog('run-no-history', 'no-history', []);
|
|
115
|
+
assert.equal(
|
|
116
|
+
findStuckFailedJobs(
|
|
117
|
+
[{ slug: 'no-history', status: 'failed', runId: 'run-no-history', statusHistory: [] }],
|
|
118
|
+
now,
|
|
119
|
+
STUCK_FAILED_ESCALATE_MS,
|
|
120
|
+
).length,
|
|
121
|
+
0,
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('stuckFailedEscalationDisabled reflects SM_STUCK_FAILED_ESCALATE_DISABLE', () => {
|
|
126
|
+
const saved = process.env.SM_STUCK_FAILED_ESCALATE_DISABLE;
|
|
127
|
+
try {
|
|
128
|
+
delete process.env.SM_STUCK_FAILED_ESCALATE_DISABLE;
|
|
129
|
+
assert.equal(stuckFailedEscalationDisabled(), false);
|
|
130
|
+
process.env.SM_STUCK_FAILED_ESCALATE_DISABLE = '1';
|
|
131
|
+
assert.equal(stuckFailedEscalationDisabled(), true);
|
|
132
|
+
} finally {
|
|
133
|
+
if (saved === undefined) delete process.env.SM_STUCK_FAILED_ESCALATE_DISABLE;
|
|
134
|
+
else process.env.SM_STUCK_FAILED_ESCALATE_DISABLE = saved;
|
|
135
|
+
}
|
|
136
|
+
});
|