claude-code-session-manager 0.76.0 → 0.78.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 (123) hide show
  1. package/dist/assets/{AgentLibrary-CBx9l4zN.js → AgentLibrary-13pfo8uY.js} +2 -2
  2. package/dist/assets/{DataModel-Bf0EIE_t.js → DataModel-SUyQbFlg.js} +1 -1
  3. package/dist/assets/{History-CpdtWhC8.js → History-2GJMS703.js} +2 -2
  4. package/dist/assets/{Hooks-DyUbMDmg.js → Hooks-DM2nS3RT.js} +2 -2
  5. package/dist/assets/{HostBilko-By-wIpry.js → HostBilko-BLeC-lpp.js} +1 -1
  6. package/dist/assets/{Library-CQmo4QVC.js → Library-BaRkU9m0.js} +1 -1
  7. package/dist/assets/{ListDetail-BQMd6NOm.js → ListDetail-D5scjSKq.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-DEp43FXX.js → MarkdownEditor-B1lgAo9T.js} +1 -1
  9. package/dist/assets/{McpServers-CLarzwqA.js → McpServers-BzyThQSM.js} +1 -1
  10. package/dist/assets/{Memory-B0sCdIy1.js → Memory-7UdaOTtl.js} +6 -6
  11. package/dist/assets/{Panel-BhWPVOCD.js → Panel-JbTMaOPq.js} +1 -1
  12. package/dist/assets/{Permissions-Ddlq8T_O.js → Permissions-UBam0bJG.js} +2 -2
  13. package/dist/assets/{Plugins-D2oA_2Jl.js → Plugins-B3gUDkeb.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-DgAgavUM.js → ProvenanceBadge-CeHOub7m.js} +1 -1
  15. package/dist/assets/SaveBar-BcvQEq6h.js +1 -0
  16. package/dist/assets/Scheduler-Dc5qiP24.js +14 -0
  17. package/dist/assets/{ScopeSwitcher-C_zWEtIl.js → ScopeSwitcher-BvGQmw4Y.js} +1 -1
  18. package/dist/assets/{Settings-2Vx3X5SI.js → Settings-C2dEFb-v.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-BDEUjlTQ.js → SkillReferenceGraph-CIwlosBc.js} +1 -1
  20. package/dist/assets/{Skills-Cmrz_LeN.js → Skills-C0GzzVrQ.js} +2 -2
  21. package/dist/assets/{SystemPrompt-DVA1eYDP.js → SystemPrompt-mtGPK8zo.js} +1 -1
  22. package/dist/assets/TagLibrary-DX54-mpd.js +1 -0
  23. package/dist/assets/{TiptapBody-DmPc3amD.js → TiptapBody-yADC2RWE.js} +1 -1
  24. package/dist/assets/{Toggle-zfd5LJkK.js → Toggle-CRxaCYLI.js} +1 -1
  25. package/dist/assets/{index-DIjnPkRN.css → index-CYhdtisq.css} +1 -1
  26. package/dist/assets/{index-B_4PNh9T.js → index-D6ymGESc.js} +469 -469
  27. package/dist/assets/{settingsSchema-B9es6fdA.js → settingsSchema-TtMvT5Sx.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +8 -2
  30. package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
  31. package/scripts/project-pages-logic/dist/logic.cjs +4709 -0
  32. package/scripts/render-project-pages/dist/renderer.cjs +18900 -0
  33. package/scripts/render-project-pages.cjs +70 -0
  34. package/scripts/scheduler-mcp-server.cjs +115 -1
  35. package/scripts/validate-project-pages-summary.cjs +62 -0
  36. package/src/main/__tests__/agentModelResolve.test.cjs +66 -0
  37. package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
  38. package/src/main/__tests__/prdAgentType.test.cjs +103 -0
  39. package/src/main/__tests__/prdCreate.test.cjs +138 -0
  40. package/src/main/__tests__/prdFrontmatterAgentType.test.cjs +117 -0
  41. package/src/main/__tests__/prdFrontmatterQuietMachine.test.cjs +108 -0
  42. package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +485 -0
  43. package/src/main/__tests__/projectPages.test.cjs +73 -1
  44. package/src/main/__tests__/rcaReport.test.cjs +54 -0
  45. package/src/main/__tests__/runVerify.test.cjs +94 -0
  46. package/src/main/__tests__/scheduler-autofix-select.test.cjs +43 -0
  47. package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +103 -0
  48. package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +10 -0
  49. package/src/main/__tests__/scheduler-foreign-wip-manifest.test.cjs +78 -0
  50. package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +242 -0
  51. package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +31 -0
  52. package/src/main/__tests__/scheduler-launch-failure.test.cjs +201 -0
  53. package/src/main/__tests__/scheduler-leftover-fields.test.cjs +52 -0
  54. package/src/main/__tests__/scheduler-looks-done.test.cjs +241 -0
  55. package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +135 -0
  56. package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +222 -0
  57. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +147 -0
  58. package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +212 -0
  59. package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +194 -0
  60. package/src/main/__tests__/seedAgentPersonas.test.cjs +75 -14
  61. package/src/main/config.cjs +4 -1
  62. package/src/main/index.cjs +16 -4
  63. package/src/main/ipcSchemas.cjs +53 -0
  64. package/src/main/lib/__tests__/childWithLog.test.cjs +78 -0
  65. package/src/main/lib/__tests__/delegationReadiness.test.cjs +197 -2
  66. package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +4 -2
  67. package/src/main/lib/__tests__/fixChainDepth.test.cjs +40 -0
  68. package/src/main/lib/__tests__/gitWorktree.test.cjs +277 -4
  69. package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +153 -0
  70. package/src/main/lib/__tests__/jobWorktree.test.cjs +5 -3
  71. package/src/main/lib/__tests__/landedSinceRun.test.cjs +73 -0
  72. package/src/main/lib/__tests__/launchFailure.test.cjs +220 -0
  73. package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +1 -0
  74. package/src/main/lib/__tests__/opsOwnership.test.cjs +7 -0
  75. package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +82 -0
  76. package/src/main/lib/__tests__/queueHealth.test.cjs +58 -0
  77. package/src/main/lib/__tests__/quietMachineLease.test.cjs +39 -0
  78. package/src/main/lib/__tests__/reaperHelpers.test.cjs +22 -1
  79. package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +125 -0
  80. package/src/main/lib/__tests__/schedulerBatchQuietMachine.test.cjs +109 -0
  81. package/src/main/lib/__tests__/schedulerMcpServerHeadlessRefusal.test.cjs +71 -0
  82. package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +350 -0
  83. package/src/main/lib/agentModelResolve.cjs +58 -0
  84. package/src/main/lib/childWithLog.cjs +40 -5
  85. package/src/main/lib/claudeBin.cjs +54 -1
  86. package/src/main/lib/definitionOfDone.cjs +3 -2
  87. package/src/main/lib/delegationReadiness.cjs +115 -9
  88. package/src/main/lib/epicWorktreeMint.cjs +5 -2
  89. package/src/main/lib/fixChainDepth.cjs +45 -0
  90. package/src/main/lib/gitWorktree.cjs +464 -19
  91. package/src/main/lib/jobWorktree.cjs +1 -0
  92. package/src/main/lib/landedSinceRun.cjs +55 -0
  93. package/src/main/lib/launchFailure.cjs +357 -0
  94. package/src/main/lib/mcpToolCatalog.cjs +87 -2
  95. package/src/main/lib/opsOwnership.cjs +12 -0
  96. package/src/main/lib/prdAgentType.cjs +84 -0
  97. package/src/main/lib/prdCreate.cjs +57 -1
  98. package/src/main/lib/prdDeclaredPaths.cjs +70 -0
  99. package/src/main/lib/prdFrontmatter.cjs +17 -3
  100. package/src/main/lib/projectHomeAdminRoutes.cjs +402 -0
  101. package/src/main/lib/projectPageSummarySchema.cjs +181 -0
  102. package/src/main/lib/queueHealth.cjs +38 -0
  103. package/src/main/lib/queueStore.cjs +9 -2
  104. package/src/main/lib/quietMachineLease.cjs +48 -0
  105. package/src/main/lib/rcaReport.cjs +53 -3
  106. package/src/main/lib/reaperHelpers.cjs +18 -1
  107. package/src/main/lib/scheduleJobSchema.cjs +31 -0
  108. package/src/main/lib/scheduleJobTransitions.cjs +6 -2
  109. package/src/main/lib/schedulerBatch.cjs +133 -29
  110. package/src/main/lib/schedulerConfig.cjs +19 -0
  111. package/src/main/projectPages.cjs +160 -2
  112. package/src/main/runVerify.cjs +50 -9
  113. package/src/main/scheduler/prdParser.cjs +18 -1
  114. package/src/main/scheduler.cjs +1371 -97
  115. package/src/main/seedAgentPersonas.cjs +62 -21
  116. package/src/main/templates/project-pages-catalog.json +741 -0
  117. package/src/main/templates/project-pages-pipeline.md +417 -0
  118. package/src/preload/api.d.ts +126 -6
  119. package/src/preload/index.cjs +8 -0
  120. package/src/seed/agents/project-home-builder.md +59 -0
  121. package/dist/assets/SaveBar-Qvc4Ek-H.js +0 -1
  122. package/dist/assets/Scheduler-BmYJvNzK.js +0 -14
  123. package/dist/assets/TagLibrary-DYJGAKZu.js +0 -1
@@ -0,0 +1,201 @@
1
+ /**
2
+ * scheduler-launch-failure.test.cjs — issue #11 end to end at the executeJob
3
+ * + handleLaunchFailure level with a stub `claude` that reproduces the
4
+ * macOS incident verbatim: HTTP 400 `thinking.type.enabled` on the first
5
+ * request, one turn, zero output tokens, exit 1 in seconds.
6
+ *
7
+ * Proves: the run is classified as a launch failure (not a PRD failure), the
8
+ * API message lands on the row, the row goes back to `pending` (never
9
+ * `failed`), the persona's launch breaker is armed in the machine state, the
10
+ * outcome sidecar is written, the child env carries the headless-job marker
11
+ * and the mitigation env, and a stub that then takes a real turn is NOT a
12
+ * launch failure.
13
+ *
14
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-launch-failure.test.cjs
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ import { test, expect, beforeAll, afterAll, afterEach } from 'vitest';
20
+ const fs = require('node:fs');
21
+ const os = require('node:os');
22
+ const path = require('node:path');
23
+
24
+ let tmpHome;
25
+ let originalHome;
26
+ let executeJob;
27
+ let handleLaunchFailure;
28
+ let applyLaunchFailure;
29
+ let writeQueue;
30
+ let queueStore;
31
+
32
+ beforeAll(() => {
33
+ originalHome = process.env.HOME;
34
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-launch-failure-'));
35
+ process.env.HOME = tmpHome;
36
+ fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
37
+ ({ executeJob, handleLaunchFailure, applyLaunchFailure, writeQueue } = require('../scheduler.cjs'));
38
+ queueStore = require('../lib/queueStore.cjs');
39
+ });
40
+
41
+ afterAll(() => {
42
+ process.env.HOME = originalHome;
43
+ fs.rmSync(tmpHome, { recursive: true, force: true });
44
+ });
45
+
46
+ afterEach(() => {
47
+ delete process.env.SM_CLAUDE_BIN;
48
+ });
49
+
50
+ const THINKING_400 = 'API Error: 400 {"detail":{"error":"{\\"message\\":\\"\\\\\\"thinking.type.enabled\\\\\\" is not supported for this model. Use \\\\\\"thinking.type.adaptive\\\\\\" and \\\\\\"output_config.effort\\\\\\" to control thinking behavior.\\"}"}}';
51
+
52
+ function writeStub({ mode }) {
53
+ const stubPath = path.join(os.tmpdir(), `sm-claude-stub-launch-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
54
+ const body = `
55
+ const fs = require('fs');
56
+ const path = require('path');
57
+ fs.writeFileSync(path.join(process.cwd(), 'env.marker'), JSON.stringify({
58
+ SM_SCHEDULER_JOB_SLUG: process.env.SM_SCHEDULER_JOB_SLUG || null,
59
+ SM_SCHEDULER_JOB_MAY_QUEUE: process.env.SM_SCHEDULER_JOB_MAY_QUEUE || null,
60
+ MAX_THINKING_TOKENS: process.env.MAX_THINKING_TOKENS || null,
61
+ }), 'utf8');
62
+ const mode = ${JSON.stringify(mode)};
63
+ if (mode === 'thinking-400') {
64
+ process.stdout.write(JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: ${JSON.stringify(THINKING_400)} }] } }) + '\\n');
65
+ process.stdout.write(JSON.stringify({ type: 'result', subtype: 'error', is_error: true, api_error_status: 400, num_turns: 1, duration_ms: 2000, total_cost_usd: 0, usage: { input_tokens: 0, output_tokens: 0 }, result: ${JSON.stringify(THINKING_400)} }) + '\\n');
66
+ process.exit(1);
67
+ }
68
+ process.stdout.write(JSON.stringify({ type: 'result', subtype: 'error', is_error: true, num_turns: 9, usage: { input_tokens: 100, output_tokens: 250 }, result: 'Error: tests failed' }) + '\\n');
69
+ process.exit(1);
70
+ `;
71
+ fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
72
+ return stubPath;
73
+ }
74
+
75
+ function mkProject() {
76
+ const mainCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-launch-main-'));
77
+ const slug = `1201-launch-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
78
+ const prdsDir = path.join(mainCwd, 'session-manager-operations', 'scheduler', 'prds');
79
+ fs.mkdirSync(prdsDir, { recursive: true });
80
+ fs.writeFileSync(path.join(prdsDir, `${slug}.md`), 'Do the thing.', 'utf8');
81
+ const runDir = fs.mkdtempSync(path.join(tmpHome, '.claude', 'sm-launch-run-'));
82
+ return { mainCwd, slug, runDir };
83
+ }
84
+
85
+ test('executeJob: the issue-#11 400 is a launch failure with the API message, and meta.json records it', async () => {
86
+ const { mainCwd, slug, runDir } = mkProject();
87
+ process.env.SM_CLAUDE_BIN = writeStub({ mode: 'thinking-400' });
88
+ const job = { slug, cwd: mainCwd, agentType: 'dev-lead' };
89
+
90
+ const res = await executeJob(job, runDir, mainCwd, () => Promise.resolve(), undefined, null, null, { MAX_THINKING_TOKENS: '0' });
91
+
92
+ expect(res.exitCode).toBe(1);
93
+ expect(res.launchFailure).toMatchObject({ kind: 'model_config_rejected', httpStatus: 400 });
94
+ expect(res.launchFailure.message).toMatch(/thinking\.type\.enabled.*not supported/);
95
+ expect(res.resultStats).toMatchObject({ numTurns: 1, outputTokens: 0 });
96
+
97
+ const meta = JSON.parse(fs.readFileSync(path.join(runDir, `${slug}.meta.json`), 'utf8'));
98
+ expect(meta.launchFailure.kind).toBe('model_config_rejected');
99
+ expect(meta.numTurns).toBe(1);
100
+ expect(meta.outputTokens).toBe(0);
101
+ expect(meta.launchEnvApplied).toEqual(['MAX_THINKING_TOKENS']);
102
+
103
+ const env = JSON.parse(fs.readFileSync(path.join(mainCwd, 'env.marker'), 'utf8'));
104
+ expect(env.SM_SCHEDULER_JOB_SLUG).toBe(slug);
105
+ expect(env.SM_SCHEDULER_JOB_MAY_QUEUE).toBe('0');
106
+ expect(env.MAX_THINKING_TOKENS).toBe('0');
107
+
108
+ const log = fs.readFileSync(path.join(runDir, `${slug}.log`), 'utf8');
109
+ expect(log).toMatch(/LAUNCH FAILURE \(model_config_rejected HTTP 400\)/);
110
+ expect(log).toMatch(/launch mitigation env applied: MAX_THINKING_TOKENS=0/);
111
+
112
+ fs.rmSync(mainCwd, { recursive: true, force: true });
113
+ });
114
+
115
+ test('executeJob: a run that took real turns and failed is NOT a launch failure; architect persona may queue', async () => {
116
+ const { mainCwd, slug, runDir } = mkProject();
117
+ process.env.SM_CLAUDE_BIN = writeStub({ mode: 'real-failure' });
118
+ const job = { slug, cwd: mainCwd, agentType: 'architect' };
119
+
120
+ const res = await executeJob(job, runDir, mainCwd, () => Promise.resolve());
121
+
122
+ expect(res.exitCode).toBe(1);
123
+ expect(res.launchFailure).toBeNull();
124
+ expect(res.resultStats).toMatchObject({ numTurns: 9, outputTokens: 250 });
125
+ const env = JSON.parse(fs.readFileSync(path.join(mainCwd, 'env.marker'), 'utf8'));
126
+ expect(env.SM_SCHEDULER_JOB_MAY_QUEUE).toBe('1');
127
+ expect(env.MAX_THINKING_TOKENS).toBeNull();
128
+
129
+ fs.rmSync(mainCwd, { recursive: true, force: true });
130
+ });
131
+
132
+ test('applyLaunchFailure: row → pending with the API message, breaker armed, never failed; a repeat escalates the same block', () => {
133
+ const runId = '2026-09-02T17-54-14-770Z';
134
+ const job = { slug: '1201-a', cwd: '/tmp/p', agentType: 'dev-lead', status: 'running', runId, startedAt: '2026-09-02T17:54:14.770Z', statusHistory: [] };
135
+ const s = { jobs: [job], launchBlocks: {}, launchMitigations: { 'dev-lead': { env: { MAX_THINKING_TOKENS: '0' } } } };
136
+ const lf = { kind: 'model_config_rejected', httpStatus: 400, message: '"thinking.type.enabled" is not supported for this model.' };
137
+ const T0 = Date.parse('2026-09-02T18:00:00Z');
138
+
139
+ const armed = applyLaunchFailure(s, { job, lf, runId, launchKey: 'dev-lead', mitigationApplied: false, claudeVersion: '1.0.90', now: T0 });
140
+
141
+ expect(armed.kind).toBe('model_config_rejected');
142
+ expect(armed.attempts).toBe(1);
143
+ expect(s.launchBlocks['dev-lead']).toBe(armed);
144
+ expect(s.launchMitigations['dev-lead']).toBeTruthy(); // not dropped: no mitigation was applied to THIS run
145
+
146
+ const row = s.jobs[0];
147
+ expect(row.status).toBe('pending');
148
+ expect(row.error).toMatch(/launch failure \(model_config_rejected HTTP 400\): "thinking\.type\.enabled"/);
149
+ expect(row.launchFailure).toMatchObject({ kind: 'model_config_rejected', httpStatus: 400, count: 1, runId, mitigationApplied: false });
150
+ expect(row.terminalReason).toBe('launch_failure:model_config_rejected');
151
+ expect(row.heldReason).toMatch(/^launch blocked \(model_config_rejected\)/);
152
+ expect(row.transientRetries ?? 0).toBe(0);
153
+ expect(row.autoFixOutcome).toBeUndefined();
154
+ expect(row.runId).toBeNull();
155
+
156
+ // Second identical failure, this time WITH the mitigation applied → same
157
+ // block escalates, the unproven mitigation is dropped, the hint is honest.
158
+ row.status = 'running';
159
+ const armed2 = applyLaunchFailure(s, { job: row, lf, runId, launchKey: 'dev-lead', mitigationApplied: true, claudeVersion: '1.0.90', now: T0 + 1000 });
160
+ expect(armed2.attempts).toBe(2);
161
+ expect(armed2.mitigationApplied).toBe(true);
162
+ expect(armed2.hint).toMatch(/did not get past it/);
163
+ expect(Date.parse(armed2.until)).toBeGreaterThan(Date.parse(armed.until));
164
+ expect(s.launchMitigations['dev-lead']).toBeUndefined();
165
+ expect(row.launchFailure.count).toBe(2);
166
+ expect(row.status).toBe('pending');
167
+ });
168
+
169
+ test('applyLaunchFailure: a row already finalized by someone else (cancel) is left alone, breaker still arms', () => {
170
+ const job = { slug: '1201-b', agentType: 'dev-lead', status: 'failed', error: 'cancelled via admin API' };
171
+ const s = { jobs: [job], launchBlocks: {}, launchMitigations: {} };
172
+ applyLaunchFailure(s, { job, lf: { kind: 'auth_failed', httpStatus: 401, message: 'bad key' }, runId: 'r', launchKey: 'dev-lead', mitigationApplied: false, claudeVersion: null, now: Date.now() });
173
+ expect(s.jobs[0].status).toBe('failed');
174
+ expect(s.jobs[0].error).toBe('cancelled via admin API');
175
+ expect(s.launchBlocks['dev-lead'].kind).toBe('auth_failed');
176
+ });
177
+
178
+ test('handleLaunchFailure: persists the breaker into the machine state file and writes the outcome sidecar', async () => {
179
+ const { mainCwd, slug, runDir } = mkProject();
180
+ const runId = path.basename(runDir);
181
+ const job = { slug, cwd: mainCwd, agentType: 'dev-lead', status: 'running', runId };
182
+ await writeQueue({ config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null, launchBlocks: {}, launchMitigations: {} });
183
+
184
+ const res = {
185
+ exitCode: 1, durationMs: 2000,
186
+ launchFailure: { kind: 'model_config_rejected', httpStatus: 400, message: '"thinking.type.enabled" is not supported for this model.' },
187
+ resultStats: { numTurns: 1, outputTokens: 0, totalCostUsd: 0 },
188
+ };
189
+ await handleLaunchFailure({ job, res, runId, runDir, launchKey: 'dev-lead', launchEnv: null, claudeVersion: '1.0.90' });
190
+
191
+ const machine = JSON.parse(fs.readFileSync(queueStore.MACHINE_STATE_PATH, 'utf8'));
192
+ const block = machine.launchBlocks['dev-lead'];
193
+ expect(block).toMatchObject({ kind: 'model_config_rejected', attempts: 1, claudeVersion: '1.0.90', lastSlug: slug, mitigationEnv: { MAX_THINKING_TOKENS: '0' } });
194
+ expect(block.hint).toMatch(/claude update/);
195
+
196
+ const outcome = JSON.parse(fs.readFileSync(path.join(runDir, `${slug}.outcome.json`), 'utf8'));
197
+ expect(outcome).toMatchObject({ slug, runId, numTurns: 1, outputTokens: 0, status: 'pending', terminalReason: 'launch_failure:model_config_rejected' });
198
+ expect(outcome.launchFailure.kind).toBe('model_config_rejected');
199
+
200
+ fs.rmSync(mainCwd, { recursive: true, force: true });
201
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * scheduler-leftover-fields.test.cjs — leftoverFieldsFrom/applyLeftoverFields,
3
+ * the pure helpers that turn a newly-dirty path list into the
4
+ * `leftoverPaths`/`leftoverCount`/`leftoverPathsTruncated` triple stamped on
5
+ * a terminal job row (see the leftover-attribution PRD: a `failed` row that
6
+ * left uncommitted work must be visually distinct from one that left
7
+ * nothing).
8
+ *
9
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-leftover-fields.test.cjs
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ import { test, expect } from 'vitest';
15
+ const { leftoverFieldsFrom, applyLeftoverFields, LEFTOVER_PATHS_CAP } = require('../scheduler.cjs');
16
+
17
+ test('leftoverFieldsFrom: null when the path list is null (git status unavailable) — never treated as "left nothing"', () => {
18
+ expect(leftoverFieldsFrom(null)).toBeNull();
19
+ });
20
+
21
+ test('leftoverFieldsFrom: null for an empty array (left nothing)', () => {
22
+ expect(leftoverFieldsFrom([])).toBeNull();
23
+ });
24
+
25
+ test('leftoverFieldsFrom: a real list returns leftoverPaths + leftoverCount, no truncation flag under the cap', () => {
26
+ const fields = leftoverFieldsFrom(['a.js', 'b.js', 'c.js']);
27
+ expect(fields).toEqual({ leftoverPaths: ['a.js', 'b.js', 'c.js'], leftoverCount: 3 });
28
+ });
29
+
30
+ test('leftoverFieldsFrom: caps the displayed path list at LEFTOVER_PATHS_CAP but keeps the true total count', () => {
31
+ const many = Array.from({ length: LEFTOVER_PATHS_CAP + 12 }, (_, i) => `file-${i}.txt`);
32
+ const fields = leftoverFieldsFrom(many);
33
+ expect(fields.leftoverPaths).toHaveLength(LEFTOVER_PATHS_CAP);
34
+ expect(fields.leftoverCount).toBe(LEFTOVER_PATHS_CAP + 12);
35
+ expect(fields.leftoverPathsTruncated).toBe(true);
36
+ });
37
+
38
+ test('applyLeftoverFields: stamps the fields on the row when paths are present', () => {
39
+ const row = { slug: 'x' };
40
+ applyLeftoverFields(row, ['a.js']);
41
+ expect(row.leftoverPaths).toEqual(['a.js']);
42
+ expect(row.leftoverCount).toBe(1);
43
+ expect(row.leftoverPathsTruncated).toBeUndefined();
44
+ });
45
+
46
+ test('applyLeftoverFields: clears any stale fields from a prior call when the new list is empty/null', () => {
47
+ const row = { slug: 'x', leftoverPaths: ['stale.js'], leftoverCount: 1, leftoverPathsTruncated: true };
48
+ applyLeftoverFields(row, []);
49
+ expect(row.leftoverPaths).toBeUndefined();
50
+ expect(row.leftoverCount).toBeUndefined();
51
+ expect(row.leftoverPathsTruncated).toBeUndefined();
52
+ });
@@ -0,0 +1,241 @@
1
+ /**
2
+ * scheduler-looks-done.test.cjs — PRD 1102.
3
+ *
4
+ * reverifyNeedsReview() used to have two hard limits that left real, finished
5
+ * work parked red forever: (1) isRescanCandidate required status ===
6
+ * 'needs_review' — a `failed` row was never revisited at all; (2)
7
+ * committedInWindow only looked inside [startedAt, finishedAt+60s], missing a
8
+ * commit that landed later (a retry, a sibling run, a human). This tests the
9
+ * widened pass: a `failed` row whose failure is unverified-shaped (no result
10
+ * event at all — classifyRunOutcome === 'no_result') is now a rescan
11
+ * candidate, and any candidate with a POST-window commit touching its own
12
+ * declared paths gets annotated `looksDone` for a human — never auto-
13
+ * completed. A `failed` row with a genuine result event (a real red gate) must
14
+ * stay excluded, and the pre-existing needs_review heal path must be
15
+ * unaffected.
16
+ *
17
+ * HOME is overridden to a tmp dir BEFORE requiring scheduler.cjs — see
18
+ * scheduler-reap-dead-running-jobs.test.cjs's comment for why (every path
19
+ * this code touches is baked into a top-level const from os.homedir() at
20
+ * require time).
21
+ *
22
+ * Run: timeout 180 npx vitest run src/main/__tests__/scheduler-looks-done.test.cjs
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const assert = require('node:assert/strict');
28
+ const fs = require('node:fs');
29
+ const os = require('node:os');
30
+ const path = require('node:path');
31
+ const { execFileSync } = require('node:child_process');
32
+
33
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'looks-done-test-'));
34
+ process.env.HOME = tmpHome;
35
+
36
+ const { reverifyNeedsReview, computeLooksDone } = require('../scheduler.cjs');
37
+ const { resolvePrdWriteDir } = require('../lib/prdLocations.cjs');
38
+ const { bustCwdCache } = require('../lib/queueStore.cjs');
39
+
40
+ function git(args, cwd) {
41
+ return execFileSync('git', args, { cwd, encoding: 'utf8' });
42
+ }
43
+
44
+ function initRepo(dir) {
45
+ fs.mkdirSync(dir, { recursive: true });
46
+ git(['init', '-q'], dir);
47
+ git(['config', 'user.email', 'test@example.com'], dir);
48
+ git(['config', 'user.name', 'Test'], dir);
49
+ fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
50
+ git(['add', '-A'], dir);
51
+ git(['commit', '-q', '-m', 'initial'], dir);
52
+ }
53
+
54
+ function commitFile(dir, relPath, content, message) {
55
+ const abs = path.join(dir, relPath);
56
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
57
+ fs.writeFileSync(abs, content);
58
+ git(['add', relPath], dir);
59
+ git(['commit', '-q', '-m', message], dir);
60
+ }
61
+
62
+ function registerActiveProject(cwd, slug) {
63
+ const projectsDir = path.join(tmpHome, '.claude', 'projects');
64
+ const slugDir = path.join(projectsDir, slug);
65
+ fs.mkdirSync(slugDir, { recursive: true });
66
+ fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
67
+ bustCwdCache();
68
+ }
69
+
70
+ function writeProjectQueue(cwd, jobs) {
71
+ const stateDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
72
+ fs.mkdirSync(stateDir, { recursive: true });
73
+ fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs }, null, 2));
74
+ return path.join(stateDir, 'queue.json');
75
+ }
76
+
77
+ function writeRunLog(runId, slug, lines) {
78
+ const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
79
+ fs.mkdirSync(runDir, { recursive: true });
80
+ fs.writeFileSync(path.join(runDir, `${slug}.log`), lines.join('\n') + '\n');
81
+ }
82
+
83
+ function writePrd(cwd, slug, body) {
84
+ const dir = resolvePrdWriteDir(cwd);
85
+ fs.mkdirSync(dir, { recursive: true });
86
+ const text = `---\ntitle: Test PRD\ncwd: ${cwd}\nestimateMinutes: 30\n---\n${body}\n`;
87
+ fs.writeFileSync(path.join(dir, `${slug}.md`), text);
88
+ }
89
+
90
+ async function wait(ms) {
91
+ await new Promise((r) => setTimeout(r, ms));
92
+ }
93
+
94
+ test('failed + no result event (unverified-shaped) + later commit touching declared path → looksDone, transitions to needs_review', async () => {
95
+ const projectCwd = path.join(tmpHome, 'proj-171');
96
+ initRepo(projectCwd);
97
+ registerActiveProject(projectCwd, 'proj-171-slug');
98
+ writePrd(projectCwd, '171-example', [
99
+ '# Implementation notes',
100
+ 'Edit `src/foo.js`.',
101
+ ].join('\n'));
102
+
103
+ const startedAt = new Date().toISOString();
104
+ const queuePath = writeProjectQueue(projectCwd, [
105
+ {
106
+ slug: '171-example',
107
+ status: 'failed',
108
+ cwd: projectCwd,
109
+ runId: 'run-171',
110
+ startedAt,
111
+ finishedAt: new Date().toISOString(),
112
+ error: 'reaped: process gone (outcome=no_result) (outcome=no_result)',
113
+ },
114
+ ]);
115
+ writeRunLog('run-171', '171-example', ['[scheduler] starting 171-example']); // no result event
116
+
117
+ await wait(1100); // git --since has 1s resolution
118
+ commitFile(projectCwd, 'src/foo.js', 'hello', 'fix 171');
119
+
120
+ await reverifyNeedsReview();
121
+
122
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
123
+ assert.equal(jobs.length, 1);
124
+ assert.equal(jobs[0].status, 'needs_review', 'looksDone never auto-completes — only surfaces for a human');
125
+ assert.ok(jobs[0].looksDone, 'expected a looksDone annotation');
126
+ assert.equal(jobs[0].looksDone.commits.length, 1);
127
+ assert.deepEqual(jobs[0].looksDone.paths, ['src/foo.js']);
128
+ assert.match(jobs[0].error, /looks done — 1 commit/);
129
+ });
130
+
131
+ test('failed + a real result event (genuine gate failure) → not a candidate, stays failed untouched', async () => {
132
+ const projectCwd = path.join(tmpHome, 'proj-real-fail');
133
+ initRepo(projectCwd);
134
+ registerActiveProject(projectCwd, 'proj-real-fail-slug');
135
+ writePrd(projectCwd, '05-real-fail', [
136
+ '# Implementation notes',
137
+ 'Edit `src/bar.js`.',
138
+ ].join('\n'));
139
+
140
+ const startedAt = new Date().toISOString();
141
+ const queuePath = writeProjectQueue(projectCwd, [
142
+ {
143
+ slug: '05-real-fail',
144
+ status: 'failed',
145
+ cwd: projectCwd,
146
+ runId: 'run-real-fail',
147
+ startedAt,
148
+ finishedAt: new Date().toISOString(),
149
+ error: 'AC gate red: tests failed',
150
+ },
151
+ ]);
152
+ writeRunLog('run-real-fail', '05-real-fail', [
153
+ '[scheduler] starting 05-real-fail',
154
+ JSON.stringify({ type: 'result', subtype: 'error_during_execution', is_error: true, result: 'tests failed' }),
155
+ ]);
156
+
157
+ await wait(1100);
158
+ commitFile(projectCwd, 'src/bar.js', 'hello', 'unrelated later commit touching the declared path');
159
+
160
+ await reverifyNeedsReview();
161
+
162
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
163
+ assert.equal(jobs.length, 1);
164
+ assert.equal(jobs[0].status, 'failed', 'a genuine red gate must never become a heal candidate');
165
+ assert.equal(jobs[0].looksDone, undefined);
166
+ assert.equal(jobs[0].error, 'AC gate red: tests failed');
167
+ });
168
+
169
+ test('needs_review with an in-window commit heals exactly as today (regression — unchanged heal semantics)', async () => {
170
+ const projectCwd = path.join(tmpHome, 'proj-heals-today');
171
+ initRepo(projectCwd);
172
+ registerActiveProject(projectCwd, 'proj-heals-today-slug');
173
+ writePrd(projectCwd, '20-clean-feature', [
174
+ '# Acceptance criteria',
175
+ '- [ ] `npm test` passes',
176
+ ].join('\n'));
177
+
178
+ const startedAt = new Date().toISOString();
179
+ const queuePath = writeProjectQueue(projectCwd, [
180
+ {
181
+ slug: '20-clean-feature',
182
+ status: 'needs_review',
183
+ cwd: projectCwd,
184
+ runId: 'run-clean',
185
+ startedAt,
186
+ finishedAt: new Date().toISOString(),
187
+ verifierVerdict: 'transcript_errors',
188
+ error: 'transcript_errors: stale verdict',
189
+ },
190
+ ]);
191
+ writeRunLog('run-clean', '20-clean-feature', [
192
+ '[scheduler] starting 20-clean-feature',
193
+ JSON.stringify({
194
+ type: 'assistant',
195
+ message: { role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'npm test' } }] },
196
+ }),
197
+ JSON.stringify({
198
+ type: 'user',
199
+ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'all tests passed' }] },
200
+ }),
201
+ 'SCHEDULER_VERDICT: PASS',
202
+ '[scheduler] exit code=0 (raw code=0 signal=null) duration=10s',
203
+ ]);
204
+
205
+ await reverifyNeedsReview();
206
+
207
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
208
+ assert.equal(jobs.length, 1);
209
+ assert.equal(jobs[0].status, 'completed', 'a clean re-verify on a RESCANNABLE verdict must still heal');
210
+ assert.equal(jobs[0].verifierVerdict, undefined);
211
+ assert.equal(jobs[0].error, null);
212
+ });
213
+
214
+ test('computeLooksDone: no declared paths on the PRD → null, never fabricates evidence', async () => {
215
+ const projectCwd = path.join(tmpHome, 'proj-no-paths');
216
+ initRepo(projectCwd);
217
+ writePrd(projectCwd, '09-no-paths', [
218
+ '# Implementation notes',
219
+ 'Just prose here, no backticked file paths.',
220
+ ].join('\n'));
221
+
222
+ const startedAt = new Date().toISOString();
223
+ await wait(1100);
224
+ commitFile(projectCwd, 'src/whatever.js', 'x', 'some later commit');
225
+
226
+ const job = { slug: '09-no-paths', cwd: projectCwd, startedAt };
227
+ const looksDone = await computeLooksDone(job);
228
+ assert.equal(looksDone, null);
229
+ });
230
+
231
+ test('computeLooksDone: non-git cwd → null without throwing (git-unavailable)', async () => {
232
+ const notARepo = fs.mkdtempSync(path.join(tmpHome, 'not-a-repo-'));
233
+ writePrd(notARepo, '11-no-repo', [
234
+ '# Implementation notes',
235
+ 'Edit `src/thing.js`.',
236
+ ].join('\n'));
237
+
238
+ const job = { slug: '11-no-repo', cwd: notARepo, startedAt: new Date().toISOString() };
239
+ const looksDone = await computeLooksDone(job);
240
+ assert.equal(looksDone, null);
241
+ });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * scheduler-prd-persona-spawn.test.cjs — PRD 1115: a PRD's `agentType`
3
+ * frontmatter must actually drive the headless spawn (--append-system-prompt
4
+ * with the persona's frontmatter-stripped, capped body, and --model with the
5
+ * persona's own model), not just sit in the prompt as prose the executor may
6
+ * or may not obey. Covers buildClaudeSpawnArgs' pure argv shape plus a real
7
+ * executeJob spawn (stub `claude` binary) proving the resolved persona
8
+ * actually reaches the child process, and that a dangling agentType still
9
+ * runs the job to completion (never parks or fails it).
10
+ *
11
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-prd-persona-spawn.test.cjs
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ import { test, expect, beforeAll, afterAll, afterEach } from 'vitest';
17
+ const fs = require('node:fs');
18
+ const os = require('node:os');
19
+ const path = require('node:path');
20
+
21
+ let tmpHome;
22
+ let originalHome;
23
+ let executeJob;
24
+ let buildClaudeSpawnArgs;
25
+
26
+ beforeAll(() => {
27
+ originalHome = process.env.HOME;
28
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-prd-persona-spawn-'));
29
+ process.env.HOME = tmpHome;
30
+ ({ executeJob, buildClaudeSpawnArgs } = require('../scheduler.cjs'));
31
+ });
32
+
33
+ afterAll(() => {
34
+ process.env.HOME = originalHome;
35
+ fs.rmSync(tmpHome, { recursive: true, force: true });
36
+ });
37
+
38
+ afterEach(() => {
39
+ delete process.env.SM_CLAUDE_BIN;
40
+ });
41
+
42
+ // ---------- buildClaudeSpawnArgs: systemPrompt -> --append-system-prompt ----------
43
+
44
+ test('buildClaudeSpawnArgs: a given systemPrompt is passed via --append-system-prompt', () => {
45
+ const args = buildClaudeSpawnArgs({ prompt: 'prd body', model: 'opus', sessionId: 'sid', resume: false, systemPrompt: 'You are dev-lead.' });
46
+ const idx = args.indexOf('--append-system-prompt');
47
+ expect(idx).toBeGreaterThanOrEqual(0);
48
+ expect(args[idx + 1]).toBe('You are dev-lead.');
49
+ });
50
+
51
+ test('buildClaudeSpawnArgs: omits --append-system-prompt entirely when systemPrompt is absent', () => {
52
+ const args = buildClaudeSpawnArgs({ prompt: 'prd body', model: 'sonnet', sessionId: 'sid', resume: false });
53
+ expect(args).not.toContain('--append-system-prompt');
54
+ });
55
+
56
+ test('buildClaudeSpawnArgs: model is never omitted even without a systemPrompt', () => {
57
+ const args = buildClaudeSpawnArgs({ prompt: 'x', model: 'sonnet', sessionId: 'sid', resume: false });
58
+ const idx = args.indexOf('--model');
59
+ expect(idx).toBeGreaterThanOrEqual(0);
60
+ expect(args[idx + 1]).toBe('sonnet');
61
+ });
62
+
63
+ // ---------- real spawn: persona actually reaches the child process ----------
64
+
65
+ // Stub `claude` binary: dumps its own argv into a marker file, then emits a
66
+ // stream-json success result and exits 0 — same technique as
67
+ // scheduler-bash-timeout-env.test.cjs's env-var proof, applied to argv.
68
+ function writeClaudeStub() {
69
+ const stubPath = path.join(os.tmpdir(), `sm-claude-stub-argv-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
70
+ const body = `
71
+ const fs = require('fs');
72
+ const path = require('path');
73
+ fs.writeFileSync(path.join(process.cwd(), 'argv.marker'), JSON.stringify(process.argv.slice(2)), 'utf8');
74
+ process.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', result: 'ok' }) + '\\n');
75
+ process.exit(0);
76
+ `;
77
+ fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
78
+ return stubPath;
79
+ }
80
+
81
+ function writePersona(dir, name, frontmatterLines, bodyText) {
82
+ fs.mkdirSync(dir, { recursive: true });
83
+ const lines = ['---', `name: ${name}`, ...frontmatterLines, '---', bodyText];
84
+ fs.writeFileSync(path.join(dir, `${name}.md`), lines.join('\n'));
85
+ }
86
+
87
+ async function runJobWithAgentType({ agentType }) {
88
+ const mainCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-prd-persona-main-'));
89
+ fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
90
+ const runDir = fs.mkdtempSync(path.join(tmpHome, '.claude', 'sm-prd-persona-run-'));
91
+ const slug = `1115-test-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
92
+ const prdsDir = path.join(mainCwd, 'session-manager-operations', 'scheduler', 'prds');
93
+ fs.mkdirSync(prdsDir, { recursive: true });
94
+ fs.writeFileSync(path.join(prdsDir, `${slug}.md`), 'Do the thing.', 'utf8');
95
+ process.env.SM_CLAUDE_BIN = writeClaudeStub();
96
+
97
+ const job = { slug, cwd: mainCwd, agentType };
98
+ const result = await executeJob(job, runDir, mainCwd, () => Promise.resolve());
99
+ const argv = JSON.parse(fs.readFileSync(path.join(mainCwd, 'argv.marker'), 'utf8'));
100
+ fs.rmSync(mainCwd, { recursive: true, force: true });
101
+ fs.rmSync(runDir, { recursive: true, force: true });
102
+ return { result, argv };
103
+ }
104
+
105
+ test('executeJob: a resolvable agentType reaches the spawned child as --append-system-prompt + --model', async () => {
106
+ const globalAgentsDir = path.join(tmpHome, '.claude', 'agents');
107
+ writePersona(globalAgentsDir, 'dev-lead', ['model: opus'], 'You are dev-lead. Ship it.');
108
+
109
+ const { result, argv } = await runJobWithAgentType({ agentType: 'dev-lead' });
110
+
111
+ expect(result.exitCode).toBe(0);
112
+ const spIdx = argv.indexOf('--append-system-prompt');
113
+ expect(spIdx).toBeGreaterThanOrEqual(0);
114
+ expect(argv[spIdx + 1]).toBe('You are dev-lead. Ship it.');
115
+ const modelIdx = argv.indexOf('--model');
116
+ expect(argv[modelIdx + 1]).toBe('opus');
117
+ });
118
+
119
+ test('executeJob: a dangling agentType (no resolvable persona file) still runs the job to completion, falling back to sonnet with no --append-system-prompt', async () => {
120
+ const { result, argv } = await runJobWithAgentType({ agentType: 'renamed-or-deleted-persona' });
121
+
122
+ expect(result.exitCode).toBe(0);
123
+ expect(argv).not.toContain('--append-system-prompt');
124
+ const modelIdx = argv.indexOf('--model');
125
+ expect(argv[modelIdx + 1]).toBe('sonnet');
126
+ });
127
+
128
+ test('executeJob: a job with no agentType at all runs unaffected (no persona, sonnet fallback)', async () => {
129
+ const { result, argv } = await runJobWithAgentType({ agentType: null });
130
+
131
+ expect(result.exitCode).toBe(0);
132
+ expect(argv).not.toContain('--append-system-prompt');
133
+ const modelIdx = argv.indexOf('--model');
134
+ expect(argv[modelIdx + 1]).toBe('sonnet');
135
+ });