claude-code-session-manager 0.88.1 → 0.90.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-kQF8_wAw.js → AgentLibrary-CrmoSn9-.js} +1 -1
- package/dist/assets/DataModel-SPLQTR8E.js +1 -0
- package/dist/assets/{History-Sji2brb0.js → History-DT6S3abz.js} +2 -2
- package/dist/assets/{Hooks-CtqpO-p5.js → Hooks-CC2iv5Oq.js} +3 -3
- package/dist/assets/{HostBilko-DN0ydOCr.js → HostBilko-DqY7dA2a.js} +1 -1
- package/dist/assets/{Library-CoiLZTKh.js → Library-CUoOC77T.js} +1 -1
- package/dist/assets/{ListDetail-CmqiQ_io.js → ListDetail-DgrG72f4.js} +1 -1
- package/dist/assets/MarkdownEditor-C8dB3ZWU.js +1 -0
- package/dist/assets/{McpServers-Bj1X3_NT.js → McpServers-DupdjvFt.js} +2 -2
- package/dist/assets/{Memory-1PKjsM3b.js → Memory-C_4LhC2r.js} +6 -6
- package/dist/assets/{Panel-I1s6ghPo.js → Panel-DWmE5Fu9.js} +1 -1
- package/dist/assets/{Permissions-BhiiZjHQ.js → Permissions-hxiN2IWM.js} +3 -3
- package/dist/assets/{Plugins-gv3_fkPX.js → Plugins-fttEgZqy.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BGcgBd8V.js → ProvenanceBadge-DROCzlbx.js} +1 -1
- package/dist/assets/{SaveBar-BGr9eZ4g.js → SaveBar-BZs0IQHq.js} +1 -1
- package/dist/assets/Scheduler-Bi4oZ8gg.js +16 -0
- package/dist/assets/{ScopeSwitcher-ukl0qWaA.js → ScopeSwitcher-C0538XU8.js} +1 -1
- package/dist/assets/{Settings-DJkFj22G.js → Settings-CcW6x-wu.js} +2 -2
- package/dist/assets/{SkillReferenceGraph-0t1Qblp6.js → SkillReferenceGraph-BtCcPAYO.js} +1 -1
- package/dist/assets/{Skills-CeVdBGE-.js → Skills-Daf_1sQR.js} +2 -2
- package/dist/assets/SystemPrompt-rYIla_wt.js +1 -0
- package/dist/assets/{TagLibrary-DAzNaUPd.js → TagLibrary-81Mr1RLU.js} +1 -1
- package/dist/assets/{TiptapBody--S_rGXoA.js → TiptapBody-BxuLNg1e.js} +1 -1
- package/dist/assets/{Toggle-Dszm0xnp.js → Toggle-CN8EVEai.js} +1 -1
- package/dist/assets/{index-Dp4Rc--q.js → index-A9TWmB7_.js} +345 -345
- package/dist/assets/{index-BHTX4OTc.css → index-Dh7BLxdA.css} +1 -1
- package/dist/assets/{settingsSchema-BLbssMYo.js → settingsSchema-C9I9a0jv.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/README.md +1 -0
- package/src/main/__tests__/chatRunner-session-flag-retry.test.cjs +159 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +3 -1
- package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +16 -4
- package/src/main/__tests__/scheduler-default-eligible-heal.test.cjs +10 -3
- package/src/main/__tests__/scheduler-integration-failure-stamp.test.cjs +41 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +9 -2
- package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +23 -0
- package/src/main/__tests__/scheduler-needs-review-autoresolve.test.cjs +14 -6
- package/src/main/__tests__/scheduler-shard-quarantine.test.cjs +2 -2
- package/src/main/__tests__/scheduler-stranded-autofix-park.test.cjs +8 -1
- package/src/main/__tests__/transcripts-paged-reads.test.cjs +4 -2
- package/src/main/__tests__/transcripts-worktree-epic-path.test.cjs +154 -0
- package/src/main/__tests__/usageSingleFlight.test.cjs +3 -1
- package/src/main/build-info.json +4 -4
- package/src/main/chatRunner.cjs +108 -45
- package/src/main/health.cjs +20 -3
- package/src/main/ipcSchemas.cjs +3 -0
- package/src/main/lib/__tests__/epicSpawnCwd.test.cjs +34 -0
- package/src/main/lib/__tests__/epicSpawnPlan.test.cjs +117 -0
- package/src/main/lib/__tests__/epicTranscriptPath.test.cjs +163 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +172 -0
- package/src/main/lib/__tests__/localAdminHttp.test.cjs +5 -1
- package/src/main/lib/__tests__/schedulerPathsWorktree.test.cjs +40 -1
- package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +8 -5
- package/src/main/lib/effectiveModelInfo.cjs +3 -3
- package/src/main/lib/epicDelegationStats.cjs +3 -2
- package/src/main/lib/epicSpawnCwd.cjs +36 -5
- package/src/main/lib/epicSpawnPlan.cjs +119 -0
- package/src/main/lib/epicTranscriptDiagnostic.cjs +79 -0
- package/src/main/lib/epicTranscriptPath.cjs +184 -0
- package/src/main/lib/gitWorktree.cjs +196 -4
- package/src/main/lib/mcpToolCatalog.cjs +6 -1
- package/src/main/lib/prdCreate.cjs +27 -1
- package/src/main/lib/prdFrontmatter.cjs +16 -5
- package/src/main/lib/rcaReport.cjs +35 -4
- package/src/main/lib/schedulerPaths.cjs +27 -4
- package/src/main/lib/telemetryClient.cjs +11 -4
- package/src/main/lib/terminalRunOutcome.cjs +1 -0
- package/src/main/pty.cjs +1 -1
- package/src/main/runVerify.cjs +89 -1
- package/src/main/scheduler.cjs +106 -18
- package/src/main/templates/PRD_AUTHORING.md +11 -0
- package/src/main/transcripts.cjs +28 -7
- package/src/preload/api.d.ts +2 -0
- package/src/preload/index.cjs +1 -0
- package/dist/assets/DataModel-CY_mMnsr.js +0 -1
- package/dist/assets/MarkdownEditor-HPoNeKhT.js +0 -1
- package/dist/assets/Scheduler-Bdo_awPq.js +0 -14
- package/dist/assets/SystemPrompt-CfKvNHi3.js +0 -1
|
@@ -1310,3 +1310,175 @@ test('[guard][epic] integrateEpicBranch inherits the same guard and still merges
|
|
|
1310
1310
|
|
|
1311
1311
|
await gitWorktree.cleanupEpicWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
|
|
1312
1312
|
});
|
|
1313
|
+
|
|
1314
|
+
// ── integrateBranch failure subtype classification (mc-01) ──
|
|
1315
|
+
|
|
1316
|
+
test('[job] integrateBranch classifies a dirty different tracked file as failureKind blocking_paths', async () => {
|
|
1317
|
+
const slug = 'test-slug-kind-blocking';
|
|
1318
|
+
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
1319
|
+
expect(worktree.ok).toBe(true);
|
|
1320
|
+
|
|
1321
|
+
fs.writeFileSync(path.join(worktree.dir, 'README.md'), 'job edit\n', 'utf8');
|
|
1322
|
+
git(['add', '-A'], worktree.dir);
|
|
1323
|
+
git(['commit', '-q', '-m', 'job edits README'], worktree.dir);
|
|
1324
|
+
// Dirty (uncommitted), byte-DIFFERENT edit in the main tree blocks the merge.
|
|
1325
|
+
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'different local edit\n', 'utf8');
|
|
1326
|
+
|
|
1327
|
+
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
1328
|
+
expect(outcome.ok).toBe(false);
|
|
1329
|
+
expect(outcome.reason).toMatch(/merge failed \(likely a real content conflict\)/);
|
|
1330
|
+
expect(outcome.failureKind).toBe('blocking_paths');
|
|
1331
|
+
expect(outcome.blockingPaths.tracked).toEqual(['README.md']);
|
|
1332
|
+
expect(outcome.blockingPaths.untracked).toEqual([]);
|
|
1333
|
+
|
|
1334
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
test('[job] integrateBranch classifies diverging same-line commits as content_conflict with the conflicted path', async () => {
|
|
1338
|
+
const slug = 'test-slug-kind-content';
|
|
1339
|
+
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
1340
|
+
expect(worktree.ok).toBe(true);
|
|
1341
|
+
|
|
1342
|
+
fs.writeFileSync(path.join(worktree.dir, 'README.md'), 'job edit\n', 'utf8');
|
|
1343
|
+
git(['add', '-A'], worktree.dir);
|
|
1344
|
+
git(['commit', '-q', '-m', 'job edits README'], worktree.dir);
|
|
1345
|
+
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'main tree edit\n', 'utf8');
|
|
1346
|
+
git(['add', '-A'], repoCwd);
|
|
1347
|
+
git(['commit', '-q', '-m', 'main tree edits README'], repoCwd);
|
|
1348
|
+
|
|
1349
|
+
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
1350
|
+
expect(outcome.ok).toBe(false);
|
|
1351
|
+
expect(outcome.failureKind).toBe('content_conflict');
|
|
1352
|
+
expect(outcome.conflictedPaths).toEqual(['README.md']);
|
|
1353
|
+
// The abort still ran.
|
|
1354
|
+
expect(fs.existsSync(path.join(repoCwd, '.git', 'MERGE_HEAD'))).toBe(false);
|
|
1355
|
+
expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
|
|
1356
|
+
|
|
1357
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
1358
|
+
});
|
|
1359
|
+
|
|
1360
|
+
test('[job] integrateBranch classifies disjoint appends to the END of one file as content_conflict when the path is not allowlisted (.js)', async () => {
|
|
1361
|
+
const slug = 'test-slug-kind-append';
|
|
1362
|
+
fs.writeFileSync(path.join(repoCwd, 'src.js'), '// intro\n', 'utf8');
|
|
1363
|
+
git(['add', '-A'], repoCwd);
|
|
1364
|
+
git(['commit', '-q', '-m', 'add src.js'], repoCwd);
|
|
1365
|
+
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
1366
|
+
expect(worktree.ok).toBe(true);
|
|
1367
|
+
|
|
1368
|
+
fs.appendFileSync(path.join(worktree.dir, 'src.js'), '\n// Section Six\nsix body\n', 'utf8');
|
|
1369
|
+
git(['add', '-A'], worktree.dir);
|
|
1370
|
+
git(['commit', '-q', '-m', 'job appends section six'], worktree.dir);
|
|
1371
|
+
fs.appendFileSync(path.join(repoCwd, 'src.js'), '\n## Section Seven\nseven body\n\n## Section Ten\nten body\n', 'utf8');
|
|
1372
|
+
git(['add', '-A'], repoCwd);
|
|
1373
|
+
git(['commit', '-q', '-m', 'main appends sections seven and ten'], repoCwd);
|
|
1374
|
+
|
|
1375
|
+
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
1376
|
+
expect(outcome.ok).toBe(false);
|
|
1377
|
+
expect(outcome.failureKind).toBe('content_conflict');
|
|
1378
|
+
expect(outcome.conflictedPaths).toEqual(['src.js']);
|
|
1379
|
+
|
|
1380
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
1381
|
+
});
|
|
1382
|
+
|
|
1383
|
+
// ── pure-addition merge-conflict auto-resolve (mc-02) ──
|
|
1384
|
+
|
|
1385
|
+
async function conflictScenario(slug, { base, side, main }) {
|
|
1386
|
+
for (const [f, c] of Object.entries(base)) {
|
|
1387
|
+
fs.mkdirSync(path.dirname(path.join(repoCwd, f)), { recursive: true });
|
|
1388
|
+
fs.writeFileSync(path.join(repoCwd, f), c, 'utf8');
|
|
1389
|
+
}
|
|
1390
|
+
git(['add', '-A'], repoCwd);
|
|
1391
|
+
git(['commit', '-q', '-m', 'base files'], repoCwd);
|
|
1392
|
+
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
1393
|
+
expect(worktree.ok).toBe(true);
|
|
1394
|
+
for (const [f, c] of Object.entries(side)) {
|
|
1395
|
+
fs.mkdirSync(path.dirname(path.join(worktree.dir, f)), { recursive: true });
|
|
1396
|
+
fs.writeFileSync(path.join(worktree.dir, f), c, 'utf8');
|
|
1397
|
+
}
|
|
1398
|
+
git(['add', '-A'], worktree.dir);
|
|
1399
|
+
git(['commit', '-q', '-m', 'job side'], worktree.dir);
|
|
1400
|
+
for (const [f, c] of Object.entries(main)) {
|
|
1401
|
+
fs.mkdirSync(path.dirname(path.join(repoCwd, f)), { recursive: true });
|
|
1402
|
+
fs.writeFileSync(path.join(repoCwd, f), c, 'utf8');
|
|
1403
|
+
}
|
|
1404
|
+
git(['add', '-A'], repoCwd);
|
|
1405
|
+
git(['commit', '-q', '-m', 'main side'], repoCwd);
|
|
1406
|
+
return worktree;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
const MARKER_RE = /^(<<<<<<<|\|\|\|\|\|\|\||=======|>>>>>>>)/m;
|
|
1410
|
+
|
|
1411
|
+
test('[job] integrateBranch auto-resolves a pure-addition .md conflict by concatenating ours then theirs', async () => {
|
|
1412
|
+
const slug = 'test-slug-pure-add';
|
|
1413
|
+
const worktree = await conflictScenario(slug, {
|
|
1414
|
+
base: { 'd.md': '# Doc\n\nintro\n' },
|
|
1415
|
+
side: { 'd.md': '# Doc\n\nintro\n\n## Section A\na body\n' },
|
|
1416
|
+
main: { 'd.md': '# Doc\n\nintro\n\n## Section B\nb body\n\n## Section C\nc body\n' },
|
|
1417
|
+
});
|
|
1418
|
+
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
1419
|
+
expect(outcome.ok).toBe(true);
|
|
1420
|
+
expect(outcome.autoResolved).toBe('pure_addition_concat');
|
|
1421
|
+
expect(outcome.resolvedPaths).toEqual(['d.md']);
|
|
1422
|
+
const merged = fs.readFileSync(path.join(repoCwd, 'd.md'), 'utf8');
|
|
1423
|
+
expect(merged).toBe('# Doc\n\nintro\n\n## Section B\nb body\n\n## Section C\nc body\n\n## Section A\na body\n');
|
|
1424
|
+
expect(merged).not.toMatch(MARKER_RE);
|
|
1425
|
+
expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
|
|
1426
|
+
expect(git(['log', '-1', '--format=%s'], repoCwd).trim()).toBe(`merge scheduler job ${slug}`);
|
|
1427
|
+
expect(git(['rev-list', '--parents', '-n', '1', 'HEAD'], repoCwd).trim().split(' ').length).toBe(3);
|
|
1428
|
+
|
|
1429
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
test('[job] integrateBranch does NOT auto-resolve a same-line .md edit and leaves the tree clean', async () => {
|
|
1433
|
+
const slug = 'test-slug-pure-add-neg-sameline';
|
|
1434
|
+
const worktree = await conflictScenario(slug, {
|
|
1435
|
+
base: { 'd.md': 'intro\nORIGINAL LINE\n' },
|
|
1436
|
+
side: { 'd.md': 'intro\nSIDE EDIT\n' },
|
|
1437
|
+
main: { 'd.md': 'intro\nMAIN EDIT\n' },
|
|
1438
|
+
});
|
|
1439
|
+
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
1440
|
+
expect(outcome.ok).toBe(false);
|
|
1441
|
+
expect(outcome.autoResolved).toBeUndefined();
|
|
1442
|
+
expect(outcome.failureKind).toBe('content_conflict');
|
|
1443
|
+
expect(outcome.conflictedPaths).toEqual(['d.md']);
|
|
1444
|
+
expect(fs.readFileSync(path.join(repoCwd, 'd.md'), 'utf8')).toBe('intro\nMAIN EDIT\n');
|
|
1445
|
+
expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
|
|
1446
|
+
|
|
1447
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
test('[job] integrateBranch does NOT auto-resolve a pure-addition conflict on a non-allowlisted path', async () => {
|
|
1451
|
+
const slug = 'test-slug-pure-add-neg-js';
|
|
1452
|
+
const worktree = await conflictScenario(slug, {
|
|
1453
|
+
base: { 'src/thing.js': '// intro\n' },
|
|
1454
|
+
side: { 'src/thing.js': '// intro\n// A\n' },
|
|
1455
|
+
main: { 'src/thing.js': '// intro\n// B\n// C\n' },
|
|
1456
|
+
});
|
|
1457
|
+
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
1458
|
+
expect(outcome.ok).toBe(false);
|
|
1459
|
+
expect(outcome.autoResolved).toBeUndefined();
|
|
1460
|
+
expect(outcome.failureKind).toBe('content_conflict');
|
|
1461
|
+
expect(fs.readFileSync(path.join(repoCwd, 'src/thing.js'), 'utf8')).toBe('// intro\n// B\n// C\n');
|
|
1462
|
+
expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
|
|
1463
|
+
|
|
1464
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
test('[job] integrateBranch bails the whole merge when only some conflicted paths are provable (mixed .md + .js)', async () => {
|
|
1468
|
+
const slug = 'test-slug-pure-add-neg-mixed';
|
|
1469
|
+
const worktree = await conflictScenario(slug, {
|
|
1470
|
+
base: { 'd.md': 'intro\n', 'src/thing.js': 'ORIGINAL\n' },
|
|
1471
|
+
side: { 'd.md': 'intro\n## A\n', 'src/thing.js': 'SIDE\n' },
|
|
1472
|
+
main: { 'd.md': 'intro\n## B\n', 'src/thing.js': 'MAIN\n' },
|
|
1473
|
+
});
|
|
1474
|
+
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
1475
|
+
expect(outcome.ok).toBe(false);
|
|
1476
|
+
expect(outcome.autoResolved).toBeUndefined();
|
|
1477
|
+
expect(outcome.conflictedPaths).toEqual(['d.md', 'src/thing.js']);
|
|
1478
|
+
expect(fs.readFileSync(path.join(repoCwd, 'd.md'), 'utf8')).toBe('intro\n## B\n');
|
|
1479
|
+
expect(fs.readFileSync(path.join(repoCwd, 'src/thing.js'), 'utf8')).toBe('MAIN\n');
|
|
1480
|
+
expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
|
|
1481
|
+
expect(fs.existsSync(path.join(repoCwd, '.git', 'MERGE_HEAD'))).toBe(false);
|
|
1482
|
+
|
|
1483
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
1484
|
+
});
|
|
@@ -147,8 +147,12 @@ test('SM_DEV=1 writes to a dev-suffixed path, never the production admin-api.jso
|
|
|
147
147
|
expect(parsed.port).toBe(port);
|
|
148
148
|
expect(parsed.token).toBe(token);
|
|
149
149
|
// Production file must be untouched by the dev-mode instance.
|
|
150
|
+
// Content, not mtime: a live Session Manager on this machine legitimately rewrites the real
|
|
151
|
+
// admin-api.json at any moment (mtime drifted 12ms mid-test under load). What must hold is
|
|
152
|
+
// that THIS dev instance never wrote its port/token there.
|
|
150
153
|
if (prevMtime !== null) {
|
|
151
|
-
|
|
154
|
+
const prod = JSON.parse(fs.readFileSync(TOKEN_PATH, 'utf8'));
|
|
155
|
+
expect(prod.token).not.toBe(token);
|
|
152
156
|
} else {
|
|
153
157
|
expect(fs.existsSync(TOKEN_PATH)).toBe(false);
|
|
154
158
|
}
|
|
@@ -68,8 +68,47 @@ test('resolver and KIND_CONFIG roots follow SM_WORKTREE_ROOT together', () => {
|
|
|
68
68
|
expect(schedulerPaths.worktreeRoot('epic')).toBe(path.join(override, 'session-manager-epic-worktrees'));
|
|
69
69
|
expect(gitWorktree.KIND_CONFIG.job.root).toBe(schedulerPaths.worktreeRoot('job'));
|
|
70
70
|
expect(gitWorktree.KIND_CONFIG.epic.root).toBe(schedulerPaths.worktreeRoot('epic'));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('unset SM_WORKTREE_ROOT resolves the persistent per-user dir (0700, created), never os.tmpdir()', () => {
|
|
74
|
+
const state = path.join(scratch, 'state');
|
|
75
|
+
const prevXdg = process.env.XDG_STATE_HOME;
|
|
76
|
+
process.env.XDG_STATE_HOME = state;
|
|
71
77
|
delete process.env.SM_WORKTREE_ROOT;
|
|
72
|
-
|
|
78
|
+
try {
|
|
79
|
+
// scratch lives under os.tmpdir(), so the state dir passes the (unchanged) guard as a tmp child.
|
|
80
|
+
const base = schedulerPaths.worktreeBase();
|
|
81
|
+
expect(base).toBe(path.join(state, 'session-manager'));
|
|
82
|
+
expect(base).not.toBe(os.tmpdir());
|
|
83
|
+
expect(fs.statSync(base).mode & 0o777).toBe(0o700);
|
|
84
|
+
expect(schedulerPaths.worktreeRoot('epic')).toBe(path.join(base, 'session-manager-epic-worktrees'));
|
|
85
|
+
} finally {
|
|
86
|
+
if (prevXdg === undefined) delete process.env.XDG_STATE_HOME; else process.env.XDG_STATE_HOME = prevXdg;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('the vitest guard still throws for a live default (no SM_WORKTREE_ROOT, real ~/.local/state) and creates nothing', () => {
|
|
91
|
+
const prevXdg = process.env.XDG_STATE_HOME;
|
|
92
|
+
delete process.env.XDG_STATE_HOME;
|
|
93
|
+
delete process.env.SM_WORKTREE_ROOT;
|
|
94
|
+
const prevHome = process.env.HOME;
|
|
95
|
+
const fakeHome = path.join(scratch, 'nohome');
|
|
96
|
+
process.env.HOME = '/nonexistent-live-home-for-guard-test';
|
|
97
|
+
try {
|
|
98
|
+
expect(() => schedulerPaths.worktreeBase()).toThrow(/live root/);
|
|
99
|
+
expect(fs.existsSync(path.join(fakeHome, '.local'))).toBe(false);
|
|
100
|
+
} finally {
|
|
101
|
+
process.env.HOME = prevHome;
|
|
102
|
+
if (prevXdg !== undefined) process.env.XDG_STATE_HOME = prevXdg;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('SM_WORKTREE_ROOT set wins over the persistent default', () => {
|
|
107
|
+
process.env.XDG_STATE_HOME = path.join(scratch, 'ignored');
|
|
108
|
+
try {
|
|
109
|
+
expect(schedulerPaths.worktreeBase()).toBe(override);
|
|
110
|
+
expect(fs.existsSync(path.join(scratch, 'ignored'))).toBe(false);
|
|
111
|
+
} finally { delete process.env.XDG_STATE_HOME; }
|
|
73
112
|
});
|
|
74
113
|
|
|
75
114
|
test('create / sweep / isEphemeralCwd operate under the override, never the real root', async () => {
|
|
@@ -80,11 +80,14 @@ test('every counter event still yields appVersion + machineDigest once persisted
|
|
|
80
80
|
counters.trackEpicCreate(deps);
|
|
81
81
|
counters.trackSchedulerJobFinish({ status: 'completed' }, deps);
|
|
82
82
|
|
|
83
|
-
// track() ingress does real
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
83
|
+
// track() ingress does real fs I/O and the counter functions fire it without
|
|
84
|
+
// awaiting the promise, so poll (bounded) for all 4 records rather than a fixed
|
|
85
|
+
// sleep — a fixed 50ms lost the race under CPU contention.
|
|
86
|
+
let recs = [];
|
|
87
|
+
for (let i = 0; i < 200 && recs.length < 4; i++) {
|
|
88
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
89
|
+
recs = await readQueueLines(client);
|
|
90
|
+
}
|
|
88
91
|
expect(recs.length).toBe(4);
|
|
89
92
|
for (const rec of recs) {
|
|
90
93
|
const meta = asPersistedMetadata(rec);
|
|
@@ -27,7 +27,7 @@ const fs = require('node:fs');
|
|
|
27
27
|
const path = require('node:path');
|
|
28
28
|
const os = require('node:os');
|
|
29
29
|
const { splitFrontmatter } = require('./prdFrontmatter.cjs');
|
|
30
|
-
const {
|
|
30
|
+
const { resolveEpicTranscriptPath } = require('./epicTranscriptPath.cjs');
|
|
31
31
|
|
|
32
32
|
// Mirrors rawSessionModel.ts's RAW_MODELS — duplicated rather than imported
|
|
33
33
|
// because that file is a renderer ES module and this is a main-process CJS
|
|
@@ -252,10 +252,10 @@ function findLatestTranscriptModel(cwd, agentType, deps) {
|
|
|
252
252
|
matches.sort((a, b) => (Date.parse(b.createdAt || 0) || 0) - (Date.parse(a.createdAt || 0) || 0));
|
|
253
253
|
|
|
254
254
|
const homeDir = deps.homeDir || os.homedir();
|
|
255
|
-
const encodeCwdFn = deps.encodeCwd || encodeCwd;
|
|
256
255
|
const re = /"model":"([^"]+)"/g;
|
|
257
256
|
for (const session of matches) {
|
|
258
|
-
const filePath =
|
|
257
|
+
const filePath = resolveEpicTranscriptPath({ cwd, claudeSessionId: session.claudeSessionId, deps: { homeDir } }).path;
|
|
258
|
+
if (!filePath) continue;
|
|
259
259
|
const tail = readTail(filePath, MAX_TRANSCRIPT_TAIL_BYTES, deps);
|
|
260
260
|
if (!tail) continue;
|
|
261
261
|
let match;
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
const fs = require('node:fs');
|
|
28
28
|
const os = require('node:os');
|
|
29
29
|
const path = require('node:path');
|
|
30
|
-
const {
|
|
30
|
+
const { resolveEpicTranscriptPath } = require('./epicTranscriptPath.cjs');
|
|
31
31
|
const { classifyLine } = require('./classifyTranscriptLine.cjs');
|
|
32
32
|
|
|
33
33
|
// Same cap transcripts.cjs's readDelta uses for a single pass — bounds
|
|
@@ -85,7 +85,8 @@ function countInlineEdits(cwd, claudeSessionId, deps = {}) {
|
|
|
85
85
|
const readFn = deps.readTranscriptTail || readTranscriptTail;
|
|
86
86
|
const homeDir = deps.homeDir || os.homedir();
|
|
87
87
|
try {
|
|
88
|
-
const filePath =
|
|
88
|
+
const filePath = resolveEpicTranscriptPath({ cwd, claudeSessionId, deps: { homeDir } }).path;
|
|
89
|
+
if (!filePath) return 0;
|
|
89
90
|
const text = readFn(filePath, deps);
|
|
90
91
|
let count = 0;
|
|
91
92
|
for (const line of text.split('\n')) {
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
const fs = require('node:fs');
|
|
30
|
+
const path = require('node:path');
|
|
31
|
+
const { execFileSync } = require('node:child_process');
|
|
30
32
|
const { readActiveIndex } = require('./epicMint.cjs');
|
|
31
33
|
|
|
32
34
|
/**
|
|
@@ -61,6 +63,26 @@ function isUsableDir(dir, statImpl) {
|
|
|
61
63
|
}
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Re-attach `branch` at `dir` (`git worktree add`). Sync + bounded; never throws. `dir` comes from
|
|
68
|
+
* a git-tracked file, so it is only honoured when it sits in a `session-manager-epic-worktrees`
|
|
69
|
+
* segment and the branch is an `sm-epic/` one — nothing else may be materialised from that record.
|
|
70
|
+
* @returns {boolean} true when `dir` now exists as a checkout.
|
|
71
|
+
*/
|
|
72
|
+
function restoreEpicWorktree({ dir, branch, baseCwd }) {
|
|
73
|
+
try {
|
|
74
|
+
if (!path.isAbsolute(dir) || !path.isAbsolute(baseCwd)) return false;
|
|
75
|
+
if (!path.resolve(dir).split(path.sep).includes('session-manager-epic-worktrees')) return false;
|
|
76
|
+
if (typeof branch !== 'string' || !/^sm-epic\/[A-Za-z0-9._-]+$/.test(branch)) return false;
|
|
77
|
+
const run = (args) => execFileSync('git', args, { cwd: baseCwd, stdio: 'ignore', timeout: 15_000 });
|
|
78
|
+
run(['worktree', 'prune']);
|
|
79
|
+
run(['worktree', 'add', dir, branch]);
|
|
80
|
+
return isUsableDir(dir, fs.statSync);
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
64
86
|
/**
|
|
65
87
|
* @param {{ cwd: string, claudeSessionId: string, deps?: object }} opts
|
|
66
88
|
* @returns {string} `worktree.dir` when the matching Epic has one AND it still
|
|
@@ -77,10 +99,19 @@ function resolveEpicSpawnCwd({ cwd, claudeSessionId, deps = {} } = {}) {
|
|
|
77
99
|
const dir = session.worktree?.dir;
|
|
78
100
|
if (!dir) return cwd;
|
|
79
101
|
if (!isUsableDir(dir, statSync)) {
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
102
|
+
// NEVER fall back to the project cwd: the CLI files the transcript under
|
|
103
|
+
// encodeCwd(spawn cwd), so a resumed session started in a different dir splits
|
|
104
|
+
// its transcript across two encodings ("lost session"). Instead re-attach the
|
|
105
|
+
// Epic's own branch at the SAME recorded path (encoding unchanged); if that is
|
|
106
|
+
// impossible, return the recorded dir anyway so the spawn fails loudly (ENOENT)
|
|
107
|
+
// rather than silently forking the transcript. Read-side callers (no `restore`)
|
|
108
|
+
// keep the old cwd answer — they never spawn and must never mutate git state.
|
|
109
|
+
if (deps.restore) {
|
|
110
|
+
const restored = (deps.restoreWorktree || restoreEpicWorktree)({ dir, branch: session.worktree?.branch, baseCwd: cwd });
|
|
111
|
+
console.log(`[epicSpawnCwd] worktree dir gone (${dir}) — ${restored ? 'restored at same path' : 'restore failed; spawn will fail rather than split the transcript'}`);
|
|
112
|
+
return dir;
|
|
113
|
+
}
|
|
114
|
+
console.log(`[epicSpawnCwd] worktree dir gone (${dir}) — read-side falls back to ${cwd}`);
|
|
84
115
|
return cwd;
|
|
85
116
|
}
|
|
86
117
|
return dir;
|
|
@@ -92,4 +123,4 @@ function resolveEpicSpawnCwd({ cwd, claudeSessionId, deps = {} } = {}) {
|
|
|
92
123
|
return cwd;
|
|
93
124
|
}
|
|
94
125
|
|
|
95
|
-
module.exports = { resolveEpicSpawnCwd };
|
|
126
|
+
module.exports = { resolveEpicSpawnCwd, restoreEpicWorktree };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* epicSpawnPlan.cjs — decides the spawn cwd AND the --resume/--session-id flag for an Epic's
|
|
5
|
+
* chat run TOGETHER (PRD 1319). They used to be computed independently: the flag from "does a
|
|
6
|
+
* transcript exist under ANY encoding" and the cwd separately, so a transcript stranded under a
|
|
7
|
+
* swept /tmp worktree encoding produced `--resume` from a cwd that cannot see it ("No
|
|
8
|
+
* conversation found"), the flag-swap retry then hit `--session-id` ("already in use"), and both
|
|
9
|
+
* doors were locked.
|
|
10
|
+
*
|
|
11
|
+
* Chosen fix: (a) first — resolveEpicSpawnCwd({restore:true}) re-attaches the Epic's own branch
|
|
12
|
+
* at the SAME recorded path (encoding unchanged), so the CLI sees the transcript again. When that
|
|
13
|
+
* is impossible, (b): refuse BEFORE spawning with an actionable message naming the missing
|
|
14
|
+
* worktree path, never claim a flag that cannot work. Reachability = "some existing transcript
|
|
15
|
+
* lives under encodeCwd(spawn cwd)" and the spawn cwd is a real directory.
|
|
16
|
+
*
|
|
17
|
+
* Also refuses requests routed to a closed (`completed`) Epic, and keeps a per-session circuit
|
|
18
|
+
* breaker: once a session is found unreachable, later requests are refused after ONE stat (is the
|
|
19
|
+
* worktree dir still missing?) without re-running the restore git commands.
|
|
20
|
+
*
|
|
21
|
+
* Ops-root hazard (epicSpawnCwd.cjs): `execCwd` returned here is for the spawn cwd option ONLY;
|
|
22
|
+
* ops reads/writes keep using the project `cwd` (projectRootOf / resolveProjectRoot).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require('node:fs');
|
|
26
|
+
const os = require('node:os');
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
const { encodeCwd } = require('./encodeCwd.cjs');
|
|
29
|
+
const { readActiveIndex } = require('./epicMint.cjs');
|
|
30
|
+
const { resolveEpicSpawnCwd } = require('./epicSpawnCwd.cjs');
|
|
31
|
+
const { resolveEpicTranscriptPath } = require('./epicTranscriptPath.cjs');
|
|
32
|
+
|
|
33
|
+
/** Map<sessionId, { dir, message }> — sessions found unreachable; valid while `dir` stays missing. */
|
|
34
|
+
const unreachable = new Map();
|
|
35
|
+
|
|
36
|
+
function __resetForTests() {
|
|
37
|
+
unreachable.clear();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isDir(dir, statSync) {
|
|
41
|
+
try {
|
|
42
|
+
return statSync(dir).isDirectory();
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The Epic record for a session id: active-index first, else a bounded scan of archived `<id>.json`. */
|
|
49
|
+
function findEpic(cwd, claudeSessionId, deps) {
|
|
50
|
+
try {
|
|
51
|
+
for (const s of Object.values((deps.readActiveIndex || readActiveIndex)(cwd).sessions || {})) {
|
|
52
|
+
if (s && s.claudeSessionId === claudeSessionId) return s;
|
|
53
|
+
}
|
|
54
|
+
} catch { /* unreadable index → try archives */ }
|
|
55
|
+
try {
|
|
56
|
+
const { opsPath } = require('./opsOwnership.cjs');
|
|
57
|
+
const dir = opsPath(cwd, 'prompt-sessions');
|
|
58
|
+
for (const name of fs.readdirSync(dir)) {
|
|
59
|
+
if (!name.endsWith('.json') || name === 'active-index.json') continue;
|
|
60
|
+
try {
|
|
61
|
+
const s = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'))?.session;
|
|
62
|
+
if (s && s.claudeSessionId === claudeSessionId) return s;
|
|
63
|
+
} catch { /* malformed archive */ }
|
|
64
|
+
}
|
|
65
|
+
} catch { /* no ops dir */ }
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {{ cwd: string, claudeSessionId: string, fallbackResume?: boolean, deps?: object }} opts
|
|
71
|
+
* @returns {{ ok: true, execCwd: string, useResume: boolean, transcriptPath: string|null }
|
|
72
|
+
* | { ok: false, code: 'epic_closed'|'session_unreachable', message: string }}
|
|
73
|
+
*/
|
|
74
|
+
function planEpicSpawn({ cwd, claudeSessionId, fallbackResume = false, deps = {} } = {}) {
|
|
75
|
+
const statSync = deps.statSync || fs.statSync;
|
|
76
|
+
const epic = findEpic(cwd, claudeSessionId, deps);
|
|
77
|
+
|
|
78
|
+
if (epic && epic.status === 'completed') {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
code: 'epic_closed',
|
|
82
|
+
message: `This Epic is closed (completed) — it no longer accepts requests. Start a new Epic instead of routing work into session ${claudeSessionId}.`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const brokenDir = epic?.worktree?.dir;
|
|
87
|
+
const tripped = unreachable.get(claudeSessionId);
|
|
88
|
+
if (tripped && !isDir(tripped.dir, statSync)) return { ok: false, code: 'session_unreachable', message: tripped.message };
|
|
89
|
+
unreachable.delete(claudeSessionId);
|
|
90
|
+
|
|
91
|
+
let useResume = !!fallbackResume;
|
|
92
|
+
let resolved = null;
|
|
93
|
+
try {
|
|
94
|
+
resolved = resolveEpicTranscriptPath({ cwd, claudeSessionId, deps });
|
|
95
|
+
useResume = !!resolved.existsAnywhere;
|
|
96
|
+
} catch { /* keep the caller-supplied flag */ }
|
|
97
|
+
|
|
98
|
+
const execCwd = (deps.resolveSpawnCwd || resolveEpicSpawnCwd)({ cwd, claudeSessionId, deps: { restore: true, readActiveIndex: deps.readActiveIndex, statSync: deps.statSync, restoreWorktree: deps.restoreWorktree } });
|
|
99
|
+
|
|
100
|
+
if (resolved && resolved.existsAnywhere) {
|
|
101
|
+
const projectsDir = path.join(deps.homeDir || os.homedir(), '.claude', 'projects');
|
|
102
|
+
const home = path.join(projectsDir, encodeCwd(execCwd));
|
|
103
|
+
const reachable = isDir(execCwd, statSync) && resolved.existingPaths.some((p) => path.dirname(p) === home);
|
|
104
|
+
if (!reachable) {
|
|
105
|
+
const missing = brokenDir || execCwd;
|
|
106
|
+
const message =
|
|
107
|
+
`Session ${claudeSessionId} cannot be resumed: its transcript is at ${resolved.existingPaths[0]}, ` +
|
|
108
|
+
`which the CLI only finds when run from ${missing}, and that worktree no longer exists and could not be ` +
|
|
109
|
+
`re-attached (likely swept from /tmp). Recreate it (git worktree add "${missing}" ${epic?.worktree?.branch || '<sm-epic branch>'}) ` +
|
|
110
|
+
`or start a new Epic.`;
|
|
111
|
+
unreachable.set(claudeSessionId, { dir: missing, message });
|
|
112
|
+
return { ok: false, code: 'session_unreachable', message };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { ok: true, execCwd, useResume, transcriptPath: resolved?.path ?? null };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = { planEpicSpawn, __resetForTests };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* epicTranscriptDiagnostic.cjs — read-only scan for Epics whose claude
|
|
5
|
+
* transcript is not where the project-scoped lookup would put it.
|
|
6
|
+
*
|
|
7
|
+
* mislocated — exactly one transcript exists and it is not under
|
|
8
|
+
* <encodeCwd(project cwd)> (i.e. only a worktree encoding).
|
|
9
|
+
* duplicated — the same session id has transcripts under 2+ encodings; the
|
|
10
|
+
* state in which `claude --resume` fails from any third cwd.
|
|
11
|
+
*
|
|
12
|
+
* Bounded: walks Epic records of the supplied project cwds and stats each
|
|
13
|
+
* Epic's candidate paths via resolveEpicTranscriptPath. Never enumerates
|
|
14
|
+
* ~/.claude/projects. Writes nothing. Complexity: O(epics x candidates).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const os = require('node:os');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
const { encodeCwd } = require('./encodeCwd.cjs');
|
|
21
|
+
const { readActiveIndex } = require('./epicMint.cjs');
|
|
22
|
+
const { opsPath } = require('./opsOwnership.cjs');
|
|
23
|
+
const { resolveEpicTranscriptPath } = require('./epicTranscriptPath.cjs');
|
|
24
|
+
|
|
25
|
+
/** [epicId, session] pairs: active-index rows plus archived per-Epic `<id>.json` records. */
|
|
26
|
+
function epicRecords(cwd) {
|
|
27
|
+
const out = new Map();
|
|
28
|
+
try {
|
|
29
|
+
for (const [id, s] of Object.entries(readActiveIndex(cwd).sessions || {})) out.set(id, s);
|
|
30
|
+
} catch { /* unreadable index → archives only */ }
|
|
31
|
+
let dir;
|
|
32
|
+
let names = [];
|
|
33
|
+
try {
|
|
34
|
+
dir = opsPath(cwd, 'prompt-sessions');
|
|
35
|
+
names = fs.readdirSync(dir);
|
|
36
|
+
} catch { /* no ops dir → skip */ }
|
|
37
|
+
for (const name of names) {
|
|
38
|
+
if (!name.endsWith('.json') || name === 'active-index.json') continue;
|
|
39
|
+
const id = name.slice(0, -5);
|
|
40
|
+
if (out.has(id)) continue;
|
|
41
|
+
try {
|
|
42
|
+
const s = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'))?.session;
|
|
43
|
+
if (s && typeof s === 'object') out.set(id, s);
|
|
44
|
+
} catch { /* malformed record → skip */ }
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {{ cwds: string[], homeDir?: string }} opts
|
|
51
|
+
* @returns {Array<{ sessionId: string, epicId: string, cwd: string, paths: string[], classification: 'mislocated'|'duplicated' }>}
|
|
52
|
+
*/
|
|
53
|
+
function scanEpicTranscripts({ cwds, homeDir = os.homedir() }) {
|
|
54
|
+
const findings = [];
|
|
55
|
+
const projectsDir = path.join(homeDir, '.claude', 'projects');
|
|
56
|
+
for (const cwd of cwds || []) {
|
|
57
|
+
const sessions = epicRecords(cwd);
|
|
58
|
+
const home = path.join(projectsDir, encodeCwd(cwd));
|
|
59
|
+
for (const [epicId, s] of sessions) {
|
|
60
|
+
const sessionId = s && s.claudeSessionId;
|
|
61
|
+
if (!sessionId || typeof sessionId !== 'string') continue;
|
|
62
|
+
let res;
|
|
63
|
+
try {
|
|
64
|
+
res = resolveEpicTranscriptPath({ cwd, claudeSessionId: sessionId, deps: { homeDir } });
|
|
65
|
+
} catch {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const paths = res.existingPaths;
|
|
69
|
+
if (paths.length > 1) {
|
|
70
|
+
findings.push({ sessionId, epicId, cwd, paths, classification: 'duplicated' });
|
|
71
|
+
} else if (paths.length === 1 && path.dirname(paths[0]) !== home) {
|
|
72
|
+
findings.push({ sessionId, epicId, cwd, paths, classification: 'mislocated' });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return findings;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { scanEpicTranscripts };
|