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,179 @@
1
+ /**
2
+ * activeIndexRebuild.test.cjs — rebuildActiveIndex reconstructs
3
+ * active-index.json's `sessions` map from the per-Epic status mirrors
4
+ * epicStatusMirror.cjs writes, honoring tombstones and archivedAt exactly
5
+ * the way the domain model requires (never resurrecting either).
6
+ *
7
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/activeIndexRebuild.test.cjs
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ import { test, expect, afterEach } from 'vitest';
13
+ const fs = require('node:fs');
14
+ const fsp = require('node:fs/promises');
15
+ const os = require('node:os');
16
+ const path = require('node:path');
17
+ const { rebuildActiveIndex } = require('../activeIndexRebuild.cjs');
18
+ const { activeIndexPath, readActiveIndex } = require('../epicMint.cjs');
19
+
20
+ const tmpDirs = [];
21
+ afterEach(async () => {
22
+ while (tmpDirs.length) {
23
+ const d = tmpDirs.pop();
24
+ await fsp.rm(d, { recursive: true, force: true });
25
+ }
26
+ });
27
+
28
+ async function mkCwd() {
29
+ const cwd = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-rebuild-cwd-'));
30
+ tmpDirs.push(cwd);
31
+ return cwd;
32
+ }
33
+
34
+ function promptSessionsDir(cwd) {
35
+ return path.join(cwd, 'session-manager-operations', 'prompt-sessions');
36
+ }
37
+
38
+ function writeMirrorFile(cwd, id, data) {
39
+ const dir = promptSessionsDir(cwd);
40
+ fs.mkdirSync(dir, { recursive: true });
41
+ fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify(data, null, 2));
42
+ }
43
+
44
+ function writeIndex(cwd, index) {
45
+ const dir = promptSessionsDir(cwd);
46
+ fs.mkdirSync(dir, { recursive: true });
47
+ fs.writeFileSync(path.join(dir, 'active-index.json'), JSON.stringify(index, null, 2));
48
+ }
49
+
50
+ function liveMirror(id, cwd, overrides = {}) {
51
+ return {
52
+ id,
53
+ cwd,
54
+ goalText: `goal for ${id}`,
55
+ claudeSessionId: `sess-${id}`,
56
+ status: 'proposed',
57
+ createdAt: '2026-01-01T00:00:00.000Z',
58
+ completedAt: null,
59
+ archivedAt: null,
60
+ indexedAt: '2026-01-01T00:00:01.000Z',
61
+ ...overrides,
62
+ };
63
+ }
64
+
65
+ test('restores a row for every mirrored file with a live status', async () => {
66
+ const cwd = await mkCwd();
67
+ writeMirrorFile(cwd, 'e-1', liveMirror('e-1', cwd, { status: 'proposed' }));
68
+ writeMirrorFile(cwd, 'e-2', liveMirror('e-2', cwd, { status: 'active' }));
69
+
70
+ const { rows, skipped, reasons } = await rebuildActiveIndex(cwd);
71
+
72
+ expect(rows.map((r) => r.id).sort()).toEqual(['e-1', 'e-2']);
73
+ expect(skipped).toEqual([]);
74
+ expect(reasons).toEqual({});
75
+
76
+ const onDisk = readActiveIndex(cwd);
77
+ expect(onDisk.sessions['e-1'].status).toBe('proposed');
78
+ expect(onDisk.sessions['e-2'].status).toBe('active');
79
+ });
80
+
81
+ test('respects tombstones — a tombstoned id is never resurrected even if its file says active', async () => {
82
+ const cwd = await mkCwd();
83
+ writeIndex(cwd, { sessions: {}, events: {}, tombstones: { 'e-dead': '2026-01-02T00:00:00.000Z' } });
84
+ writeMirrorFile(cwd, 'e-dead', liveMirror('e-dead', cwd, { status: 'active' }));
85
+ writeMirrorFile(cwd, 'e-alive', liveMirror('e-alive', cwd, { status: 'proposed' }));
86
+
87
+ const { rows, skipped, reasons } = await rebuildActiveIndex(cwd);
88
+
89
+ expect(rows.map((r) => r.id)).toEqual(['e-alive']);
90
+ expect(skipped).toContain('e-dead.json');
91
+ expect(reasons['e-dead.json']).toMatch(/tombstoned/);
92
+
93
+ const onDisk = readActiveIndex(cwd);
94
+ expect(onDisk.sessions['e-dead']).toBeUndefined();
95
+ expect(onDisk.tombstones['e-dead']).toBe('2026-01-02T00:00:00.000Z');
96
+ });
97
+
98
+ test('respects archivedAt — a completed Epic file is never restored as a live row', async () => {
99
+ const cwd = await mkCwd();
100
+ writeMirrorFile(cwd, 'e-done', {
101
+ ...liveMirror('e-done', cwd, { status: 'completed' }),
102
+ archivedAt: '2026-01-03T00:00:00.000Z',
103
+ });
104
+ writeMirrorFile(cwd, 'e-open', liveMirror('e-open', cwd, { status: 'active' }));
105
+
106
+ const { rows, skipped, reasons } = await rebuildActiveIndex(cwd);
107
+
108
+ expect(rows.map((r) => r.id)).toEqual(['e-open']);
109
+ expect(skipped).toContain('e-done.json');
110
+ expect(reasons['e-done.json']).toMatch(/archivedAt/);
111
+ });
112
+
113
+ test('is idempotent — running it twice on a clean project yields a byte-identical index', async () => {
114
+ const cwd = await mkCwd();
115
+ writeMirrorFile(cwd, 'e-1', liveMirror('e-1', cwd));
116
+ writeMirrorFile(cwd, 'e-2', liveMirror('e-2', cwd, { status: 'active' }));
117
+
118
+ await rebuildActiveIndex(cwd);
119
+ const first = fs.readFileSync(activeIndexPath(cwd), 'utf8');
120
+
121
+ await rebuildActiveIndex(cwd);
122
+ const second = fs.readFileSync(activeIndexPath(cwd), 'utf8');
123
+
124
+ expect(second).toBe(first);
125
+ });
126
+
127
+ test('skips an unreadable Epic file, naming it in reasons, without aborting the rebuild', async () => {
128
+ const cwd = await mkCwd();
129
+ const dir = promptSessionsDir(cwd);
130
+ fs.mkdirSync(dir, { recursive: true });
131
+ fs.writeFileSync(path.join(dir, 'e-corrupt.json'), '{ this is not json');
132
+ writeMirrorFile(cwd, 'e-good', liveMirror('e-good', cwd));
133
+
134
+ const { rows, skipped, reasons } = await rebuildActiveIndex(cwd);
135
+
136
+ expect(rows.map((r) => r.id)).toEqual(['e-good']);
137
+ expect(skipped).toContain('e-corrupt.json');
138
+ expect(reasons['e-corrupt.json']).toMatch(/unreadable/);
139
+ });
140
+
141
+ test('a file with no mirrored status is skipped and listed with a reason, never guessed at', async () => {
142
+ const cwd = await mkCwd();
143
+ const dir = promptSessionsDir(cwd);
144
+ fs.mkdirSync(dir, { recursive: true });
145
+ fs.writeFileSync(path.join(dir, 'e-legacy.json'), JSON.stringify({ id: 'e-legacy', cwd }));
146
+
147
+ const { rows, skipped, reasons } = await rebuildActiveIndex(cwd);
148
+
149
+ expect(rows).toEqual([]);
150
+ expect(skipped).toContain('e-legacy.json');
151
+ expect(reasons['e-legacy.json']).toMatch(/no status mirror/);
152
+ });
153
+
154
+ test('preserves the existing events and tombstones maps untouched', async () => {
155
+ const cwd = await mkCwd();
156
+ const events = { 'e-1': [{ id: 'evt-1', promptSessionId: 'e-1', kind: 'prompt', causedByEventId: null, at: '2026-01-01T00:00:00.000Z', text: 'go' }] };
157
+ const tombstones = { 'e-old': '2026-01-01T00:00:00.000Z' };
158
+ writeIndex(cwd, { sessions: {}, events, tombstones });
159
+ writeMirrorFile(cwd, 'e-1', liveMirror('e-1', cwd));
160
+
161
+ await rebuildActiveIndex(cwd);
162
+
163
+ const onDisk = readActiveIndex(cwd);
164
+ expect(onDisk.events).toEqual(events);
165
+ expect(onDisk.tombstones).toEqual(tombstones);
166
+ });
167
+
168
+ test('dryRun previews without writing anything to disk', async () => {
169
+ const cwd = await mkCwd();
170
+ writeIndex(cwd, { sessions: {}, events: {}, tombstones: {} });
171
+ writeMirrorFile(cwd, 'e-1', liveMirror('e-1', cwd));
172
+
173
+ const before = fs.readFileSync(activeIndexPath(cwd), 'utf8');
174
+ const { rows } = await rebuildActiveIndex(cwd, { dryRun: true });
175
+ const after = fs.readFileSync(activeIndexPath(cwd), 'utf8');
176
+
177
+ expect(rows.map((r) => r.id)).toEqual(['e-1']);
178
+ expect(after).toBe(before);
179
+ });
@@ -0,0 +1,141 @@
1
+ // PRD 1065 — post-exit process-group sweep. A detached job's descendants must
2
+ // not outlive the job (2026-08-31 starry-night-ships incident: orphaned test
3
+ // batteries reparented to init after their job exited normally).
4
+ import { describe, it, expect, afterEach } from 'vitest';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+
9
+ const { openLog, withChildAndLog, POST_EXIT_GROUP_SWEEP_GRACE_MS } = require('../childWithLog.cjs');
10
+
11
+ let dir;
12
+
13
+ afterEach(() => {
14
+ if (dir) fs.rmSync(dir, { recursive: true, force: true });
15
+ dir = undefined;
16
+ });
17
+
18
+ describe('childWithLog post-exit group sweep (PRD 1065)', () => {
19
+ it.skipIf(process.platform !== 'linux')(
20
+ 'kills a detached child\'s backgrounded grandchild after exit + grace',
21
+ async () => {
22
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-child-sweep-'));
23
+ const logPath = path.join(dir, 'job.log');
24
+ const pidFile = path.join(dir, 'grandchild.pid');
25
+ const { fd, safeLog, closeFd } = openLog(logPath);
26
+
27
+ const exited = new Promise((resolve) => {
28
+ withChildAndLog({
29
+ fd,
30
+ logPath,
31
+ safeLog,
32
+ closeFd,
33
+ spawn: {
34
+ command: 'sh',
35
+ args: [
36
+ '-c',
37
+ `sleep 30 & echo $! > ${pidFile}; exec sleep 0.2`,
38
+ ],
39
+ options: { detached: true },
40
+ },
41
+ onExit: () => resolve(),
42
+ });
43
+ });
44
+
45
+ await exited;
46
+
47
+ // Grandchild pid file is written by a backgrounded subshell that may
48
+ // race the parent's own exit; give it a moment to land.
49
+ for (let i = 0; i < 20 && !fs.existsSync(pidFile); i++) {
50
+ await new Promise((r) => setTimeout(r, 50));
51
+ }
52
+ expect(fs.existsSync(pidFile)).toBe(true);
53
+ const grandchildPid = Number(fs.readFileSync(pidFile, 'utf8').trim());
54
+ expect(Number.isInteger(grandchildPid) && grandchildPid > 1).toBe(true);
55
+
56
+ // Wait past the SIGTERM->SIGKILL grace window.
57
+ await new Promise((r) => setTimeout(r, POST_EXIT_GROUP_SWEEP_GRACE_MS + 1000));
58
+
59
+ expect(() => process.kill(grandchildPid, 0)).toThrow();
60
+ },
61
+ 15000,
62
+ );
63
+ });
64
+
65
+ // PRD 1110 — the sweep above kills silently; onExit must also report what it
66
+ // swept so a project whose jobs leak repeatedly can see it instead of
67
+ // inferring it from ps.
68
+ describe('childWithLog onExit leakedDescendants reporting (PRD 1110)', () => {
69
+ it.skipIf(process.platform !== 'linux')(
70
+ 'reports the same backgrounded grandchild the sweep reaped',
71
+ async () => {
72
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-child-leak-'));
73
+ const logPath = path.join(dir, 'job.log');
74
+ const pidFile = path.join(dir, 'grandchild.pid');
75
+ const { fd, safeLog, closeFd } = openLog(logPath);
76
+
77
+ const exitInfo = await new Promise((resolve) => {
78
+ withChildAndLog({
79
+ fd,
80
+ logPath,
81
+ safeLog,
82
+ closeFd,
83
+ spawn: {
84
+ command: 'sh',
85
+ args: [
86
+ '-c',
87
+ `sleep 30 & echo $! > ${pidFile}; exec sleep 0.2`,
88
+ ],
89
+ options: { detached: true },
90
+ },
91
+ onExit: (info) => resolve(info),
92
+ });
93
+ });
94
+
95
+ for (let i = 0; i < 20 && !fs.existsSync(pidFile); i++) {
96
+ await new Promise((r) => setTimeout(r, 50));
97
+ }
98
+ const grandchildPid = Number(fs.readFileSync(pidFile, 'utf8').trim());
99
+
100
+ expect(Array.isArray(exitInfo.leakedDescendants)).toBe(true);
101
+ expect(exitInfo.leakedDescendants.length).toBeGreaterThan(0);
102
+ const found = exitInfo.leakedDescendants.find((p) => p.pid === grandchildPid);
103
+ expect(found).toBeTruthy();
104
+ expect(found.comm).toBeTruthy();
105
+ expect(typeof found.pcpu).toBe('number');
106
+ expect(typeof found.etimes).toBe('number');
107
+
108
+ // Let the sweep's SIGTERM->SIGKILL cascade finish so the grandchild
109
+ // doesn't leak past this test.
110
+ await new Promise((r) => setTimeout(r, POST_EXIT_GROUP_SWEEP_GRACE_MS + 1000));
111
+ },
112
+ 15000,
113
+ );
114
+
115
+ it.skipIf(process.platform !== 'linux')(
116
+ 'reports [] for a clean job that leaked nothing',
117
+ async () => {
118
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-child-clean-'));
119
+ const logPath = path.join(dir, 'job.log');
120
+ const { fd, safeLog, closeFd } = openLog(logPath);
121
+
122
+ const exitInfo = await new Promise((resolve) => {
123
+ withChildAndLog({
124
+ fd,
125
+ logPath,
126
+ safeLog,
127
+ closeFd,
128
+ spawn: {
129
+ command: 'sh',
130
+ args: ['-c', 'exit 0'],
131
+ options: { detached: true },
132
+ },
133
+ onExit: (info) => resolve(info),
134
+ });
135
+ });
136
+
137
+ expect(exitInfo.leakedDescendants).toEqual([]);
138
+ },
139
+ 15000,
140
+ );
141
+ });