claude-code-session-manager 0.75.3 → 0.77.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 (164) hide show
  1. package/dist/assets/{AgentLibrary-CzQqcObq.js → AgentLibrary-B2ie8bbw.js} +2 -2
  2. package/dist/assets/{DataModel-Bj_WlLz8.js → DataModel-BIJPYw32.js} +1 -1
  3. package/dist/assets/{History-DnSi_OHm.js → History-CeY6dk9S.js} +2 -2
  4. package/dist/assets/{Hooks-0BB0dp3S.js → Hooks-BFH2ocKg.js} +2 -2
  5. package/dist/assets/{HostBilko-DHpwwsLQ.js → HostBilko-36gj9wLz.js} +1 -1
  6. package/dist/assets/{Library-CaJVqVvi.js → Library-C-hBct39.js} +1 -1
  7. package/dist/assets/{ListDetail-C1W2HmC2.js → ListDetail-CNq64VWV.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-5Ob9FW3z.js → MarkdownEditor-Bh3qt5-1.js} +1 -1
  9. package/dist/assets/{McpServers-JxCSfm1S.js → McpServers-DpGN0oyz.js} +1 -1
  10. package/dist/assets/{Memory-BDeqlqwH.js → Memory-D59hUjC4.js} +6 -6
  11. package/dist/assets/{Panel-Dh9ZHuEj.js → Panel-DCgbaoci.js} +1 -1
  12. package/dist/assets/{Permissions-DXy-CbEY.js → Permissions-DAmQ0DYV.js} +2 -2
  13. package/dist/assets/{Plugins-_n1Iuc8T.js → Plugins-Dyfgn6Is.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-BP_evfxE.js → ProvenanceBadge-BiYhPO1U.js} +1 -1
  15. package/dist/assets/SaveBar-RV7B6sOh.js +1 -0
  16. package/dist/assets/Scheduler-BPaNqx1b.js +14 -0
  17. package/dist/assets/{ScopeSwitcher-CAWzM6RI.js → ScopeSwitcher-P4mdLGNU.js} +1 -1
  18. package/dist/assets/{Settings-DRRozLyT.js → Settings-BL4vf5aX.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-DGHDWlz4.js → SkillReferenceGraph-BRBDyi1_.js} +1 -1
  20. package/dist/assets/{Skills-D8L66eiX.js → Skills-BV08gDUH.js} +2 -2
  21. package/dist/assets/{SystemPrompt-CYtUsonD.js → SystemPrompt-CLftSsDw.js} +1 -1
  22. package/dist/assets/TagLibrary-Bp8jGsd5.js +1 -0
  23. package/dist/assets/{TiptapBody-B2hRgbPE.js → TiptapBody-jCpuB6E5.js} +1 -1
  24. package/dist/assets/{Toggle-BTwsbxam.js → Toggle-D2paA1xf.js} +1 -1
  25. package/dist/assets/{index-DijufvkJ.js → index-BDRSqBl3.js} +704 -704
  26. package/dist/assets/{index-CMLnzdZC.css → index-CYhdtisq.css} +1 -1
  27. package/dist/assets/{settingsSchema-D6wzxAi6.js → settingsSchema-6IOLjZZN.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/lib/activeSessions.cjs +116 -6
  32. package/scripts/project-pages-logic/dist/logic.cjs +4709 -0
  33. package/scripts/render-project-pages/dist/renderer.cjs +18900 -0
  34. package/scripts/render-project-pages.cjs +70 -0
  35. package/scripts/scheduler-mcp-server.cjs +269 -96
  36. package/scripts/validate-project-pages-summary.cjs +62 -0
  37. package/src/main/__tests__/agentModelResolve.test.cjs +66 -0
  38. package/src/main/__tests__/epicStatusMirror.test.cjs +110 -0
  39. package/src/main/__tests__/health-delegation-chain.test.cjs +106 -0
  40. package/src/main/__tests__/prdAdminRoutes.test.cjs +295 -0
  41. package/src/main/__tests__/prdAgentType.test.cjs +103 -0
  42. package/src/main/__tests__/prdCreate.test.cjs +247 -0
  43. package/src/main/__tests__/prdFrontmatterAgentType.test.cjs +117 -0
  44. package/src/main/__tests__/prdFrontmatterQuietMachine.test.cjs +108 -0
  45. package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +485 -0
  46. package/src/main/__tests__/projectPages.test.cjs +73 -1
  47. package/src/main/__tests__/rcaReport.test.cjs +54 -0
  48. package/src/main/__tests__/runVerify.test.cjs +94 -0
  49. package/src/main/__tests__/scheduler-autofix-select.test.cjs +58 -3
  50. package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +103 -0
  51. package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +41 -0
  52. package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +10 -0
  53. package/src/main/__tests__/scheduler-foreign-wip-manifest.test.cjs +78 -0
  54. package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +242 -0
  55. package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +31 -0
  56. package/src/main/__tests__/scheduler-launch-failure.test.cjs +201 -0
  57. package/src/main/__tests__/scheduler-leftover-fields.test.cjs +52 -0
  58. package/src/main/__tests__/scheduler-looks-done.test.cjs +241 -0
  59. package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +135 -0
  60. package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +222 -0
  61. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +207 -1
  62. package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +212 -0
  63. package/src/main/__tests__/scheduler-stranded-investigation.test.cjs +185 -0
  64. package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +194 -0
  65. package/src/main/__tests__/seedAgentPersonas.test.cjs +75 -14
  66. package/src/main/__tests__/seedSchedulerMcp.test.cjs +66 -0
  67. package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -5
  68. package/src/main/bilkoHost.cjs +4 -3
  69. package/src/main/chatRunner.cjs +6 -1
  70. package/src/main/config.cjs +25 -33
  71. package/src/main/health.cjs +153 -2
  72. package/src/main/index.cjs +64 -5
  73. package/src/main/ipcSchemas.cjs +69 -1
  74. package/src/main/lib/__tests__/activeIndexRebuild.test.cjs +179 -0
  75. package/src/main/lib/__tests__/childWithLog.test.cjs +141 -0
  76. package/src/main/lib/__tests__/delegationReadiness.test.cjs +391 -42
  77. package/src/main/lib/__tests__/ephemeralCwd.test.cjs +91 -0
  78. package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +5 -3
  79. package/src/main/lib/__tests__/fixChainDepth.test.cjs +40 -0
  80. package/src/main/lib/__tests__/gitWorktree.test.cjs +290 -5
  81. package/src/main/lib/__tests__/gitWorktreeSalvage.test.cjs +107 -0
  82. package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +153 -0
  83. package/src/main/lib/__tests__/jobWorktree.test.cjs +6 -4
  84. package/src/main/lib/__tests__/landedSinceRun.test.cjs +73 -0
  85. package/src/main/lib/__tests__/launchFailure.test.cjs +220 -0
  86. package/src/main/lib/__tests__/loadGate.test.cjs +159 -0
  87. package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +102 -0
  88. package/src/main/lib/__tests__/opsOwnership.test.cjs +7 -0
  89. package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +151 -0
  90. package/src/main/lib/__tests__/opsRootResolve.test.cjs +149 -0
  91. package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +82 -0
  92. package/src/main/lib/__tests__/projectRootResolve.test.cjs +148 -0
  93. package/src/main/lib/__tests__/queueHealth.test.cjs +58 -0
  94. package/src/main/lib/__tests__/quietMachineLease.test.cjs +39 -0
  95. package/src/main/lib/__tests__/reaperHelpers.test.cjs +133 -0
  96. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +19 -9
  97. package/src/main/lib/__tests__/schedulerBatchFairness.test.cjs +213 -0
  98. package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +125 -0
  99. package/src/main/lib/__tests__/schedulerBatchProjectCap.test.cjs +127 -0
  100. package/src/main/lib/__tests__/schedulerBatchQuietMachine.test.cjs +109 -0
  101. package/src/main/lib/__tests__/schedulerMcpServerHeadlessRefusal.test.cjs +71 -0
  102. package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +217 -0
  103. package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +350 -0
  104. package/src/main/lib/activeIndexMerge.cjs +15 -0
  105. package/src/main/lib/activeIndexRebuild.cjs +133 -0
  106. package/src/main/lib/agentModelResolve.cjs +58 -0
  107. package/src/main/lib/buildTarget.cjs +3 -2
  108. package/src/main/lib/childWithLog.cjs +69 -2
  109. package/src/main/lib/claudeBin.cjs +54 -1
  110. package/src/main/lib/crossProjectFeedback.cjs +8 -1
  111. package/src/main/lib/definitionOfDone.cjs +3 -2
  112. package/src/main/lib/delegationReadiness.cjs +514 -26
  113. package/src/main/lib/ephemeralCwd.cjs +78 -0
  114. package/src/main/lib/epicDelegationStats.cjs +2 -1
  115. package/src/main/lib/epicMint.cjs +17 -1
  116. package/src/main/lib/epicStatusMirror.cjs +95 -0
  117. package/src/main/lib/epicValidationHook.cjs +2 -1
  118. package/src/main/lib/epicWorktreeMint.cjs +5 -2
  119. package/src/main/lib/fixChainDepth.cjs +45 -0
  120. package/src/main/lib/gitWorktree.cjs +520 -21
  121. package/src/main/lib/jobWorktree.cjs +2 -0
  122. package/src/main/lib/landedSinceRun.cjs +55 -0
  123. package/src/main/lib/launchFailure.cjs +357 -0
  124. package/src/main/lib/loadGate.cjs +134 -0
  125. package/src/main/lib/mcpToolCatalog.cjs +370 -0
  126. package/src/main/lib/opsErrorLog.cjs +12 -1
  127. package/src/main/lib/opsOwnership.cjs +106 -0
  128. package/src/main/lib/prdAdminRoutes.cjs +43 -3
  129. package/src/main/lib/prdAgentType.cjs +84 -0
  130. package/src/main/lib/prdCreate.cjs +103 -15
  131. package/src/main/lib/prdDeclaredPaths.cjs +70 -0
  132. package/src/main/lib/prdFrontmatter.cjs +17 -3
  133. package/src/main/lib/prdLocations.cjs +13 -6
  134. package/src/main/lib/projectHomeAdminRoutes.cjs +402 -0
  135. package/src/main/lib/projectPageSummarySchema.cjs +181 -0
  136. package/src/main/lib/projectRootResolve.cjs +134 -0
  137. package/src/main/lib/promptSessionSchema.cjs +7 -0
  138. package/src/main/lib/queueHealth.cjs +38 -0
  139. package/src/main/lib/queueStore.cjs +40 -7
  140. package/src/main/lib/quietMachineLease.cjs +48 -0
  141. package/src/main/lib/rcaReport.cjs +54 -4
  142. package/src/main/lib/reaperHelpers.cjs +64 -1
  143. package/src/main/lib/scheduleJobSchema.cjs +31 -0
  144. package/src/main/lib/scheduleJobTransitions.cjs +6 -2
  145. package/src/main/lib/schedulerBatch.cjs +301 -55
  146. package/src/main/lib/schedulerConfig.cjs +99 -0
  147. package/src/main/projectBrief.cjs +3 -2
  148. package/src/main/projectPages.cjs +162 -3
  149. package/src/main/promptSessionTranscript.cjs +0 -0
  150. package/src/main/pty.cjs +5 -0
  151. package/src/main/queueOps.cjs +15 -8
  152. package/src/main/runVerify.cjs +50 -9
  153. package/src/main/scheduler/prdParser.cjs +18 -1
  154. package/src/main/scheduler.cjs +1701 -130
  155. package/src/main/seedAgentPersonas.cjs +62 -21
  156. package/src/main/seedSchedulerMcp.cjs +58 -4
  157. package/src/main/templates/project-pages-catalog.json +741 -0
  158. package/src/main/templates/project-pages-pipeline.md +417 -0
  159. package/src/preload/api.d.ts +187 -3
  160. package/src/preload/index.cjs +9 -0
  161. package/src/seed/agents/project-home-builder.md +59 -0
  162. package/dist/assets/SaveBar-D-gCUx4n.js +0 -1
  163. package/dist/assets/Scheduler-Bpd4OGju.js +0 -14
  164. package/dist/assets/TagLibrary-E5CLeuVk.js +0 -1
@@ -0,0 +1,242 @@
1
+ /**
2
+ * scheduler-inplace-salvage.test.cjs — PRD 1098: an in-place run (no
3
+ * worktree — SM_JOB_WORKTREE_DISABLE=1 here, but the same finally-block path
4
+ * also covers a non-git cwd, the worktree cap, or a carry-over failure) must
5
+ * still salvage its uncommitted work, exactly like the worktree path already
6
+ * did (gitWorktreeSalvage.test.cjs), and regardless of exit code. Drives
7
+ * spawnJob end-to-end against a real git fixture repo with a stub `claude`
8
+ * binary (SM_CLAUDE_BIN, same pattern as scheduler-worktree-exec-cwd.test.cjs)
9
+ * that dirties the tree then exits 137 (SIGKILL), simulating a job killed
10
+ * mid-run before its finish-protocol commit.
11
+ *
12
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-inplace-salvage.test.cjs
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ import { test, expect, beforeAll, afterAll, afterEach } from 'vitest';
18
+ const fs = require('node:fs');
19
+ const os = require('node:os');
20
+ const path = require('node:path');
21
+ const { execFileSync } = require('node:child_process');
22
+
23
+ let tmpHome;
24
+ let originalHome;
25
+ let originalDisable;
26
+ let spawnJob;
27
+ let RUNS_DIR;
28
+
29
+ function git(args, cwd) {
30
+ return execFileSync('git', args, { cwd, encoding: 'utf8' });
31
+ }
32
+
33
+ function initRepo(dir) {
34
+ fs.mkdirSync(dir, { recursive: true });
35
+ git(['init', '-q'], dir);
36
+ git(['config', 'user.email', 'test@example.com'], dir);
37
+ git(['config', 'user.name', 'Test'], dir);
38
+ fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
39
+ git(['add', '-A'], dir);
40
+ git(['commit', '-q', '-m', 'initial'], dir);
41
+ }
42
+
43
+ // readQueue()'s per-project discovery walks ~/.claude/projects/*/*.jsonl
44
+ // transcripts for a `cwd` field to find known project queue.json shards — an
45
+ // unregistered project's queue.json is invisible to mutate()/readQueue() and
46
+ // silently never gets written back to. Same helper scheduler-reap-dead-
47
+ // running-jobs.test.cjs uses.
48
+ function registerActiveProject(cwd) {
49
+ const projectsDir = path.join(tmpHome, '.claude', 'projects');
50
+ const slugDir = path.join(projectsDir, `fake-project-slug-${path.basename(cwd)}`);
51
+ fs.mkdirSync(slugDir, { recursive: true });
52
+ fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
53
+ }
54
+
55
+ function writeProjectQueue(cwd, jobs) {
56
+ const stateDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
57
+ fs.mkdirSync(stateDir, { recursive: true });
58
+ fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs }, null, 2));
59
+ return path.join(stateDir, 'queue.json');
60
+ }
61
+
62
+ // Stub `claude` binary: dirties the repo it runs in (a tracked-file edit and
63
+ // a brand-new untracked file — this job's own delta) then exits 137, with NO
64
+ // result:success event — simulating a job SIGKILLed mid-run before its
65
+ // finish-protocol commit. Deliberately emits no success result: scheduler.cjs
66
+ // maps a killed process straight back to exitCode 0 when it already saw
67
+ // result:success (the intentional "cleanup hung after real success" case),
68
+ // which is not the scenario under test here — this must land as a genuine
69
+ // non-zero exit.
70
+ function writeKilledClaudeStub() {
71
+ const stubPath = path.join(os.tmpdir(), `sm-claude-stub-killed-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
72
+ const body = `
73
+ const fs = require('fs');
74
+ const path = require('path');
75
+ fs.writeFileSync(path.join(process.cwd(), 'README.md'), 'hello\\nedited by job\\n', 'utf8');
76
+ fs.writeFileSync(path.join(process.cwd(), 'job-output.txt'), 'work the job produced before dying\\n', 'utf8');
77
+ process.exit(137);
78
+ `;
79
+ fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
80
+ return stubPath;
81
+ }
82
+
83
+ beforeAll(() => {
84
+ originalHome = process.env.HOME;
85
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-inplace-salvage-'));
86
+ process.env.HOME = tmpHome;
87
+ originalDisable = process.env.SM_JOB_WORKTREE_DISABLE;
88
+ // Force the in-place path deterministically — this test is about the
89
+ // salvage-on-every-exit-code behaviour, not about which condition
90
+ // triggered the fallback (not-a-repo / cap / carry-over failure all land
91
+ // in the same else branch).
92
+ process.env.SM_JOB_WORKTREE_DISABLE = '1';
93
+ ({ spawnJob, RUNS_DIR } = require('../scheduler.cjs'));
94
+ });
95
+
96
+ afterAll(() => {
97
+ process.env.HOME = originalHome;
98
+ if (originalDisable === undefined) delete process.env.SM_JOB_WORKTREE_DISABLE;
99
+ else process.env.SM_JOB_WORKTREE_DISABLE = originalDisable;
100
+ fs.rmSync(tmpHome, { recursive: true, force: true });
101
+ });
102
+
103
+ afterEach(() => {
104
+ delete process.env.SM_CLAUDE_BIN;
105
+ });
106
+
107
+ test('an in-place job killed (exit 137) mid-run salvages a delta-scoped patch that applies cleanly, without disturbing pre-existing WIP', async () => {
108
+ const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-inplace-salvage-project-'));
109
+ initRepo(projectCwd);
110
+ registerActiveProject(projectCwd);
111
+
112
+ // Pre-existing human WIP untouched by the job — must survive completely
113
+ // unmodified, and must never appear in the job's salvage patch.
114
+ fs.writeFileSync(path.join(projectCwd, 'human-wip.txt'), 'human work in progress\n', 'utf8');
115
+ git(['add', '-A'], projectCwd);
116
+ // Leave it staged-but-uncommitted (real dirty WIP), not committed.
117
+ fs.appendFileSync(path.join(projectCwd, 'human-wip.txt'), 'more human edits\n', 'utf8');
118
+
119
+ const slug = `1098-test-inplace-killed-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
120
+ const prdsDir = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'prds');
121
+ fs.mkdirSync(prdsDir, { recursive: true });
122
+ fs.writeFileSync(path.join(prdsDir, `${slug}.md`), 'Do a thing that gets killed mid-run.', 'utf8');
123
+
124
+ const queuePath = writeProjectQueue(projectCwd, [
125
+ { slug, status: 'pending', cwd: projectCwd },
126
+ ]);
127
+
128
+ const statusBefore = git(['status', '--porcelain'], projectCwd);
129
+ const stashBefore = git(['stash', 'list'], projectCwd);
130
+
131
+ process.env.SM_CLAUDE_BIN = writeKilledClaudeStub();
132
+
133
+ const runId = `run-${slug}`;
134
+ const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
135
+ fs.mkdirSync(runDir, { recursive: true });
136
+
137
+ try {
138
+ await spawnJob({ slug, cwd: projectCwd }, runId, runDir, projectCwd);
139
+
140
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
141
+ const row = jobs.find((j) => j.slug === slug);
142
+ expect(row).toBeTruthy();
143
+ expect(row.exitCode).toBe(137);
144
+ expect(row.salvagePatch).toBeTruthy();
145
+ expect(fs.existsSync(row.salvagePatch)).toBe(true);
146
+
147
+ // Leftover-attribution fields: this job's own delta (README.md +
148
+ // job-output.txt), never the human's pre-existing baseline WIP.
149
+ expect(row.leftoverCount).toBe(2);
150
+ expect(new Set(row.leftoverPaths)).toEqual(new Set(['README.md', 'job-output.txt']));
151
+ // The pre-run baseline is cleared once the run has finalized — it was
152
+ // only ever needed to compute the delta above.
153
+ expect(row.guardBaseline).toBeUndefined();
154
+ expect(row.guardHeadBefore).toBeUndefined();
155
+
156
+ const patch = fs.readFileSync(row.salvagePatch, 'utf8');
157
+ expect(patch).toContain('README.md');
158
+ expect(patch).toContain('edited by job');
159
+ expect(patch).toContain('job-output.txt');
160
+ expect(patch).toContain('work the job produced before dying');
161
+ // The human's pre-existing WIP (baseline-dirty, not part of this job's
162
+ // delta) must never leak into the job's salvage patch.
163
+ expect(patch).not.toContain('more human edits');
164
+
165
+ // The patch must be self-sufficient and apply cleanly against a clean
166
+ // checkout of the same base commit.
167
+ const cleanCheckout = path.join(os.tmpdir(), `sm-inplace-salvage-clean-${process.pid}-${Math.floor(Math.random() * 1e6)}`);
168
+ git(['clone', '-q', projectCwd, cleanCheckout], os.tmpdir());
169
+ try {
170
+ expect(() => git(['apply', '--check', row.salvagePatch], cleanCheckout)).not.toThrow();
171
+ } finally {
172
+ fs.rmSync(cleanCheckout, { recursive: true, force: true });
173
+ }
174
+
175
+ // The shared tree itself was never mutated by the salvage pass — no
176
+ // add/stash/reset/checkout/clean. (The job's own dirtying of README.md
177
+ // and job-output.txt is real, expected, working-tree state left by the
178
+ // stub process itself, not something salvage did — so we only assert no
179
+ // stash was created and the human's pre-existing WIP file is untouched.)
180
+ const stashAfter = git(['stash', 'list'], projectCwd);
181
+ expect(stashAfter).toBe(stashBefore);
182
+ const humanWipContent = fs.readFileSync(path.join(projectCwd, 'human-wip.txt'), 'utf8');
183
+ expect(humanWipContent).toBe('human work in progress\nmore human edits\n');
184
+ void statusBefore; // baseline captured for readability of intent above
185
+ } finally {
186
+ fs.rmSync(projectCwd, { recursive: true, force: true });
187
+ fs.rmSync(runDir, { recursive: true, force: true });
188
+ }
189
+ }, 30_000);
190
+
191
+ // Stub `claude` binary: touches nothing, emits a success result, and exits 0
192
+ // — a job whose only working-tree dirt is pre-existing human/sibling WIP
193
+ // present before the run even started.
194
+ function writeNoopClaudeStub() {
195
+ const stubPath = path.join(os.tmpdir(), `sm-claude-stub-noop-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
196
+ const body = `
197
+ process.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', result: 'nothing to do', is_error: false }) + '\\n');
198
+ process.exit(0);
199
+ `;
200
+ fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
201
+ return stubPath;
202
+ }
203
+
204
+ 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
+ const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-inplace-salvage-project-'));
206
+ initRepo(projectCwd);
207
+ registerActiveProject(projectCwd);
208
+
209
+ // Pre-existing WIP present BEFORE the run — must never be attributed to
210
+ // this job, even though it's still dirty at exit.
211
+ fs.writeFileSync(path.join(projectCwd, 'human-wip.txt'), 'human work in progress\n', 'utf8');
212
+
213
+ const slug = `1098-test-inplace-noop-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
214
+ const prdsDir = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'prds');
215
+ fs.mkdirSync(prdsDir, { recursive: true });
216
+ fs.writeFileSync(path.join(prdsDir, `${slug}.md`), 'Do nothing.', 'utf8');
217
+
218
+ const queuePath = writeProjectQueue(projectCwd, [
219
+ { slug, status: 'pending', cwd: projectCwd },
220
+ ]);
221
+
222
+ process.env.SM_CLAUDE_BIN = writeNoopClaudeStub();
223
+
224
+ const runId = `run-${slug}`;
225
+ const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
226
+ fs.mkdirSync(runDir, { recursive: true });
227
+
228
+ try {
229
+ await spawnJob({ slug, cwd: projectCwd }, runId, runDir, projectCwd);
230
+
231
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
232
+ const row = jobs.find((j) => j.slug === slug);
233
+ expect(row).toBeTruthy();
234
+ expect(row.exitCode).toBe(0);
235
+ // No leftover attribution: the only dirt is baseline WIP, not this job's.
236
+ expect(row.leftoverPaths).toBeUndefined();
237
+ expect(row.leftoverCount).toBeUndefined();
238
+ } finally {
239
+ fs.rmSync(projectCwd, { recursive: true, force: true });
240
+ fs.rmSync(runDir, { recursive: true, force: true });
241
+ }
242
+ }, 30_000);
@@ -54,6 +54,37 @@ test('prompt includes the resolved job fields', () => {
54
54
  assert.match(prompt, /05-fix-my-feature\.md/);
55
55
  });
56
56
 
57
+ // ─── abandoned_background_task: investigation prompt directs a commit-first check ─
58
+
59
+ test('prompt for an abandoned_background_task job directs inspecting the working tree and committing before re-implementing', () => {
60
+ const prompt = buildInvestigationPrompt(makeArgs({
61
+ failedJob: { slug: '05-my-feature', title: 'My feature', exitCode: 0, verifierVerdict: 'abandoned_background_task' },
62
+ }));
63
+ assert.match(prompt, /abandoned_background_task/);
64
+ assert.match(prompt, /git status/);
65
+ assert.match(prompt, /COMMIT/);
66
+ assert.match(prompt, /not re-implement or re-plan the PRD from scratch/);
67
+ });
68
+
69
+ test('prompt for a job with no abandoned_background_task verdict omits the note', () => {
70
+ const prompt = buildInvestigationPrompt(makeArgs());
71
+ assert.doesNotMatch(prompt, /abandoned_background_task/);
72
+ });
73
+
74
+ test('prompt for an abandoned_background_task job with a salvage patch names its exact path', () => {
75
+ const prompt = buildInvestigationPrompt(makeArgs({
76
+ failedJob: {
77
+ slug: '05-my-feature',
78
+ title: 'My feature',
79
+ exitCode: 0,
80
+ verifierVerdict: 'abandoned_background_task',
81
+ salvagePatch: '/tmp/runs/2026-09-02/05-my-feature.uncommitted.patch',
82
+ },
83
+ }));
84
+ assert.match(prompt, /05-my-feature\.uncommitted\.patch/);
85
+ assert.match(prompt, /apply it to the working tree BEFORE inspecting/);
86
+ });
87
+
57
88
  // ─── cwd-must-be-git-repo-root guidance (99-fix-e2e-needs-review-test) ────────
58
89
 
59
90
  test('prompt instructs that cwd must be the git repo root where the fix will land', () => {
@@ -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
+ });