claude-code-session-manager 0.78.0 → 0.80.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 (74) hide show
  1. package/dist/assets/{AgentLibrary-13pfo8uY.js → AgentLibrary-zS3jw_1e.js} +1 -1
  2. package/dist/assets/{DataModel-SUyQbFlg.js → DataModel-Cy_vxTpi.js} +1 -1
  3. package/dist/assets/{History-2GJMS703.js → History-C6JRuqfT.js} +1 -1
  4. package/dist/assets/{Hooks-DM2nS3RT.js → Hooks-BafPy9mB.js} +1 -1
  5. package/dist/assets/{HostBilko-BLeC-lpp.js → HostBilko-BZwhQOFt.js} +1 -1
  6. package/dist/assets/{Library-BaRkU9m0.js → Library-C8JDDliz.js} +1 -1
  7. package/dist/assets/{ListDetail-D5scjSKq.js → ListDetail-CqiOdwLc.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-B1lgAo9T.js → MarkdownEditor-CyLyP67L.js} +1 -1
  9. package/dist/assets/{McpServers-BzyThQSM.js → McpServers-BzMv-_84.js} +1 -1
  10. package/dist/assets/{Memory-7UdaOTtl.js → Memory-DSBYQdJR.js} +1 -1
  11. package/dist/assets/{Panel-JbTMaOPq.js → Panel-CLUhkNNA.js} +1 -1
  12. package/dist/assets/{Permissions-UBam0bJG.js → Permissions-BfC2-HN4.js} +1 -1
  13. package/dist/assets/{Plugins-B3gUDkeb.js → Plugins-BKi40jT5.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-CeHOub7m.js → ProvenanceBadge-BzFw4KhD.js} +1 -1
  15. package/dist/assets/{SaveBar-BcvQEq6h.js → SaveBar-avk2p9jv.js} +1 -1
  16. package/dist/assets/{Scheduler-Dc5qiP24.js → Scheduler-Bf_6MdJo.js} +7 -7
  17. package/dist/assets/{ScopeSwitcher-BvGQmw4Y.js → ScopeSwitcher-C-RwYUVZ.js} +1 -1
  18. package/dist/assets/{Settings-C2dEFb-v.js → Settings-Djd8OoBA.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-CIwlosBc.js → SkillReferenceGraph-DuogY6s7.js} +1 -1
  20. package/dist/assets/{Skills-C0GzzVrQ.js → Skills-D_qAqxZ_.js} +1 -1
  21. package/dist/assets/{SystemPrompt-mtGPK8zo.js → SystemPrompt-DbHFLQV3.js} +1 -1
  22. package/dist/assets/{TagLibrary-DX54-mpd.js → TagLibrary-C2y91BT0.js} +1 -1
  23. package/dist/assets/{TiptapBody-yADC2RWE.js → TiptapBody-D9iz4xQx.js} +1 -1
  24. package/dist/assets/{Toggle-CRxaCYLI.js → Toggle-BGnFL2E5.js} +1 -1
  25. package/dist/assets/{index-D6ymGESc.js → index-_2ARyFDj.js} +4 -4
  26. package/dist/assets/{settingsSchema-TtMvT5Sx.js → settingsSchema-JK15eJU8.js} +1 -1
  27. package/dist/index.html +1 -1
  28. package/package.json +1 -1
  29. package/plugins/session-manager-dev/skills/builder/3-publish/SKILL.md +10 -0
  30. package/scripts/project-pages-logic/dist/logic.cjs +12 -12
  31. package/scripts/render-project-pages/dist/renderer.cjs +22 -22
  32. package/src/main/__tests__/computeDepHistorySatisfaction.test.cjs +66 -0
  33. package/src/main/__tests__/prdCreate.test.cjs +133 -8
  34. package/src/main/__tests__/prdFrontmatterDependsOn.test.cjs +136 -0
  35. package/src/main/__tests__/prdUpdateDependsOn.test.cjs +160 -0
  36. package/src/main/__tests__/queueHistory.test.cjs +33 -0
  37. package/src/main/__tests__/runLogRetention.test.cjs +59 -0
  38. package/src/main/__tests__/scheduleJobTransitions.test.cjs +1 -0
  39. package/src/main/__tests__/scheduler-autofix-outcome.test.cjs +73 -1
  40. package/src/main/__tests__/scheduler-autofix-select.test.cjs +17 -0
  41. package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +20 -0
  42. package/src/main/__tests__/scheduler-leftover-quarantine.test.cjs +199 -0
  43. package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +222 -0
  44. package/src/main/__tests__/scheduler-never-stop.test.cjs +157 -0
  45. package/src/main/__tests__/scheduler-no-orphan-run-dir.test.cjs +81 -0
  46. package/src/main/__tests__/scheduler-rate-limit-cooldown-freshness.test.cjs +123 -0
  47. package/src/main/__tests__/scheduler-rate-limit-spin-guard.test.cjs +158 -0
  48. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +30 -0
  49. package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +51 -0
  50. package/src/main/__tests__/scheduler-resume-recovery.test.cjs +254 -0
  51. package/src/main/__tests__/schedulerBatchRootBlocker.test.cjs +117 -0
  52. package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -2
  53. package/src/main/ipcSchemas.cjs +15 -1
  54. package/src/main/lib/__tests__/gitWorktree.test.cjs +129 -10
  55. package/src/main/lib/__tests__/reaperHelpers.test.cjs +90 -1
  56. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +59 -7
  57. package/src/main/lib/depSlugResolve.cjs +72 -0
  58. package/src/main/lib/epicWorktreeMerge.cjs +3 -3
  59. package/src/main/lib/epicWorktreeMint.cjs +17 -5
  60. package/src/main/lib/fixPlanSlug.cjs +62 -0
  61. package/src/main/lib/gitWorktree.cjs +97 -12
  62. package/src/main/lib/jobDirtFilter.cjs +54 -0
  63. package/src/main/lib/mcpToolCatalog.cjs +4 -1
  64. package/src/main/lib/prdCreate.cjs +84 -5
  65. package/src/main/lib/prdFrontmatter.cjs +56 -8
  66. package/src/main/lib/queueHistory.cjs +50 -5
  67. package/src/main/lib/rateLimitDetect.cjs +35 -0
  68. package/src/main/lib/reaperHelpers.cjs +31 -6
  69. package/src/main/lib/runLogRetention.cjs +82 -4
  70. package/src/main/lib/scheduleJobTransitions.cjs +12 -2
  71. package/src/main/lib/schedulerBatch.cjs +181 -23
  72. package/src/main/scheduler/prdParser.cjs +7 -0
  73. package/src/main/scheduler.cjs +1165 -70
  74. package/src/preload/api.d.ts +8 -0
@@ -0,0 +1,157 @@
1
+ /**
2
+ * scheduler-never-stop.test.cjs — the invariant: if a queue holds ready PRDs,
3
+ * something must drive them.
4
+ *
5
+ * Every stall observed in this codebase was a different CAUSE with the same
6
+ * SHAPE — ready rows, nothing running, nobody ticking:
7
+ * - a rate-limited exit stamped terminal 'failed' (2026-09-05: 42 rows)
8
+ * - a spin loop past the manual-clear cooldown
9
+ * - a worktree merge-back leaving the project on a job branch
10
+ * - a job parked needs_review with no fix plan
11
+ * - app churn under session-manager-operations/ counted as unfinished work
12
+ * - the billing meter itself 429ing so utilization never reported
13
+ * Guarding each cause individually always lags the next one, so the watchdog
14
+ * guards the shape.
15
+ *
16
+ * Run: timeout 180 npx vitest run src/main/__tests__/scheduler-never-stop.test.cjs
17
+ */
18
+
19
+ 'use strict';
20
+
21
+ // vitest, NOT node:test — this repo's suite is vitest-only (CLAUDE.md).
22
+ import { test } from 'vitest';
23
+ const assert = require('node:assert/strict');
24
+ const fs = require('node:fs');
25
+ const os = require('node:os');
26
+ const path = require('node:path');
27
+
28
+ // HOME before require: every state path is baked from os.homedir() at load.
29
+ process.env.HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'never-stop-test-'));
30
+
31
+ const {
32
+ classifyQueueStarvation,
33
+ QUEUE_STARVATION_MS,
34
+ stripAppOwnedChurn,
35
+ } = require('../scheduler.cjs');
36
+
37
+ const CWD = '/home/bilko/Projects/starry-night-ships';
38
+ const NOW = Date.parse('2026-09-05T23:00:00.000Z');
39
+ const idleFor = (ms) => NOW - ms;
40
+ const LONG = QUEUE_STARVATION_MS + 60_000;
41
+
42
+ const base = (over = {}) => ({
43
+ jobs: [{ slug: 'a', cwd: CWD, status: 'pending', dependsOn: [] }],
44
+ paused: null,
45
+ runningCount: 0,
46
+ lastRunAtMs: idleFor(LONG),
47
+ now: NOW,
48
+ ...over,
49
+ });
50
+
51
+ // ── the invariant ────────────────────────────────────────────────────────────
52
+
53
+ test('ready work + nothing running + long idle => starved, and a tick would help', () => {
54
+ const v = classifyQueueStarvation(base());
55
+ assert.equal(v.kind, 'starved');
56
+ assert.equal(v.pending, 1);
57
+ assert.equal(v.dispatchable, 1);
58
+ });
59
+
60
+ test('the real incident shape: 40 ready rows, 0 running, idle for hours', () => {
61
+ const jobs = Array.from({ length: 40 }, (_, i) => ({
62
+ slug: `${207 + i}-step`, cwd: CWD, status: 'pending', dependsOn: [],
63
+ }));
64
+ const v = classifyQueueStarvation(base({ jobs, lastRunAtMs: idleFor(3 * 60 * 60_000) }));
65
+ assert.equal(v.kind, 'starved');
66
+ assert.equal(v.dispatchable, 40);
67
+ });
68
+
69
+ // ── it must NOT fire when the queue is legitimately quiet ────────────────────
70
+
71
+ test('a running job means the queue is flowing — never starved', () => {
72
+ assert.equal(classifyQueueStarvation(base({ runningCount: 1 })), null);
73
+ });
74
+
75
+ test('no pending rows is not a stall', () => {
76
+ assert.equal(classifyQueueStarvation(base({ jobs: [{ slug: 'a', cwd: CWD, status: 'completed' }] })), null);
77
+ });
78
+
79
+ test('paused is a DECISION, not a stall — the watchdog must never override it', () => {
80
+ assert.equal(classifyQueueStarvation(base({ paused: { reason: 'rate_limit', resumeAt: null } })), null);
81
+ });
82
+
83
+ test('inside the idle threshold the normal path gets its chance first', () => {
84
+ assert.equal(classifyQueueStarvation(base({ lastRunAtMs: idleFor(QUEUE_STARVATION_MS - 1000) })), null);
85
+ });
86
+
87
+ test('a never-recorded lastRunAt counts as infinitely idle, not as healthy', () => {
88
+ const v = classifyQueueStarvation(base({ lastRunAtMs: NaN }));
89
+ assert.equal(v.kind, 'starved');
90
+ });
91
+
92
+ // ── blocked is a DIFFERENT verdict from starved ──────────────────────────────
93
+
94
+ test('every pending row behind a terminal dep => blocked, not starved (a tick cannot help)', () => {
95
+ const jobs = [
96
+ { slug: '206-lava-ape', cwd: CWD, status: 'failed', dependsOn: [] },
97
+ { slug: '207-body', cwd: CWD, status: 'pending', dependsOn: ['206-lava-ape'] },
98
+ { slug: '208-docs', cwd: CWD, status: 'pending', dependsOn: ['207-body'] },
99
+ ];
100
+ const v = classifyQueueStarvation(base({ jobs }));
101
+ assert.equal(v.kind, 'blocked');
102
+ assert.equal(v.pending, 2);
103
+ assert.equal(v.dispatchable, 0);
104
+ assert.deepEqual(v.blockedChains[0].blockedBy, ['206-lava-ape']);
105
+ });
106
+
107
+ test('one dispatchable row among blocked ones still counts as starved — the queue can make progress', () => {
108
+ const jobs = [
109
+ { slug: 'x-dead', cwd: CWD, status: 'failed', dependsOn: [] },
110
+ { slug: 'y-blocked', cwd: CWD, status: 'pending', dependsOn: ['x-dead'] },
111
+ { slug: 'z-ready', cwd: CWD, status: 'pending', dependsOn: [] },
112
+ ];
113
+ const v = classifyQueueStarvation(base({ jobs }));
114
+ assert.equal(v.kind, 'starved');
115
+ assert.equal(v.dispatchable, 1);
116
+ });
117
+
118
+ test('a project blocked while ANOTHER project has ready work is still starved overall', () => {
119
+ const jobs = [
120
+ { slug: 'a-dead', cwd: '/p/one', status: 'failed', dependsOn: [] },
121
+ { slug: 'b-blocked', cwd: '/p/one', status: 'pending', dependsOn: ['a-dead'] },
122
+ { slug: 'c-ready', cwd: '/p/two', status: 'pending', dependsOn: [] },
123
+ ];
124
+ assert.equal(classifyQueueStarvation(base({ jobs })).kind, 'starved');
125
+ });
126
+
127
+ // ── app-owned churn must never be blamed on a job ────────────────────────────
128
+
129
+ test('stripAppOwnedChurn drops session-manager-operations churn, keeps real source', () => {
130
+ const out = stripAppOwnedChurn([
131
+ 'src/main/scheduler.cjs',
132
+ 'session-manager-operations/scheduler/state/queue.json',
133
+ 'session-manager-operations/scheduler/state/history.jsonl',
134
+ 'session-manager-operations/prompt-sessions/active-index.json',
135
+ 'dashboard/panels/outcome-stats.jsx',
136
+ ]);
137
+ assert.deepEqual(out, ['src/main/scheduler.cjs', 'dashboard/panels/outcome-stats.jsx']);
138
+ });
139
+
140
+ test('stripAppOwnedChurn preserves the null contract (git status unavailable != left nothing)', () => {
141
+ assert.equal(stripAppOwnedChurn(null), null);
142
+ assert.equal(stripAppOwnedChurn(undefined), undefined);
143
+ });
144
+
145
+ test('stripAppOwnedChurn only matches a whole path segment, never a substring', () => {
146
+ assert.deepEqual(
147
+ stripAppOwnedChurn(['docs/session-manager-operations-guide.md', 'a/session-manager-operations/x.json']),
148
+ ['docs/session-manager-operations-guide.md'],
149
+ );
150
+ });
151
+
152
+ test('a job that leaves REAL source dirty still parks — the filter must not excuse that', () => {
153
+ assert.deepEqual(
154
+ stripAppOwnedChurn(['session-manager-operations/scheduler/state/queue.json', 'scripts/arena/titan.gd']),
155
+ ['scripts/arena/titan.gd'],
156
+ );
157
+ });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * scheduler-no-orphan-run-dir.test.cjs — PRD 1120: pickRunDir() must not
3
+ * mkdir a run directory until a dispatch actually commits (executeJob's
4
+ * first write). A dispatch that aborts inside spawnJob before that point —
5
+ * e.g. sessionSlots.acquire() returning null, the earliest exit in spawnJob
6
+ * — must leave RUNS_DIR untouched.
7
+ *
8
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-no-orphan-run-dir.test.cjs
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ import { test, expect, beforeAll, afterAll, afterEach } from 'vitest';
14
+ const fs = require('node:fs');
15
+ const os = require('node:os');
16
+ const path = require('node:path');
17
+
18
+ let tmpHome;
19
+ let originalHome;
20
+ let originalSlots;
21
+ let scheduler;
22
+ let sessionSlots;
23
+
24
+ beforeAll(() => {
25
+ originalHome = process.env.HOME;
26
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-no-orphan-rundir-'));
27
+ process.env.HOME = tmpHome;
28
+ originalSlots = process.env.SM_SESSION_SLOTS;
29
+ scheduler = require('../scheduler.cjs');
30
+ sessionSlots = require('../lib/sessionSlots.cjs');
31
+ });
32
+
33
+ afterAll(() => {
34
+ process.env.HOME = originalHome;
35
+ if (originalSlots === undefined) delete process.env.SM_SESSION_SLOTS;
36
+ else process.env.SM_SESSION_SLOTS = originalSlots;
37
+ fs.rmSync(tmpHome, { recursive: true, force: true });
38
+ });
39
+
40
+ afterEach(() => {
41
+ sessionSlots.__resetForTests();
42
+ });
43
+
44
+ function runsDirEntryCount() {
45
+ const runsDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs');
46
+ try {
47
+ return fs.readdirSync(runsDir).length;
48
+ } catch {
49
+ return 0;
50
+ }
51
+ }
52
+
53
+ test('pickRunDir() allocates a runId/dir pair without creating the directory', () => {
54
+ const before = runsDirEntryCount();
55
+ const { runId, dir } = scheduler.pickRunDir();
56
+ expect(runId).toBeTruthy();
57
+ expect(fs.existsSync(dir)).toBe(false);
58
+ expect(runsDirEntryCount()).toBe(before);
59
+ });
60
+
61
+ test('a dispatch that bails at slot-acquisition (earliest spawnJob exit) leaves no directory under RUNS_DIR', async () => {
62
+ // Force sessionSlots.acquire() to return null immediately — no slots at all.
63
+ process.env.SM_SESSION_SLOTS = '0';
64
+ const before = runsDirEntryCount();
65
+
66
+ const slug = `1120-slot-miss-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
67
+ const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-no-orphan-rundir-project-'));
68
+ try {
69
+ await scheduler.spawnJob({ slug, cwd: projectCwd }, `run-${slug}`, path.join(
70
+ tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', `run-${slug}`
71
+ ), projectCwd);
72
+
73
+ expect(runsDirEntryCount()).toBe(before);
74
+ expect(fs.existsSync(path.join(
75
+ tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', `run-${slug}`
76
+ ))).toBe(false);
77
+ } finally {
78
+ delete process.env.SM_SESSION_SLOTS;
79
+ fs.rmSync(projectCwd, { recursive: true, force: true });
80
+ }
81
+ });
@@ -0,0 +1,123 @@
1
+ /**
2
+ * scheduler-rate-limit-cooldown-freshness.test.cjs — PRD 1119.
3
+ *
4
+ * setPaused()'s manual-override cooldown used to suppress WRITING any pause
5
+ * for 5 minutes after a human Resume/Run now, unconditionally — even when
6
+ * the triggering rate-limit observation came from a run that started AFTER
7
+ * the manual clear (i.e. brand-new evidence, not the stale auto-detection
8
+ * the cooldown exists to ignore). Combined with the halt-reset that resets a
9
+ * rate-limited job straight back to 'pending', this produced a hot dispatch
10
+ * loop: dispatch -> 429 in seconds -> reset to pending -> pause suppressed
11
+ * -> dispatch again, on a ~12s cadence, until the cooldown itself expired.
12
+ *
13
+ * isCooldownSuppressed() is the pure predicate this PRD extracts so both
14
+ * directions of the fix (fresh observation bypasses the cooldown; stale one
15
+ * stays suppressed) are directly unit-testable without touching queue.json,
16
+ * mutate(), or any fs state. nextRapidRateLimitCount() is the pure reducer
17
+ * behind the independent rapid-repeat hard-pause cap (a second circuit
18
+ * breaker for when the computed resumeAt is itself stale/wrong and would
19
+ * otherwise let the resume timer keep re-clearing the pause every ~30s,
20
+ * with every subsequent dispatch genuinely "fresh").
21
+ *
22
+ * Run: timeout 60 npx vitest run src/main/__tests__/scheduler-rate-limit-cooldown-freshness.test.cjs
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ import { test, expect } from 'vitest';
28
+ const {
29
+ isCooldownSuppressed,
30
+ nextRapidRateLimitCount,
31
+ CONSECUTIVE_RAPID_RATE_LIMIT_THRESHOLD,
32
+ RAPID_RATE_LIMIT_WINDOW_MS,
33
+ MANUAL_PAUSE_COOLDOWN_MS,
34
+ } = require('../scheduler.cjs');
35
+
36
+ const clearedAt = 1_000_000;
37
+
38
+ test('a rate_limit pause triggered by a run that STARTED AFTER the manual clear engages regardless of the cooldown', () => {
39
+ const now = clearedAt + 60_000; // well inside the 5-minute cooldown window
40
+ const observedAt = clearedAt + 5_000; // the triggering run started after the clear
41
+ const suppressed = isCooldownSuppressed({ pauseClearedManuallyAt: clearedAt, now, observedAt, force: false });
42
+ expect(suppressed).toBe(false);
43
+ });
44
+
45
+ test('a rate_limit pause triggered by an observation OLDER than the manual clear stays suppressed', () => {
46
+ const now = clearedAt + 60_000;
47
+ const observedAt = clearedAt - 5_000; // stale: this run started before the human cleared the pause
48
+ const suppressed = isCooldownSuppressed({ pauseClearedManuallyAt: clearedAt, now, observedAt, force: false });
49
+ expect(suppressed).toBe(true);
50
+ });
51
+
52
+ test('no observedAt at all (unrelated pause reasons, e.g. auth/network) is treated as stale — unchanged prior behavior', () => {
53
+ const now = clearedAt + 60_000;
54
+ const suppressed = isCooldownSuppressed({ pauseClearedManuallyAt: clearedAt, now, observedAt: null, force: false });
55
+ expect(suppressed).toBe(true);
56
+ });
57
+
58
+ test('once the cooldown window itself has elapsed, suppression lifts regardless of freshness', () => {
59
+ const now = clearedAt + MANUAL_PAUSE_COOLDOWN_MS + 1;
60
+ const suppressed = isCooldownSuppressed({ pauseClearedManuallyAt: clearedAt, now, observedAt: null, force: false });
61
+ expect(suppressed).toBe(false);
62
+ });
63
+
64
+ test('no manual clear on record never suppresses', () => {
65
+ const suppressed = isCooldownSuppressed({ pauseClearedManuallyAt: null, now: Date.now(), observedAt: null, force: false });
66
+ expect(suppressed).toBe(false);
67
+ });
68
+
69
+ test('force always bypasses the cooldown, fresh or not', () => {
70
+ const now = clearedAt + 1_000;
71
+ const staleObservedAt = clearedAt - 5_000;
72
+ const suppressed = isCooldownSuppressed({ pauseClearedManuallyAt: clearedAt, now, observedAt: staleObservedAt, force: true });
73
+ expect(suppressed).toBe(false);
74
+ });
75
+
76
+ // ---------- rapid-repeat consecutive counter ----------
77
+
78
+ test('a rate-limited run under the rapid window increments the counter', () => {
79
+ expect(nextRapidRateLimitCount(0, { rateLimited: true, durationMs: 4_000 })).toBe(1);
80
+ expect(nextRapidRateLimitCount(1, { rateLimited: true, durationMs: 4_000 })).toBe(2);
81
+ });
82
+
83
+ test('a rate-limited run that ran for >= the rapid window does not increment the counter', () => {
84
+ expect(nextRapidRateLimitCount(1, { rateLimited: true, durationMs: RAPID_RATE_LIMIT_WINDOW_MS })).toBe(1);
85
+ });
86
+
87
+ test('any non-rate-limited outcome resets the counter to 0', () => {
88
+ expect(nextRapidRateLimitCount(2, { rateLimited: false, durationMs: 999_999 })).toBe(0);
89
+ });
90
+
91
+ test('driving the counter to the documented threshold engages the hard-pause condition', () => {
92
+ let count = 0;
93
+ for (let i = 0; i < CONSECUTIVE_RAPID_RATE_LIMIT_THRESHOLD; i += 1) {
94
+ count = nextRapidRateLimitCount(count, { rateLimited: true, durationMs: 3_000 });
95
+ }
96
+ expect(count).toBe(CONSECUTIVE_RAPID_RATE_LIMIT_THRESHOLD);
97
+ expect(count >= CONSECUTIVE_RAPID_RATE_LIMIT_THRESHOLD).toBe(true);
98
+ });
99
+
100
+ test('reaching the threshold forces the pause through even against a STALE observation still inside the cooldown window', () => {
101
+ // This is the scenario the hard cap exists for: freshness alone is not
102
+ // enough when the computed resumeAt is itself stale/wrong and the resume
103
+ // timer keeps re-clearing the pause, so a LATER dispatch's observation can
104
+ // legitimately look "stale" relative to the most recent clear. The
105
+ // rapid-repeat counter is an independent, unconditional circuit breaker.
106
+ let count = 0;
107
+ for (let i = 0; i < CONSECUTIVE_RAPID_RATE_LIMIT_THRESHOLD; i += 1) {
108
+ count = nextRapidRateLimitCount(count, { rateLimited: true, durationMs: 3_000 });
109
+ }
110
+ const force = count >= CONSECUTIVE_RAPID_RATE_LIMIT_THRESHOLD;
111
+ const staleObservedAt = clearedAt - 999_999;
112
+ const suppressed = isCooldownSuppressed({ pauseClearedManuallyAt: clearedAt, now: clearedAt + 1_000, observedAt: staleObservedAt, force });
113
+ expect(suppressed).toBe(false);
114
+ });
115
+
116
+ test('one non-rate-limited run in between resets the streak so the hard pause never engages', () => {
117
+ let count = 0;
118
+ count = nextRapidRateLimitCount(count, { rateLimited: true, durationMs: 3_000 });
119
+ count = nextRapidRateLimitCount(count, { rateLimited: true, durationMs: 3_000 });
120
+ count = nextRapidRateLimitCount(count, { rateLimited: false, durationMs: 60_000 }); // job actually ran fine
121
+ count = nextRapidRateLimitCount(count, { rateLimited: true, durationMs: 3_000 });
122
+ expect(count).toBeLessThan(CONSECUTIVE_RAPID_RATE_LIMIT_THRESHOLD);
123
+ });
@@ -0,0 +1,158 @@
1
+ /**
2
+ * scheduler-rate-limit-spin-guard.test.cjs — PRD 1119 end-to-end repro of the
3
+ * 2026-09-05 incident: a human pressed Resume/Run now on job 204, and for the
4
+ * next hour the scheduler dispatched it, watched it 429 in seconds, reset it
5
+ * to pending (halt-reset), and dispatched it again — 291 times, one every
6
+ * ~12s — because setPaused()'s manual-override cooldown suppressed WRITING
7
+ * the rate_limit pause for the whole 5-minute window, even though every one
8
+ * of those 429s was a brand-new observation from a run that started AFTER
9
+ * the human's clear.
10
+ *
11
+ * This drives the REAL dispatch path (spawnJob, via a stub `claude` binary
12
+ * that always 429s in well under a second) repeatedly, immediately after a
13
+ * simulated manual Resume, and asserts the scheduler engages a genuine pause
14
+ * almost immediately — never anywhere near the 291-dispatch runaway, and
15
+ * never past the documented CONSECUTIVE_RAPID_RATE_LIMIT_THRESHOLD hard cap
16
+ * even in the worst case. Compressed in wall-clock time (a stub exits in
17
+ * milliseconds, not the ~12s a real `claude -p` round trip took) — the
18
+ * assertion is on DISPATCH COUNT, which is what the fix actually bounds,
19
+ * not on wall-clock duration.
20
+ *
21
+ * Run: timeout 60 npx vitest run src/main/__tests__/scheduler-rate-limit-spin-guard.test.cjs
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ import { test, expect, beforeAll, afterAll, afterEach } from 'vitest';
27
+ const fs = require('node:fs');
28
+ const os = require('node:os');
29
+ const path = require('node:path');
30
+ const { execFileSync } = require('node:child_process');
31
+
32
+ let tmpHome;
33
+ let originalHome;
34
+ let scheduler;
35
+ let queueStore;
36
+
37
+ beforeAll(() => {
38
+ originalHome = process.env.HOME;
39
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-rate-limit-spin-guard-'));
40
+ process.env.HOME = tmpHome;
41
+ // Worktree isolation is irrelevant to this repro (it's about pause
42
+ // suppression, not tree isolation) and each real `git worktree add` adds
43
+ // real wall-clock overhead this test does not need to pay 40 times over.
44
+ process.env.SM_JOB_WORKTREE_DISABLE = '1';
45
+ scheduler = require('../scheduler.cjs');
46
+ queueStore = require('../lib/queueStore.cjs');
47
+ });
48
+
49
+ afterAll(() => {
50
+ process.env.HOME = originalHome;
51
+ delete process.env.SM_JOB_WORKTREE_DISABLE;
52
+ fs.rmSync(tmpHome, { recursive: true, force: true });
53
+ });
54
+
55
+ afterEach(() => {
56
+ delete process.env.SM_CLAUDE_BIN;
57
+ });
58
+
59
+ function git(args, cwd) {
60
+ return execFileSync('git', args, { cwd, encoding: 'utf8' });
61
+ }
62
+
63
+ function initRepo(dir) {
64
+ fs.mkdirSync(dir, { recursive: true });
65
+ git(['init', '-q'], dir);
66
+ git(['config', 'user.email', 'test@example.com'], dir);
67
+ git(['config', 'user.name', 'Test'], dir);
68
+ fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
69
+ git(['add', '-A'], dir);
70
+ git(['commit', '-q', '-m', 'initial'], dir);
71
+ }
72
+
73
+ function registerActiveProject(cwd) {
74
+ const projectsDir = path.join(tmpHome, '.claude', 'projects');
75
+ const slugDir = path.join(projectsDir, 'spin-guard-project-slug');
76
+ fs.mkdirSync(slugDir, { recursive: true });
77
+ fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
78
+ queueStore.bustCwdCache();
79
+ }
80
+
81
+ function writeProjectQueue(cwd, jobs) {
82
+ const stateDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
83
+ fs.mkdirSync(stateDir, { recursive: true });
84
+ const queuePath = path.join(stateDir, 'queue.json');
85
+ fs.writeFileSync(queuePath, JSON.stringify({ jobs }, null, 2));
86
+ return queuePath;
87
+ }
88
+
89
+ // Stub `claude` binary: always dies as a rate limit, in well under 30s (same
90
+ // technique as scheduler-prd-persona-spawn.test.cjs's argv-marker stub) —
91
+ // emits the canonical 429 signal detectRateLimitInLog looks for, then exits
92
+ // non-zero almost instantly, and bumps a counter file each invocation so the
93
+ // test can assert exactly how many times it was actually spawned.
94
+ function writeAlways429ClaudeStub(counterPath) {
95
+ const stubPath = path.join(os.tmpdir(), `sm-claude-stub-429-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
96
+ const body = `
97
+ const fs = require('fs');
98
+ fs.appendFileSync(${JSON.stringify(counterPath)}, 'x');
99
+ process.stdout.write(JSON.stringify({ type: 'result', subtype: 'error', is_error: true, api_error_status: 429 }) + '\\n');
100
+ process.exit(1);
101
+ `;
102
+ fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
103
+ return stubPath;
104
+ }
105
+
106
+ test('a manual Resume immediately followed by a permanently-429ing job never produces a runaway dispatch loop', async () => {
107
+ const projectCwd = fs.mkdtempSync(path.join(tmpHome, 'spin-guard-project-'));
108
+ initRepo(projectCwd);
109
+ registerActiveProject(projectCwd);
110
+ const slug = `1119-spin-guard-${process.pid}`;
111
+ const prdsDir = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'prds');
112
+ fs.mkdirSync(prdsDir, { recursive: true });
113
+ fs.writeFileSync(path.join(prdsDir, `${slug}.md`), 'Do the thing.', 'utf8');
114
+
115
+ const counterPath = path.join(tmpHome, 'dispatch-count.txt');
116
+ fs.writeFileSync(counterPath, '');
117
+ process.env.SM_CLAUDE_BIN = writeAlways429ClaudeStub(counterPath);
118
+
119
+ writeProjectQueue(projectCwd, [
120
+ { slug, status: 'pending', cwd: projectCwd },
121
+ ]);
122
+
123
+ // The human just pressed Resume/Run now — this is the exact call clearPause
124
+ // makes from the IPC handlers, and it starts the 5-minute cooldown window.
125
+ await scheduler.clearPause('manual');
126
+
127
+ const job = { slug, cwd: projectCwd };
128
+
129
+ // Simulate the tick loop's dispatch decision being driven repeatedly "over
130
+ // ten minutes" — compressed here to however many cycles a permanently-429
131
+ // job can actually complete, since each cycle is a real (fast) subprocess
132
+ // spawn rather than a real 12s `claude -p` round trip. tickQueue()'s own
133
+ // paused early-return (unchanged by this PRD) is exactly what this loop
134
+ // reproduces by hand: stop dispatching the moment the queue is paused.
135
+ const MAX_TICKS = 40;
136
+ for (let i = 0; i < MAX_TICKS; i += 1) {
137
+ // eslint-disable-next-line no-await-in-loop
138
+ const state = await queueStore.readMerged();
139
+ if (state.paused) break;
140
+ const row = state.jobs.find((j) => j.slug === slug);
141
+ if (!row || row.status !== 'pending') break;
142
+ const runId = `run-${i}`;
143
+ const runDir = path.join(scheduler.RUNS_DIR, runId);
144
+ fs.mkdirSync(runDir, { recursive: true });
145
+ // eslint-disable-next-line no-await-in-loop
146
+ await scheduler.spawnJob(job, runId, runDir, projectCwd);
147
+ }
148
+
149
+ const dispatchCount = fs.readFileSync(counterPath, 'utf8').length;
150
+ expect(dispatchCount).toBeLessThanOrEqual(scheduler.CONSECUTIVE_RAPID_RATE_LIMIT_THRESHOLD);
151
+ expect(dispatchCount).toBeGreaterThan(0);
152
+ expect(dispatchCount).toBeLessThan(10); // nowhere near the 291-dispatch incident
153
+
154
+ const finalState = await queueStore.readMerged();
155
+ expect(finalState.paused).toBeTruthy();
156
+ expect(finalState.paused.reason).toBe('rate_limit');
157
+ expect(finalState.jobs.find((j) => j.slug === slug).status).toBe('pending');
158
+ });
@@ -271,6 +271,36 @@ test('reapDeadRunningJobs: a failed reap with a persisted guardBaseline names th
271
271
  assert.match(lastTransition.reason, /left 1 files uncommitted/);
272
272
  });
273
273
 
274
+ test('reapDeadRunningJobs: a rate-limited death (api_error_status:429) is reset to pending, not stamped failed, and the reason names the rate limit (PRD 1117)', async () => {
275
+ const projectCwd = path.join(tmpHome, 'h-project');
276
+ fs.mkdirSync(projectCwd, { recursive: true });
277
+ registerActiveProject(projectCwd);
278
+
279
+ const runId = 'run-rate-limited';
280
+ const queuePath = writeProjectQueue(projectCwd, [
281
+ {
282
+ slug: 'rate-limited-job',
283
+ status: 'running',
284
+ cwd: projectCwd,
285
+ runId,
286
+ runtime: { pid: 999999 }, // guaranteed-dead pid — the reaper won the race
287
+ },
288
+ ]);
289
+ // Real-shape tail: a 429 result event, the exact signal the reaper
290
+ // previously had no branch for and stamped terminal 'failed'.
291
+ writeRunLog(runId, 'rate-limited-job', [
292
+ '{"type":"result","subtype":"success","is_error":true,"api_error_status":429,"rateLimitType":"seven_day","result":"You\'ve reached your Fable limit.","uuid":"x"}',
293
+ ]);
294
+
295
+ await reapDeadRunningJobs();
296
+
297
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
298
+ const row = jobs.find((j) => j.slug === 'rate-limited-job');
299
+ assert.equal(row.status, 'pending', 'a rate-limited reap must be retryable, never terminal failed');
300
+ assert.match(row.error, /rate limit/i, 'the reset reason must name the rate limit, not read as a generic reap failure');
301
+ assert.equal(row.runtime, undefined);
302
+ });
303
+
274
304
  test('reapDeadRunningJobs skips in-place salvage (no whole-tree dump) when the row has no persisted guardBaseline', async () => {
275
305
  const projectCwd = path.join(tmpHome, 'f-project');
276
306
  initRepo(projectCwd);
@@ -146,6 +146,57 @@ test('reconcile() exempts a fix-plan PRD (NN-fix-*) from quarantine even with no
146
146
  }
147
147
  });
148
148
 
149
+ test('reconcile() does NOT stamp investigationDepth for a createdVia-stamped PRD whose slug merely starts with "fix-" (PRD 1126/1131 regression)', async () => {
150
+ const { cwd, prdsDir, stateDir } = makeFixtureProject('sm-quarantine-fake-fixplan-');
151
+ try {
152
+ // Mirrors PRD 1126: authored via scheduler_create_prd (createdVia stamped),
153
+ // slug happens to kebab-case into "fix-..." — must NOT be treated as a
154
+ // genuine scheduler-authored fix plan just because the name matches.
155
+ fs.writeFileSync(
156
+ path.join(prdsDir, '9005-fix-plan-death-reopens-parent.md'),
157
+ `---\ntitle: Fix plan death reopens parent\ncwd: ${cwd}\nestimateMinutes: 15\ncreatedVia: scheduler-api\nissuedAt: 2026-08-07T00:00:00.000Z\n---\n\n# Goal\nDo the thing.\n`,
158
+ 'utf8',
159
+ );
160
+ fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs: [] }, null, 2), 'utf8');
161
+
162
+ queueStore.bustCwdCache();
163
+ const state = { jobs: [], invalidJobs: [], paused: null };
164
+ await scheduler.reconcile(state);
165
+
166
+ const row = state.jobs.find((j) => j.slug === '9005-fix-plan-death-reopens-parent');
167
+ expect(row).toBeDefined();
168
+ expect(row.status).toBe('pending');
169
+ expect(row.isFixPlan).toBe(false);
170
+ expect(row.investigationDepth).toBeUndefined();
171
+ } finally {
172
+ fs.rmSync(cwd, { recursive: true, force: true });
173
+ }
174
+ });
175
+
176
+ test('reconcile() stamps isFixPlan + investigationDepth for a genuine spawnInvestigation-authored fix plan (explicit isFixPlan:true, no createdVia)', async () => {
177
+ const { cwd, prdsDir, stateDir } = makeFixtureProject('sm-quarantine-genuine-fixplan-');
178
+ try {
179
+ fs.writeFileSync(
180
+ path.join(prdsDir, '9006-fix-something.md'),
181
+ `---\ntitle: Fix something\ncwd: ${cwd}\nestimateMinutes: 15\nisFixPlan: true\n---\n\n# Goal\nHeal it.\n`,
182
+ 'utf8',
183
+ );
184
+ fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs: [] }, null, 2), 'utf8');
185
+
186
+ queueStore.bustCwdCache();
187
+ const state = { jobs: [], invalidJobs: [], paused: null };
188
+ await scheduler.reconcile(state);
189
+
190
+ const row = state.jobs.find((j) => j.slug === '9006-fix-something');
191
+ expect(row).toBeDefined();
192
+ expect(row.status).toBe('pending');
193
+ expect(row.isFixPlan).toBe(true);
194
+ expect(row.investigationDepth).toBe(2);
195
+ } finally {
196
+ fs.rmSync(cwd, { recursive: true, force: true });
197
+ }
198
+ });
199
+
149
200
  test('reconcile() adopts a quarantined row to pending once its PRD file is stamped', async () => {
150
201
  const { cwd, prdsDir, stateDir } = makeFixtureProject('sm-quarantine-adopt-');
151
202
  try {