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
|
@@ -267,12 +267,18 @@ test('depth-1 (ordinary, no investigationDepth field) needs_review job is eligib
|
|
|
267
267
|
assert.strictEqual(result.length, 1);
|
|
268
268
|
});
|
|
269
269
|
|
|
270
|
-
test('fix-plan job with investigationDepth:
|
|
271
|
-
const jobs = [makeJob({ slug: '05-fix-foo', investigationDepth:
|
|
270
|
+
test('fix-plan job with investigationDepth: 1 is still eligible (its first -fix follow-up)', () => {
|
|
271
|
+
const jobs = [makeJob({ slug: '05-fix-foo', investigationDepth: 1 })];
|
|
272
272
|
const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
|
|
273
273
|
assert.strictEqual(result.length, 1);
|
|
274
274
|
});
|
|
275
275
|
|
|
276
|
+
test('fix-plan job with investigationDepth: 2 is now NOT eligible (cap lowered to permit at most -fix-fix)', () => {
|
|
277
|
+
const jobs = [makeJob({ slug: '05-fix-foo', investigationDepth: 2 })];
|
|
278
|
+
const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
|
|
279
|
+
assert.strictEqual(result.length, 0);
|
|
280
|
+
});
|
|
281
|
+
|
|
276
282
|
test('fix-plan job with investigationDepth: 3 is NOT eligible (bound holds)', () => {
|
|
277
283
|
const jobs = [makeJob({ slug: '05-fix-foo', investigationDepth: 3 })];
|
|
278
284
|
const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
|
|
@@ -289,7 +295,7 @@ test('isFixPlanBeyondDepthCap: non-fix-plan slug is never capped regardless of d
|
|
|
289
295
|
assert.strictEqual(isFixPlanBeyondDepthCap('05-my-feature', 99), false);
|
|
290
296
|
});
|
|
291
297
|
|
|
292
|
-
test('isFixPlanBeyondDepthCap: fix-plan at depth 1
|
|
298
|
+
test('isFixPlanBeyondDepthCap: fix-plan at depth 1 is not capped, depth 2 is', () => {
|
|
293
299
|
assert.strictEqual(isFixPlanBeyondDepthCap('05-fix-foo', 1), false);
|
|
294
300
|
assert.strictEqual(isFixPlanBeyondDepthCap('05-fix-foo', MAX_INVESTIGATION_DEPTH), false);
|
|
295
301
|
assert.strictEqual(isFixPlanBeyondDepthCap('05-fix-foo', MAX_INVESTIGATION_DEPTH + 1), true);
|
|
@@ -349,6 +355,12 @@ test('isEligibleForImmediateAutoFix: fix-plan job beyond the depth cap is exclud
|
|
|
349
355
|
assert.strictEqual(result, false);
|
|
350
356
|
});
|
|
351
357
|
|
|
358
|
+
test('isEligibleForImmediateAutoFix: fix-plan job at investigationDepth 2 is now excluded (previously eligible under the old cap)', () => {
|
|
359
|
+
const job = makeJob({ slug: '05-fix-foo', investigationDepth: 2 });
|
|
360
|
+
const result = isEligibleForImmediateAutoFix(job, [job], noSiblingOnDisk);
|
|
361
|
+
assert.strictEqual(result, false);
|
|
362
|
+
});
|
|
363
|
+
|
|
352
364
|
test('isEligibleForImmediateAutoFix: a different job in the same queue does not affect this job\'s eligibility', () => {
|
|
353
365
|
const job = makeJob();
|
|
354
366
|
const other = { slug: '05-fix-my-feature', status: 'pending', runId: null };
|
|
@@ -165,6 +165,47 @@ test('commitGuardVerdict: self-commit skip still works (regression check)', () =
|
|
|
165
165
|
expect(verdict).toBeNull();
|
|
166
166
|
});
|
|
167
167
|
|
|
168
|
+
test('commitGuardVerdict: ranInWorktree=true defeats siblingRunning — clean tree, no self-commit → silent_no_op (PRD 109 shape)', () => {
|
|
169
|
+
const verdict = commitGuardVerdict({
|
|
170
|
+
newlyDirty: [],
|
|
171
|
+
siblingRunning: true,
|
|
172
|
+
ranInWorktree: true,
|
|
173
|
+
jobSelfCommitted: false,
|
|
174
|
+
legitimateNoOp: false,
|
|
175
|
+
isFixPlanJob: false,
|
|
176
|
+
verifyResult: null,
|
|
177
|
+
});
|
|
178
|
+
expect(verdict).not.toBeNull();
|
|
179
|
+
expect(verdict.verdict).toBe('silent_no_op');
|
|
180
|
+
expect(verdict.downgradeTo).toBe('needs_review');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('commitGuardVerdict: identical inputs with ranInWorktree=false → no flag (legacy shared-tree behaviour unchanged)', () => {
|
|
184
|
+
const verdict = commitGuardVerdict({
|
|
185
|
+
newlyDirty: [],
|
|
186
|
+
siblingRunning: true,
|
|
187
|
+
ranInWorktree: false,
|
|
188
|
+
jobSelfCommitted: false,
|
|
189
|
+
legitimateNoOp: false,
|
|
190
|
+
isFixPlanJob: false,
|
|
191
|
+
verifyResult: null,
|
|
192
|
+
});
|
|
193
|
+
expect(verdict).toBeNull();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('commitGuardVerdict: ranInWorktree=true + siblingRunning=true but jobSelfCommitted=true → no flag (self-commit carve-out still applies)', () => {
|
|
197
|
+
const verdict = commitGuardVerdict({
|
|
198
|
+
newlyDirty: [],
|
|
199
|
+
siblingRunning: true,
|
|
200
|
+
ranInWorktree: true,
|
|
201
|
+
jobSelfCommitted: true,
|
|
202
|
+
legitimateNoOp: false,
|
|
203
|
+
isFixPlanJob: false,
|
|
204
|
+
verifyResult: null,
|
|
205
|
+
});
|
|
206
|
+
expect(verdict).toBeNull();
|
|
207
|
+
});
|
|
208
|
+
|
|
168
209
|
test('commitGuardVerdict: carries forward a non-clean prior verdict as an annotation', () => {
|
|
169
210
|
const verifyResult = {
|
|
170
211
|
verdict: 'transcript_errors',
|
|
@@ -36,7 +36,8 @@ const path = require('node:path');
|
|
|
36
36
|
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'reap-dead-running-jobs-test-'));
|
|
37
37
|
process.env.HOME = tmpHome;
|
|
38
38
|
|
|
39
|
-
const { reapDeadRunningJobs } = require('../scheduler.cjs');
|
|
39
|
+
const { reapDeadRunningJobs, PIDLESS_SPAWN_GRACE_MS } = require('../scheduler.cjs');
|
|
40
|
+
const { AUDIT_LOG_PATH } = require('../lib/auditLog.cjs');
|
|
40
41
|
|
|
41
42
|
function registerActiveProject(cwd) {
|
|
42
43
|
const projectsDir = path.join(tmpHome, '.claude', 'projects');
|
|
@@ -87,6 +88,64 @@ test('reapDeadRunningJobs reconciles a queue.json row stuck at status:running wi
|
|
|
87
88
|
assert.equal(jobs[0].runtime, undefined);
|
|
88
89
|
});
|
|
89
90
|
|
|
91
|
+
test('reapDeadRunningJobs reaps a pidless row older than PIDLESS_SPAWN_GRACE_MS with an empty run dir as failed, not completed, and audits it', async () => {
|
|
92
|
+
const projectCwd = path.join(tmpHome, 'c-project');
|
|
93
|
+
fs.mkdirSync(projectCwd, { recursive: true });
|
|
94
|
+
registerActiveProject(projectCwd);
|
|
95
|
+
|
|
96
|
+
const staleStartedAt = new Date(Date.now() - PIDLESS_SPAWN_GRACE_MS - 60_000).toISOString();
|
|
97
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
98
|
+
{
|
|
99
|
+
slug: 'pidless-zombie',
|
|
100
|
+
status: 'running',
|
|
101
|
+
cwd: projectCwd,
|
|
102
|
+
runId: 'run-pidless-zombie',
|
|
103
|
+
startedAt: staleStartedAt,
|
|
104
|
+
estimateMinutes: 24,
|
|
105
|
+
// no runtime key at all — the spawn never got far enough to record one
|
|
106
|
+
},
|
|
107
|
+
]);
|
|
108
|
+
// Empty run dir: created, but never written to (the exact 2026-09-01 repro).
|
|
109
|
+
fs.mkdirSync(path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', 'run-pidless-zombie'), { recursive: true });
|
|
110
|
+
|
|
111
|
+
const auditSizeBefore = fs.existsSync(AUDIT_LOG_PATH) ? fs.statSync(AUDIT_LOG_PATH).size : 0;
|
|
112
|
+
|
|
113
|
+
await reapDeadRunningJobs();
|
|
114
|
+
|
|
115
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
116
|
+
assert.equal(jobs.length, 1);
|
|
117
|
+
assert.equal(jobs[0].status, 'failed', 'an empty run dir must never be reaped as completed');
|
|
118
|
+
assert.match(jobs[0].error, /no runtime\.pid recorded/);
|
|
119
|
+
assert.equal(jobs[0].runtime, undefined);
|
|
120
|
+
|
|
121
|
+
const auditText = fs.readFileSync(AUDIT_LOG_PATH, 'utf8').slice(auditSizeBefore);
|
|
122
|
+
const auditLines = auditText.trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
123
|
+
const pidlessEvent = auditLines.find((e) => e.kind === 'job_reaped_pidless' && e.slug === 'pidless-zombie');
|
|
124
|
+
assert.ok(pidlessEvent, 'reaping a pidless row must leave an audit trace');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('reapDeadRunningJobs leaves a pidless row alone while it is still within the grace window', async () => {
|
|
128
|
+
const projectCwd = path.join(tmpHome, 'd-project');
|
|
129
|
+
fs.mkdirSync(projectCwd, { recursive: true });
|
|
130
|
+
registerActiveProject(projectCwd);
|
|
131
|
+
|
|
132
|
+
const freshStartedAt = new Date(Date.now() - 60_000).toISOString(); // 1 minute ago
|
|
133
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
134
|
+
{
|
|
135
|
+
slug: 'mid-flight-spawn',
|
|
136
|
+
status: 'running',
|
|
137
|
+
cwd: projectCwd,
|
|
138
|
+
runId: 'run-mid-flight',
|
|
139
|
+
startedAt: freshStartedAt,
|
|
140
|
+
},
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
await reapDeadRunningJobs();
|
|
144
|
+
|
|
145
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
146
|
+
assert.equal(jobs[0].status, 'running', 'a genuinely mid-flight spawn must not be reaped');
|
|
147
|
+
});
|
|
148
|
+
|
|
90
149
|
test('reapDeadRunningJobs is a no-op when no job is actually running in queue.json', async () => {
|
|
91
150
|
const projectCwd = path.join(tmpHome, 'b-project');
|
|
92
151
|
fs.mkdirSync(projectCwd, { recursive: true });
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-stranded-investigation.test.cjs — findStrandedInvestigations: a
|
|
3
|
+
* job left 'investigating' with nothing to restore it once the process that
|
|
4
|
+
* spawned its probe is gone.
|
|
5
|
+
*
|
|
6
|
+
* spawnInvestigation's own restore (onExit / synchronous-throw catch) runs
|
|
7
|
+
* ONLY inside the live process that spawned the probe. An app restart (or
|
|
8
|
+
* crash) mid-probe leaves the row frozen at 'investigating' forever — the
|
|
9
|
+
* comment at spawnInvestigation's onExit already asserts "'investigating'
|
|
10
|
+
* must never be the job's resting state", but nothing enforced that across a
|
|
11
|
+
* restart. Live repro (burrow, 2026-09-01): 834-session-summary-... sat
|
|
12
|
+
* 'investigating' for 1653 minutes even though finishedAt+exitCode were
|
|
13
|
+
* already recorded 27h earlier.
|
|
14
|
+
*
|
|
15
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-stranded-investigation.test.cjs
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
// vitest, NOT node:test — this repo's suite is vitest-only (CLAUDE.md).
|
|
21
|
+
import { test } from 'vitest';
|
|
22
|
+
const assert = require('node:assert/strict');
|
|
23
|
+
const { findStrandedInvestigations, INVESTIGATION_MAX_MS } = require('../scheduler.cjs');
|
|
24
|
+
|
|
25
|
+
const NOW = Date.parse('2026-09-01T12:00:00.000Z');
|
|
26
|
+
const agoMin = (m) => new Date(NOW - m * 60_000).toISOString();
|
|
27
|
+
const neverAlive = () => false;
|
|
28
|
+
const alwaysAlive = () => true;
|
|
29
|
+
|
|
30
|
+
test('stranded past the window is selected, with the pre-probe status as restoreStatus', () => {
|
|
31
|
+
const jobs = [
|
|
32
|
+
{
|
|
33
|
+
slug: 'a',
|
|
34
|
+
cwd: '/p1',
|
|
35
|
+
status: 'investigating',
|
|
36
|
+
statusHistory: [
|
|
37
|
+
{ from: 'failed', to: 'investigating', at: agoMin(120), reason: 'spawning investigation probe' },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
const out = findStrandedInvestigations(jobs, NOW, INVESTIGATION_MAX_MS, neverAlive);
|
|
42
|
+
assert.strictEqual(out.length, 1);
|
|
43
|
+
assert.strictEqual(out[0].slug, 'a');
|
|
44
|
+
assert.strictEqual(out[0].restoreStatus, 'failed');
|
|
45
|
+
assert.ok(out[0].ageMs >= 119 * 60_000);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('within the window is left alone — a live probe must not be yanked mid-flight', () => {
|
|
49
|
+
const jobs = [
|
|
50
|
+
{
|
|
51
|
+
slug: 'b',
|
|
52
|
+
cwd: '/p1',
|
|
53
|
+
status: 'investigating',
|
|
54
|
+
statusHistory: [{ from: 'needs_review', to: 'investigating', at: agoMin(10) }],
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
assert.deepStrictEqual(findStrandedInvestigations(jobs, NOW, INVESTIGATION_MAX_MS, neverAlive), []);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('a live probe process (pid alive) is never restored, even past the window', () => {
|
|
61
|
+
const jobs = [
|
|
62
|
+
{
|
|
63
|
+
slug: 'c',
|
|
64
|
+
cwd: '/p1',
|
|
65
|
+
status: 'investigating',
|
|
66
|
+
runtime: { pid: 4242 },
|
|
67
|
+
statusHistory: [{ from: 'failed', to: 'investigating', at: agoMin(120) }],
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
assert.deepStrictEqual(findStrandedInvestigations(jobs, NOW, INVESTIGATION_MAX_MS, alwaysAlive), []);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('a row with no runtime.pid past the window is still selected (dead-process default)', () => {
|
|
74
|
+
const jobs = [
|
|
75
|
+
{
|
|
76
|
+
slug: 'd',
|
|
77
|
+
cwd: '/p1',
|
|
78
|
+
status: 'investigating',
|
|
79
|
+
statusHistory: [{ from: 'failed', to: 'investigating', at: agoMin(120) }],
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
const out = findStrandedInvestigations(jobs, NOW, INVESTIGATION_MAX_MS, alwaysAlive);
|
|
83
|
+
assert.strictEqual(out.length, 1);
|
|
84
|
+
assert.strictEqual(out[0].slug, 'd');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('missing statusHistory transition is warn-logged and not restored — cannot prove age', () => {
|
|
88
|
+
const jobs = [{ slug: 'e', cwd: '/p1', status: 'investigating', statusHistory: [] }];
|
|
89
|
+
const warnSpy = [];
|
|
90
|
+
const origWarn = console.warn;
|
|
91
|
+
console.warn = (...args) => warnSpy.push(args.join(' '));
|
|
92
|
+
try {
|
|
93
|
+
const out = findStrandedInvestigations(jobs, NOW, INVESTIGATION_MAX_MS, neverAlive);
|
|
94
|
+
assert.deepStrictEqual(out, []);
|
|
95
|
+
assert.ok(warnSpy.some((line) => line.includes('e') && line.includes('no statusHistory entry')), `expected a warn mentioning slug 'e', got: ${JSON.stringify(warnSpy)}`);
|
|
96
|
+
} finally {
|
|
97
|
+
console.warn = origWarn;
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('unparseable transition timestamp is warn-logged and not restored', () => {
|
|
102
|
+
const jobs = [
|
|
103
|
+
{
|
|
104
|
+
slug: 'f',
|
|
105
|
+
cwd: '/p1',
|
|
106
|
+
status: 'investigating',
|
|
107
|
+
statusHistory: [{ from: 'failed', to: 'investigating', at: 'not-a-date' }],
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
const warnSpy = [];
|
|
111
|
+
const origWarn = console.warn;
|
|
112
|
+
console.warn = (...args) => warnSpy.push(args.join(' '));
|
|
113
|
+
try {
|
|
114
|
+
const out = findStrandedInvestigations(jobs, NOW, INVESTIGATION_MAX_MS, neverAlive);
|
|
115
|
+
assert.deepStrictEqual(out, []);
|
|
116
|
+
assert.ok(warnSpy.some((line) => line.includes('f') && line.includes('unparseable')), `expected a warn mentioning slug 'f', got: ${JSON.stringify(warnSpy)}`);
|
|
117
|
+
} finally {
|
|
118
|
+
console.warn = origWarn;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('the burrow-834 shape: finishedAt + non-null exitCode already recorded is restored using that recorded outcome, not re-run', () => {
|
|
123
|
+
const jobs = [
|
|
124
|
+
{
|
|
125
|
+
slug: '834-session-summary-reports-planner-intent-as-outcome',
|
|
126
|
+
cwd: '/home/bilko/Projects/burrow',
|
|
127
|
+
status: 'investigating',
|
|
128
|
+
finishedAt: '2026-08-31T15:13:15.181Z',
|
|
129
|
+
exitCode: 0,
|
|
130
|
+
error: 'is_error=true in final 20% of transcript (event 91/101)',
|
|
131
|
+
estimateMinutes: 60,
|
|
132
|
+
statusHistory: [
|
|
133
|
+
{ from: 'needs_review', to: 'investigating', at: '2026-08-31T15:20:00.000Z', reason: 'spawning investigation probe' },
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
|
+
];
|
|
137
|
+
const out = findStrandedInvestigations(jobs, NOW, INVESTIGATION_MAX_MS, neverAlive);
|
|
138
|
+
assert.strictEqual(out.length, 1);
|
|
139
|
+
assert.strictEqual(out[0].restoreStatus, 'needs_review');
|
|
140
|
+
assert.ok(out[0].ageMs / 60_000 > 1200, `expected well past the 60m threshold, got ${out[0].ageMs / 60_000}m`);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('only the most recent investigating transition is used when a job was investigated more than once', () => {
|
|
144
|
+
const jobs = [
|
|
145
|
+
{
|
|
146
|
+
slug: 'g',
|
|
147
|
+
cwd: '/p1',
|
|
148
|
+
status: 'investigating',
|
|
149
|
+
statusHistory: [
|
|
150
|
+
{ from: 'failed', to: 'investigating', at: agoMin(500) },
|
|
151
|
+
{ from: 'investigating', to: 'failed', at: agoMin(400) },
|
|
152
|
+
{ from: 'failed', to: 'investigating', at: agoMin(10) }, // recent retry — still within the window
|
|
153
|
+
],
|
|
154
|
+
},
|
|
155
|
+
];
|
|
156
|
+
assert.deepStrictEqual(findStrandedInvestigations(jobs, NOW, INVESTIGATION_MAX_MS, neverAlive), []);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('non-investigating rows are never considered', () => {
|
|
160
|
+
const base = { cwd: '/p1', statusHistory: [{ from: 'x', to: 'investigating', at: agoMin(500) }] };
|
|
161
|
+
const jobs = [
|
|
162
|
+
{ ...base, slug: 'h', status: 'failed' },
|
|
163
|
+
{ ...base, slug: 'i', status: 'running' },
|
|
164
|
+
{ ...base, slug: 'j', status: 'needs_review' },
|
|
165
|
+
];
|
|
166
|
+
assert.deepStrictEqual(findStrandedInvestigations(jobs, NOW, INVESTIGATION_MAX_MS, neverAlive), []);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('a corrupted/illegal statusHistory `from` value falls back to failed rather than being handed to transitionJob unvalidated', () => {
|
|
170
|
+
const jobs = [
|
|
171
|
+
{
|
|
172
|
+
slug: 'k',
|
|
173
|
+
cwd: '/p1',
|
|
174
|
+
status: 'investigating',
|
|
175
|
+
statusHistory: [{ from: 'quarantined', to: 'investigating', at: agoMin(120) }],
|
|
176
|
+
},
|
|
177
|
+
];
|
|
178
|
+
const out = findStrandedInvestigations(jobs, NOW, INVESTIGATION_MAX_MS, neverAlive);
|
|
179
|
+
assert.strictEqual(out.length, 1);
|
|
180
|
+
assert.strictEqual(out[0].restoreStatus, 'failed');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('the shipped default is the documented one (60 minutes)', () => {
|
|
184
|
+
assert.strictEqual(INVESTIGATION_MAX_MS, 60 * 60_000);
|
|
185
|
+
});
|
|
@@ -143,3 +143,69 @@ test('a failed attempt bumps the counter and stops after MAX_ATTEMPTS', async ()
|
|
|
143
143
|
expect(called).toBe(false);
|
|
144
144
|
expect(readMarker()).toMatchObject({ done: false, attempts: 3 });
|
|
145
145
|
});
|
|
146
|
+
|
|
147
|
+
// ─────────────────────────────── repair: marker done, script path stale
|
|
148
|
+
|
|
149
|
+
test('marker done + user-scope script path missing -> removes then re-adds, marker stays done', async () => {
|
|
150
|
+
fs.mkdirSync(path.dirname(markerPath()), { recursive: true });
|
|
151
|
+
fs.writeFileSync(markerPath(), JSON.stringify({ done: true, attempts: 0, ts: '2026-01-01T00:00:00.000Z' }));
|
|
152
|
+
fs.writeFileSync(
|
|
153
|
+
claudeJsonPath(),
|
|
154
|
+
JSON.stringify({
|
|
155
|
+
mcpServers: {
|
|
156
|
+
'session-manager-scheduler': { type: 'stdio', command: 'node', args: ['/definitely/does/not/exist.cjs'] },
|
|
157
|
+
},
|
|
158
|
+
})
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const removeCalls = [];
|
|
162
|
+
const removeFn = async () => { removeCalls.push(1); return { ok: true }; };
|
|
163
|
+
const addCalls = [];
|
|
164
|
+
const addFn = async (serverPath) => { addCalls.push(serverPath); return { ok: true, exitCode: 0 }; };
|
|
165
|
+
|
|
166
|
+
await seedSchedulerMcp({ logger: silentLogger(), addFn, removeFn });
|
|
167
|
+
|
|
168
|
+
expect(removeCalls).toHaveLength(1);
|
|
169
|
+
expect(addCalls).toHaveLength(1);
|
|
170
|
+
expect(path.isAbsolute(addCalls[0])).toBe(true);
|
|
171
|
+
expect(readMarker().done).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('marker done + user-scope entry ABSENT (deliberate remove) stays removed, no repair attempted', async () => {
|
|
175
|
+
fs.mkdirSync(path.dirname(markerPath()), { recursive: true });
|
|
176
|
+
fs.writeFileSync(markerPath(), JSON.stringify({ done: true, attempts: 0, ts: '2026-01-01T00:00:00.000Z' }));
|
|
177
|
+
// No ~/.claude.json at all — same as a deliberate `claude mcp remove`.
|
|
178
|
+
|
|
179
|
+
let removeCalled = false;
|
|
180
|
+
let addCalled = false;
|
|
181
|
+
const removeFn = async () => { removeCalled = true; return { ok: true }; };
|
|
182
|
+
const addFn = async () => { addCalled = true; return { ok: true }; };
|
|
183
|
+
|
|
184
|
+
await seedSchedulerMcp({ logger: silentLogger(), addFn, removeFn });
|
|
185
|
+
|
|
186
|
+
expect(removeCalled).toBe(false);
|
|
187
|
+
expect(addCalled).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('marker done + user-scope script path exists -> healthy, no repair attempted', async () => {
|
|
191
|
+
fs.mkdirSync(path.dirname(markerPath()), { recursive: true });
|
|
192
|
+
fs.writeFileSync(markerPath(), JSON.stringify({ done: true, attempts: 0, ts: '2026-01-01T00:00:00.000Z' }));
|
|
193
|
+
fs.writeFileSync(
|
|
194
|
+
claudeJsonPath(),
|
|
195
|
+
JSON.stringify({
|
|
196
|
+
mcpServers: {
|
|
197
|
+
'session-manager-scheduler': { type: 'stdio', command: 'node', args: [markerPath()] }, // any existing file
|
|
198
|
+
},
|
|
199
|
+
})
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
let removeCalled = false;
|
|
203
|
+
let addCalled = false;
|
|
204
|
+
const removeFn = async () => { removeCalled = true; return { ok: true }; };
|
|
205
|
+
const addFn = async () => { addCalled = true; return { ok: true }; };
|
|
206
|
+
|
|
207
|
+
await seedSchedulerMcp({ logger: silentLogger(), addFn, removeFn });
|
|
208
|
+
|
|
209
|
+
expect(removeCalled).toBe(false);
|
|
210
|
+
expect(addCalled).toBe(false);
|
|
211
|
+
});
|
|
@@ -101,11 +101,20 @@ describe('pickForProject dependsOn eligibility (PRD 832)', () => {
|
|
|
101
101
|
// like anything else with no blocking dependsOn — the 828 row is no longer
|
|
102
102
|
// held back merely for carrying a higher number.
|
|
103
103
|
it('legacy same-NN pending jobs are eligible alongside higher-numbered ones', () => {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
104
|
+
// Predates the per-project job cap (PRD 1066, default 2); raise it so
|
|
105
|
+
// this test keeps exercising parallelGroup semantics, not the cap.
|
|
106
|
+
const prevCap = process.env.SM_PROJECT_JOB_CAP;
|
|
107
|
+
process.env.SM_PROJECT_JOB_CAP = '10';
|
|
108
|
+
try {
|
|
109
|
+
const a = job('827-one', 'pending', 827);
|
|
110
|
+
const b = job('827-two', 'pending', 827);
|
|
111
|
+
const later = job('828-three', 'pending', 828);
|
|
112
|
+
const res = pickForProject([a, b, later], new Set(), 3);
|
|
113
|
+
expect(res.batch.map((j) => j.slug).sort()).toEqual(['827-one', '827-two', '828-three']);
|
|
114
|
+
} finally {
|
|
115
|
+
if (prevCap === undefined) delete process.env.SM_PROJECT_JOB_CAP;
|
|
116
|
+
else process.env.SM_PROJECT_JOB_CAP = prevCap;
|
|
117
|
+
}
|
|
109
118
|
});
|
|
110
119
|
});
|
|
111
120
|
|
package/src/main/bilkoHost.cjs
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
const { ipcMain } = require('electron');
|
|
32
|
+
const { opsPath } = require('./lib/opsOwnership.cjs');
|
|
32
33
|
const fsp = require('node:fs/promises');
|
|
33
34
|
const path = require('node:path');
|
|
34
35
|
const crypto = require('node:crypto');
|
|
@@ -46,7 +47,7 @@ const WRITER = 'bilko-host';
|
|
|
46
47
|
const PROJECT_PAGE_LENSES = new Set(['home', 'marketing', 'feature', 'architecture']);
|
|
47
48
|
|
|
48
49
|
function bilkoHostDir(cwd) {
|
|
49
|
-
return
|
|
50
|
+
return opsPath(cwd, 'bilko-host');
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
function distDir(cwd) {
|
|
@@ -103,7 +104,7 @@ async function get({ cwd }) {
|
|
|
103
104
|
const realCwd = config.validatePath(cwd);
|
|
104
105
|
|
|
105
106
|
const marketingResult = await config.readText(
|
|
106
|
-
|
|
107
|
+
opsPath(realCwd, 'project-pages', 'output', 'marketing.html'),
|
|
107
108
|
);
|
|
108
109
|
const pkgResult = await config.readJson(path.join(realCwd, 'package.json'));
|
|
109
110
|
const pkg = pkgResult.exists && !pkgResult.parseError ? pkgResult.data : null;
|
|
@@ -215,7 +216,7 @@ async function removeDocument({ cwd, id }) {
|
|
|
215
216
|
async function resolveDocumentHtml(cwd, doc) {
|
|
216
217
|
if (doc.source.kind === 'project-page-lens') {
|
|
217
218
|
const result = await config.readText(
|
|
218
|
-
|
|
219
|
+
opsPath(cwd, 'project-pages', 'output', `${doc.source.lens}.html`),
|
|
219
220
|
);
|
|
220
221
|
if (!result.exists) {
|
|
221
222
|
throw new Error(`document "${doc.title}" points at the ${doc.source.lens} Project Page, but it hasn't been generated yet`);
|
package/src/main/chatRunner.cjs
CHANGED
|
@@ -518,7 +518,12 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
|
|
|
518
518
|
// resolveSourcePromptIdFromClaudeSession. sessionId IS the Epic's
|
|
519
519
|
// claudeSessionId for an Epic-backed tab (domain model: Epic:claude-session
|
|
520
520
|
// 1:1); for a non-Epic tab, resolution below simply finds no match.
|
|
521
|
-
|
|
521
|
+
// SM_PROJECT_ROOT is the real project cwd (never the worktree spawn cwd
|
|
522
|
+
// resolved below) — scheduler-mcp-server.cjs forwards it as
|
|
523
|
+
// originProjectRoot on create-prd/open-session/readiness calls so those
|
|
524
|
+
// routes can resolve the right project even from a worktree pwd. See
|
|
525
|
+
// projectRootResolve.cjs.
|
|
526
|
+
const childEnv = cleanChildEnv({ PATH: pathWithUserBins(), SM_CHAT_SESSION_ID: sessionId, SM_PROJECT_ROOT: cwd });
|
|
522
527
|
|
|
523
528
|
// Prepend the stop-signal protocol instruction and the chat-mode truth
|
|
524
529
|
// instruction to every prompt
|
package/src/main/config.cjs
CHANGED
|
@@ -19,7 +19,7 @@ const fs = require('node:fs');
|
|
|
19
19
|
const fsp = require('node:fs/promises');
|
|
20
20
|
const path = require('node:path');
|
|
21
21
|
const os = require('node:os');
|
|
22
|
-
const { assertOpsWrite } = require('./lib/opsOwnership.cjs');
|
|
22
|
+
const { assertOpsWrite, opsPath } = require('./lib/opsOwnership.cjs');
|
|
23
23
|
const chokidar = require('chokidar');
|
|
24
24
|
const logs = require('./logs.cjs');
|
|
25
25
|
const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
|
|
@@ -139,39 +139,28 @@ function validateWrite(realAbs) {
|
|
|
139
139
|
if (realAbs === claudeSub || realAbs.startsWith(claudeSub + path.sep)) {
|
|
140
140
|
return;
|
|
141
141
|
}
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
// Project Brief persistence (PRD 837 — projectBrief.cjs's writeJson of
|
|
162
|
-
// brief.json): narrowly scoped to session-manager-operations/project-brief/,
|
|
163
|
-
// this repo's existing per-project artifact-store convention.
|
|
164
|
-
const briefSub = path.join(realRoot, 'session-manager-operations', 'project-brief');
|
|
165
|
-
if (realAbs === briefSub || realAbs.startsWith(briefSub + path.sep)) {
|
|
166
|
-
return;
|
|
142
|
+
// Writable ops namespaces under a registered project root — this
|
|
143
|
+
// repo's per-project artifact-store convention:
|
|
144
|
+
// prompt-sessions PromptSession persistence (promptSessions.ts) and
|
|
145
|
+
// the scheduler's read-modify-write of active-index
|
|
146
|
+
// (promptSessionEvents.cjs, PRD 814)
|
|
147
|
+
// scheduler PRD sources + Epic PRD dirs + state shards
|
|
148
|
+
// (PRD 825; scheduler.cjs remote.writePrd)
|
|
149
|
+
// project-brief projectBrief.cjs's brief.json (PRD 837)
|
|
150
|
+
// bilko-host bilkoHost.cjs's bundle prep
|
|
151
|
+
// Paths come from opsOwnership.opsPath (PRD 1082) — the one ops-root
|
|
152
|
+
// resolver — which THROWS for an ephemeral root (a linked worktree that
|
|
153
|
+
// pty.cjs registered as an allowed root for its spawn cwd, or
|
|
154
|
+
// os.tmpdir()). That throw is the point: a worktree's ops subtree is
|
|
155
|
+
// never a writable destination, so its exemptions simply do not exist.
|
|
156
|
+
let opsSubs;
|
|
157
|
+
try {
|
|
158
|
+
opsSubs = ['prompt-sessions', 'scheduler', 'project-brief', 'bilko-host'].map((ns) => opsPath(realRoot, ns));
|
|
159
|
+
} catch {
|
|
160
|
+
opsSubs = [];
|
|
167
161
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
// session-manager-operations/bilko-host/, this repo's existing
|
|
171
|
-
// per-project artifact-store convention.
|
|
172
|
-
const bilkoHostSub = path.join(realRoot, 'session-manager-operations', 'bilko-host');
|
|
173
|
-
if (realAbs === bilkoHostSub || realAbs.startsWith(bilkoHostSub + path.sep)) {
|
|
174
|
-
return;
|
|
162
|
+
for (const sub of opsSubs) {
|
|
163
|
+
if (realAbs === sub || realAbs.startsWith(sub + path.sep)) return;
|
|
175
164
|
}
|
|
176
165
|
}
|
|
177
166
|
}
|