claude-code-session-manager 0.89.0 → 0.91.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-tuj-j1kQ.js → AgentLibrary-zkgdnCnG.js} +1 -1
- package/dist/assets/DataModel-CNFdqxrR.js +1 -0
- package/dist/assets/{History-DylcasUk.js → History-C-qijfXz.js} +2 -2
- package/dist/assets/{Hooks--2xoag9a.js → Hooks-DGxt1nBd.js} +3 -3
- package/dist/assets/{HostBilko-oaw8paAh.js → HostBilko-sIYTK-m9.js} +1 -1
- package/dist/assets/{Library-DzKA09Iy.js → Library-51Jx0oGl.js} +1 -1
- package/dist/assets/{ListDetail-CEdcO80v.js → ListDetail-ByNJU-ie.js} +1 -1
- package/dist/assets/MarkdownEditor-Wwcpe9O1.js +1 -0
- package/dist/assets/{McpServers-BB7ayW7W.js → McpServers-Bs5xPbTG.js} +2 -2
- package/dist/assets/{Memory-BOdYvrL4.js → Memory-BsHGInmd.js} +6 -6
- package/dist/assets/{Panel-Yf3SxsRv.js → Panel-ilEDcV0m.js} +1 -1
- package/dist/assets/{Permissions-BYCpHNy5.js → Permissions-Djgc3TFk.js} +3 -3
- package/dist/assets/{Plugins-B0lXiYqV.js → Plugins-BBojr1mt.js} +2 -2
- package/dist/assets/{ProvenanceBadge-T7x8LWcV.js → ProvenanceBadge-Cz1VLnXb.js} +1 -1
- package/dist/assets/{SaveBar-BdVb4DbR.js → SaveBar-Cj0wz84u.js} +1 -1
- package/dist/assets/Scheduler-D4Ekm93R.js +16 -0
- package/dist/assets/{ScopeSwitcher-WVhmNsL5.js → ScopeSwitcher-DA5MqWli.js} +1 -1
- package/dist/assets/{Settings-Bt4dVsl6.js → Settings-B1Kfgjvl.js} +2 -2
- package/dist/assets/{SkillReferenceGraph-R-0YykV8.js → SkillReferenceGraph-Dew-1uMy.js} +1 -1
- package/dist/assets/{Skills-DXV_LdJP.js → Skills-DzuhnXy9.js} +2 -2
- package/dist/assets/SystemPrompt-CNcCwEev.js +1 -0
- package/dist/assets/{TagLibrary-B9OuqU6M.js → TagLibrary-D0plTZgP.js} +1 -1
- package/dist/assets/{TiptapBody-HKKmB0AE.js → TiptapBody-BlmUt7SR.js} +1 -1
- package/dist/assets/{Toggle-CBggPonH.js → Toggle-yTfx2R-e.js} +1 -1
- package/dist/assets/{index-BHTX4OTc.css → index-CRPJslSA.css} +1 -1
- package/dist/assets/{index-BcJqLDiY.js → index-ju-h7BFf.js} +444 -444
- package/dist/assets/{settingsSchema-BYE9DVw_.js → settingsSchema-BkcnLBYk.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/README.md +1 -0
- package/src/main/__tests__/bilkoHost-integration.test.cjs +22 -9
- 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__/projectHomeAdminRoutes.test.cjs +12 -0
- package/src/main/__tests__/scheduler-default-eligible-heal.test.cjs +10 -3
- package/src/main/__tests__/scheduler-looks-done.test.cjs +9 -2
- 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/bilkoHost.cjs +6 -7
- 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/index.cjs +1 -1
- 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__/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 +1 -1
- 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 +27 -3
- 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 +17 -4
- package/src/main/templates/PRD_AUTHORING.md +11 -0
- package/src/main/transcripts.cjs +28 -7
- package/src/preload/api.d.ts +4 -2
- package/src/preload/index.cjs +1 -0
- package/dist/assets/DataModel-DOim5SXW.js +0 -1
- package/dist/assets/MarkdownEditor-BMyo0F7s.js +0 -1
- package/dist/assets/Scheduler-DlOCWioV.js +0 -14
- package/dist/assets/SystemPrompt-DiQRo_WF.js +0 -1
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transcripts-worktree-epic-path.test.cjs — transcriptPath()/subscribe() resolve
|
|
3
|
+
* an Epic's transcript through epicTranscriptPath (worktree encoding), while a
|
|
4
|
+
* non-Epic tab keeps the plain project encoding. Includes a parity check of the
|
|
5
|
+
* renderer's transcriptExists() (EpicTerminalPane's resume-vs-create decision)
|
|
6
|
+
* against the REAL transcriptPath instead of a constant mock.
|
|
7
|
+
*
|
|
8
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/transcripts-worktree-epic-path.test.cjs
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
import { test, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
|
|
14
|
+
import { transcriptExists } from '../../renderer/lib/transcriptExists';
|
|
15
|
+
|
|
16
|
+
const fs = require('node:fs');
|
|
17
|
+
const os = require('node:os');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
|
|
20
|
+
let tmpHome;
|
|
21
|
+
let originalHome;
|
|
22
|
+
let tr;
|
|
23
|
+
let resolver;
|
|
24
|
+
let encodeCwd;
|
|
25
|
+
let proj;
|
|
26
|
+
const SID = 'b8a5d6f4-0092-4db0-a687-009241f2a8b2';
|
|
27
|
+
const WT = '/tmp/sm-wt-test-epic-worktree-dir';
|
|
28
|
+
|
|
29
|
+
beforeAll(() => {
|
|
30
|
+
originalHome = process.env.HOME;
|
|
31
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-transcripts-wt-home-'));
|
|
32
|
+
process.env.HOME = tmpHome;
|
|
33
|
+
tr = require('../transcripts.cjs');
|
|
34
|
+
resolver = require('../lib/epicTranscriptPath.cjs');
|
|
35
|
+
({ encodeCwd } = require('../lib/encodeCwd.cjs'));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterAll(() => {
|
|
39
|
+
process.env.HOME = originalHome;
|
|
40
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
resolver.__resetCacheForTests();
|
|
45
|
+
proj = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-transcripts-wt-proj-'));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
fs.rmSync(proj, { recursive: true, force: true });
|
|
50
|
+
fs.rmSync(path.join(tmpHome, '.claude'), { recursive: true, force: true });
|
|
51
|
+
vi.unstubAllGlobals();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const projectsDir = () => path.join(tmpHome, '.claude', 'projects');
|
|
55
|
+
const plain = (cwd, id = SID) => path.join(projectsDir(), encodeCwd(cwd), `${id}.jsonl`);
|
|
56
|
+
|
|
57
|
+
function writeIndex(sessionId = SID) {
|
|
58
|
+
const dir = path.join(proj, 'session-manager-operations', 'prompt-sessions');
|
|
59
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
60
|
+
const s = { id: 'epic-1', cwd: proj, claudeSessionId: sessionId, status: 'active', worktree: { dir: WT, branch: 'sm-epic/epic-1', baseCwd: proj, status: 'active' } };
|
|
61
|
+
fs.writeFileSync(path.join(dir, 'active-index.json'), JSON.stringify({ sessions: { 'epic-1': s }, events: {} }));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function writeTranscript(p, body = '{"type":"user"}\n') {
|
|
65
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
66
|
+
fs.writeFileSync(p, body);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test('worktree-present: transcript only under the worktree encoding resolves there', () => {
|
|
70
|
+
writeIndex();
|
|
71
|
+
fs.mkdirSync(WT, { recursive: true });
|
|
72
|
+
try {
|
|
73
|
+
writeTranscript(plain(WT));
|
|
74
|
+
expect(tr.transcriptPath(proj, SID)).toBe(plain(WT));
|
|
75
|
+
} finally {
|
|
76
|
+
fs.rmSync(WT, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('worktree-swept: worktree dir gone from disk, transcript still found', () => {
|
|
81
|
+
writeIndex();
|
|
82
|
+
expect(fs.existsSync(WT)).toBe(false);
|
|
83
|
+
writeTranscript(plain(WT));
|
|
84
|
+
expect(tr.transcriptPath(proj, SID)).toBe(plain(WT));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('non-Epic tab resolves to exactly the pre-change path, even when an Epic exists', () => {
|
|
88
|
+
writeIndex();
|
|
89
|
+
writeTranscript(plain(WT));
|
|
90
|
+
const rawTab = 'raw-tab-id-123';
|
|
91
|
+
expect(tr.transcriptPath(proj, rawTab)).toBe(path.join(os.homedir(), '.claude', 'projects', encodeCwd(proj), `${rawTab}.jsonl`));
|
|
92
|
+
// no index at all
|
|
93
|
+
const other = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-transcripts-wt-other-'));
|
|
94
|
+
try {
|
|
95
|
+
expect(tr.transcriptPath(other, SID)).toBe(plain(other));
|
|
96
|
+
expect(path.isAbsolute(tr.transcriptPath(other, SID))).toBe(true);
|
|
97
|
+
} finally {
|
|
98
|
+
fs.rmSync(other, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('zero-byte transcript counts as existing and does not break usageFor', async () => {
|
|
103
|
+
writeIndex();
|
|
104
|
+
writeTranscript(plain(WT), '');
|
|
105
|
+
expect(tr.transcriptPath(proj, SID)).toBe(plain(WT));
|
|
106
|
+
const out = await tr.usageFor(proj, [SID]);
|
|
107
|
+
expect(out).toHaveProperty(SID);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('subscribe does NOT mkdir when a transcript exists elsewhere (worktree encoding)', async () => {
|
|
111
|
+
writeIndex();
|
|
112
|
+
writeTranscript(plain(WT));
|
|
113
|
+
const before = fs.readdirSync(projectsDir()).sort();
|
|
114
|
+
const res = await tr.subscribe({ tabId: 'tab-wt', cwd: proj, sessionUuid: SID });
|
|
115
|
+
try {
|
|
116
|
+
expect(res.ok).toBe(true);
|
|
117
|
+
expect(res.path).toBe(plain(WT));
|
|
118
|
+
expect(fs.readdirSync(projectsDir()).sort()).toEqual(before);
|
|
119
|
+
expect(fs.existsSync(path.dirname(plain(proj)))).toBe(false);
|
|
120
|
+
} finally {
|
|
121
|
+
tr.closeTab('tab-wt');
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('subscribe of a brand-new Epic watches the spawn-cwd path and creates its dir', async () => {
|
|
126
|
+
writeIndex();
|
|
127
|
+
const res = await tr.subscribe({ tabId: 'tab-new', cwd: proj, sessionUuid: SID });
|
|
128
|
+
try {
|
|
129
|
+
expect(res.ok).toBe(true);
|
|
130
|
+
// no transcript anywhere → resolver's first candidate (spawn cwd), whose dir the CLI will write
|
|
131
|
+
expect(fs.existsSync(path.dirname(res.path))).toBe(true);
|
|
132
|
+
expect(fs.existsSync(res.path)).toBe(false);
|
|
133
|
+
} finally {
|
|
134
|
+
tr.closeTab('tab-new');
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('parity: renderer transcriptExists (Terminal resume decision) sees a worktree-only transcript', async () => {
|
|
139
|
+
writeIndex();
|
|
140
|
+
vi.stubGlobal('window', {
|
|
141
|
+
api: {
|
|
142
|
+
transcripts: { pathFor: async (c, s) => tr.transcriptPath(c, s) },
|
|
143
|
+
config: { exists: async (p) => fs.existsSync(p) },
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
expect(await transcriptExists(proj, SID)).toBe(false);
|
|
147
|
+
writeTranscript(plain(WT));
|
|
148
|
+
expect(await transcriptExists(proj, SID)).toBe(true);
|
|
149
|
+
// zero-byte still "exists"
|
|
150
|
+
writeTranscript(plain(WT), '');
|
|
151
|
+
expect(await transcriptExists(proj, SID)).toBe(true);
|
|
152
|
+
// raw tab never resolves into the Epic's worktree encoding
|
|
153
|
+
expect(await transcriptExists(proj, 'raw-tab')).toBe(false);
|
|
154
|
+
});
|
|
@@ -89,7 +89,9 @@ afterEach(() => {
|
|
|
89
89
|
vi.unstubAllGlobals();
|
|
90
90
|
vi.restoreAllMocks();
|
|
91
91
|
purgeRequireCache();
|
|
92
|
-
|
|
92
|
+
// maxRetries: a fire-and-forget cache write inside usage.cjs can land in .claude/ mid-rm
|
|
93
|
+
// (ENOTEMPTY) when the machine is loaded; this is cleanup robustness, not a test retry.
|
|
94
|
+
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 });
|
|
93
95
|
});
|
|
94
96
|
|
|
95
97
|
test('two concurrent fetchUsage() calls produce exactly one HTTP fetch', async () => {
|
package/src/main/bilkoHost.cjs
CHANGED
|
@@ -220,13 +220,12 @@ async function removeDocument({ cwd, id }) {
|
|
|
220
220
|
|
|
221
221
|
async function resolveDocumentHtml(cwd, doc) {
|
|
222
222
|
if (doc.source.kind === 'project-page-lens') {
|
|
223
|
-
// Only `home` is generated now (one home.html);
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
doc.source.lens
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
);
|
|
223
|
+
// Only `home` is generated now (one home.html); other lens names are
|
|
224
|
+
// retired and have no output anywhere — fail loudly rather than fall back.
|
|
225
|
+
if (doc.source.lens !== 'home') {
|
|
226
|
+
throw new Error(`document "${doc.title}" points at the retired "${doc.source.lens}" Project Page lens; only the single home.html page is generated now`);
|
|
227
|
+
}
|
|
228
|
+
const result = await config.readText(homePagePath(cwd));
|
|
230
229
|
if (!result.exists) {
|
|
231
230
|
throw new Error(`document "${doc.title}" points at the ${doc.source.lens} Project Page, but it hasn't been generated yet`);
|
|
232
231
|
}
|
package/src/main/build-info.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
3
|
-
"gitSha": "
|
|
4
|
-
"gitShortSha": "
|
|
2
|
+
"version": "0.91.0",
|
|
3
|
+
"gitSha": "0029caa246d6296f7f8e83a502f5351c670b269c",
|
|
4
|
+
"gitShortSha": "0029caa",
|
|
5
5
|
"gitBranch": "HEAD",
|
|
6
|
-
"builtAt": "2026-09-
|
|
6
|
+
"builtAt": "2026-09-20T19:42:50.657Z",
|
|
7
7
|
"dirty": false
|
|
8
8
|
}
|
package/src/main/chatRunner.cjs
CHANGED
|
@@ -53,7 +53,14 @@ const { classifyPromptTicket } = require('./lib/classifyPromptTicket.cjs');
|
|
|
53
53
|
const sessionSlots = require('./lib/sessionSlots.cjs');
|
|
54
54
|
const opsErrorLog = require('./lib/opsErrorLog.cjs');
|
|
55
55
|
const agentModelResolve = require('./lib/agentModelResolve.cjs');
|
|
56
|
-
const {
|
|
56
|
+
const { planEpicSpawn } = require('./lib/epicSpawnPlan.cjs');
|
|
57
|
+
const logs = require('./logs.cjs');
|
|
58
|
+
|
|
59
|
+
// The only two CLI errors that mean "wrong session flag" (verified against the
|
|
60
|
+
// real CLI). "in use" → the transcript exists, so resume; "no conversation" →
|
|
61
|
+
// nothing to resume, so create. Never widen this into a general retry.
|
|
62
|
+
const SESSION_IN_USE_RE = /Session ID .* is already in use/i;
|
|
63
|
+
const NO_CONVERSATION_RE = /No conversation found with session ID/i;
|
|
57
64
|
|
|
58
65
|
// ─── Stop-signal protocol ──────────────────────────────────────────────────
|
|
59
66
|
// Single source of truth for the sentinel and parser. The renderer (PRD 320)
|
|
@@ -540,21 +547,43 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
|
|
|
540
547
|
// must never be left unpinned (CLAUDE.md model-pinning rule).
|
|
541
548
|
const model = agentModelResolve.resolveEpicModel({ cwd, claudeSessionId: sessionId });
|
|
542
549
|
|
|
543
|
-
//
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
'
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
550
|
+
// Main is authoritative about --resume vs --session-id AND the spawn cwd, and it decides
|
|
551
|
+
// them TOGETHER (epicSpawnPlan.cjs): a transcript under an encoding the spawn cwd cannot see
|
|
552
|
+
// must never yield a claimed flag. Unreachable / closed Epics are refused before any spawn.
|
|
553
|
+
const plan = planEpicSpawn({ cwd, claudeSessionId: sessionId, fallbackResume: !!resume });
|
|
554
|
+
if (!plan.ok) {
|
|
555
|
+
emitTerminal('chat:run:error', { tabId, sessionId, code: plan.code, message: plan.message });
|
|
556
|
+
settle();
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const useResume = plan.useResume;
|
|
560
|
+
if (useResume !== !!resume) {
|
|
561
|
+
try {
|
|
562
|
+
logs.writeLine({
|
|
563
|
+
scope: 'chatRunner',
|
|
564
|
+
level: 'warn',
|
|
565
|
+
message: `session flag mismatch: renderer resume=${!!resume}, main resume=${useResume}; transcript=${plan.transcriptPath ?? 'none'}`,
|
|
566
|
+
meta: { tabId, sessionId, rendererResume: !!resume, mainResume: useResume, transcriptPath: plan.transcriptPath ?? null },
|
|
567
|
+
});
|
|
568
|
+
} catch { /* logging must never fail a turn */ }
|
|
556
569
|
}
|
|
557
570
|
|
|
571
|
+
// Build argv as an array — no shell: true, no string interpolation. Also
|
|
572
|
+
// used for the flag-swap retry so both attempts share one construction path.
|
|
573
|
+
const buildArgs = (resumeFlag) => {
|
|
574
|
+
const a = [
|
|
575
|
+
'-p', fullPrompt,
|
|
576
|
+
'--model', model,
|
|
577
|
+
'--dangerously-skip-permissions',
|
|
578
|
+
'--output-format', 'stream-json',
|
|
579
|
+
'--verbose',
|
|
580
|
+
];
|
|
581
|
+
// --resume carries the session context; no --session-id needed
|
|
582
|
+
if (resumeFlag) a.push('--resume', sessionId);
|
|
583
|
+
else a.push('--session-id', sessionId);
|
|
584
|
+
return a;
|
|
585
|
+
};
|
|
586
|
+
|
|
558
587
|
if (!silent) broadcast('chat:run:started', { tabId, sessionId });
|
|
559
588
|
|
|
560
589
|
// sessionId IS the Epic's claudeSessionId for an Epic-backed tab (see
|
|
@@ -564,36 +593,14 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
|
|
|
564
593
|
// below keeps using it exactly as before — only this ACTUAL spawn cwd
|
|
565
594
|
// option does. See epicSpawnCwd.cjs's header comment (the ops-root
|
|
566
595
|
// hazard) and jobWorktree.cjs's own (execCwd vs job.cwd) for why.
|
|
567
|
-
const execCwd =
|
|
596
|
+
const execCwd = plan.execCwd;
|
|
568
597
|
|
|
569
|
-
// Spawn with stdin closed (mirrors scheduler's 'ignore' — prevents the
|
|
570
|
-
// "claude -p stdin must be closed" gotcha from kg.cjs). stdout is piped for
|
|
571
|
-
// real-time NDJSON streaming; stderr piped for error-message capture.
|
|
572
|
-
let child;
|
|
573
|
-
try {
|
|
574
|
-
const target = claudeSpawnTarget('chat', sessionId, claudeBin);
|
|
575
|
-
child = spawn(target.command, args, {
|
|
576
|
-
...(target.argv0 ? { argv0: target.argv0 } : {}),
|
|
577
|
-
cwd: execCwd,
|
|
578
|
-
env: childEnv,
|
|
579
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
580
|
-
detached: true, // own process group so killTree can SIGTERM descendants
|
|
581
|
-
});
|
|
582
|
-
} catch (err) {
|
|
583
|
-
emitTerminal('chat:run:error', {
|
|
584
|
-
tabId,
|
|
585
|
-
sessionId,
|
|
586
|
-
message: `spawn failed: ${err?.message ?? String(err)}`,
|
|
587
|
-
});
|
|
588
|
-
settle();
|
|
589
|
-
return;
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
// One-shot kill helper — idempotent via the `killed` flag
|
|
593
598
|
let killed = false;
|
|
599
|
+
let child = null;
|
|
594
600
|
const doKill = (sig) => {
|
|
595
601
|
if (killed) return;
|
|
596
602
|
killed = true;
|
|
603
|
+
if (!child) return;
|
|
597
604
|
// Negative pid targets the process group (requires detached: true)
|
|
598
605
|
try { process.kill(-child.pid, sig); }
|
|
599
606
|
catch { try { child.kill(sig); } catch { /* already dead */ } }
|
|
@@ -647,10 +654,40 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
|
|
|
647
654
|
}, KILL_CEILING_MS);
|
|
648
655
|
if (killTimer.unref) killTimer.unref();
|
|
649
656
|
|
|
657
|
+
// One spawn of the CLI. Per-attempt state (buffers, child) lives in here;
|
|
658
|
+
// everything turn-level (timers, latch, lane) is in the enclosing scope.
|
|
659
|
+
const startAttempt = (resumeFlag, attemptNo) => {
|
|
660
|
+
// Spawn with stdin closed (mirrors scheduler's 'ignore' — prevents the
|
|
661
|
+
// "claude -p stdin must be closed" gotcha from kg.cjs). stdout is piped for
|
|
662
|
+
// real-time NDJSON streaming; stderr piped for error-message capture.
|
|
663
|
+
let thisChild;
|
|
664
|
+
try {
|
|
665
|
+
const target = claudeSpawnTarget('chat', sessionId, claudeBin);
|
|
666
|
+
thisChild = spawn(target.command, buildArgs(resumeFlag), {
|
|
667
|
+
...(target.argv0 ? { argv0: target.argv0 } : {}),
|
|
668
|
+
cwd: execCwd,
|
|
669
|
+
env: childEnv,
|
|
670
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
671
|
+
detached: true, // own process group so killTree can SIGTERM descendants
|
|
672
|
+
});
|
|
673
|
+
} catch (err) {
|
|
674
|
+
clearTimeout(killTimer);
|
|
675
|
+
clearTimeout(warnTimer);
|
|
676
|
+
emitTerminal('chat:run:error', {
|
|
677
|
+
tabId,
|
|
678
|
+
sessionId,
|
|
679
|
+
message: `spawn failed: ${err?.message ?? String(err)}`,
|
|
680
|
+
});
|
|
681
|
+
settle();
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
child = thisChild;
|
|
685
|
+
|
|
650
686
|
// ─── Stream parsing ────────────────────────────────────────────────────
|
|
651
687
|
// stdout is newline-delimited JSON (stream-json format). We buffer partial
|
|
652
688
|
// lines across TCP/pipe chunks. O(output-size) in memory per run.
|
|
653
689
|
|
|
690
|
+
// Per-attempt state — a retry starts from empty buffers.
|
|
654
691
|
let lineBuffer = '';
|
|
655
692
|
let finalAssistantText = '';
|
|
656
693
|
let stderrBuffer = '';
|
|
@@ -738,7 +775,7 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
|
|
|
738
775
|
}
|
|
739
776
|
};
|
|
740
777
|
|
|
741
|
-
|
|
778
|
+
thisChild.stdout.on('data', (chunk) => {
|
|
742
779
|
lineBuffer += chunk.toString('utf8');
|
|
743
780
|
let nl;
|
|
744
781
|
while ((nl = lineBuffer.indexOf('\n')) !== -1) {
|
|
@@ -747,11 +784,11 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
|
|
|
747
784
|
}
|
|
748
785
|
});
|
|
749
786
|
|
|
750
|
-
|
|
787
|
+
thisChild.stderr.on('data', (chunk) => {
|
|
751
788
|
stderrBuffer += chunk.toString('utf8');
|
|
752
789
|
});
|
|
753
790
|
|
|
754
|
-
|
|
791
|
+
thisChild.on('error', (err) => {
|
|
755
792
|
clearTimeout(killTimer);
|
|
756
793
|
clearTimeout(warnTimer);
|
|
757
794
|
emitTerminal('chat:run:error', {
|
|
@@ -768,12 +805,35 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
|
|
|
768
805
|
// line) reaches the 'data' handler above, which raced this fallback into
|
|
769
806
|
// firing first and dropping the real chat:run:complete behind the
|
|
770
807
|
// terminalSent latch.
|
|
771
|
-
|
|
772
|
-
clearTimeout(killTimer);
|
|
773
|
-
clearTimeout(warnTimer);
|
|
808
|
+
thisChild.on('close', (code, signal) => {
|
|
774
809
|
// Flush any partial line that didn't end with \n
|
|
775
810
|
if (lineBuffer.trim()) processLine(lineBuffer.trim());
|
|
776
811
|
|
|
812
|
+
// Flag-swap retry: the CLI rejected the session flag before doing any
|
|
813
|
+
// work. Retry exactly once with the other flag; the timers, lane and
|
|
814
|
+
// terminal latch stay at executeRun level, so this is still one turn.
|
|
815
|
+
if (!terminalSent && !killed && code !== 0 && attemptNo === 1
|
|
816
|
+
&& finalAssistantText === '' && recentToolUses.length === 0
|
|
817
|
+
&& !/rate.?limit/i.test(stderrBuffer)) {
|
|
818
|
+
const swapTo = resumeFlag
|
|
819
|
+
? (NO_CONVERSATION_RE.test(stderrBuffer) ? false : null)
|
|
820
|
+
: (SESSION_IN_USE_RE.test(stderrBuffer) ? true : null);
|
|
821
|
+
if (swapTo !== null) {
|
|
822
|
+
if (!silent) {
|
|
823
|
+
broadcast('chat:run:notice', {
|
|
824
|
+
tabId,
|
|
825
|
+
sessionId,
|
|
826
|
+
message: `Session flag mismatch (${resumeFlag ? '--resume' : '--session-id'} rejected); retrying once with ${swapTo ? '--resume' : '--session-id'}.`,
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
startAttempt(swapTo, 2);
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
clearTimeout(killTimer);
|
|
835
|
+
clearTimeout(warnTimer);
|
|
836
|
+
|
|
777
837
|
// Emit a fallback terminal event for any run that ended without one — a
|
|
778
838
|
// cancel/SIGTERM (killed=true), a crash before a result, or a silent
|
|
779
839
|
// exit. `emitTerminal` no-ops if the run already sent complete/needs-
|
|
@@ -799,6 +859,9 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
|
|
|
799
859
|
}
|
|
800
860
|
settle();
|
|
801
861
|
});
|
|
862
|
+
};
|
|
863
|
+
|
|
864
|
+
startAttempt(useResume, 1);
|
|
802
865
|
});
|
|
803
866
|
}
|
|
804
867
|
|
package/src/main/health.cjs
CHANGED
|
@@ -25,6 +25,7 @@ const { auditLogPath, readTail } = require('./lib/auditLog.cjs');
|
|
|
25
25
|
const { resolveBuildIdentity } = require('./lib/buildIdentity.cjs');
|
|
26
26
|
const { DEFAULT_RUNS_DIR, computeReport, isRetentionEnabled, liveKeysFromJobs } = require('./lib/runLogRetention.cjs');
|
|
27
27
|
const { allProjectCwds } = require('./lib/activeSessions.cjs');
|
|
28
|
+
const { scanEpicTranscripts } = require('./lib/epicTranscriptDiagnostic.cjs');
|
|
28
29
|
|
|
29
30
|
const MAX_LOG_AGE_MS = 5 * 60_000; // 5 min — warn if no logs this old
|
|
30
31
|
const PROJECT_ROOT = path.resolve(__dirname, '../..');
|
|
@@ -716,7 +717,10 @@ function evaluateBlockingParkHealth(jobs, now, thresholdMs = REVERIFY_INTERVAL_M
|
|
|
716
717
|
};
|
|
717
718
|
}
|
|
718
719
|
|
|
719
|
-
|
|
720
|
+
// opts.skipTypecheck: skip the whole-repo `tsc` shell-out (~15s idle, unbounded under CPU load).
|
|
721
|
+
// For callers that assert on one other component; the typescript component is then
|
|
722
|
+
// reported { ok: true, skipped: true } so it can never read as a real pass.
|
|
723
|
+
async function check(opts = {}) {
|
|
720
724
|
const start = Date.now();
|
|
721
725
|
const status = {
|
|
722
726
|
ok: true,
|
|
@@ -736,8 +740,8 @@ async function check() {
|
|
|
736
740
|
}
|
|
737
741
|
|
|
738
742
|
// 1.5. Check TypeScript compilation (no errors).
|
|
739
|
-
const typesOk = runCheck('npm run typecheck 2>&1 | grep -q "error" && exit 1 || exit 0');
|
|
740
|
-
status.components.typescript = { ok: typesOk };
|
|
743
|
+
const typesOk = opts.skipTypecheck ? true : runCheck('npm run typecheck 2>&1 | grep -q "error" && exit 1 || exit 0');
|
|
744
|
+
status.components.typescript = opts.skipTypecheck ? { ok: true, skipped: true } : { ok: typesOk };
|
|
741
745
|
if (!typesOk) {
|
|
742
746
|
status.issues.push('TypeScript compilation has errors');
|
|
743
747
|
status.ok = false;
|
|
@@ -1146,6 +1150,19 @@ async function check() {
|
|
|
1146
1150
|
status.issues.push(`Epic index health check failed: ${e.message}`);
|
|
1147
1151
|
}
|
|
1148
1152
|
|
|
1153
|
+
// 6.66. Epic transcript location (NON-fatal warning): a mislocated/duplicated
|
|
1154
|
+
// transcript is read correctly by epicTranscriptPath, so it never reddens the
|
|
1155
|
+
// rollup (not in criticalComponents). See scripts/check-epic-transcripts.cjs.
|
|
1156
|
+
try {
|
|
1157
|
+
const findings = scanEpicTranscripts({ cwds: allProjectCwds() });
|
|
1158
|
+
status.components.epic_transcripts = { ok: true, findings: findings.length, duplicated: findings.filter((f) => f.classification === 'duplicated').length };
|
|
1159
|
+
if (findings.length > 0) {
|
|
1160
|
+
status.issues.push(`Warning: ${findings.length} Epic transcript(s) mislocated/duplicated — run: node scripts/check-epic-transcripts.cjs`);
|
|
1161
|
+
}
|
|
1162
|
+
} catch (e) {
|
|
1163
|
+
status.components.epic_transcripts = { ok: true, error: e.message };
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1149
1166
|
// 6.7. Build freshness (informational): running heartbeat vs installed
|
|
1150
1167
|
// build-info vs repo HEAD.
|
|
1151
1168
|
try {
|
package/src/main/index.cjs
CHANGED
|
@@ -513,7 +513,7 @@ ipcMain.handle('app:launch-mode', () => ({
|
|
|
513
513
|
// `claude mcp list`. Read-only, single in-flight call — no polling.
|
|
514
514
|
ipcMain.handle('mcp:status', () => probeMcpStatus());
|
|
515
515
|
|
|
516
|
-
//
|
|
516
|
+
// The Dashboard's "Agent tools" section (HomeAgentTools): the same catalog data
|
|
517
517
|
// GET /admin/mcp/catalog serves to external callers, read in-process here so
|
|
518
518
|
// the renderer never makes a direct HTTP call. Static data, no cwd needed.
|
|
519
519
|
ipcMain.handle('mcp:catalog', () => ({ ok: true, tools: MCP_TOOL_CATALOG, recipes: MCP_RECIPES }));
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -351,6 +351,9 @@ const schedulerCreatePrd = z.object({
|
|
|
351
351
|
// createPrd() requires it in that situation for an interactive caller and
|
|
352
352
|
// defaults to 'append' (logged as a default) for a non-interactive one.
|
|
353
353
|
disposition: PrdDispositionSchema.optional(),
|
|
354
|
+
// Artifact-only PRD declaration — cross-field rules enforced in prdCreate.cjs.
|
|
355
|
+
deliverable: z.literal('artifact').optional(),
|
|
356
|
+
artifactPaths: z.array(z.string().min(1).max(1024).regex(NO_NEWLINE_RE, 'must not contain newlines')).max(50).optional(),
|
|
354
357
|
// An EXISTING Epic's promptSessionId (PRD 748) — NOT a PromptTicket.id.
|
|
355
358
|
// ensureEpic() (epicMint.cjs) joins the Epic whose active-index.json
|
|
356
359
|
// sessions key literally equals this value; the renderer only ever sends
|
|
@@ -191,3 +191,37 @@ test('a real, existing directory IS returned — the check gates on disk state,
|
|
|
191
191
|
fs.rmSync(realDir, { recursive: true, force: true });
|
|
192
192
|
}
|
|
193
193
|
});
|
|
194
|
+
|
|
195
|
+
// ─── spawn-side: a vanished worktree is restored at the SAME path, never swapped for cwd ───
|
|
196
|
+
|
|
197
|
+
test('restore:true re-attaches the worktree and returns the recorded dir (transcript encoding unchanged)', () => {
|
|
198
|
+
const calls = [];
|
|
199
|
+
const result = resolveEpicSpawnCwd({
|
|
200
|
+
cwd: '/projects/foo',
|
|
201
|
+
claudeSessionId: 'sess-1',
|
|
202
|
+
deps: {
|
|
203
|
+
readActiveIndex: stubReadActiveIndex(WORKTREE_SESSIONS),
|
|
204
|
+
statSync: statGone,
|
|
205
|
+
restore: true,
|
|
206
|
+
restoreWorktree: (a) => { calls.push(a); return true; },
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
expect(result).toBe('/tmp/session-manager-epic-worktrees/deadbeef/epic-1');
|
|
210
|
+
expect(calls).toEqual([{ dir: '/tmp/session-manager-epic-worktrees/deadbeef/epic-1', branch: 'sm-epic/epic-1', baseCwd: '/projects/foo' }]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('restore:true with a failed restore still returns the recorded dir — never the project cwd', () => {
|
|
214
|
+
const result = resolveEpicSpawnCwd({
|
|
215
|
+
cwd: '/projects/foo',
|
|
216
|
+
claudeSessionId: 'sess-1',
|
|
217
|
+
deps: { readActiveIndex: stubReadActiveIndex(WORKTREE_SESSIONS), statSync: statGone, restore: true, restoreWorktree: () => false },
|
|
218
|
+
});
|
|
219
|
+
expect(result).toBe('/tmp/session-manager-epic-worktrees/deadbeef/epic-1');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('restoreEpicWorktree refuses dirs outside an epic-worktrees segment and non sm-epic branches', () => {
|
|
223
|
+
const { restoreEpicWorktree } = require('../epicSpawnCwd.cjs');
|
|
224
|
+
expect(restoreEpicWorktree({ dir: '/etc/evil', branch: 'sm-epic/x', baseCwd: '/projects/foo' })).toBe(false);
|
|
225
|
+
expect(restoreEpicWorktree({ dir: '/tmp/session-manager-epic-worktrees/a/b', branch: 'main; rm -rf', baseCwd: '/projects/foo' })).toBe(false);
|
|
226
|
+
expect(restoreEpicWorktree({ dir: 'relative/session-manager-epic-worktrees/a', branch: 'sm-epic/x', baseCwd: '/projects/foo' })).toBe(false);
|
|
227
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* epicSpawnPlan.test.cjs — PRD 1319: spawn cwd + --resume flag are decided together; a session
|
|
3
|
+
* whose transcript is stranded under a dead worktree encoding is refused BEFORE any CLI spawn
|
|
4
|
+
* with an actionable message, later requests are refused cheaply, closed Epics are refused.
|
|
5
|
+
*
|
|
6
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/epicSpawnPlan.test.cjs
|
|
7
|
+
*/
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
import { test, expect, beforeEach, afterEach } from 'vitest';
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const os = require('node:os');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const { EventEmitter } = require('node:events');
|
|
15
|
+
const { encodeCwd } = require('../encodeCwd.cjs');
|
|
16
|
+
const { planEpicSpawn, __resetForTests } = require('../epicSpawnPlan.cjs');
|
|
17
|
+
const { __resetCacheForTests } = require('../epicTranscriptPath.cjs');
|
|
18
|
+
|
|
19
|
+
const SID = '00ff31cb-56b6-421c-829c-012ef95b15e2';
|
|
20
|
+
const PROJECT = '/projects/starry-night-ships';
|
|
21
|
+
const DEAD = '/tmp/session-manager-epic-worktrees/abc123/blender-epic-442d711d';
|
|
22
|
+
|
|
23
|
+
let home;
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
__resetForTests();
|
|
26
|
+
__resetCacheForTests();
|
|
27
|
+
home = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-home-'));
|
|
28
|
+
});
|
|
29
|
+
afterEach(() => fs.rmSync(home, { recursive: true, force: true }));
|
|
30
|
+
|
|
31
|
+
function transcriptUnder(dir) {
|
|
32
|
+
const d = path.join(home, '.claude', 'projects', encodeCwd(dir));
|
|
33
|
+
fs.mkdirSync(d, { recursive: true });
|
|
34
|
+
const f = path.join(d, `${SID}.jsonl`);
|
|
35
|
+
fs.writeFileSync(f, '{"type":"user"}\n');
|
|
36
|
+
return f;
|
|
37
|
+
}
|
|
38
|
+
const epic = (extra = {}) => ({ e1: { id: 'e1', claudeSessionId: SID, status: 'active', worktree: { dir: DEAD, branch: 'sm-epic/e1', baseCwd: PROJECT, status: 'active' }, ...extra } });
|
|
39
|
+
|
|
40
|
+
test('transcript under a dead worktree encoding + restore failure => refused, names the worktree, no raw CLI text', () => {
|
|
41
|
+
transcriptUnder(DEAD);
|
|
42
|
+
const plan = planEpicSpawn({ cwd: PROJECT, claudeSessionId: SID, deps: { homeDir: home, readActiveIndex: () => ({ sessions: epic() }), restoreWorktree: () => false } });
|
|
43
|
+
expect(plan.ok).toBe(false);
|
|
44
|
+
expect(plan.code).toBe('session_unreachable');
|
|
45
|
+
expect(plan.message).toContain(DEAD);
|
|
46
|
+
expect(plan.message).not.toMatch(/already in use/i);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('circuit breaker: 2nd..Nth request refused without re-running restore', () => {
|
|
50
|
+
transcriptUnder(DEAD);
|
|
51
|
+
let restores = 0;
|
|
52
|
+
const deps = { homeDir: home, readActiveIndex: () => ({ sessions: epic() }), restoreWorktree: () => { restores++; return false; } };
|
|
53
|
+
const first = planEpicSpawn({ cwd: PROJECT, claudeSessionId: SID, deps });
|
|
54
|
+
for (let i = 0; i < 4; i++) expect(planEpicSpawn({ cwd: PROJECT, claudeSessionId: SID, deps })).toEqual(first);
|
|
55
|
+
expect(restores).toBe(1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('worktree re-attached at the same path => ok, resume, spawn cwd is the worktree', () => {
|
|
59
|
+
transcriptUnder(DEAD);
|
|
60
|
+
const live = new Set();
|
|
61
|
+
const statSync = (p) => (live.has(p) ? { isDirectory: () => true } : fs.statSync(p));
|
|
62
|
+
const plan = planEpicSpawn({
|
|
63
|
+
cwd: PROJECT, claudeSessionId: SID,
|
|
64
|
+
deps: { homeDir: home, readActiveIndex: () => ({ sessions: epic() }), statSync, restoreWorktree: ({ dir }) => { live.add(dir); return true; } },
|
|
65
|
+
});
|
|
66
|
+
expect(plan.ok).toBe(true);
|
|
67
|
+
expect(plan.useResume).toBe(true);
|
|
68
|
+
expect(plan.execCwd).toBe(DEAD);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('transcript under the project encoding, no worktree => ok, resume from project cwd', () => {
|
|
72
|
+
transcriptUnder(PROJECT);
|
|
73
|
+
const plan = planEpicSpawn({ cwd: PROJECT, claudeSessionId: SID, deps: { homeDir: home, readActiveIndex: () => ({ sessions: epic({ worktree: undefined }) }), statSync: (p) => (p === PROJECT ? { isDirectory: () => true } : fs.statSync(p)) } });
|
|
74
|
+
expect(plan).toMatchObject({ ok: true, useResume: true, execCwd: PROJECT });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('closed (completed) Epic is refused with its own message', () => {
|
|
78
|
+
const plan = planEpicSpawn({ cwd: PROJECT, claudeSessionId: SID, deps: { homeDir: home, readActiveIndex: () => ({ sessions: epic({ status: 'completed' }) }) } });
|
|
79
|
+
expect(plan).toMatchObject({ ok: false, code: 'epic_closed' });
|
|
80
|
+
expect(plan.message).toMatch(/closed/i);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('chatRunner: dead-encoding transcript => zero CLI spawns, exactly one clear error event', async () => {
|
|
84
|
+
const project = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-proj-'));
|
|
85
|
+
const prevHome = process.env.HOME;
|
|
86
|
+
process.env.HOME = home;
|
|
87
|
+
try {
|
|
88
|
+
const idxDir = path.join(project, 'session-manager-operations', 'prompt-sessions');
|
|
89
|
+
fs.mkdirSync(idxDir, { recursive: true });
|
|
90
|
+
fs.writeFileSync(path.join(idxDir, 'active-index.json'), JSON.stringify({ sessions: epic() }));
|
|
91
|
+
transcriptUnder(DEAD);
|
|
92
|
+
const cp = require('node:child_process');
|
|
93
|
+
const realSpawn = cp.spawn;
|
|
94
|
+
let spawns = 0;
|
|
95
|
+
cp.spawn = () => { spawns++; const c = new EventEmitter(); c.stdout = new EventEmitter(); c.stderr = new EventEmitter(); c.pid = 1; c.kill = () => {}; return c; };
|
|
96
|
+
const chatRunner = require('../../chatRunner.cjs');
|
|
97
|
+
chatRunner.__resetQueueForTests();
|
|
98
|
+
const sent = [];
|
|
99
|
+
chatRunner.attachWindow({ isDestroyed: () => false, webContents: { isDestroyed: () => false, send: (channel, payload) => sent.push({ channel, payload }) } });
|
|
100
|
+
try {
|
|
101
|
+
for (let i = 0; i < 3; i++) chatRunner.run({ tabId: `t${i}`, sessionId: SID, prompt: 'hi', cwd: project, resume: true });
|
|
102
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
103
|
+
} finally { cp.spawn = realSpawn; }
|
|
104
|
+
const errs = sent.filter((e) => e.channel === 'chat:run:error');
|
|
105
|
+
expect(spawns).toBe(0);
|
|
106
|
+
expect(errs.length).toBe(3);
|
|
107
|
+
for (const e of errs) expect(e.payload.message).toContain(DEAD);
|
|
108
|
+
// Single-writer law: the rescued session's ops write (error log) lands under the PROJECT root.
|
|
109
|
+
const logsDir = path.join(project, 'session-manager-operations', 'logs');
|
|
110
|
+
expect(fs.existsSync(logsDir)).toBe(true);
|
|
111
|
+
expect(fs.readdirSync(logsDir).some((n) => n.startsWith('errors-'))).toBe(true);
|
|
112
|
+
expect(fs.existsSync(path.join(DEAD, 'session-manager-operations'))).toBe(false);
|
|
113
|
+
} finally {
|
|
114
|
+
process.env.HOME = prevHome;
|
|
115
|
+
fs.rmSync(project, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
});
|