claude-code-session-manager 0.82.0 → 0.84.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.
Files changed (92) hide show
  1. package/dist/assets/{AgentLibrary-pwlkAFb3.js → AgentLibrary-CCVIpoSz.js} +1 -1
  2. package/dist/assets/{DataModel-BZK9PXFD.js → DataModel-BltREYde.js} +1 -1
  3. package/dist/assets/{History-BVRjxJjS.js → History-BZxFkOp6.js} +2 -2
  4. package/dist/assets/{Hooks-CNuwHeGx.js → Hooks-Bznlfaaa.js} +1 -1
  5. package/dist/assets/{HostBilko-cjwNodhV.js → HostBilko-DUG5YHA_.js} +1 -1
  6. package/dist/assets/{Library-YPNm9W92.js → Library-Bi2Fn3w9.js} +1 -1
  7. package/dist/assets/{ListDetail-CY4GM1Om.js → ListDetail-4VBvXKrz.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-BF4y2Jiz.js → MarkdownEditor-D2ft_v5j.js} +1 -1
  9. package/dist/assets/{McpServers-CmBdWtX_.js → McpServers-FDekKkyE.js} +1 -1
  10. package/dist/assets/{Memory-CvkIXNl1.js → Memory-Dkl80uj_.js} +6 -6
  11. package/dist/assets/{Panel-D93o-sxe.js → Panel-LurG5VfD.js} +1 -1
  12. package/dist/assets/{Permissions-DQipg16I.js → Permissions-D2wHBCFA.js} +1 -1
  13. package/dist/assets/{Plugins-B6NwzPfK.js → Plugins-CTw_wwbI.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-DACVJhrB.js → ProvenanceBadge-CW6HNUv6.js} +1 -1
  15. package/dist/assets/{SaveBar-Cg4lbChb.js → SaveBar-alDHG6cP.js} +1 -1
  16. package/dist/assets/{Scheduler-Dr5ZcBLe.js → Scheduler-DxiPcaiW.js} +7 -7
  17. package/dist/assets/{ScopeSwitcher-C-locvy0.js → ScopeSwitcher-DzFXUKLZ.js} +1 -1
  18. package/dist/assets/Settings-B6H3v2am.js +3 -0
  19. package/dist/assets/{SkillReferenceGraph-DDzuYgSK.js → SkillReferenceGraph-B8EZalpN.js} +1 -1
  20. package/dist/assets/{Skills-DFvhiOAQ.js → Skills-DwuHuA08.js} +2 -2
  21. package/dist/assets/SystemPrompt-CMMqpGYn.js +1 -0
  22. package/dist/assets/{TagLibrary-DruUYaAc.js → TagLibrary-C2N5C1n9.js} +1 -1
  23. package/dist/assets/{TiptapBody-Dr4a--42.js → TiptapBody-btlID-dQ.js} +1 -1
  24. package/dist/assets/{Toggle-B_EH2TFb.js → Toggle-BGOn3DZj.js} +1 -1
  25. package/dist/assets/{index-DApB4DHS.js → index-QLRf0epp.js} +316 -314
  26. package/dist/assets/{index-CYhdtisq.css → index-mnjNDpb1.css} +1 -1
  27. package/dist/assets/{settingsSchema-BKa-xk8g.js → settingsSchema-DA3N2Up3.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +4 -1
  30. package/scripts/hooks/guard-destructive-git.cjs +514 -0
  31. package/scripts/hooks/guard-inline-implementation.cjs +219 -0
  32. package/scripts/hooks/guard-prd-writes.cjs +200 -0
  33. package/src/main/__tests__/epicMintTelemetryTap.test.cjs +64 -0
  34. package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
  35. package/src/main/__tests__/health-queue-dispatch.test.cjs +84 -0
  36. package/src/main/__tests__/health-usage-poller.test.cjs +97 -0
  37. package/src/main/__tests__/health-worktree-cap-blocked.test.cjs +65 -0
  38. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +143 -0
  39. package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +120 -0
  40. package/src/main/__tests__/promptSessionTranscript.test.cjs +0 -0
  41. package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +143 -0
  42. package/src/main/__tests__/rateLimitPollerStreak.test.cjs +79 -0
  43. package/src/main/__tests__/scheduleJobTransitionsTelemetryTap.test.cjs +72 -0
  44. package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +74 -0
  45. package/src/main/__tests__/scheduler-job-overrun.test.cjs +58 -0
  46. package/src/main/__tests__/scheduler-notify-originating-tab-transcript.test.cjs +1 -0
  47. package/src/main/__tests__/scheduler-periodic-reverify-guard.test.cjs +134 -2
  48. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +33 -0
  49. package/src/main/__tests__/scheduler-stuck-failed-escalation.test.cjs +136 -0
  50. package/src/main/__tests__/telemetryClient.test.cjs +810 -0
  51. package/src/main/__tests__/telemetryContract.test.cjs +883 -0
  52. package/src/main/crashDiagnostics.cjs +29 -1
  53. package/src/main/health.cjs +197 -3
  54. package/src/main/index.cjs +65 -6
  55. package/src/main/ipcSchemas.cjs +19 -2
  56. package/src/main/lib/__tests__/crashTelemetry.test.cjs +97 -0
  57. package/src/main/lib/__tests__/delegationReadiness.test.cjs +302 -4
  58. package/src/main/lib/__tests__/fixtures/scheduler-machine.json.corrupt-1789147548 +34 -0
  59. package/src/main/lib/__tests__/gitWorktree.test.cjs +413 -1
  60. package/src/main/lib/__tests__/jobWorktreeBootLive.test.cjs +71 -0
  61. package/src/main/lib/__tests__/queueStoreAtomicWrite.test.cjs +88 -0
  62. package/src/main/lib/__tests__/queueStoreMachineStateRecovery.test.cjs +123 -0
  63. package/src/main/lib/__tests__/reaperHelpers.test.cjs +58 -0
  64. package/src/main/lib/__tests__/telemetryBacklog.test.cjs +620 -0
  65. package/src/main/lib/__tests__/telemetryBoot.test.cjs +125 -0
  66. package/src/main/lib/__tests__/telemetryConsent.test.cjs +130 -0
  67. package/src/main/lib/__tests__/telemetryCounters.test.cjs +57 -0
  68. package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +89 -0
  69. package/src/main/lib/crashTelemetry.cjs +37 -0
  70. package/src/main/lib/delegationReadiness.cjs +119 -1
  71. package/src/main/lib/epicMint.cjs +2 -0
  72. package/src/main/lib/gitWorktree.cjs +427 -17
  73. package/src/main/lib/jobWorktree.cjs +2 -1
  74. package/src/main/lib/jobWorktreeBootLive.cjs +51 -0
  75. package/src/main/lib/jobWorktreeTerminalOrphanLive.cjs +68 -0
  76. package/src/main/lib/opsErrorLog.cjs +78 -25
  77. package/src/main/lib/queueStore.cjs +233 -20
  78. package/src/main/lib/reaperHelpers.cjs +23 -1
  79. package/src/main/lib/scheduleJobSchema.cjs +7 -0
  80. package/src/main/lib/scheduleJobTransitions.cjs +12 -0
  81. package/src/main/lib/telemetryBacklog.cjs +601 -0
  82. package/src/main/lib/telemetryBoot.cjs +71 -0
  83. package/src/main/lib/telemetryClient.cjs +653 -0
  84. package/src/main/lib/telemetryConsent.cjs +34 -0
  85. package/src/main/lib/telemetryCounters.cjs +45 -0
  86. package/src/main/promptSessionTranscript.cjs +0 -0
  87. package/src/main/pty.cjs +2 -0
  88. package/src/main/scheduler.cjs +481 -33
  89. package/src/preload/api.d.ts +84 -4
  90. package/src/preload/index.cjs +9 -0
  91. package/dist/assets/Settings-BVrAle90.js +0 -3
  92. package/dist/assets/SystemPrompt-8PiTyUyL.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 { shouldRunPeriodicReverify, isRescanCandidate } = require('../scheduler.cjs');
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
- false,
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
+ });