claude-code-session-manager 0.76.0 → 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 (123) hide show
  1. package/dist/assets/{AgentLibrary-CBx9l4zN.js → AgentLibrary-B2ie8bbw.js} +2 -2
  2. package/dist/assets/{DataModel-Bf0EIE_t.js → DataModel-BIJPYw32.js} +1 -1
  3. package/dist/assets/{History-CpdtWhC8.js → History-CeY6dk9S.js} +2 -2
  4. package/dist/assets/{Hooks-DyUbMDmg.js → Hooks-BFH2ocKg.js} +2 -2
  5. package/dist/assets/{HostBilko-By-wIpry.js → HostBilko-36gj9wLz.js} +1 -1
  6. package/dist/assets/{Library-CQmo4QVC.js → Library-C-hBct39.js} +1 -1
  7. package/dist/assets/{ListDetail-BQMd6NOm.js → ListDetail-CNq64VWV.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-DEp43FXX.js → MarkdownEditor-Bh3qt5-1.js} +1 -1
  9. package/dist/assets/{McpServers-CLarzwqA.js → McpServers-DpGN0oyz.js} +1 -1
  10. package/dist/assets/{Memory-B0sCdIy1.js → Memory-D59hUjC4.js} +6 -6
  11. package/dist/assets/{Panel-BhWPVOCD.js → Panel-DCgbaoci.js} +1 -1
  12. package/dist/assets/{Permissions-Ddlq8T_O.js → Permissions-DAmQ0DYV.js} +2 -2
  13. package/dist/assets/{Plugins-D2oA_2Jl.js → Plugins-Dyfgn6Is.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-DgAgavUM.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-C_zWEtIl.js → ScopeSwitcher-P4mdLGNU.js} +1 -1
  18. package/dist/assets/{Settings-2Vx3X5SI.js → Settings-BL4vf5aX.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-BDEUjlTQ.js → SkillReferenceGraph-BRBDyi1_.js} +1 -1
  20. package/dist/assets/{Skills-Cmrz_LeN.js → Skills-BV08gDUH.js} +2 -2
  21. package/dist/assets/{SystemPrompt-DVA1eYDP.js → SystemPrompt-CLftSsDw.js} +1 -1
  22. package/dist/assets/TagLibrary-Bp8jGsd5.js +1 -0
  23. package/dist/assets/{TiptapBody-DmPc3amD.js → TiptapBody-jCpuB6E5.js} +1 -1
  24. package/dist/assets/{Toggle-zfd5LJkK.js → Toggle-D2paA1xf.js} +1 -1
  25. package/dist/assets/{index-B_4PNh9T.js → index-BDRSqBl3.js} +175 -175
  26. package/dist/assets/{index-DIjnPkRN.css → index-CYhdtisq.css} +1 -1
  27. package/dist/assets/{settingsSchema-B9es6fdA.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/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 +8 -1
  63. package/src/main/ipcSchemas.cjs +51 -0
  64. package/src/main/lib/__tests__/childWithLog.test.cjs +78 -0
  65. package/src/main/lib/__tests__/delegationReadiness.test.cjs +152 -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 +118 -2
  119. package/src/preload/index.cjs +7 -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,222 @@
1
+ /**
2
+ * scheduler-quiet-machine-lease.test.cjs — PRD 1107: the exclusive
3
+ * quiet-machine lease must be released on EVERY spawnJob exit path,
4
+ * including a killed job (SIGKILL/exit 137), not just a clean exit — the
5
+ * same finally block that already releases the sessionSlots token. Drives
6
+ * spawnJob end-to-end against a real git fixture repo with a stub `claude`
7
+ * binary that exits 137 mid-run (same pattern as
8
+ * scheduler-inplace-salvage.test.cjs), then asserts:
9
+ * 1. the lease is released once the job settles, and
10
+ * 2. an ordinary pending job — held behind the lease while it was held —
11
+ * is now eligible to dispatch.
12
+ *
13
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-quiet-machine-lease.test.cjs
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ import { test, expect, beforeAll, afterAll, afterEach } from 'vitest';
19
+ const fs = require('node:fs');
20
+ const os = require('node:os');
21
+ const path = require('node:path');
22
+ const { execFileSync } = require('node:child_process');
23
+
24
+ let tmpHome;
25
+ let originalHome;
26
+ let originalDisable;
27
+ let spawnJob;
28
+ let quietMachineLease;
29
+ let pickNextBatch;
30
+
31
+ function git(args, cwd) {
32
+ return execFileSync('git', args, { cwd, encoding: 'utf8' });
33
+ }
34
+
35
+ function initRepo(dir) {
36
+ fs.mkdirSync(dir, { recursive: true });
37
+ git(['init', '-q'], dir);
38
+ git(['config', 'user.email', 'test@example.com'], dir);
39
+ git(['config', 'user.name', 'Test'], dir);
40
+ fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
41
+ git(['add', '-A'], dir);
42
+ git(['commit', '-q', '-m', 'initial'], dir);
43
+ }
44
+
45
+ // See scheduler-inplace-salvage.test.cjs's identical helper: readQueue()'s
46
+ // per-project discovery walks ~/.claude/projects/*/*.jsonl transcripts for a
47
+ // `cwd` field to find known project queue.json shards.
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 that exits 137 (SIGKILL) with no result:success event,
63
+ // simulating a leased job killed mid-run before it could finish and release
64
+ // naturally through its own clean-exit path. Dirties the tree first (like
65
+ // scheduler-inplace-salvage.test.cjs's stub) so the scheduler classifies this
66
+ // as a genuine kill-with-partial-work, not an auto-retryable transient blip
67
+ // with no evidence of real execution.
68
+ function writeKilledClaudeStub() {
69
+ const stubPath = path.join(os.tmpdir(), `sm-claude-stub-quiet-killed-${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(), 'job-output.txt'), 'work in progress when killed\\n', 'utf8');
74
+ process.exit(137);
75
+ `;
76
+ fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
77
+ return stubPath;
78
+ }
79
+
80
+ let reapDeadRunningJobs;
81
+
82
+ beforeAll(() => {
83
+ originalHome = process.env.HOME;
84
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-quiet-lease-'));
85
+ process.env.HOME = tmpHome;
86
+ originalDisable = process.env.SM_JOB_WORKTREE_DISABLE;
87
+ process.env.SM_JOB_WORKTREE_DISABLE = '1';
88
+ ({ spawnJob, reapDeadRunningJobs } = require('../scheduler.cjs'));
89
+ ({ pickNextBatch } = require('../lib/schedulerBatch.cjs'));
90
+ quietMachineLease = require('../lib/quietMachineLease.cjs');
91
+ });
92
+
93
+ afterAll(() => {
94
+ process.env.HOME = originalHome;
95
+ if (originalDisable === undefined) delete process.env.SM_JOB_WORKTREE_DISABLE;
96
+ else process.env.SM_JOB_WORKTREE_DISABLE = originalDisable;
97
+ fs.rmSync(tmpHome, { recursive: true, force: true });
98
+ });
99
+
100
+ afterEach(() => {
101
+ delete process.env.SM_CLAUDE_BIN;
102
+ quietMachineLease.__resetForTests();
103
+ });
104
+
105
+ test('a killed quietMachine job releases the exclusive lease, and an ordinary job dispatches on the next pick', async () => {
106
+ const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-quiet-lease-project-'));
107
+ initRepo(projectCwd);
108
+ registerActiveProject(projectCwd);
109
+
110
+ const quietSlug = `1107-test-quiet-killed-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
111
+ const ordinarySlug = `1107-test-ordinary-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
112
+ const prdsDir = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'prds');
113
+ fs.mkdirSync(prdsDir, { recursive: true });
114
+ fs.writeFileSync(path.join(prdsDir, `${quietSlug}.md`), '---\nquietMachine: true\n---\nMeasure timing without contention, then get killed.', 'utf8');
115
+ fs.writeFileSync(path.join(prdsDir, `${ordinarySlug}.md`), 'An ordinary PRD queued behind the quiet job.', 'utf8');
116
+
117
+ const queuePath = writeProjectQueue(projectCwd, [
118
+ { slug: quietSlug, status: 'pending', cwd: projectCwd, quietMachine: true },
119
+ { slug: ordinarySlug, status: 'pending', cwd: projectCwd, createdAt: new Date().toISOString() },
120
+ ]);
121
+
122
+ process.env.SM_CLAUDE_BIN = writeKilledClaudeStub();
123
+
124
+ const runId = `run-${quietSlug}`;
125
+ const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
126
+ fs.mkdirSync(runDir, { recursive: true });
127
+
128
+ try {
129
+ // Before spawnJob ever runs, demonstrate that a held lease (as it would
130
+ // be while quietSlug is genuinely running) blocks EVERY dispatch,
131
+ // including the ordinary sibling job sitting right behind it in the same
132
+ // project. Uses a standalone dummy holder so it doesn't interact with
133
+ // spawnJob's own acquire/release of the real lease below.
134
+ expect(quietMachineLease.acquire('dummy-holder-simulating-in-flight-run')).toBe(true);
135
+ const heldPick = pickNextBatch(
136
+ [
137
+ { slug: quietSlug, status: 'running', cwd: projectCwd, quietMachine: true },
138
+ { slug: ordinarySlug, status: 'pending', cwd: projectCwd, createdAt: new Date().toISOString() },
139
+ ],
140
+ new Set([quietSlug]),
141
+ 5,
142
+ { leaseHeld: quietMachineLease.isHeld(), machineInUse: 1, now: Date.now() },
143
+ );
144
+ expect(heldPick.batch).toEqual([]);
145
+ quietMachineLease.release('dummy-holder-simulating-in-flight-run');
146
+
147
+ // Now the real thing: spawnJob acquires the lease itself for quietSlug,
148
+ // runs the killed stub, and must release it on this exit path.
149
+ await spawnJob({ slug: quietSlug, cwd: projectCwd, quietMachine: true }, runId, runDir, projectCwd);
150
+
151
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
152
+ const quietRow = jobs.find((j) => j.slug === quietSlug);
153
+ expect(quietRow).toBeTruthy();
154
+ expect(quietRow.exitCode).toBe(137);
155
+ expect(quietRow.quietMachine).toBe(true);
156
+
157
+ // The lease was released on this killed-job exit path — spawnJob's own
158
+ // finally block, the same one that releases the sessionSlots token.
159
+ expect(quietMachineLease.isHeld()).toBe(false);
160
+
161
+ // With the lease free again, the ordinary sibling job is now eligible.
162
+ const nextPick = pickNextBatch(
163
+ [
164
+ { slug: quietSlug, status: 'failed', cwd: projectCwd, quietMachine: true },
165
+ { slug: ordinarySlug, status: 'pending', cwd: projectCwd, createdAt: new Date().toISOString() },
166
+ ],
167
+ new Set(),
168
+ 5,
169
+ { leaseHeld: quietMachineLease.isHeld(), machineInUse: 0, now: Date.now() },
170
+ );
171
+ expect(nextPick.batch.map((j) => j.slug)).toEqual([ordinarySlug]);
172
+ } finally {
173
+ fs.rmSync(projectCwd, { recursive: true, force: true });
174
+ fs.rmSync(runDir, { recursive: true, force: true });
175
+ }
176
+ }, 30_000);
177
+
178
+ test('reapDeadRunningJobs releases a quietMachine job\'s lease too — the process vanished without spawnJob\'s own finally ever running', async () => {
179
+ // This reaper exists exactly for the case spawnJob's own finally never
180
+ // runs (the process dies without the completion mutate() finishing) — see
181
+ // reapDeadRunningJobs's header comment. A quietMachine row reaped through
182
+ // THIS path must release the lease itself, or it stays wedged forever.
183
+ const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-quiet-lease-reap-project-'));
184
+ registerActiveProject(projectCwd);
185
+
186
+ const slug = `1107-test-reaped-quiet-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
187
+ const queuePath = writeProjectQueue(projectCwd, [
188
+ {
189
+ slug,
190
+ status: 'running',
191
+ cwd: projectCwd,
192
+ quietMachine: true,
193
+ runId: `run-${slug}`,
194
+ runtime: { pid: 999999 }, // guaranteed-dead pid (see reaperHelpers tests)
195
+ },
196
+ ]);
197
+ const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', `run-${slug}`);
198
+ fs.mkdirSync(runDir, { recursive: true });
199
+ fs.writeFileSync(
200
+ path.join(runDir, `${slug}.log`),
201
+ '{"type":"result","subtype":"success","result":"done","is_error":false}\n',
202
+ );
203
+
204
+ // Simulate the lease as held by this row, exactly as it would be if
205
+ // spawnJob's own acquire ran but its finally never got the chance to.
206
+ expect(quietMachineLease.acquire(slug)).toBe(true);
207
+
208
+ try {
209
+ await reapDeadRunningJobs();
210
+
211
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
212
+ const row = jobs.find((j) => j.slug === slug);
213
+ expect(row).toBeTruthy();
214
+ expect(row.status).toBe('completed');
215
+
216
+ // The lease must be released here too, not just in spawnJob's finally.
217
+ expect(quietMachineLease.isHeld()).toBe(false);
218
+ } finally {
219
+ fs.rmSync(projectCwd, { recursive: true, force: true });
220
+ fs.rmSync(runDir, { recursive: true, force: true });
221
+ }
222
+ });
@@ -32,12 +32,34 @@ const assert = require('node:assert/strict');
32
32
  const fs = require('node:fs');
33
33
  const os = require('node:os');
34
34
  const path = require('node:path');
35
+ const { execFileSync } = require('node:child_process');
35
36
 
36
37
  const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'reap-dead-running-jobs-test-'));
37
38
  process.env.HOME = tmpHome;
38
39
 
39
40
  const { reapDeadRunningJobs, PIDLESS_SPAWN_GRACE_MS } = require('../scheduler.cjs');
40
41
  const { AUDIT_LOG_PATH } = require('../lib/auditLog.cjs');
42
+ // queueStore's cwd discovery is cached for 30s (queueStore.cjs's CACHE_MS) —
43
+ // a project registered by THIS test after an earlier test already populated
44
+ // that cache (and didn't itself bust it, e.g. because it found nothing
45
+ // reapable and returned before ever calling writeQueue) would otherwise be
46
+ // invisible to readQueue() for the rest of that window. Bust explicitly
47
+ // after registering a new project so each test sees its own fixture.
48
+ const { bustCwdCache } = require('../lib/queueStore.cjs');
49
+
50
+ function git(args, cwd) {
51
+ return execFileSync('git', args, { cwd, encoding: 'utf8' });
52
+ }
53
+
54
+ function initRepo(dir) {
55
+ fs.mkdirSync(dir, { recursive: true });
56
+ git(['init', '-q'], dir);
57
+ git(['config', 'user.email', 'test@example.com'], dir);
58
+ git(['config', 'user.name', 'Test'], dir);
59
+ fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
60
+ git(['add', '-A'], dir);
61
+ git(['commit', '-q', '-m', 'initial'], dir);
62
+ }
41
63
 
42
64
  function registerActiveProject(cwd) {
43
65
  const projectsDir = path.join(tmpHome, '.claude', 'projects');
@@ -86,6 +108,7 @@ test('reapDeadRunningJobs reconciles a queue.json row stuck at status:running wi
86
108
  assert.equal(jobs[0].status, 'completed', 'job must be reconciled from disk state, not skipped via the runningSet gate');
87
109
  assert.equal(jobs[0].exitCode, 0);
88
110
  assert.equal(jobs[0].runtime, undefined);
111
+ assert.equal(jobs[0].gateOutcome, 'passed', 'a success result event maps to gateOutcome:passed');
89
112
  });
90
113
 
91
114
  test('reapDeadRunningJobs reaps a pidless row older than PIDLESS_SPAWN_GRACE_MS with an empty run dir as failed, not completed, and audits it', async () => {
@@ -117,6 +140,7 @@ test('reapDeadRunningJobs reaps a pidless row older than PIDLESS_SPAWN_GRACE_MS
117
140
  assert.equal(jobs[0].status, 'failed', 'an empty run dir must never be reaped as completed');
118
141
  assert.match(jobs[0].error, /no runtime\.pid recorded/);
119
142
  assert.equal(jobs[0].runtime, undefined);
143
+ assert.equal(jobs[0].gateOutcome, 'never_ran', 'a pidless reap means the gate never had a chance to run');
120
144
 
121
145
  const auditText = fs.readFileSync(AUDIT_LOG_PATH, 'utf8').slice(auditSizeBefore);
122
146
  const auditLines = auditText.trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
@@ -160,3 +184,126 @@ test('reapDeadRunningJobs is a no-op when no job is actually running in queue.js
160
184
  assert.equal(jobs.length, 1);
161
185
  assert.equal(jobs[0].status, 'completed');
162
186
  });
187
+
188
+ test('reapDeadRunningJobs salvages a delta-scoped in-place patch (PRD 1098) when the row carries a persisted guardBaseline', async () => {
189
+ const projectCwd = path.join(tmpHome, 'e-project');
190
+ initRepo(projectCwd);
191
+ registerActiveProject(projectCwd);
192
+
193
+ // Pre-existing WIP present at the recorded pre-run baseline — must never
194
+ // appear in the salvaged patch.
195
+ fs.writeFileSync(path.join(projectCwd, 'human-wip.txt'), 'human wip\n', 'utf8');
196
+
197
+ const runId = 'run-vanished-inplace';
198
+ const queuePath = writeProjectQueue(projectCwd, [
199
+ {
200
+ slug: 'vanished-inplace',
201
+ status: 'running',
202
+ cwd: projectCwd,
203
+ runId,
204
+ runtime: { pid: 999999 }, // guaranteed-dead pid
205
+ // Includes the untracked session-manager-operations/ dir itself (git
206
+ // status --porcelain reports an untracked DIRECTORY, not the file
207
+ // inside it) — writeProjectQueue below writes queue.json INSIDE this
208
+ // same git repo, so it's part of the true pre-run baseline exactly
209
+ // like human-wip.txt.
210
+ guardBaseline: ['human-wip.txt', 'session-manager-operations/'],
211
+ },
212
+ ]);
213
+ const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
214
+ fs.mkdirSync(runDir, { recursive: true });
215
+ fs.writeFileSync(path.join(runDir, 'vanished-inplace.log'), '{"type":"result","subtype":"success","result":"done","is_error":false}\n');
216
+
217
+ // The job's own delta, dirtied before its process vanished.
218
+ fs.writeFileSync(path.join(projectCwd, 'README.md'), 'hello\nedited by job\n', 'utf8');
219
+ fs.writeFileSync(path.join(projectCwd, 'job-output.txt'), 'job work\n', 'utf8');
220
+
221
+ bustCwdCache();
222
+ await reapDeadRunningJobs();
223
+
224
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
225
+ const row = jobs.find((j) => j.slug === 'vanished-inplace');
226
+ assert.equal(row.status, 'completed');
227
+ assert.ok(row.salvagePatch, 'a salvage patch must be recorded on the row');
228
+ assert.ok(fs.existsSync(row.salvagePatch));
229
+ const patch = fs.readFileSync(row.salvagePatch, 'utf8');
230
+ assert.match(patch, /README\.md/);
231
+ assert.match(patch, /edited by job/);
232
+ assert.match(patch, /job-output\.txt/);
233
+ assert.doesNotMatch(patch, /human wip/, 'baseline WIP must never leak into the salvaged patch');
234
+ assert.equal(row.leftoverCount, 2, 'the reaper must attribute the job\'s own delta (README.md + job-output.txt), never the baseline WIP');
235
+ assert.deepEqual(new Set(row.leftoverPaths), new Set(['README.md', 'job-output.txt']));
236
+ });
237
+
238
+ test('reapDeadRunningJobs: a failed reap with a persisted guardBaseline names the leftover count in its transition reason and error', async () => {
239
+ const projectCwd = path.join(tmpHome, 'g-project');
240
+ initRepo(projectCwd);
241
+ registerActiveProject(projectCwd);
242
+
243
+ const runId = 'run-vanished-failed';
244
+ const queuePath = writeProjectQueue(projectCwd, [
245
+ {
246
+ slug: 'vanished-failed',
247
+ status: 'running',
248
+ cwd: projectCwd,
249
+ runId,
250
+ runtime: { pid: 999999 },
251
+ // Includes the untracked session-manager-operations/ dir itself — see
252
+ // the comment on the salvage test above for why.
253
+ guardBaseline: ['session-manager-operations/'],
254
+ },
255
+ ]);
256
+ // Empty run dir → classifyRunOutcome finds no result event → 'no_result' → failed.
257
+ fs.mkdirSync(path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId), { recursive: true });
258
+
259
+ fs.writeFileSync(path.join(projectCwd, 'partial-work.txt'), 'unfinished\n', 'utf8');
260
+
261
+ bustCwdCache();
262
+ await reapDeadRunningJobs();
263
+
264
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
265
+ const row = jobs.find((j) => j.slug === 'vanished-failed');
266
+ assert.equal(row.status, 'failed');
267
+ assert.equal(row.leftoverCount, 1);
268
+ assert.deepEqual(row.leftoverPaths, ['partial-work.txt']);
269
+ assert.match(row.error, /left 1 files uncommitted/, 'the leftover count must be visible in the error string, not just a separate field');
270
+ const lastTransition = row.statusHistory[row.statusHistory.length - 1];
271
+ assert.match(lastTransition.reason, /left 1 files uncommitted/);
272
+ });
273
+
274
+ test('reapDeadRunningJobs skips in-place salvage (no whole-tree dump) when the row has no persisted guardBaseline', async () => {
275
+ const projectCwd = path.join(tmpHome, 'f-project');
276
+ initRepo(projectCwd);
277
+ registerActiveProject(projectCwd);
278
+
279
+ fs.writeFileSync(path.join(projectCwd, 'human-wip.txt'), 'human wip\n', 'utf8');
280
+ fs.writeFileSync(path.join(projectCwd, 'README.md'), 'hello\nedited by job\n', 'utf8');
281
+
282
+ const runId = 'run-vanished-no-baseline';
283
+ const queuePath = writeProjectQueue(projectCwd, [
284
+ {
285
+ slug: 'vanished-no-baseline',
286
+ status: 'running',
287
+ cwd: projectCwd,
288
+ runId,
289
+ runtime: { pid: 999999 },
290
+ // No guardBaseline field — simulates a row whose owning process
291
+ // vanished before spawnJob's dispatch-time persist-mutate ever ran, so
292
+ // there's no safe way to distinguish this job's own dirt from the
293
+ // human's pre-existing WIP above.
294
+ },
295
+ ]);
296
+ const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
297
+ fs.mkdirSync(runDir, { recursive: true });
298
+ fs.writeFileSync(path.join(runDir, 'vanished-no-baseline.log'), '{"type":"result","subtype":"success","result":"done","is_error":false}\n');
299
+
300
+ bustCwdCache();
301
+ await reapDeadRunningJobs();
302
+
303
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
304
+ const row = jobs.find((j) => j.slug === 'vanished-no-baseline');
305
+ assert.equal(row.status, 'completed');
306
+ assert.equal(row.salvagePatch, undefined, 'must skip salvage entirely rather than ever dumping the whole tree');
307
+ assert.equal(row.leftoverPaths, undefined, 'no baseline means no safe attribution — must not guess');
308
+ assert.equal(row.leftoverCount, undefined);
309
+ });
@@ -0,0 +1,212 @@
1
+ /**
2
+ * scheduler-shared-tree-guard.test.cjs — the shared-tree stash guard added
3
+ * for the 2026-09-01 social-signals-trader incident: a headless PRD executor
4
+ * ran a blanket `git stash` in a working tree shared with a live trading
5
+ * service, silently reverting an uncommitted operator config edit with no
6
+ * error anywhere.
7
+ *
8
+ * evaluateSharedTreeGuard is the pure diff (no I/O); checkSharedTreeGuard is
9
+ * the orchestrator that also restores a single unambiguous stash, using the
10
+ * same module.exports spy pattern computeCommittedDuringRun/committedInWindow
11
+ * use so the underlying git calls never actually run.
12
+ *
13
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-shared-tree-guard.test.cjs
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ import { test, expect, vi, afterEach } from 'vitest';
19
+ const scheduler = require('../scheduler.cjs');
20
+ const { evaluateSharedTreeGuard, checkSharedTreeGuard } = scheduler;
21
+
22
+ afterEach(() => {
23
+ vi.restoreAllMocks();
24
+ });
25
+
26
+ test('evaluateSharedTreeGuard: no-op when nothing changed', () => {
27
+ const result = evaluateSharedTreeGuard({
28
+ stashBefore: ['aaa stash@{0} WIP on main: aaa old'],
29
+ stashAfter: ['aaa stash@{0} WIP on main: aaa old'],
30
+ dirtyBefore: ['data/spread_config.json'],
31
+ dirtyAfter: ['data/spread_config.json'],
32
+ pathsCommittedDuringRun: [],
33
+ });
34
+ expect(result.newStashes).toEqual([]);
35
+ expect(result.reverted).toEqual([]);
36
+ });
37
+
38
+ test('evaluateSharedTreeGuard: flags one new stash entry not present in the baseline', () => {
39
+ const result = evaluateSharedTreeGuard({
40
+ stashBefore: [],
41
+ stashAfter: ['bbb stash@{0} WIP on fix/x: bbb f5ae98f'],
42
+ dirtyBefore: [],
43
+ dirtyAfter: [],
44
+ pathsCommittedDuringRun: [],
45
+ });
46
+ expect(result.newStashes).toEqual([{ hash: 'bbb', ref: 'stash@{0}', subject: 'WIP on fix/x: bbb f5ae98f' }]);
47
+ });
48
+
49
+ test('evaluateSharedTreeGuard: two new stash entries both surface (caller decides ambiguity, not this function)', () => {
50
+ const result = evaluateSharedTreeGuard({
51
+ stashBefore: [],
52
+ stashAfter: [
53
+ 'bbb stash@{0} WIP on fix/x: bbb newest',
54
+ 'ccc stash@{1} WIP on fix/x: ccc older',
55
+ ],
56
+ dirtyBefore: [],
57
+ dirtyAfter: [],
58
+ pathsCommittedDuringRun: [],
59
+ });
60
+ expect(result.newStashes).toHaveLength(2);
61
+ expect(result.newStashes.map((e) => e.ref)).toEqual(['stash@{0}', 'stash@{1}']);
62
+ });
63
+
64
+ test('evaluateSharedTreeGuard: baseline-dirty path clean after the run with no commit to explain it is flagged reverted', () => {
65
+ const result = evaluateSharedTreeGuard({
66
+ stashBefore: [],
67
+ stashAfter: [],
68
+ dirtyBefore: ['data/spread_config.json'],
69
+ dirtyAfter: [],
70
+ pathsCommittedDuringRun: [],
71
+ });
72
+ expect(result.reverted).toEqual(['data/spread_config.json']);
73
+ });
74
+
75
+ test('evaluateSharedTreeGuard: baseline-dirty path clean after the run but touched by a commit made during the run is NOT flagged', () => {
76
+ const result = evaluateSharedTreeGuard({
77
+ stashBefore: [],
78
+ stashAfter: [],
79
+ dirtyBefore: ['src/foo.js'],
80
+ dirtyAfter: [],
81
+ pathsCommittedDuringRun: ['src/foo.js'],
82
+ });
83
+ expect(result.reverted).toEqual([]);
84
+ });
85
+
86
+ test('checkSharedTreeGuard: no-op when nothing changed returns null', async () => {
87
+ vi.spyOn(scheduler, 'stashList').mockResolvedValue([]);
88
+ vi.spyOn(scheduler, 'uncommittedChanges').mockResolvedValue([]);
89
+ vi.spyOn(scheduler, 'gitHead').mockResolvedValue('sha1');
90
+ vi.spyOn(scheduler, 'pathsChangedSince').mockResolvedValue([]);
91
+ const restoreSpy = vi.spyOn(scheduler, 'restoreSpecificStash');
92
+
93
+ const result = await checkSharedTreeGuard({
94
+ cwd: '/repo',
95
+ stashBaseline: [],
96
+ dirtyBaseline: [],
97
+ headBefore: 'sha1',
98
+ slug: 'job-1',
99
+ });
100
+ expect(result).toBeNull();
101
+ expect(restoreSpy).not.toHaveBeenCalled();
102
+ });
103
+
104
+ test('checkSharedTreeGuard: a single new stash is restored via apply+drop of that specific ref', async () => {
105
+ vi.spyOn(scheduler, 'stashList').mockResolvedValue(['newhash stash@{0} WIP on main: f5ae98f']);
106
+ vi.spyOn(scheduler, 'uncommittedChanges').mockResolvedValue([]);
107
+ vi.spyOn(scheduler, 'gitHead').mockResolvedValue('sha1');
108
+ vi.spyOn(scheduler, 'pathsChangedSince').mockResolvedValue([]);
109
+ const restoreSpy = vi.spyOn(scheduler, 'restoreSpecificStash').mockResolvedValue({ ok: true });
110
+
111
+ const result = await checkSharedTreeGuard({
112
+ cwd: '/repo',
113
+ stashBaseline: [],
114
+ dirtyBaseline: [],
115
+ headBefore: 'sha1',
116
+ slug: 'job-1',
117
+ });
118
+ expect(restoreSpy).toHaveBeenCalledWith('/repo', 'stash@{0}');
119
+ expect(result).toEqual({ restoredStash: 'stash@{0}' });
120
+ });
121
+
122
+ test('checkSharedTreeGuard: a baseline-dirty path stashed by the job and successfully restored is not ALSO flagged reverted', async () => {
123
+ vi.spyOn(scheduler, 'stashList').mockResolvedValue(['newhash stash@{0} WIP on main: f5ae98f']);
124
+ vi.spyOn(scheduler, 'gitHead').mockResolvedValue('sha1');
125
+ vi.spyOn(scheduler, 'pathsChangedSince').mockResolvedValue([]);
126
+ vi.spyOn(scheduler, 'restoreSpecificStash').mockResolvedValue({ ok: true });
127
+ // uncommittedChanges is queried AFTER the restore attempt in the fixed
128
+ // implementation — simulate the stashed file reappearing dirty once the
129
+ // apply+drop succeeds (this is the exact 2026-09-01 incident shape: an
130
+ // operator's pre-existing dirty file gets swept into the job's stash).
131
+ vi.spyOn(scheduler, 'uncommittedChanges').mockResolvedValue(['data/spread_config.json']);
132
+
133
+ const result = await checkSharedTreeGuard({
134
+ cwd: '/repo',
135
+ stashBaseline: [],
136
+ dirtyBaseline: ['data/spread_config.json'],
137
+ headBefore: 'sha1',
138
+ slug: 'job-1',
139
+ });
140
+ expect(result).toEqual({ restoredStash: 'stash@{0}' });
141
+ });
142
+
143
+ test('checkSharedTreeGuard: two new stashes are reported, not restored (ambiguous)', async () => {
144
+ vi.spyOn(scheduler, 'stashList').mockResolvedValue([
145
+ 'aaa stash@{0} WIP on main: newest',
146
+ 'bbb stash@{1} WIP on main: older',
147
+ ]);
148
+ vi.spyOn(scheduler, 'uncommittedChanges').mockResolvedValue([]);
149
+ vi.spyOn(scheduler, 'gitHead').mockResolvedValue('sha1');
150
+ vi.spyOn(scheduler, 'pathsChangedSince').mockResolvedValue([]);
151
+ const restoreSpy = vi.spyOn(scheduler, 'restoreSpecificStash');
152
+
153
+ const result = await checkSharedTreeGuard({
154
+ cwd: '/repo',
155
+ stashBaseline: [],
156
+ dirtyBaseline: [],
157
+ headBefore: 'sha1',
158
+ slug: 'job-1',
159
+ });
160
+ expect(restoreSpy).not.toHaveBeenCalled();
161
+ expect(result.ambiguousStashes).toEqual(['stash@{0}', 'stash@{1}']);
162
+ expect(result.restoredStash).toBeUndefined();
163
+ });
164
+
165
+ test('checkSharedTreeGuard: a baseline-dirty path reverted with no stash and no commit is flagged reverted', async () => {
166
+ vi.spyOn(scheduler, 'stashList').mockResolvedValue([]);
167
+ vi.spyOn(scheduler, 'uncommittedChanges').mockResolvedValue([]);
168
+ vi.spyOn(scheduler, 'gitHead').mockResolvedValue('sha1');
169
+ vi.spyOn(scheduler, 'pathsChangedSince').mockResolvedValue([]);
170
+
171
+ const result = await checkSharedTreeGuard({
172
+ cwd: '/repo',
173
+ stashBaseline: [],
174
+ dirtyBaseline: ['data/spread_config.json'],
175
+ headBefore: 'sha1',
176
+ slug: 'job-1',
177
+ });
178
+ expect(result).toEqual({ reverted: ['data/spread_config.json'] });
179
+ });
180
+
181
+ test('checkSharedTreeGuard: a failed restore is reported as restoreFailed, entry left in place', async () => {
182
+ vi.spyOn(scheduler, 'stashList').mockResolvedValue(['newhash stash@{0} WIP on main: f5ae98f']);
183
+ vi.spyOn(scheduler, 'uncommittedChanges').mockResolvedValue([]);
184
+ vi.spyOn(scheduler, 'gitHead').mockResolvedValue('sha1');
185
+ vi.spyOn(scheduler, 'pathsChangedSince').mockResolvedValue([]);
186
+ vi.spyOn(scheduler, 'restoreSpecificStash').mockResolvedValue({ ok: false, error: 'conflict applying stash' });
187
+
188
+ const result = await checkSharedTreeGuard({
189
+ cwd: '/repo',
190
+ stashBaseline: [],
191
+ dirtyBaseline: [],
192
+ headBefore: 'sha1',
193
+ slug: 'job-1',
194
+ });
195
+ expect(result.restoreFailed).toMatch(/stash@\{0\}.*conflict applying stash/);
196
+ });
197
+
198
+ test('checkSharedTreeGuard never throws when the underlying git calls reject', async () => {
199
+ vi.spyOn(scheduler, 'stashList').mockRejectedValue(new Error('boom'));
200
+ vi.spyOn(scheduler, 'uncommittedChanges').mockResolvedValue([]);
201
+ vi.spyOn(scheduler, 'gitHead').mockResolvedValue('sha1');
202
+ vi.spyOn(scheduler, 'pathsChangedSince').mockResolvedValue([]);
203
+
204
+ const result = await checkSharedTreeGuard({
205
+ cwd: '/repo',
206
+ stashBaseline: [],
207
+ dirtyBaseline: [],
208
+ headBefore: 'sha1',
209
+ slug: 'job-1',
210
+ });
211
+ expect(result).toBeNull();
212
+ });