claude-code-session-manager 0.75.3 → 0.76.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-CzQqcObq.js → AgentLibrary-CBx9l4zN.js} +1 -1
- package/dist/assets/{DataModel-Bj_WlLz8.js → DataModel-Bf0EIE_t.js} +1 -1
- package/dist/assets/{History-DnSi_OHm.js → History-CpdtWhC8.js} +1 -1
- package/dist/assets/{Hooks-0BB0dp3S.js → Hooks-DyUbMDmg.js} +1 -1
- package/dist/assets/{HostBilko-DHpwwsLQ.js → HostBilko-By-wIpry.js} +1 -1
- package/dist/assets/{Library-CaJVqVvi.js → Library-CQmo4QVC.js} +1 -1
- package/dist/assets/{ListDetail-C1W2HmC2.js → ListDetail-BQMd6NOm.js} +1 -1
- package/dist/assets/{MarkdownEditor-5Ob9FW3z.js → MarkdownEditor-DEp43FXX.js} +1 -1
- package/dist/assets/{McpServers-JxCSfm1S.js → McpServers-CLarzwqA.js} +1 -1
- package/dist/assets/{Memory-BDeqlqwH.js → Memory-B0sCdIy1.js} +1 -1
- package/dist/assets/{Panel-Dh9ZHuEj.js → Panel-BhWPVOCD.js} +1 -1
- package/dist/assets/{Permissions-DXy-CbEY.js → Permissions-Ddlq8T_O.js} +1 -1
- package/dist/assets/{Plugins-_n1Iuc8T.js → Plugins-D2oA_2Jl.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BP_evfxE.js → ProvenanceBadge-DgAgavUM.js} +1 -1
- package/dist/assets/{SaveBar-D-gCUx4n.js → SaveBar-Qvc4Ek-H.js} +1 -1
- package/dist/assets/{Scheduler-Bpd4OGju.js → Scheduler-BmYJvNzK.js} +1 -1
- package/dist/assets/{ScopeSwitcher-CAWzM6RI.js → ScopeSwitcher-C_zWEtIl.js} +1 -1
- package/dist/assets/{Settings-DRRozLyT.js → Settings-2Vx3X5SI.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-DGHDWlz4.js → SkillReferenceGraph-BDEUjlTQ.js} +1 -1
- package/dist/assets/{Skills-D8L66eiX.js → Skills-Cmrz_LeN.js} +1 -1
- package/dist/assets/{SystemPrompt-CYtUsonD.js → SystemPrompt-DVA1eYDP.js} +1 -1
- package/dist/assets/{TagLibrary-E5CLeuVk.js → TagLibrary-DYJGAKZu.js} +1 -1
- package/dist/assets/{TiptapBody-B2hRgbPE.js → TiptapBody-DmPc3amD.js} +1 -1
- package/dist/assets/{Toggle-BTwsbxam.js → Toggle-zfd5LJkK.js} +1 -1
- package/dist/assets/{index-DijufvkJ.js → index-B_4PNh9T.js} +676 -676
- package/dist/assets/{index-CMLnzdZC.css → index-DIjnPkRN.css} +1 -1
- package/dist/assets/{settingsSchema-D6wzxAi6.js → settingsSchema-B9es6fdA.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/lib/activeSessions.cjs +116 -6
- package/scripts/scheduler-mcp-server.cjs +154 -95
- package/src/main/__tests__/epicStatusMirror.test.cjs +110 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +105 -0
- package/src/main/__tests__/prdAdminRoutes.test.cjs +295 -0
- package/src/main/__tests__/prdCreate.test.cjs +109 -0
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +15 -3
- package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +41 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +60 -1
- package/src/main/__tests__/scheduler-stranded-investigation.test.cjs +185 -0
- package/src/main/__tests__/seedSchedulerMcp.test.cjs +66 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -5
- package/src/main/bilkoHost.cjs +4 -3
- package/src/main/chatRunner.cjs +6 -1
- package/src/main/config.cjs +22 -33
- package/src/main/health.cjs +153 -2
- package/src/main/index.cjs +56 -4
- package/src/main/ipcSchemas.cjs +18 -1
- package/src/main/lib/__tests__/activeIndexRebuild.test.cjs +179 -0
- package/src/main/lib/__tests__/childWithLog.test.cjs +63 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +241 -42
- package/src/main/lib/__tests__/ephemeralCwd.test.cjs +91 -0
- package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +1 -1
- package/src/main/lib/__tests__/gitWorktree.test.cjs +14 -2
- package/src/main/lib/__tests__/gitWorktreeSalvage.test.cjs +107 -0
- package/src/main/lib/__tests__/jobWorktree.test.cjs +2 -2
- package/src/main/lib/__tests__/loadGate.test.cjs +159 -0
- package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +101 -0
- package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +151 -0
- package/src/main/lib/__tests__/opsRootResolve.test.cjs +149 -0
- package/src/main/lib/__tests__/projectRootResolve.test.cjs +148 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +112 -0
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +19 -9
- package/src/main/lib/__tests__/schedulerBatchFairness.test.cjs +213 -0
- package/src/main/lib/__tests__/schedulerBatchProjectCap.test.cjs +127 -0
- package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +217 -0
- package/src/main/lib/activeIndexMerge.cjs +15 -0
- package/src/main/lib/activeIndexRebuild.cjs +133 -0
- package/src/main/lib/buildTarget.cjs +3 -2
- package/src/main/lib/childWithLog.cjs +33 -1
- package/src/main/lib/crossProjectFeedback.cjs +8 -1
- package/src/main/lib/delegationReadiness.cjs +408 -26
- package/src/main/lib/ephemeralCwd.cjs +78 -0
- package/src/main/lib/epicDelegationStats.cjs +2 -1
- package/src/main/lib/epicMint.cjs +17 -1
- package/src/main/lib/epicStatusMirror.cjs +95 -0
- package/src/main/lib/epicValidationHook.cjs +2 -1
- package/src/main/lib/gitWorktree.cjs +56 -2
- package/src/main/lib/jobWorktree.cjs +1 -0
- package/src/main/lib/loadGate.cjs +134 -0
- package/src/main/lib/mcpToolCatalog.cjs +285 -0
- package/src/main/lib/opsErrorLog.cjs +12 -1
- package/src/main/lib/opsOwnership.cjs +94 -0
- package/src/main/lib/prdAdminRoutes.cjs +43 -3
- package/src/main/lib/prdCreate.cjs +46 -14
- package/src/main/lib/prdLocations.cjs +13 -6
- package/src/main/lib/projectRootResolve.cjs +134 -0
- package/src/main/lib/promptSessionSchema.cjs +7 -0
- package/src/main/lib/queueStore.cjs +31 -5
- package/src/main/lib/rcaReport.cjs +1 -1
- package/src/main/lib/reaperHelpers.cjs +47 -1
- package/src/main/lib/schedulerBatch.cjs +171 -29
- package/src/main/lib/schedulerConfig.cjs +80 -0
- package/src/main/projectBrief.cjs +3 -2
- package/src/main/projectPages.cjs +2 -1
- package/src/main/promptSessionTranscript.cjs +0 -0
- package/src/main/pty.cjs +5 -0
- package/src/main/queueOps.cjs +15 -8
- package/src/main/scheduler.cjs +346 -49
- package/src/main/seedSchedulerMcp.cjs +58 -4
- package/src/preload/api.d.ts +69 -1
- package/src/preload/index.cjs +2 -0
package/src/main/health.cjs
CHANGED
|
@@ -9,13 +9,16 @@ const fsp = require('node:fs/promises');
|
|
|
9
9
|
const path = require('node:path');
|
|
10
10
|
const os = require('node:os');
|
|
11
11
|
const { execFileSync } = require('node:child_process');
|
|
12
|
-
const { POLL_INTERVAL_MS } = require('./lib/schedulerConfig.cjs');
|
|
12
|
+
const { POLL_INTERVAL_MS, loadGateThreshold } = require('./lib/schedulerConfig.cjs');
|
|
13
|
+
const { opsPath } = require('./lib/opsOwnership.cjs');
|
|
13
14
|
const { checkPersonaImports } = require('./lib/personaImportHealth.cjs');
|
|
15
|
+
const { checkDelegationReadiness } = require('./lib/delegationReadiness.cjs');
|
|
14
16
|
const { resolvePrdsDirs } = require('./lib/prdLocations.cjs');
|
|
15
17
|
const { migratePrds } = require('./lib/prdMigration.cjs');
|
|
16
18
|
const queueStore = require('./lib/queueStore.cjs');
|
|
17
19
|
const { computeStallSummary } = require('./scheduler.cjs');
|
|
18
20
|
const { DEFAULT_RUNS_DIR, computeReport, isRetentionEnabled, liveKeysFromJobs } = require('./lib/runLogRetention.cjs');
|
|
21
|
+
const { allProjectCwds } = require('../../scripts/lib/activeSessions.cjs');
|
|
19
22
|
|
|
20
23
|
const MAX_LOG_AGE_MS = 5 * 60_000; // 5 min — warn if no logs this old
|
|
21
24
|
const PROJECT_ROOT = path.resolve(__dirname, '../..');
|
|
@@ -225,6 +228,113 @@ function evaluatePrdMigrationHealth(migrationResult, legacyPrdsDir) {
|
|
|
225
228
|
};
|
|
226
229
|
}
|
|
227
230
|
|
|
231
|
+
// Pure shaping of checkDelegationReadiness()'s result (delegationReadiness.cjs)
|
|
232
|
+
// into a health component + issue lines — kept separate from the fs-touching
|
|
233
|
+
// check() call site so it's directly unit-testable, matching
|
|
234
|
+
// evaluatePrdMigrationHealth's pattern. delegationReadiness.cjs already wraps
|
|
235
|
+
// every filesystem read (readJsonSafe / try-catch), so a missing/unreadable
|
|
236
|
+
// config surfaces here as one of `checks` with ok:false, never a throw.
|
|
237
|
+
function evaluateDelegationChainHealth(delegationResult) {
|
|
238
|
+
const component = { ok: delegationResult.ok, checks: delegationResult.checks };
|
|
239
|
+
const issues = delegationResult.checks
|
|
240
|
+
.filter((c) => !c.ok)
|
|
241
|
+
.map((c) => `Delegation readiness: ${c.label} failed — ${c.detail}${c.fix ? ` (fix: ${c.fix})` : ''}`);
|
|
242
|
+
return { component, issues };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// computeEpicIndexDrift(cwd) → { orphan_rows, orphan_files, unmirrored,
|
|
246
|
+
// orphanRowIds, orphanFileIds, unmirroredFiles }
|
|
247
|
+
//
|
|
248
|
+
// One project's drift between active-index.json (the index) and prompt-
|
|
249
|
+
// sessions/<id>.json (the status mirror epicStatusMirror.cjs writes on every
|
|
250
|
+
// status-changing write path). Pure filesystem read, never throws — an
|
|
251
|
+
// unreadable index or prompt-sessions dir degrades to empty rather than
|
|
252
|
+
// aborting the whole health check (matches evaluatePrdMigrationHealth's
|
|
253
|
+
// fail-open-on-read spirit).
|
|
254
|
+
function computeEpicIndexDrift(cwd) {
|
|
255
|
+
let dir;
|
|
256
|
+
try {
|
|
257
|
+
dir = opsPath(cwd, 'prompt-sessions');
|
|
258
|
+
} catch {
|
|
259
|
+
return { orphan_rows: 0, orphan_files: 0, unmirrored: 0 }; // unusable cwd degrades to empty, same as an unreadable index below
|
|
260
|
+
}
|
|
261
|
+
const indexPath = path.join(dir, 'active-index.json');
|
|
262
|
+
let sessions = {};
|
|
263
|
+
let tombstones = {};
|
|
264
|
+
try {
|
|
265
|
+
const parsed = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
|
|
266
|
+
sessions = parsed && typeof parsed.sessions === 'object' && parsed.sessions ? parsed.sessions : {};
|
|
267
|
+
tombstones = parsed && typeof parsed.tombstones === 'object' && parsed.tombstones ? parsed.tombstones : {};
|
|
268
|
+
} catch { /* missing/unreadable index — treat as empty, not a throw */ }
|
|
269
|
+
|
|
270
|
+
let files = [];
|
|
271
|
+
try {
|
|
272
|
+
files = fs.readdirSync(dir).filter((f) => f.endsWith('.json') && f !== 'active-index.json');
|
|
273
|
+
} catch { /* prompt-sessions/ doesn't exist yet — nothing to compare */ }
|
|
274
|
+
|
|
275
|
+
const fileIds = new Set(files.map((f) => f.slice(0, -'.json'.length)));
|
|
276
|
+
const orphanRowIds = Object.keys(sessions).filter((id) => !fileIds.has(id));
|
|
277
|
+
|
|
278
|
+
const orphanFileIds = [];
|
|
279
|
+
const unmirroredFiles = [];
|
|
280
|
+
for (const file of files) {
|
|
281
|
+
const id = file.slice(0, -'.json'.length);
|
|
282
|
+
let parsed;
|
|
283
|
+
try {
|
|
284
|
+
parsed = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
|
|
285
|
+
} catch {
|
|
286
|
+
continue; // unreadable file — neither orphan nor unmirrored classification applies
|
|
287
|
+
}
|
|
288
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.status) {
|
|
289
|
+
unmirroredFiles.push(file);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const looksLive = (parsed.status === 'proposed' || parsed.status === 'active') && !parsed.archivedAt;
|
|
293
|
+
const hasRow = Object.prototype.hasOwnProperty.call(sessions, id);
|
|
294
|
+
const isTombstoned = Object.prototype.hasOwnProperty.call(tombstones, id);
|
|
295
|
+
if (looksLive && !hasRow && !isTombstoned) orphanFileIds.push(file);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
orphan_rows: orphanRowIds.length,
|
|
300
|
+
orphan_files: orphanFileIds.length,
|
|
301
|
+
unmirrored: unmirroredFiles.length,
|
|
302
|
+
orphanRowIds,
|
|
303
|
+
orphanFileIds,
|
|
304
|
+
unmirroredFiles,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// evaluateEpicIndexHealth(cwds) → { component, issues }
|
|
309
|
+
//
|
|
310
|
+
// Shapes computeEpicIndexDrift's per-project result into a health component +
|
|
311
|
+
// issue lines, matching evaluateDelegationChainHealth's pattern. Only
|
|
312
|
+
// orphan_rows (an index row whose Epic file is gone — the actual data-loss
|
|
313
|
+
// signal) is an issue; orphan_files and unmirrored are informational drift,
|
|
314
|
+
// not failures — a fresh install or an Epic mid-migration legitimately has
|
|
315
|
+
// unmirrored files.
|
|
316
|
+
function evaluateEpicIndexHealth(cwds) {
|
|
317
|
+
const byProject = {};
|
|
318
|
+
const issues = [];
|
|
319
|
+
let anyOrphanRows = false;
|
|
320
|
+
for (const cwd of cwds) {
|
|
321
|
+
const drift = computeEpicIndexDrift(cwd);
|
|
322
|
+
byProject[cwd] = {
|
|
323
|
+
orphan_rows: drift.orphan_rows,
|
|
324
|
+
orphan_files: drift.orphan_files,
|
|
325
|
+
unmirrored: drift.unmirrored,
|
|
326
|
+
};
|
|
327
|
+
if (drift.orphan_rows > 0) {
|
|
328
|
+
anyOrphanRows = true;
|
|
329
|
+
issues.push(
|
|
330
|
+
`Epic index drift: ${cwd} has ${drift.orphan_rows} active-index.json row(s) with no matching `
|
|
331
|
+
+ `prompt-sessions/<id>.json file (${drift.orphanRowIds.join(', ')})`
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return { component: { ok: !anyOrphanRows, byProject }, issues };
|
|
336
|
+
}
|
|
337
|
+
|
|
228
338
|
async function check() {
|
|
229
339
|
const start = Date.now();
|
|
230
340
|
const status = {
|
|
@@ -340,6 +450,15 @@ async function check() {
|
|
|
340
450
|
byProject: computeProjectProblemCounts(queueState.jobs),
|
|
341
451
|
perProjectStall,
|
|
342
452
|
tickLiveness: liveness.reason,
|
|
453
|
+
// Informational only (PRD 1085): current 1-min loadavg per core vs the
|
|
454
|
+
// launch-gate threshold, so a "nothing is launching" report can be
|
|
455
|
+
// read next to the reason without opening the app.
|
|
456
|
+
loadRatio: (() => {
|
|
457
|
+
const cores = (os.cpus() || []).length;
|
|
458
|
+
const l1 = os.loadavg()[0];
|
|
459
|
+
return cores > 0 && Number.isFinite(l1) ? Number((l1 / cores).toFixed(3)) : null;
|
|
460
|
+
})(),
|
|
461
|
+
loadGateThreshold: loadGateThreshold(),
|
|
343
462
|
};
|
|
344
463
|
if (liveness.stalled) {
|
|
345
464
|
const ageMin = Math.round(liveness.tickAgeMs / 60_000);
|
|
@@ -505,6 +624,22 @@ async function check() {
|
|
|
505
624
|
}
|
|
506
625
|
}
|
|
507
626
|
|
|
627
|
+
// 6.55. Delegation-chain readiness — answers "can this machine actually
|
|
628
|
+
// delegate work to the scheduler?" (see delegationReadiness.cjs's header:
|
|
629
|
+
// when the scheduler MCP isn't registered, scheduler_create_prd is simply
|
|
630
|
+
// absent from the agent's tool list — no error to catch, so an agent asked
|
|
631
|
+
// to delegate just implements inline instead). Critical: a failing check
|
|
632
|
+
// here means Epics silently stop queueing PRDs on this machine.
|
|
633
|
+
try {
|
|
634
|
+
const delegation = await checkDelegationReadiness({ cwd: PROJECT_ROOT });
|
|
635
|
+
const { component, issues: delegationIssues } = evaluateDelegationChainHealth(delegation);
|
|
636
|
+
status.components.delegation_chain = component;
|
|
637
|
+
status.issues.push(...delegationIssues);
|
|
638
|
+
} catch (e) {
|
|
639
|
+
status.components.delegation_chain = { ok: false, error: e.message };
|
|
640
|
+
status.issues.push(`Delegation readiness check failed: ${e.message}`);
|
|
641
|
+
}
|
|
642
|
+
|
|
508
643
|
// 6.6. Run-log retention report (informational only — never blocks health).
|
|
509
644
|
// Read-only against the REAL scheduled-plans/runs/ dir: reports current
|
|
510
645
|
// usage + what the configured policy (if any) would remove. Nothing is
|
|
@@ -536,11 +671,24 @@ async function check() {
|
|
|
536
671
|
status.components.run_log_retention = { ok: true, error: e.message };
|
|
537
672
|
}
|
|
538
673
|
|
|
674
|
+
// 6.65. Epic index drift — orphan_rows/orphan_files/unmirrored across every
|
|
675
|
+
// project this machine has ever opened (allProjectCwds, no recency filter
|
|
676
|
+
// — a quiet project still owns its Epics). See computeEpicIndexDrift's
|
|
677
|
+
// header.
|
|
678
|
+
try {
|
|
679
|
+
const { component, issues: epicIndexIssues } = evaluateEpicIndexHealth(allProjectCwds());
|
|
680
|
+
status.components.epic_index = component;
|
|
681
|
+
status.issues.push(...epicIndexIssues);
|
|
682
|
+
} catch (e) {
|
|
683
|
+
status.components.epic_index = { ok: false, error: e.message };
|
|
684
|
+
status.issues.push(`Epic index health check failed: ${e.message}`);
|
|
685
|
+
}
|
|
686
|
+
|
|
539
687
|
// 7. Summary scoring: ok if all critical components pass.
|
|
540
688
|
// Critical: nodejs, config dir, typescript, build artifact, test infrastructure.
|
|
541
689
|
// Non-fatal: scheduler/transcripts dirs may not exist on fresh install.
|
|
542
690
|
// Informational: app log age (shows if app is running, but not blocking).
|
|
543
|
-
const criticalComponents = ['nodejs', 'config_dir', 'typescript', 'build_artifact', 'test_infrastructure', 'scheduler_queue', 'prd_migration', 'claude_md_budget'];
|
|
691
|
+
const criticalComponents = ['nodejs', 'config_dir', 'typescript', 'build_artifact', 'test_infrastructure', 'scheduler_queue', 'prd_migration', 'claude_md_budget', 'delegation_chain'];
|
|
544
692
|
status.ok = criticalComponents.every((c) => status.components[c]?.ok !== false);
|
|
545
693
|
|
|
546
694
|
status.elapsedMs = Date.now() - start;
|
|
@@ -561,6 +709,9 @@ module.exports = {
|
|
|
561
709
|
evaluateTickLiveness,
|
|
562
710
|
readFreshHeartbeat,
|
|
563
711
|
evaluatePrdMigrationHealth,
|
|
712
|
+
evaluateDelegationChainHealth,
|
|
713
|
+
computeEpicIndexDrift,
|
|
714
|
+
evaluateEpicIndexHealth,
|
|
564
715
|
computeProjectProblemCounts,
|
|
565
716
|
evaluatePerProjectStall,
|
|
566
717
|
parseClaudeMdBudget,
|
package/src/main/index.cjs
CHANGED
|
@@ -36,7 +36,8 @@ const epicWorktreeMint = require('./lib/epicWorktreeMint.cjs');
|
|
|
36
36
|
const epicWorktreeMerge = require('./lib/epicWorktreeMerge.cjs');
|
|
37
37
|
const epicWorktreeProjectConfig = require('./lib/epicWorktreeProjectConfig.cjs');
|
|
38
38
|
const agentLibrary = require('./agentLibrary.cjs');
|
|
39
|
-
const { checkDelegationReadiness } = require('./lib/delegationReadiness.cjs');
|
|
39
|
+
const { checkDelegationReadiness, installPrdWriteGuard } = require('./lib/delegationReadiness.cjs');
|
|
40
|
+
const { MCP_TOOL_CATALOG, MCP_RECIPES } = require('./lib/mcpToolCatalog.cjs');
|
|
40
41
|
const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
|
|
41
42
|
const { resolveBuildTarget } = require('./lib/buildTarget.cjs');
|
|
42
43
|
const crossProjectFeedback = require('./lib/crossProjectFeedback.cjs');
|
|
@@ -81,6 +82,9 @@ const { listExchanges } = require('./exchanges.cjs');
|
|
|
81
82
|
const { resolveClaudeBin } = require('./lib/claudeBin.cjs');
|
|
82
83
|
const { checkInsideHome, assertInsideHome } = require('./lib/insideHome.cjs');
|
|
83
84
|
const { openInEditor, openFileInEditor, openInFinder, openInTerminal } = require('./lib/openExternalApp.cjs');
|
|
85
|
+
const { rebuildActiveIndex } = require('./lib/activeIndexRebuild.cjs');
|
|
86
|
+
const { activeIndexPath: promptSessionsActiveIndexPath } = require('./lib/epicMint.cjs');
|
|
87
|
+
const { allProjectCwds } = require('../../scripts/lib/activeSessions.cjs');
|
|
84
88
|
|
|
85
89
|
let mainWindow = null;
|
|
86
90
|
let rebooting = false;
|
|
@@ -456,6 +460,11 @@ ipcMain.handle('app:launch-mode', () => ({
|
|
|
456
460
|
// `claude mcp list`. Read-only, single in-flight call — no polling.
|
|
457
461
|
ipcMain.handle('mcp:status', () => probeMcpStatus());
|
|
458
462
|
|
|
463
|
+
// Project Home's "Agent tools" block (PhAgentTools): the same catalog data
|
|
464
|
+
// GET /admin/mcp/catalog serves to external callers, read in-process here so
|
|
465
|
+
// the renderer never makes a direct HTTP call. Static data, no cwd needed.
|
|
466
|
+
ipcMain.handle('mcp:catalog', () => ({ ok: true, tools: MCP_TOOL_CATALOG, recipes: MCP_RECIPES }));
|
|
467
|
+
|
|
459
468
|
// Agent Library nav page (Home face only): global `~/.claude/agents/*.md`
|
|
460
469
|
// personas plus, per currently-open project tab, whether that project
|
|
461
470
|
// overlays the same agent name at `<cwd>/.claude/agents/<name>.md`. Read-only.
|
|
@@ -494,12 +503,19 @@ ipcMain.handle('agents:get-persona-body', validated(schemas.agentsGetPersonaBody
|
|
|
494
503
|
agentLibrary.getPersonaBody(payload)));
|
|
495
504
|
|
|
496
505
|
// "Can this project actually delegate?" probe (PRD: delegation-readiness).
|
|
497
|
-
// Structured data over the
|
|
498
|
-
//
|
|
499
|
-
ipcMain.handle('app:delegation-readiness', validated(schemas.delegationReadinessCwd, (payload) =>
|
|
506
|
+
// Structured data over the preconditions for scheduler_create_prd being in an
|
|
507
|
+
// agent's tool list at all — surfacing it in the UI is a sibling PRD.
|
|
508
|
+
ipcMain.handle('app:delegation-readiness', validated(schemas.delegationReadinessCwd, async (payload) =>
|
|
500
509
|
checkDelegationReadiness(payload)
|
|
501
510
|
));
|
|
502
511
|
|
|
512
|
+
// The one readiness check with a sanctioned one-press install. Session Manager
|
|
513
|
+
// standardizes on the REFERENCE approach (absolute path to this repo's guard
|
|
514
|
+
// script, never a vendored copy) — see installPrdWriteGuard's header.
|
|
515
|
+
ipcMain.handle('app:install-prd-write-guard', validated(schemas.delegationReadinessCwd, (payload) =>
|
|
516
|
+
installPrdWriteGuard(payload)
|
|
517
|
+
));
|
|
518
|
+
|
|
503
519
|
ipcMain.handle('app:engage-rules-path', () => process.env.SESSION_MANAGER_ENGAGE_RULES || null);
|
|
504
520
|
|
|
505
521
|
// Boot diagnostics — renderer polls these to surface toasts when `claude` isn't
|
|
@@ -1193,6 +1209,42 @@ app.whenReady().then(async () => {
|
|
|
1193
1209
|
seedSchedulerMcp({ logger: console, writeLog: logs.writeLine }).catch((e) => {
|
|
1194
1210
|
logs.writeLine({ scope: 'seed-scheduler-mcp', level: 'error', message: 'seed failed', meta: { error: e?.message } });
|
|
1195
1211
|
});
|
|
1212
|
+
// Epic index self-heal: a project whose active-index.json is MISSING or
|
|
1213
|
+
// fails to parse has lost every open Epic's status unless the per-Epic
|
|
1214
|
+
// status mirror (epicStatusMirror.cjs) can rebuild it. Runs once per boot,
|
|
1215
|
+
// fire-and-forget, and only touches a project whose index reads unclean —
|
|
1216
|
+
// a clean index is never rewritten here. See activeIndexRebuild.cjs.
|
|
1217
|
+
try {
|
|
1218
|
+
for (const projectCwd of allProjectCwds()) {
|
|
1219
|
+
const indexPath = promptSessionsActiveIndexPath(projectCwd);
|
|
1220
|
+
let unclean = false;
|
|
1221
|
+
let parseError = null;
|
|
1222
|
+
if (!fs.existsSync(indexPath)) {
|
|
1223
|
+
unclean = true;
|
|
1224
|
+
} else {
|
|
1225
|
+
try {
|
|
1226
|
+
JSON.parse(fs.readFileSync(indexPath, 'utf8'));
|
|
1227
|
+
} catch (e) {
|
|
1228
|
+
unclean = true;
|
|
1229
|
+
parseError = e.message;
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
if (!unclean) continue;
|
|
1233
|
+
try {
|
|
1234
|
+
const { rows, skipped } = rebuildActiveIndex(projectCwd);
|
|
1235
|
+
logs.writeLine({
|
|
1236
|
+
scope: 'active-index-rebuild',
|
|
1237
|
+
level: 'info',
|
|
1238
|
+
message: 'rebuilt active-index.json from status mirrors',
|
|
1239
|
+
meta: { cwd: projectCwd, parseError, restored: rows.length, skipped: skipped.length },
|
|
1240
|
+
});
|
|
1241
|
+
} catch (e) {
|
|
1242
|
+
logs.writeLine({ scope: 'active-index-rebuild', level: 'error', message: 'rebuild failed', meta: { cwd: projectCwd, error: e?.message } });
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
} catch (e) {
|
|
1246
|
+
logs.writeLine({ scope: 'active-index-rebuild', level: 'error', message: 'boot sweep failed', meta: { error: e?.message } });
|
|
1247
|
+
}
|
|
1196
1248
|
// History rollup finalize pass: deferred 30s past boot so it never competes
|
|
1197
1249
|
// with first-paint, fire-and-forget (cron/offline refresh is PRD 651 — this
|
|
1198
1250
|
// is just the one-shot in-app pass).
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -301,7 +301,12 @@ const PRD_CREATE_SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
|
301
301
|
const NO_NEWLINE_RE = /^[^\r\n]*$/;
|
|
302
302
|
const schedulerCreatePrd = z.object({
|
|
303
303
|
title: z.string().min(1).max(200).regex(NO_NEWLINE_RE, 'must not contain newlines'),
|
|
304
|
-
|
|
304
|
+
// Optional (PRD: worktree-cwd Epic-lookup hazard) — when omitted, prdCreate.cjs's
|
|
305
|
+
// createPrd resolves the real project cwd server-side via
|
|
306
|
+
// projectRootResolve.cjs's resolveProjectContext, using originClaudeSessionId/
|
|
307
|
+
// sourcePromptId/originProjectRoot. A call with none of those still fails with
|
|
308
|
+
// a clear "cwd is required" error.
|
|
309
|
+
cwd: z.string().min(1).max(4096).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
|
|
305
310
|
estimateMinutes: z.number().int().min(1).max(100000),
|
|
306
311
|
goal: z.string().min(1).max(20000),
|
|
307
312
|
acceptanceCriteria: z.array(z.string().min(1).max(2000)).min(1).max(100),
|
|
@@ -333,6 +338,13 @@ const schedulerCreatePrd = z.object({
|
|
|
333
338
|
// within an Epic's own chat session joins that Epic even if the tool call
|
|
334
339
|
// forgot to pass sourcePromptId explicitly.
|
|
335
340
|
originClaudeSessionId: z.string().min(1).max(128).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
|
|
341
|
+
// Trusted project-root hint (SM_PROJECT_ROOT env, stamped by chatRunner.cjs/
|
|
342
|
+
// pty.cjs/scheduler.cjs's job spawn onto the child that ultimately calls this
|
|
343
|
+
// route, forwarded by scheduler-mcp-server.cjs as originProjectRoot) — never
|
|
344
|
+
// written to frontmatter; projectRootResolve.cjs's resolveProjectContext
|
|
345
|
+
// treats it as a higher-priority hint than the caller-supplied cwd, but
|
|
346
|
+
// still loses to a resolved Epic's own project cwd.
|
|
347
|
+
originProjectRoot: z.string().min(1).max(4096).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
|
|
336
348
|
// User-selected work-type tag (PRD 774; independence from the parent
|
|
337
349
|
// Epic's own tag confirmed PRD 1041) — deterministic, never LLM-classified.
|
|
338
350
|
// Epic tag and PRD tag draw on the same WorkType vocabulary
|
|
@@ -343,8 +355,12 @@ const schedulerCreatePrd = z.object({
|
|
|
343
355
|
});
|
|
344
356
|
|
|
345
357
|
// Bulk archive: slug list, capped to limit unbounded retag/archive payloads.
|
|
358
|
+
// Optional cwd narrows every slug's directory search to one project (same
|
|
359
|
+
// role as scheduler_get_prd/scheduler_update_prd's cwd hint) — omitted, the
|
|
360
|
+
// search still spans every candidate PRD dir machine-wide.
|
|
346
361
|
const scheduleArchivePrd = z.object({
|
|
347
362
|
slugs: z.array(z.string().regex(SCHEDULE_SLUG_RE)).min(1).max(500),
|
|
363
|
+
cwd: z.string().min(1).max(4096).optional(),
|
|
348
364
|
});
|
|
349
365
|
|
|
350
366
|
const scheduleRetagItem = z.object({
|
|
@@ -425,6 +441,7 @@ const adminUpdatePrd = z.object({
|
|
|
425
441
|
|
|
426
442
|
const adminCancelJob = z.object({
|
|
427
443
|
slug: z.string().regex(SCHEDULE_SLUG_RE),
|
|
444
|
+
cwd: z.string().min(1).max(4096).optional(),
|
|
428
445
|
});
|
|
429
446
|
|
|
430
447
|
// ──────────────────────────────────────────── Projects
|
|
@@ -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,63 @@
|
|
|
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
|
+
});
|