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.
- package/dist/assets/{AgentLibrary-CBx9l4zN.js → AgentLibrary-B2ie8bbw.js} +2 -2
- package/dist/assets/{DataModel-Bf0EIE_t.js → DataModel-BIJPYw32.js} +1 -1
- package/dist/assets/{History-CpdtWhC8.js → History-CeY6dk9S.js} +2 -2
- package/dist/assets/{Hooks-DyUbMDmg.js → Hooks-BFH2ocKg.js} +2 -2
- package/dist/assets/{HostBilko-By-wIpry.js → HostBilko-36gj9wLz.js} +1 -1
- package/dist/assets/{Library-CQmo4QVC.js → Library-C-hBct39.js} +1 -1
- package/dist/assets/{ListDetail-BQMd6NOm.js → ListDetail-CNq64VWV.js} +1 -1
- package/dist/assets/{MarkdownEditor-DEp43FXX.js → MarkdownEditor-Bh3qt5-1.js} +1 -1
- package/dist/assets/{McpServers-CLarzwqA.js → McpServers-DpGN0oyz.js} +1 -1
- package/dist/assets/{Memory-B0sCdIy1.js → Memory-D59hUjC4.js} +6 -6
- package/dist/assets/{Panel-BhWPVOCD.js → Panel-DCgbaoci.js} +1 -1
- package/dist/assets/{Permissions-Ddlq8T_O.js → Permissions-DAmQ0DYV.js} +2 -2
- package/dist/assets/{Plugins-D2oA_2Jl.js → Plugins-Dyfgn6Is.js} +2 -2
- package/dist/assets/{ProvenanceBadge-DgAgavUM.js → ProvenanceBadge-BiYhPO1U.js} +1 -1
- package/dist/assets/SaveBar-RV7B6sOh.js +1 -0
- package/dist/assets/Scheduler-BPaNqx1b.js +14 -0
- package/dist/assets/{ScopeSwitcher-C_zWEtIl.js → ScopeSwitcher-P4mdLGNU.js} +1 -1
- package/dist/assets/{Settings-2Vx3X5SI.js → Settings-BL4vf5aX.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-BDEUjlTQ.js → SkillReferenceGraph-BRBDyi1_.js} +1 -1
- package/dist/assets/{Skills-Cmrz_LeN.js → Skills-BV08gDUH.js} +2 -2
- package/dist/assets/{SystemPrompt-DVA1eYDP.js → SystemPrompt-CLftSsDw.js} +1 -1
- package/dist/assets/TagLibrary-Bp8jGsd5.js +1 -0
- package/dist/assets/{TiptapBody-DmPc3amD.js → TiptapBody-jCpuB6E5.js} +1 -1
- package/dist/assets/{Toggle-zfd5LJkK.js → Toggle-D2paA1xf.js} +1 -1
- package/dist/assets/{index-B_4PNh9T.js → index-BDRSqBl3.js} +175 -175
- package/dist/assets/{index-DIjnPkRN.css → index-CYhdtisq.css} +1 -1
- package/dist/assets/{settingsSchema-B9es6fdA.js → settingsSchema-6IOLjZZN.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +8 -2
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
- package/scripts/project-pages-logic/dist/logic.cjs +4709 -0
- package/scripts/render-project-pages/dist/renderer.cjs +18900 -0
- package/scripts/render-project-pages.cjs +70 -0
- package/scripts/scheduler-mcp-server.cjs +115 -1
- package/scripts/validate-project-pages-summary.cjs +62 -0
- package/src/main/__tests__/agentModelResolve.test.cjs +66 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
- package/src/main/__tests__/prdAgentType.test.cjs +103 -0
- package/src/main/__tests__/prdCreate.test.cjs +138 -0
- package/src/main/__tests__/prdFrontmatterAgentType.test.cjs +117 -0
- package/src/main/__tests__/prdFrontmatterQuietMachine.test.cjs +108 -0
- package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +485 -0
- package/src/main/__tests__/projectPages.test.cjs +73 -1
- package/src/main/__tests__/rcaReport.test.cjs +54 -0
- package/src/main/__tests__/runVerify.test.cjs +94 -0
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +43 -0
- package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +103 -0
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +10 -0
- package/src/main/__tests__/scheduler-foreign-wip-manifest.test.cjs +78 -0
- package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +242 -0
- package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +31 -0
- package/src/main/__tests__/scheduler-launch-failure.test.cjs +201 -0
- package/src/main/__tests__/scheduler-leftover-fields.test.cjs +52 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +241 -0
- package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +135 -0
- package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +222 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +147 -0
- package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +212 -0
- package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +194 -0
- package/src/main/__tests__/seedAgentPersonas.test.cjs +75 -14
- package/src/main/config.cjs +4 -1
- package/src/main/index.cjs +8 -1
- package/src/main/ipcSchemas.cjs +51 -0
- package/src/main/lib/__tests__/childWithLog.test.cjs +78 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +152 -2
- package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +4 -2
- package/src/main/lib/__tests__/fixChainDepth.test.cjs +40 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +277 -4
- package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +153 -0
- package/src/main/lib/__tests__/jobWorktree.test.cjs +5 -3
- package/src/main/lib/__tests__/landedSinceRun.test.cjs +73 -0
- package/src/main/lib/__tests__/launchFailure.test.cjs +220 -0
- package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +1 -0
- package/src/main/lib/__tests__/opsOwnership.test.cjs +7 -0
- package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +82 -0
- package/src/main/lib/__tests__/queueHealth.test.cjs +58 -0
- package/src/main/lib/__tests__/quietMachineLease.test.cjs +39 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +22 -1
- package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +125 -0
- package/src/main/lib/__tests__/schedulerBatchQuietMachine.test.cjs +109 -0
- package/src/main/lib/__tests__/schedulerMcpServerHeadlessRefusal.test.cjs +71 -0
- package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +350 -0
- package/src/main/lib/agentModelResolve.cjs +58 -0
- package/src/main/lib/childWithLog.cjs +40 -5
- package/src/main/lib/claudeBin.cjs +54 -1
- package/src/main/lib/definitionOfDone.cjs +3 -2
- package/src/main/lib/delegationReadiness.cjs +115 -9
- package/src/main/lib/epicWorktreeMint.cjs +5 -2
- package/src/main/lib/fixChainDepth.cjs +45 -0
- package/src/main/lib/gitWorktree.cjs +464 -19
- package/src/main/lib/jobWorktree.cjs +1 -0
- package/src/main/lib/landedSinceRun.cjs +55 -0
- package/src/main/lib/launchFailure.cjs +357 -0
- package/src/main/lib/mcpToolCatalog.cjs +87 -2
- package/src/main/lib/opsOwnership.cjs +12 -0
- package/src/main/lib/prdAgentType.cjs +84 -0
- package/src/main/lib/prdCreate.cjs +57 -1
- package/src/main/lib/prdDeclaredPaths.cjs +70 -0
- package/src/main/lib/prdFrontmatter.cjs +17 -3
- package/src/main/lib/projectHomeAdminRoutes.cjs +402 -0
- package/src/main/lib/projectPageSummarySchema.cjs +181 -0
- package/src/main/lib/queueHealth.cjs +38 -0
- package/src/main/lib/queueStore.cjs +9 -2
- package/src/main/lib/quietMachineLease.cjs +48 -0
- package/src/main/lib/rcaReport.cjs +53 -3
- package/src/main/lib/reaperHelpers.cjs +18 -1
- package/src/main/lib/scheduleJobSchema.cjs +31 -0
- package/src/main/lib/scheduleJobTransitions.cjs +6 -2
- package/src/main/lib/schedulerBatch.cjs +133 -29
- package/src/main/lib/schedulerConfig.cjs +19 -0
- package/src/main/projectPages.cjs +160 -2
- package/src/main/runVerify.cjs +50 -9
- package/src/main/scheduler/prdParser.cjs +18 -1
- package/src/main/scheduler.cjs +1371 -97
- package/src/main/seedAgentPersonas.cjs +62 -21
- package/src/main/templates/project-pages-catalog.json +741 -0
- package/src/main/templates/project-pages-pipeline.md +417 -0
- package/src/preload/api.d.ts +118 -2
- package/src/preload/index.cjs +7 -0
- package/src/seed/agents/project-home-builder.md +59 -0
- package/dist/assets/SaveBar-Qvc4Ek-H.js +0 -1
- package/dist/assets/Scheduler-BmYJvNzK.js +0 -14
- package/dist/assets/TagLibrary-DYJGAKZu.js +0 -1
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-worktree-cap-defer.test.cjs — PRD 1112: a job that cannot get
|
|
3
|
+
* worktree isolation because the concurrency cap is currently exhausted must
|
|
4
|
+
* be DEFERRED (left 'pending', retried on the next dispatch pass), never
|
|
5
|
+
* spawned in a shared working tree the way every OTHER worktree.ok===false
|
|
6
|
+
* reason (e.g. "not a git repository") still falls back to running in place.
|
|
7
|
+
*
|
|
8
|
+
* Drives spawnJob end-to-end against a real git fixture repo, forcing the
|
|
9
|
+
* cap via SM_JOB_WORKTREE_MAX=1 plus a real occupying worktree (so the cap
|
|
10
|
+
* check itself, not a mock, is what defers the job).
|
|
11
|
+
*
|
|
12
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-worktree-cap-defer.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 originalMax;
|
|
26
|
+
let spawnJob;
|
|
27
|
+
let gitWorktree;
|
|
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
|
+
// See scheduler-quiet-machine-lease.test.cjs's identical helper: readQueue()'s
|
|
44
|
+
// per-project discovery walks ~/.claude/projects/*/*.jsonl transcripts for a
|
|
45
|
+
// `cwd` field to find known project queue.json shards.
|
|
46
|
+
function registerActiveProject(cwd) {
|
|
47
|
+
const projectsDir = path.join(tmpHome, '.claude', 'projects');
|
|
48
|
+
const slugDir = path.join(projectsDir, `fake-project-slug-${path.basename(cwd)}`);
|
|
49
|
+
fs.mkdirSync(slugDir, { recursive: true });
|
|
50
|
+
fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function writeProjectQueue(cwd, jobs) {
|
|
54
|
+
const stateDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
|
|
55
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
56
|
+
fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs }, null, 2));
|
|
57
|
+
return path.join(stateDir, 'queue.json');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Stub `claude` binary: writes a marker file into its OWN process.cwd() so a
|
|
61
|
+
// test can prove whether — and where — it was actually spawned, then emits a
|
|
62
|
+
// clean stream-json success result.
|
|
63
|
+
function writeClaudeStub() {
|
|
64
|
+
const stubPath = path.join(os.tmpdir(), `sm-claude-stub-capdefer-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
|
|
65
|
+
const body = `
|
|
66
|
+
const fs = require('fs');
|
|
67
|
+
const path = require('path');
|
|
68
|
+
const { execFileSync } = require('child_process');
|
|
69
|
+
fs.writeFileSync(path.join(process.cwd(), 'ran-here.marker'), 'yes', 'utf8');
|
|
70
|
+
// Land a real commit + finish-protocol sentinel when possible, so a run
|
|
71
|
+
// that actually reaches the finish protocol lands 'completed' — a
|
|
72
|
+
// best-effort no-op when the cwd isn't a git repo (e.g. the non-git
|
|
73
|
+
// fallback case), where the run parks in needs_review instead, which is
|
|
74
|
+
// fine — this test only cares whether it ran in place, not its verdict.
|
|
75
|
+
try {
|
|
76
|
+
execFileSync('git', ['add', '-A'], { cwd: process.cwd() });
|
|
77
|
+
execFileSync('git', ['commit', '-q', '-m', 'stub commit'], { cwd: process.cwd() });
|
|
78
|
+
} catch { /* not a git repo — best-effort only */ }
|
|
79
|
+
process.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', result: 'ok\\nSCHEDULER_VERDICT: PASS' }) + '\\n');
|
|
80
|
+
process.exit(0);
|
|
81
|
+
`;
|
|
82
|
+
fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
|
|
83
|
+
return stubPath;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
beforeAll(() => {
|
|
87
|
+
originalHome = process.env.HOME;
|
|
88
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-worktree-cap-defer-'));
|
|
89
|
+
process.env.HOME = tmpHome;
|
|
90
|
+
originalMax = process.env.SM_JOB_WORKTREE_MAX;
|
|
91
|
+
({ spawnJob } = require('../scheduler.cjs'));
|
|
92
|
+
gitWorktree = require('../lib/gitWorktree.cjs');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterAll(() => {
|
|
96
|
+
process.env.HOME = originalHome;
|
|
97
|
+
if (originalMax === undefined) delete process.env.SM_JOB_WORKTREE_MAX;
|
|
98
|
+
else process.env.SM_JOB_WORKTREE_MAX = originalMax;
|
|
99
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
delete process.env.SM_CLAUDE_BIN;
|
|
104
|
+
delete process.env.SM_JOB_WORKTREE_MAX;
|
|
105
|
+
gitWorktree._resetActiveWorktreeCountForTests('job', 0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('a job held behind the worktree cap stays pending with a heldReason, is never spawned in place, and dispatches once the cap frees up', async () => {
|
|
109
|
+
const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-worktree-cap-defer-project-'));
|
|
110
|
+
initRepo(projectCwd);
|
|
111
|
+
registerActiveProject(projectCwd);
|
|
112
|
+
|
|
113
|
+
const slug = `1112-test-held-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
114
|
+
const prdsDir = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'prds');
|
|
115
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
116
|
+
fs.writeFileSync(path.join(prdsDir, `${slug}.md`), 'Held behind the worktree cap.', 'utf8');
|
|
117
|
+
const queuePath = writeProjectQueue(projectCwd, [{ slug, status: 'pending', cwd: projectCwd }]);
|
|
118
|
+
|
|
119
|
+
process.env.SM_JOB_WORKTREE_MAX = '1';
|
|
120
|
+
process.env.SM_CLAUDE_BIN = writeClaudeStub();
|
|
121
|
+
|
|
122
|
+
// Occupy the single worktree slot with a REAL checkout, so the cap check
|
|
123
|
+
// spawnJob relies on is exercising the actual gitWorktree plumbing, not a
|
|
124
|
+
// mock.
|
|
125
|
+
const occupier = await gitWorktree.createJobWorktree({ cwd: projectCwd, slug: 'occupier' });
|
|
126
|
+
expect(occupier.ok).toBe(true);
|
|
127
|
+
|
|
128
|
+
const runId = `run-${slug}`;
|
|
129
|
+
const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
|
|
130
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
await spawnJob({ slug, cwd: projectCwd }, runId, runDir, projectCwd);
|
|
134
|
+
|
|
135
|
+
// Held: still pending, never spawned, heldReason visible on the row.
|
|
136
|
+
let jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
137
|
+
let row = jobs.find((j) => j.slug === slug);
|
|
138
|
+
expect(row.status).toBe('pending');
|
|
139
|
+
expect(row.heldReason).toMatch(/worktree cap reached/);
|
|
140
|
+
expect(fs.existsSync(path.join(projectCwd, 'ran-here.marker'))).toBe(false);
|
|
141
|
+
expect(fs.existsSync(path.join(occupier.dir, 'ran-here.marker'))).toBe(false);
|
|
142
|
+
|
|
143
|
+
// Free the cap (mirrors cleanupJobWorktree releasing the counter), then
|
|
144
|
+
// retry — the held job dispatches on this next pass, exactly like a
|
|
145
|
+
// sessionSlots-blocked job retries once a slot frees up.
|
|
146
|
+
await gitWorktree.cleanupJobWorktree({ cwd: projectCwd, dir: occupier.dir, branch: occupier.branch });
|
|
147
|
+
|
|
148
|
+
await spawnJob({ slug, cwd: projectCwd }, `${runId}-2`, runDir, projectCwd);
|
|
149
|
+
|
|
150
|
+
jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
151
|
+
row = jobs.find((j) => j.slug === slug);
|
|
152
|
+
expect(row.status).toBe('completed');
|
|
153
|
+
expect(row.heldReason).toBeUndefined();
|
|
154
|
+
} finally {
|
|
155
|
+
fs.rmSync(projectCwd, { recursive: true, force: true });
|
|
156
|
+
fs.rmSync(runDir, { recursive: true, force: true });
|
|
157
|
+
}
|
|
158
|
+
}, 30_000);
|
|
159
|
+
|
|
160
|
+
test('a non-git cwd still falls back to running in place (unchanged behaviour — only cap-reached is a deferral)', async () => {
|
|
161
|
+
const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-worktree-cap-defer-nongit-'));
|
|
162
|
+
fs.mkdirSync(projectCwd, { recursive: true }); // deliberately NOT a git repo
|
|
163
|
+
registerActiveProject(projectCwd);
|
|
164
|
+
|
|
165
|
+
const slug = `1112-test-nongit-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
166
|
+
const prdsDir = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'prds');
|
|
167
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
168
|
+
fs.writeFileSync(path.join(prdsDir, `${slug}.md`), 'Non-git cwd, must run in place.', 'utf8');
|
|
169
|
+
const queuePath = writeProjectQueue(projectCwd, [{ slug, status: 'pending', cwd: projectCwd }]);
|
|
170
|
+
|
|
171
|
+
process.env.SM_CLAUDE_BIN = writeClaudeStub();
|
|
172
|
+
|
|
173
|
+
const runId = `run-${slug}`;
|
|
174
|
+
const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
|
|
175
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
await spawnJob({ slug, cwd: projectCwd }, runId, runDir, projectCwd);
|
|
179
|
+
|
|
180
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
181
|
+
const row = jobs.find((j) => j.slug === slug);
|
|
182
|
+
// Not held/deferred — it actually ran (a non-git cwd can't commit, so it
|
|
183
|
+
// parks in needs_review rather than completed; this test only cares that
|
|
184
|
+
// it was dispatched in place, not its exact verdict).
|
|
185
|
+
expect(row.status).not.toBe('pending');
|
|
186
|
+
expect(row.heldReason).toBeUndefined();
|
|
187
|
+
expect(row.worktreeFallbackReason).toMatch(/not a git repository/);
|
|
188
|
+
// Ran IN PLACE — the marker landed directly in projectCwd, not a worktree.
|
|
189
|
+
expect(fs.existsSync(path.join(projectCwd, 'ran-here.marker'))).toBe(true);
|
|
190
|
+
} finally {
|
|
191
|
+
fs.rmSync(projectCwd, { recursive: true, force: true });
|
|
192
|
+
fs.rmSync(runDir, { recursive: true, force: true });
|
|
193
|
+
}
|
|
194
|
+
}, 30_000);
|
|
@@ -54,22 +54,20 @@ function silentLogger() {
|
|
|
54
54
|
return { log: () => {}, warn: () => {} };
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
await seedAgentPersonas({ logger: silentLogger() });
|
|
57
|
+
const ALL_PERSONAS = ['architect', 'dev-lead', 'project-home-builder'];
|
|
59
58
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
expect(fs.existsSync(architect)).toBe(true);
|
|
63
|
-
expect(fs.existsSync(devLead)).toBe(true);
|
|
59
|
+
test('fresh seed writes every persona file', async () => {
|
|
60
|
+
await seedAgentPersonas({ logger: silentLogger() });
|
|
64
61
|
|
|
65
|
-
const
|
|
66
|
-
path.join(
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
62
|
+
for (const name of ALL_PERSONAS) {
|
|
63
|
+
const dest = path.join(agentsDir(), `${name}.md`);
|
|
64
|
+
expect(fs.existsSync(dest)).toBe(true);
|
|
65
|
+
const bundled = fs.readFileSync(path.join(__dirname, '..', '..', 'seed', 'agents', `${name}.md`), 'utf8');
|
|
66
|
+
expect(fs.readFileSync(dest, 'utf8')).toBe(bundled);
|
|
67
|
+
}
|
|
70
68
|
|
|
71
69
|
const marker = JSON.parse(fs.readFileSync(markerPath(), 'utf8'));
|
|
72
|
-
expect(marker.
|
|
70
|
+
expect(marker.seeded.sort()).toEqual([...ALL_PERSONAS].sort());
|
|
73
71
|
});
|
|
74
72
|
|
|
75
73
|
test('a pre-existing persona file is left byte-identical', async () => {
|
|
@@ -81,20 +79,83 @@ test('a pre-existing persona file is left byte-identical', async () => {
|
|
|
81
79
|
await seedAgentPersonas({ logger: silentLogger() });
|
|
82
80
|
|
|
83
81
|
expect(fs.readFileSync(architect, 'utf8')).toBe(customContent);
|
|
84
|
-
// dev-lead had no pre-existing file, so
|
|
82
|
+
// dev-lead and project-home-builder had no pre-existing file, so they should still be seeded.
|
|
85
83
|
expect(fs.existsSync(path.join(agentsDir(), 'dev-lead.md'))).toBe(true);
|
|
84
|
+
expect(fs.existsSync(path.join(agentsDir(), 'project-home-builder.md'))).toBe(true);
|
|
86
85
|
});
|
|
87
86
|
|
|
88
|
-
test('done:true marker
|
|
87
|
+
test('a machine already fully seeded under the old done:true marker only picks up a newly added persona', async () => {
|
|
89
88
|
const mPath = markerPath();
|
|
90
89
|
fs.mkdirSync(path.dirname(mPath), { recursive: true });
|
|
90
|
+
// Legacy marker shape from before the seeded-set change — no `seeded` array.
|
|
91
91
|
fs.writeFileSync(mPath, JSON.stringify({ done: true, attempts: 1, ts: '2026-01-01T00:00:00.000Z' }));
|
|
92
|
+
fs.mkdirSync(agentsDir(), { recursive: true });
|
|
93
|
+
fs.writeFileSync(path.join(agentsDir(), 'architect.md'), 'old architect content\n');
|
|
94
|
+
fs.writeFileSync(path.join(agentsDir(), 'dev-lead.md'), 'old dev-lead content\n');
|
|
95
|
+
|
|
96
|
+
await seedAgentPersonas({ logger: silentLogger() });
|
|
97
|
+
|
|
98
|
+
// Pre-existing files from the old run are untouched...
|
|
99
|
+
expect(fs.readFileSync(path.join(agentsDir(), 'architect.md'), 'utf8')).toBe('old architect content\n');
|
|
100
|
+
expect(fs.readFileSync(path.join(agentsDir(), 'dev-lead.md'), 'utf8')).toBe('old dev-lead content\n');
|
|
101
|
+
// ...but the newly added persona is delivered.
|
|
102
|
+
expect(fs.existsSync(path.join(agentsDir(), 'project-home-builder.md'))).toBe(true);
|
|
103
|
+
|
|
104
|
+
const marker = JSON.parse(fs.readFileSync(mPath, 'utf8'));
|
|
105
|
+
expect(marker.seeded.sort()).toEqual([...ALL_PERSONAS].sort());
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('a machine already seeded under the new seeded-set marker picks up a newly added persona', async () => {
|
|
109
|
+
const mPath = markerPath();
|
|
110
|
+
fs.mkdirSync(path.dirname(mPath), { recursive: true });
|
|
111
|
+
fs.writeFileSync(mPath, JSON.stringify({ seeded: ['architect', 'dev-lead'], attempts: 0, ts: '2026-01-01T00:00:00.000Z' }));
|
|
112
|
+
|
|
113
|
+
await seedAgentPersonas({ logger: silentLogger() });
|
|
114
|
+
|
|
115
|
+
expect(fs.existsSync(path.join(agentsDir(), 'project-home-builder.md'))).toBe(true);
|
|
116
|
+
const marker = JSON.parse(fs.readFileSync(mPath, 'utf8'));
|
|
117
|
+
expect(marker.seeded.sort()).toEqual([...ALL_PERSONAS].sort());
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('a machine with every known persona already in the seeded set short-circuits without touching agents dir', async () => {
|
|
121
|
+
const mPath = markerPath();
|
|
122
|
+
fs.mkdirSync(path.dirname(mPath), { recursive: true });
|
|
123
|
+
fs.writeFileSync(mPath, JSON.stringify({ seeded: ALL_PERSONAS, attempts: 0, ts: '2026-01-01T00:00:00.000Z' }));
|
|
92
124
|
|
|
93
125
|
await seedAgentPersonas({ logger: silentLogger() });
|
|
94
126
|
|
|
95
127
|
expect(fs.existsSync(agentsDir())).toBe(false);
|
|
96
128
|
});
|
|
97
129
|
|
|
130
|
+
test('a corrupt/unparseable marker file still seeds correctly', async () => {
|
|
131
|
+
const mPath = markerPath();
|
|
132
|
+
fs.mkdirSync(path.dirname(mPath), { recursive: true });
|
|
133
|
+
fs.writeFileSync(mPath, '{ not valid json');
|
|
134
|
+
|
|
135
|
+
await seedAgentPersonas({ logger: silentLogger() });
|
|
136
|
+
|
|
137
|
+
for (const name of ALL_PERSONAS) {
|
|
138
|
+
expect(fs.existsSync(path.join(agentsDir(), `${name}.md`))).toBe(true);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('the bundled project-home-builder persona contains no session-manager-repo paths', () => {
|
|
143
|
+
const content = fs.readFileSync(
|
|
144
|
+
path.join(__dirname, '..', '..', 'seed', 'agents', 'project-home-builder.md'),
|
|
145
|
+
'utf8'
|
|
146
|
+
);
|
|
147
|
+
const forbidden = [
|
|
148
|
+
'session-manager-operations/architecture/',
|
|
149
|
+
'.claude/agents/',
|
|
150
|
+
'session-manager-operations/design-mocks/',
|
|
151
|
+
'scripts/',
|
|
152
|
+
'npm run build:project-pages',
|
|
153
|
+
];
|
|
154
|
+
for (const substr of forbidden) {
|
|
155
|
+
expect(content).not.toContain(substr);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
98
159
|
test('SM_SEED_AGENT_PERSONAS_DISABLE=1 short-circuits', async () => {
|
|
99
160
|
process.env.SM_SEED_AGENT_PERSONAS_DISABLE = '1';
|
|
100
161
|
|
package/src/main/config.cjs
CHANGED
|
@@ -148,6 +148,9 @@ function validateWrite(realAbs) {
|
|
|
148
148
|
// (PRD 825; scheduler.cjs remote.writePrd)
|
|
149
149
|
// project-brief projectBrief.cjs's brief.json (PRD 837)
|
|
150
150
|
// bilko-host bilkoHost.cjs's bundle prep
|
|
151
|
+
// project-pages projectHomeAdminRoutes.cjs's app-side render path
|
|
152
|
+
// (summary.json/picks.json/output/*; PRD:
|
|
153
|
+
// project-home-admin-routes)
|
|
151
154
|
// Paths come from opsOwnership.opsPath (PRD 1082) — the one ops-root
|
|
152
155
|
// resolver — which THROWS for an ephemeral root (a linked worktree that
|
|
153
156
|
// pty.cjs registered as an allowed root for its spawn cwd, or
|
|
@@ -155,7 +158,7 @@ function validateWrite(realAbs) {
|
|
|
155
158
|
// never a writable destination, so its exemptions simply do not exist.
|
|
156
159
|
let opsSubs;
|
|
157
160
|
try {
|
|
158
|
-
opsSubs = ['prompt-sessions', 'scheduler', 'project-brief', 'bilko-host'].map((ns) => opsPath(realRoot, ns));
|
|
161
|
+
opsSubs = ['prompt-sessions', 'scheduler', 'project-brief', 'bilko-host', 'project-pages'].map((ns) => opsPath(realRoot, ns));
|
|
159
162
|
} catch {
|
|
160
163
|
opsSubs = [];
|
|
161
164
|
}
|
package/src/main/index.cjs
CHANGED
|
@@ -27,6 +27,7 @@ const scheduler = require('./scheduler.cjs');
|
|
|
27
27
|
const { createAdminHttp } = require('./lib/localAdminHttp.cjs');
|
|
28
28
|
const prdCreate = require('./lib/prdCreate.cjs');
|
|
29
29
|
const prdAdminRoutes = require('./lib/prdAdminRoutes.cjs');
|
|
30
|
+
const projectHomeAdminRoutes = require('./lib/projectHomeAdminRoutes.cjs');
|
|
30
31
|
const { appendAuditEvent } = require('./lib/auditLog.cjs');
|
|
31
32
|
const chatRunner = require('./chatRunner.cjs');
|
|
32
33
|
const promptSessionEvents = require('./promptSessionEvents.cjs');
|
|
@@ -45,6 +46,7 @@ const adminHttp = createAdminHttp();
|
|
|
45
46
|
scheduler.registerAdminRoutes(adminHttp);
|
|
46
47
|
prdCreate.registerAdminRoute(adminHttp, scheduler.remote);
|
|
47
48
|
prdAdminRoutes.registerAdminRoute(adminHttp, scheduler.remote);
|
|
49
|
+
projectHomeAdminRoutes.registerAdminRoute(adminHttp);
|
|
48
50
|
chatRunner.registerAdminRoute(adminHttp);
|
|
49
51
|
// Project-to-project feedback conduit — the app is the only process holding
|
|
50
52
|
// several projects' operations roots open, so it performs the cross-folder
|
|
@@ -69,7 +71,7 @@ const schedulerConfig = require('./lib/schedulerConfig.cjs');
|
|
|
69
71
|
const memoryTool = require('./memoryTool.cjs');
|
|
70
72
|
const { registerMemoryAggregateIpc } = require('./memoryAggregate.cjs');
|
|
71
73
|
const { registerProjectBriefIpc } = require('./projectBrief.cjs');
|
|
72
|
-
const { registerProjectPagesIpc } = require('./projectPages.cjs');
|
|
74
|
+
const { registerProjectPagesIpc, attachWindow: attachProjectPagesWindow, closeAllOutputWatchers: closeAllProjectPagesWatchers } = require('./projectPages.cjs');
|
|
73
75
|
const { registerBilkoHostIpc } = require('./bilkoHost.cjs');
|
|
74
76
|
const promptSessionTranscript = require('./promptSessionTranscript.cjs');
|
|
75
77
|
const { computeEpicDelegationStats } = require('./lib/epicDelegationStats.cjs');
|
|
@@ -300,6 +302,7 @@ function relaunchViaNpx() {
|
|
|
300
302
|
async function rebootApp() {
|
|
301
303
|
ptyManager.killAll();
|
|
302
304
|
configMgr.closeAllWatchers();
|
|
305
|
+
closeAllProjectPagesWatchers();
|
|
303
306
|
transcripts.closeAll();
|
|
304
307
|
watchers.manager.killAll();
|
|
305
308
|
|
|
@@ -326,6 +329,7 @@ async function rebootApp() {
|
|
|
326
329
|
chatRunner.attachWindow(mainWindow);
|
|
327
330
|
promptSessionEvents.attachWindow(mainWindow);
|
|
328
331
|
attachDocEditWindow(mainWindow);
|
|
332
|
+
attachProjectPagesWindow(mainWindow);
|
|
329
333
|
rebooting = false;
|
|
330
334
|
return;
|
|
331
335
|
}
|
|
@@ -1168,6 +1172,7 @@ app.whenReady().then(async () => {
|
|
|
1168
1172
|
chatRunner.attachWindow(mainWindow);
|
|
1169
1173
|
promptSessionEvents.attachWindow(mainWindow);
|
|
1170
1174
|
attachDocEditWindow(mainWindow);
|
|
1175
|
+
attachProjectPagesWindow(mainWindow);
|
|
1171
1176
|
// Scheduler ownership (PRD 834): exactly one instance machine-wide runs
|
|
1172
1177
|
// scheduler mutation (boot reconciliation, ticking, sweep, supervisor) and
|
|
1173
1178
|
// the admin API. Electron's own single-instance lock is keyed by userData
|
|
@@ -1344,6 +1349,7 @@ app.on('window-all-closed', () => {
|
|
|
1344
1349
|
if (rebooting) return; // new window is about to be created
|
|
1345
1350
|
ptyManager.killAll();
|
|
1346
1351
|
configMgr.closeAllWatchers();
|
|
1352
|
+
closeAllProjectPagesWatchers();
|
|
1347
1353
|
transcripts.closeAll();
|
|
1348
1354
|
watchers.manager.killAll();
|
|
1349
1355
|
if (process.platform !== 'darwin') app.quit();
|
|
@@ -1358,6 +1364,7 @@ app.on('before-quit', () => {
|
|
|
1358
1364
|
supervisor.stopSupervisor();
|
|
1359
1365
|
ptyManager.killAll();
|
|
1360
1366
|
configMgr.closeAllWatchers();
|
|
1367
|
+
closeAllProjectPagesWatchers();
|
|
1361
1368
|
transcripts.closeAll();
|
|
1362
1369
|
watchers.manager.killAll();
|
|
1363
1370
|
adminHttp.stop().catch(() => {});
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -10,6 +10,7 @@ const os = require('node:os');
|
|
|
10
10
|
const path = require('node:path');
|
|
11
11
|
const { PromptSessionSchema, EpicTagSchema, EpicSourceSchema, EpicIntakeSectionSchema } = require('./lib/promptSessionSchema.cjs');
|
|
12
12
|
const { PrdWorkTypeSchema } = require('./lib/workTypeLibrary.cjs');
|
|
13
|
+
const { PERSONA_NAME_RE } = require('./agentLibrary.cjs');
|
|
13
14
|
const { AgentPersonaSaveSchema } = require('./lib/agentPersonaSchema.cjs');
|
|
14
15
|
|
|
15
16
|
// ──────────────────────────────────────────── PTY
|
|
@@ -224,6 +225,12 @@ const promptSessionsMergeToMain = z.object({
|
|
|
224
225
|
epicId: z.string().regex(PROMPT_SESSION_ID_RE),
|
|
225
226
|
branch: z.string().regex(/^sm-epic\/[A-Za-z0-9_-]{1,64}$/),
|
|
226
227
|
dir: z.string().min(1).max(4096),
|
|
228
|
+
// Base-tree WIP paths carried into this worktree at creation time
|
|
229
|
+
// (gitWorktree.cjs's createWorktree, PRD 1094) — threaded through so
|
|
230
|
+
// integrateEpicBranch can apply the same carried-wip-only skip the job
|
|
231
|
+
// path already gets. Absent on a worktree created before this field
|
|
232
|
+
// existed, or one whose base tree was clean at creation.
|
|
233
|
+
carriedPaths: z.array(z.string().min(1).max(4096)).max(2000).optional(),
|
|
227
234
|
});
|
|
228
235
|
|
|
229
236
|
// Renderer entry points for the per-project "disable Epic worktree
|
|
@@ -299,6 +306,13 @@ const PRD_CREATE_SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
|
299
306
|
// frontmatter-shaped lines into the body. Block newlines at the boundary
|
|
300
307
|
// instead of trying to escape them later.
|
|
301
308
|
const NO_NEWLINE_RE = /^[^\r\n]*$/;
|
|
309
|
+
// WHO executes a PRD — a persona name (agentLibrary.cjs's PERSONA_NAME_RE),
|
|
310
|
+
// distinct from `tag` (the WORK TYPE, above). Not a closed enum here: the
|
|
311
|
+
// Agent Library is user-editable, so the actual FK check (does this name
|
|
312
|
+
// resolve to a readable persona file) happens at write time in
|
|
313
|
+
// prdCreate.cjs's createPrd via prdAgentType.cjs's assertAgentTypeWritable —
|
|
314
|
+
// this schema only enforces the name SHAPE.
|
|
315
|
+
const PrdAgentTypeSchema = z.string().regex(PERSONA_NAME_RE, 'agent name must be lowercase, hyphenated (e.g. "dev-lead")');
|
|
302
316
|
const schedulerCreatePrd = z.object({
|
|
303
317
|
title: z.string().min(1).max(200).regex(NO_NEWLINE_RE, 'must not contain newlines'),
|
|
304
318
|
// Optional (PRD: worktree-cwd Epic-lookup hazard) — when omitted, prdCreate.cjs's
|
|
@@ -352,6 +366,18 @@ const schedulerCreatePrd = z.object({
|
|
|
352
366
|
// excluded here for the same reason adminPrdFrontmatterPatch excludes it
|
|
353
367
|
// below — a discussion-tagged Epic never reaches PRD authoring.
|
|
354
368
|
tag: PrdWorkTypeSchema.optional(),
|
|
369
|
+
// WHO executes this PRD (persona name, `~/.claude/agents/<name>.md`) —
|
|
370
|
+
// independent of `tag` above. Optional: prdCreate.cjs's buildPrdBody fills
|
|
371
|
+
// in DEFAULT_PRD_AGENT_TYPE ('dev-lead') when omitted.
|
|
372
|
+
agentType: PrdAgentTypeSchema.optional(),
|
|
373
|
+
// Opt-in exclusive-lease flag (PRD 1107): the scheduler dispatches this PRD
|
|
374
|
+
// only once zero other jobs are running machine-wide, and holds every other
|
|
375
|
+
// job off the whole session-slot pool while it runs — for a PRD whose
|
|
376
|
+
// acceptance criteria are wall-clock/timing measurements (frame time,
|
|
377
|
+
// performance fences) that CPU contention from sibling jobs would otherwise
|
|
378
|
+
// invalidate. See prdCreate.cjs's buildPrdBody and schedulerBatch.cjs's
|
|
379
|
+
// pickNextBatch.
|
|
380
|
+
quietMachine: z.boolean().optional(),
|
|
355
381
|
});
|
|
356
382
|
|
|
357
383
|
// Bulk archive: slug list, capped to limit unbounded retag/archive payloads.
|
|
@@ -418,12 +444,17 @@ const adminPrdFrontmatterPatch = z.object({
|
|
|
418
444
|
// discussion-tagged Epic never reaches PRD authoring) — see
|
|
419
445
|
// workTypeLibrary.cjs's PrdWorkTypeSchema.
|
|
420
446
|
tag: PrdWorkTypeSchema.optional(),
|
|
447
|
+
// WHO executes this PRD — patchable after creation too, same as `tag`.
|
|
448
|
+
agentType: PrdAgentTypeSchema.optional(),
|
|
421
449
|
// Provenance stamp (PRD-authoring lockdown). Free-text rather than an enum
|
|
422
450
|
// — 'scheduler-api' (create-time) and 'legacy-adopted' (migration/manual
|
|
423
451
|
// adopt) are the two values this codebase writes today, but the field
|
|
424
452
|
// itself is descriptive, not a closed set.
|
|
425
453
|
createdVia: z.string().min(1).max(64).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
|
|
426
454
|
issuedAt: z.string().min(1).max(64).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
|
|
455
|
+
// Opt-in exclusive-lease flag (PRD 1107) — patchable after creation too,
|
|
456
|
+
// same as every other recognized key here.
|
|
457
|
+
quietMachine: z.boolean().optional(),
|
|
427
458
|
});
|
|
428
459
|
|
|
429
460
|
const adminUpdatePrd = z.object({
|
|
@@ -582,6 +613,23 @@ const projectPagesCwd = z.object({
|
|
|
582
613
|
cwd: z.string().min(1).max(4096),
|
|
583
614
|
}).strict();
|
|
584
615
|
|
|
616
|
+
// ──────────────────────────────────────────── Project Home admin routes
|
|
617
|
+
// (PRD: project-home-admin-routes). Same validation split as
|
|
618
|
+
// projectPagesCwd above: real path validation is projectRootResolve.cjs +
|
|
619
|
+
// config.cjs's validatePath at first fs access.
|
|
620
|
+
const projectHomeAdminCwdQuery = z.object({
|
|
621
|
+
cwd: z.string().min(1).max(4096),
|
|
622
|
+
}).strict();
|
|
623
|
+
const projectHomeAdminValidateSummaryBody = z.object({
|
|
624
|
+
cwd: z.string().min(1).max(4096),
|
|
625
|
+
summary: z.unknown(),
|
|
626
|
+
}).strict();
|
|
627
|
+
const projectHomeAdminRenderBody = z.object({
|
|
628
|
+
cwd: z.string().min(1).max(4096),
|
|
629
|
+
summary: z.unknown(),
|
|
630
|
+
picks: z.unknown(),
|
|
631
|
+
}).strict();
|
|
632
|
+
|
|
585
633
|
// ──────────────────────────────────────────── Host on Bilko.run
|
|
586
634
|
// Same validation split: real path validation is config.cjs's validatePath
|
|
587
635
|
// at first fs access in bilkoHost.cjs — these schemas only bound wire shape.
|
|
@@ -990,6 +1038,9 @@ module.exports = {
|
|
|
990
1038
|
memoryStale,
|
|
991
1039
|
projectBriefCwd,
|
|
992
1040
|
projectPagesCwd,
|
|
1041
|
+
projectHomeAdminCwdQuery,
|
|
1042
|
+
projectHomeAdminValidateSummaryBody,
|
|
1043
|
+
projectHomeAdminRenderBody,
|
|
993
1044
|
bilkoHostCwd,
|
|
994
1045
|
bilkoHostPrepareBundle,
|
|
995
1046
|
bilkoHostAddDocument,
|
|
@@ -61,3 +61,81 @@ describe('childWithLog post-exit group sweep (PRD 1065)', () => {
|
|
|
61
61
|
15000,
|
|
62
62
|
);
|
|
63
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
|
+
});
|