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.
Files changed (79) hide show
  1. package/dist/assets/{AgentLibrary-kQF8_wAw.js → AgentLibrary-CrmoSn9-.js} +1 -1
  2. package/dist/assets/DataModel-SPLQTR8E.js +1 -0
  3. package/dist/assets/{History-Sji2brb0.js → History-DT6S3abz.js} +2 -2
  4. package/dist/assets/{Hooks-CtqpO-p5.js → Hooks-CC2iv5Oq.js} +3 -3
  5. package/dist/assets/{HostBilko-DN0ydOCr.js → HostBilko-DqY7dA2a.js} +1 -1
  6. package/dist/assets/{Library-CoiLZTKh.js → Library-CUoOC77T.js} +1 -1
  7. package/dist/assets/{ListDetail-CmqiQ_io.js → ListDetail-DgrG72f4.js} +1 -1
  8. package/dist/assets/MarkdownEditor-C8dB3ZWU.js +1 -0
  9. package/dist/assets/{McpServers-Bj1X3_NT.js → McpServers-DupdjvFt.js} +2 -2
  10. package/dist/assets/{Memory-1PKjsM3b.js → Memory-C_4LhC2r.js} +6 -6
  11. package/dist/assets/{Panel-I1s6ghPo.js → Panel-DWmE5Fu9.js} +1 -1
  12. package/dist/assets/{Permissions-BhiiZjHQ.js → Permissions-hxiN2IWM.js} +3 -3
  13. package/dist/assets/{Plugins-gv3_fkPX.js → Plugins-fttEgZqy.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-BGcgBd8V.js → ProvenanceBadge-DROCzlbx.js} +1 -1
  15. package/dist/assets/{SaveBar-BGr9eZ4g.js → SaveBar-BZs0IQHq.js} +1 -1
  16. package/dist/assets/Scheduler-Bi4oZ8gg.js +16 -0
  17. package/dist/assets/{ScopeSwitcher-ukl0qWaA.js → ScopeSwitcher-C0538XU8.js} +1 -1
  18. package/dist/assets/{Settings-DJkFj22G.js → Settings-CcW6x-wu.js} +2 -2
  19. package/dist/assets/{SkillReferenceGraph-0t1Qblp6.js → SkillReferenceGraph-BtCcPAYO.js} +1 -1
  20. package/dist/assets/{Skills-CeVdBGE-.js → Skills-Daf_1sQR.js} +2 -2
  21. package/dist/assets/SystemPrompt-rYIla_wt.js +1 -0
  22. package/dist/assets/{TagLibrary-DAzNaUPd.js → TagLibrary-81Mr1RLU.js} +1 -1
  23. package/dist/assets/{TiptapBody--S_rGXoA.js → TiptapBody-BxuLNg1e.js} +1 -1
  24. package/dist/assets/{Toggle-Dszm0xnp.js → Toggle-CN8EVEai.js} +1 -1
  25. package/dist/assets/{index-Dp4Rc--q.js → index-A9TWmB7_.js} +345 -345
  26. package/dist/assets/{index-BHTX4OTc.css → index-Dh7BLxdA.css} +1 -1
  27. package/dist/assets/{settingsSchema-BLbssMYo.js → settingsSchema-C9I9a0jv.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +1 -1
  30. package/scripts/README.md +1 -0
  31. package/src/main/__tests__/chatRunner-session-flag-retry.test.cjs +159 -0
  32. package/src/main/__tests__/health-delegation-chain.test.cjs +3 -1
  33. package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +16 -4
  34. package/src/main/__tests__/scheduler-default-eligible-heal.test.cjs +10 -3
  35. package/src/main/__tests__/scheduler-integration-failure-stamp.test.cjs +41 -0
  36. package/src/main/__tests__/scheduler-looks-done.test.cjs +9 -2
  37. package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +23 -0
  38. package/src/main/__tests__/scheduler-needs-review-autoresolve.test.cjs +14 -6
  39. package/src/main/__tests__/scheduler-shard-quarantine.test.cjs +2 -2
  40. package/src/main/__tests__/scheduler-stranded-autofix-park.test.cjs +8 -1
  41. package/src/main/__tests__/transcripts-paged-reads.test.cjs +4 -2
  42. package/src/main/__tests__/transcripts-worktree-epic-path.test.cjs +154 -0
  43. package/src/main/__tests__/usageSingleFlight.test.cjs +3 -1
  44. package/src/main/build-info.json +4 -4
  45. package/src/main/chatRunner.cjs +108 -45
  46. package/src/main/health.cjs +20 -3
  47. package/src/main/ipcSchemas.cjs +3 -0
  48. package/src/main/lib/__tests__/epicSpawnCwd.test.cjs +34 -0
  49. package/src/main/lib/__tests__/epicSpawnPlan.test.cjs +117 -0
  50. package/src/main/lib/__tests__/epicTranscriptPath.test.cjs +163 -0
  51. package/src/main/lib/__tests__/gitWorktree.test.cjs +172 -0
  52. package/src/main/lib/__tests__/localAdminHttp.test.cjs +5 -1
  53. package/src/main/lib/__tests__/schedulerPathsWorktree.test.cjs +40 -1
  54. package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +8 -5
  55. package/src/main/lib/effectiveModelInfo.cjs +3 -3
  56. package/src/main/lib/epicDelegationStats.cjs +3 -2
  57. package/src/main/lib/epicSpawnCwd.cjs +36 -5
  58. package/src/main/lib/epicSpawnPlan.cjs +119 -0
  59. package/src/main/lib/epicTranscriptDiagnostic.cjs +79 -0
  60. package/src/main/lib/epicTranscriptPath.cjs +184 -0
  61. package/src/main/lib/gitWorktree.cjs +196 -4
  62. package/src/main/lib/mcpToolCatalog.cjs +6 -1
  63. package/src/main/lib/prdCreate.cjs +27 -1
  64. package/src/main/lib/prdFrontmatter.cjs +16 -5
  65. package/src/main/lib/rcaReport.cjs +35 -4
  66. package/src/main/lib/schedulerPaths.cjs +27 -4
  67. package/src/main/lib/telemetryClient.cjs +11 -4
  68. package/src/main/lib/terminalRunOutcome.cjs +1 -0
  69. package/src/main/pty.cjs +1 -1
  70. package/src/main/runVerify.cjs +89 -1
  71. package/src/main/scheduler.cjs +106 -18
  72. package/src/main/templates/PRD_AUTHORING.md +11 -0
  73. package/src/main/transcripts.cjs +28 -7
  74. package/src/preload/api.d.ts +2 -0
  75. package/src/preload/index.cjs +1 -0
  76. package/dist/assets/DataModel-CY_mMnsr.js +0 -1
  77. package/dist/assets/MarkdownEditor-HPoNeKhT.js +0 -1
  78. package/dist/assets/Scheduler-Bdo_awPq.js +0 -14
  79. package/dist/assets/SystemPrompt-CfKvNHi3.js +0 -1
package/dist/index.html CHANGED
@@ -7,10 +7,10 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
9
  <link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400&family=Geist:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
10
- <script type="module" crossorigin src="./assets/index-Dp4Rc--q.js"></script>
10
+ <script type="module" crossorigin src="./assets/index-A9TWmB7_.js"></script>
11
11
  <link rel="modulepreload" crossorigin href="./assets/monaco-editor-BW5C4Iv1.js">
12
12
  <link rel="stylesheet" crossorigin href="./assets/monaco-editor-BTnBOi8r.css">
13
- <link rel="stylesheet" crossorigin href="./assets/index-BHTX4OTc.css">
13
+ <link rel="stylesheet" crossorigin href="./assets/index-Dh7BLxdA.css">
14
14
  </head>
15
15
  <body class="bg-bg text-fg font-sans antialiased">
16
16
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-session-manager",
3
- "version": "0.88.1",
3
+ "version": "0.90.0",
4
4
  "description": "Local cockpit for the Claude Code CLI — multi-tab terminal, full config surface, scheduler, voice dictation, and live observability.",
5
5
  "type": "module",
6
6
  "main": "src/main/index.cjs",
package/scripts/README.md CHANGED
@@ -10,6 +10,7 @@ scripts/lib was moved into src/main/lib (2026-09); nothing under src/ may requir
10
10
  | --- | --- | --- |
11
11
  | audit-ops-hygiene.cjs | manual; named in `ops-sweep` + `develop` SKILL.md | yes |
12
12
  | check-conditional-hooks.cjs | `npm run lint:hooks` (in `lint`) | no |
13
+ | check-epic-transcripts.cjs | manual read-only diagnostic (`--json`); also surfaced as a non-fatal warning in `npm run health` | no |
13
14
  | check-doc-hierarchy.cjs | `npm run lint:docs` (in `lint`) | no |
14
15
  | check-unregistered-tests.cjs | `npm run lint:unregistered-tests` (in `lint`) | no |
15
16
  | check-unstable-selectors.cjs | `npm run lint:selectors` (in `lint`) | no |
@@ -0,0 +1,159 @@
1
+ /**
2
+ * chatRunner-session-flag-retry.test.cjs — main is authoritative about
3
+ * --session-id vs --resume, and a CLI flag rejection is retried exactly once
4
+ * with the other flag without losing the turn.
5
+ *
6
+ * Run: timeout 300 npx vitest run src/main/__tests__/chatRunner-session-flag-retry.test.cjs
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ import { test, expect, beforeAll, afterAll, afterEach } from 'vitest';
12
+ const fs = require('node:fs');
13
+ const os = require('node:os');
14
+ const path = require('node:path');
15
+
16
+ let tmpHome;
17
+ let originalHome;
18
+ let cr;
19
+ let sent;
20
+
21
+ beforeAll(() => {
22
+ originalHome = process.env.HOME;
23
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-chatrunner-flagretry-home-'));
24
+ process.env.HOME = tmpHome;
25
+ cr = require('../chatRunner.cjs');
26
+ });
27
+
28
+ afterAll(() => {
29
+ process.env.HOME = originalHome;
30
+ fs.rmSync(tmpHome, { recursive: true, force: true });
31
+ });
32
+
33
+ afterEach(() => {
34
+ delete process.env.SM_CLAUDE_BIN;
35
+ cr.__resetQueueForTests();
36
+ cr.attachWindow(null);
37
+ });
38
+
39
+ function attachRecorder() {
40
+ sent = [];
41
+ cr.attachWindow({
42
+ isDestroyed: () => false,
43
+ webContents: { isDestroyed: () => false, send: (channel, payload) => sent.push({ channel, payload }) },
44
+ });
45
+ }
46
+
47
+ // Stub claude: logs its argv (one JSON line per spawn) to logFile. Invocation 1
48
+ // fails with `firstStderr` (optionally after emitting assistant text); later
49
+ // invocations succeed.
50
+ function writeStub(logFile, { firstStderr, textFirst }) {
51
+ const stubPath = path.join(os.tmpdir(), `sm-flagretry-stub-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
52
+ const body = `
53
+ const fs = require('fs');
54
+ const log = ${JSON.stringify(logFile)};
55
+ fs.appendFileSync(log, JSON.stringify(process.argv.slice(2)) + '\\n');
56
+ const n = fs.readFileSync(log, 'utf8').trim().split('\\n').length;
57
+ if (n === 1) {
58
+ ${textFirst ? "process.stdout.write(JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: 'partial' }] } }) + '\\n');" : ''}
59
+ process.stderr.write(${JSON.stringify(firstStderr)});
60
+ process.exit(1);
61
+ }
62
+ process.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', result: 'ok' }) + '\\n');
63
+ process.exit(0);
64
+ `;
65
+ fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
66
+ return stubPath;
67
+ }
68
+
69
+ async function waitFor(pred, timeoutMs = 8000) {
70
+ const start = Date.now();
71
+ while (!pred()) {
72
+ if (Date.now() - start > timeoutMs) throw new Error('timed out');
73
+ await new Promise((r) => setTimeout(r, 25));
74
+ }
75
+ }
76
+
77
+ const TERMINAL = ['chat:run:complete', 'chat:run:needs-input', 'chat:run:error'];
78
+ let curTab;
79
+ const terminals = () => sent.filter((e) => TERMINAL.includes(e.channel) && e.payload.tabId === curTab);
80
+ const spawns = (logFile) => fs.readFileSync(logFile, 'utf8').trim().split('\n').map((l) => JSON.parse(l));
81
+
82
+ test('in-use error on --session-id retries once with --resume; one terminal event; lane freed', async () => {
83
+ attachRecorder();
84
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-flagretry-cwd-'));
85
+ const logFile = path.join(cwd, 'argv.log');
86
+ const sessionId = `flag-sess-${process.pid}`;
87
+ process.env.SM_CLAUDE_BIN = writeStub(logFile, { firstStderr: `Error: Session ID ${sessionId} is already in use.\n` });
88
+ try {
89
+ curTab = 'tab-a';
90
+ cr.run({ tabId: 'tab-a', sessionId, prompt: 'hello', cwd, resume: false });
91
+ await waitFor(() => terminals().length > 0);
92
+ await new Promise((r) => setTimeout(r, 100));
93
+
94
+ const argvs = spawns(logFile);
95
+ expect(argvs).toHaveLength(2);
96
+ expect(argvs[0]).toContain('--session-id');
97
+ expect(argvs[1]).toContain('--resume');
98
+ expect(argvs[1]).not.toContain('--session-id');
99
+ expect(argvs[1][argvs[1].indexOf('--model') + 1]).toBe(argvs[0][argvs[0].indexOf('--model') + 1]);
100
+ expect(argvs[1][1]).toBe(argvs[0][1]);
101
+
102
+ expect(terminals()).toHaveLength(1);
103
+ expect(terminals()[0].channel).toBe('chat:run:complete');
104
+ expect(sent.some((e) => e.channel === 'chat:run:notice')).toBe(true);
105
+
106
+ // Lane freed: the same tab accepts a new run immediately (not queued).
107
+ const again = cr.run({ tabId: 'tab-a', sessionId, prompt: 'again', cwd, resume: true });
108
+ expect(again.queued).toBe(false);
109
+ await cr.cancel('tab-a');
110
+ } finally {
111
+ fs.rmSync(cwd, { recursive: true, force: true });
112
+ }
113
+ });
114
+
115
+ test('no retry when the failed attempt already streamed assistant text', async () => {
116
+ attachRecorder();
117
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-flagretry-cwd-'));
118
+ const logFile = path.join(cwd, 'argv.log');
119
+ const sessionId = `flag-sess2-${process.pid}`;
120
+ process.env.SM_CLAUDE_BIN = writeStub(logFile, {
121
+ firstStderr: `Error: Session ID ${sessionId} is already in use.\n`,
122
+ textFirst: true,
123
+ });
124
+ try {
125
+ curTab = 'tab-b';
126
+ cr.run({ tabId: 'tab-b', sessionId, prompt: 'hello', cwd, resume: false });
127
+ await waitFor(() => terminals().length > 0);
128
+ await new Promise((r) => setTimeout(r, 100));
129
+
130
+ expect(spawns(logFile)).toHaveLength(1);
131
+ expect(terminals()).toHaveLength(1);
132
+ expect(terminals()[0].channel).toBe('chat:run:error');
133
+ expect(terminals()[0].payload.message).toMatch(/already in use/);
134
+ } finally {
135
+ fs.rmSync(cwd, { recursive: true, force: true });
136
+ }
137
+ });
138
+
139
+ test('"No conversation found" on --resume retries once with --session-id (main overrides a wrong renderer flag only via transcript)', async () => {
140
+ attachRecorder();
141
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-flagretry-cwd-'));
142
+ const logFile = path.join(cwd, 'argv.log');
143
+ const sessionId = `flag-sess3-${process.pid}`;
144
+ process.env.SM_CLAUDE_BIN = writeStub(logFile, { firstStderr: `No conversation found with session ID: ${sessionId}\n` });
145
+ try {
146
+ // No transcript exists anywhere → main computes resume=false even though
147
+ // the renderer said true, so the first attempt is --session-id and the
148
+ // stub's failure is the wrong-direction message: no retry, error surfaces.
149
+ curTab = 'tab-c';
150
+ cr.run({ tabId: 'tab-c', sessionId, prompt: 'hello', cwd, resume: true });
151
+ await waitFor(() => terminals().length > 0);
152
+ const argvs = spawns(logFile);
153
+ expect(argvs).toHaveLength(1);
154
+ expect(argvs[0]).toContain('--session-id');
155
+ expect(terminals()[0].channel).toBe('chat:run:error');
156
+ } finally {
157
+ fs.rmSync(cwd, { recursive: true, force: true });
158
+ }
159
+ });
@@ -94,7 +94,9 @@ test('end-to-end: check() reports delegation_chain for this repo, expected to pa
94
94
  process.env.SM_ALLOW_LIVE_ROOT_READS = '1';
95
95
  let status;
96
96
  try {
97
- status = await check();
97
+ // skipTypecheck: this test asserts on delegation_chain only; the tsc shell-out was
98
+ // ~15s of the ~16s idle runtime and starved to the 90s budget under CPU load.
99
+ status = await check({ skipTypecheck: true });
98
100
  } finally {
99
101
  delete process.env.SM_ALLOW_LIVE_ROOT_READS;
100
102
  process.env.HOME = sandboxHome;
@@ -135,13 +135,19 @@ test('pollLoop still attempts a dispatch after a transient billing failure, usin
135
135
  await scheduler.writeQueue({ jobs: [], config: {}, paused: null });
136
136
 
137
137
  await seedOnePendingJob();
138
- expect(await lastDispatchAttemptAt()).toBeNull();
138
+ // Baseline, not `toBeNull()`: under full-suite load a straggler tick from an
139
+ // earlier poll/test (spawnJob's completion tick, a slot-release pump) can
140
+ // stamp between the reset above and here, so the stamp is not guaranteed
141
+ // null. What the test proves is that THIS poll+flush advances the stamp.
142
+ const before = await lastDispatchAttemptAt();
139
143
 
140
144
  process.env.SM_MOCK_BILLING_KIND = 'transient';
141
145
  await scheduler.pollLoop();
142
146
  await flushPendingTick();
143
147
 
144
- expect(await lastDispatchAttemptAt()).not.toBeNull();
148
+ const after = await lastDispatchAttemptAt();
149
+ expect(after).not.toBeNull();
150
+ expect(after).not.toBe(before);
145
151
  });
146
152
 
147
153
  test('pollLoop still attempts a dispatch after the billing poll throws, using the degraded budget (not utilization 0)', async () => {
@@ -152,7 +158,11 @@ test('pollLoop still attempts a dispatch after the billing poll throws, using th
152
158
  await scheduler.writeQueue({ jobs: [], config: {}, paused: null });
153
159
 
154
160
  await seedOnePendingJob();
155
- expect(await lastDispatchAttemptAt()).toBeNull();
161
+ // Baseline, not `toBeNull()`: under full-suite load a straggler tick from an
162
+ // earlier poll/test (spawnJob's completion tick, a slot-release pump) can
163
+ // stamp between the reset above and here, so the stamp is not guaranteed
164
+ // null. What the test proves is that THIS poll+flush advances the stamp.
165
+ const before = await lastDispatchAttemptAt();
156
166
 
157
167
  delete process.env.SM_E2E;
158
168
  delete process.env.SM_MOCK_BILLING_KIND;
@@ -160,5 +170,7 @@ test('pollLoop still attempts a dispatch after the billing poll throws, using th
160
170
  await scheduler.pollLoop();
161
171
  await flushPendingTick();
162
172
 
163
- expect(await lastDispatchAttemptAt()).not.toBeNull();
173
+ const after = await lastDispatchAttemptAt();
174
+ expect(after).not.toBeNull();
175
+ expect(after).not.toBe(before);
164
176
  });
@@ -19,6 +19,13 @@ const path = require('node:path');
19
19
 
20
20
  process.env.HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'default-eligible-heal-test-'));
21
21
 
22
+ // Fixture cwd must be a REAL, writable dir: transitionJob out of needs_review
23
+ // fire-and-forgets a history append under <cwd>/session-manager-operations. A fake
24
+ // path made it fail EACCES AFTER the sync test returned, so its console.error hit
25
+ // vitest mid worker-close ("Closing rpc while onUserConsoleLog was pending").
26
+ const PROJECT_CWD = path.join(process.env.HOME, 'project');
27
+ fs.mkdirSync(PROJECT_CWD, { recursive: true });
28
+
22
29
  const {
23
30
  isRescanCandidate,
24
31
  isTranscriptRescannable,
@@ -39,7 +46,7 @@ const {
39
46
  } = require('../scheduler.cjs');
40
47
  const { evaluateBlockingParkHealth } = require('../health.cjs');
41
48
 
42
- const row = (o = {}) => ({ slug: '10-x', status: 'needs_review', runId: 'r1', cwd: '/p', ...o });
49
+ const row = (o = {}) => ({ slug: '10-x', status: 'needs_review', runId: 'r1', cwd: PROJECT_CWD, ...o });
43
50
 
44
51
  test('shared_tree_reverted is a rescan candidate without being on any list', () => {
45
52
  assert.equal(RESCANNABLE_VERDICTS.has('shared_tree_reverted'), false);
@@ -142,8 +149,8 @@ test('auto-resolve ladder is capped: requeue attempts stop at NEEDS_REVIEW_RESOL
142
149
  describe('evaluateBlockingParkHealth', () => {
143
150
  const now = Date.parse('2026-09-18T20:00:00Z');
144
151
  const parkedAgo = (ms) => [{ to: 'needs_review', at: new Date(now - ms).toISOString() }];
145
- const park = (ms, o = {}) => ({ slug: '5-park', status: 'needs_review', cwd: '/p', verifierVerdict: 'shared_tree_reverted', statusHistory: parkedAgo(ms), ...o });
146
- const dep = (slug, dependsOn) => ({ slug, status: 'pending', cwd: '/p', dependsOn });
152
+ const park = (ms, o = {}) => ({ slug: '5-park', status: 'needs_review', cwd: PROJECT_CWD, verifierVerdict: 'shared_tree_reverted', statusHistory: parkedAgo(ms), ...o });
153
+ const dep = (slug, dependsOn) => ({ slug, status: 'pending', cwd: PROJECT_CWD, dependsOn });
147
154
 
148
155
  test('parked past one interval with pending dependents (transitive) is non-green, naming row + count', () => {
149
156
  const r = evaluateBlockingParkHealth([park(REVERIFY_INTERVAL_MS + 1000), dep('6-a', ['5-park']), dep('7-b', ['6-a']), dep('8-c', [])], now);
@@ -0,0 +1,41 @@
1
+ /**
2
+ * scheduler-integration-failure-stamp.test.cjs — a worktree_integration_failed
3
+ * finalize stamps integrateBranch's failure subtype onto the job row, and the
4
+ * RCA report names it.
5
+ *
6
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-integration-failure-stamp.test.cjs
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ import { test } from 'vitest';
12
+ const assert = require('node:assert/strict');
13
+ const { stampIntegrationFailure } = require('../scheduler.cjs');
14
+ const { buildRcaMarkdown } = require('../lib/rcaReport.cjs');
15
+
16
+ test('stamps content_conflict kind and conflicted paths', () => {
17
+ const row = { slug: 'x' };
18
+ stampIntegrationFailure(row, { ok: false, failureKind: 'content_conflict', conflictedPaths: ['a.md'] });
19
+ assert.equal(row.integrationFailureKind, 'content_conflict');
20
+ assert.deepEqual(row.integrationConflictPaths, ['a.md']);
21
+ });
22
+
23
+ test('blocking_paths stamps kind only; no failure clears stale fields', () => {
24
+ const row = { integrationConflictPaths: ['old'] };
25
+ stampIntegrationFailure(row, { ok: false, failureKind: 'blocking_paths', blockingPaths: { tracked: ['f'], untracked: [] } });
26
+ assert.equal(row.integrationFailureKind, 'blocking_paths');
27
+ assert.equal('integrationConflictPaths' in row, false);
28
+ stampIntegrationFailure(row, null);
29
+ assert.equal('integrationFailureKind' in row, false);
30
+ });
31
+
32
+ test('RCA markdown names the subtype and conflicted paths', () => {
33
+ const job = {
34
+ slug: 'x', runId: 'r', integrationFailureKind: 'content_conflict',
35
+ integrationConflictPaths: ['docs/asset-pipeline.md'],
36
+ };
37
+ const md = buildRcaMarkdown({
38
+ job, verdict: 'worktree_integration_failed', meta: null, logTail: '', acText: '', failureClass: 'unknown',
39
+ });
40
+ assert.ok(md.includes('Integration failure subtype: content_conflict (conflicted paths: docs/asset-pipeline.md)'));
41
+ });
@@ -210,7 +210,10 @@ test('needs_review with an in-window commit heals exactly as today (regression
210
210
  '- [ ] `npm test` passes',
211
211
  ].join('\n'));
212
212
 
213
- const startedAt = new Date().toISOString();
213
+ // Window opens a minute back: the only commit here is initRepo's, and git's --since
214
+ // compares whole seconds, so `new Date()` (taken AFTER initRepo) drops it out of the
215
+ // window whenever the clock crosses a second boundary under load.
216
+ const startedAt = new Date(Date.now() - 60_000).toISOString();
214
217
  const queuePath = writeProjectQueue(projectCwd, [
215
218
  {
216
219
  slug: '20-clean-feature',
@@ -266,7 +269,6 @@ test('computeLooksDone: no declared paths on the PRD → null, never fabricates
266
269
  test('needs_review row with autoFixAttempted:true is never also stamped looksDone (PRD 1136 — mutually exclusive)', async () => {
267
270
  const projectCwd = path.join(tmpHome, 'proj-already-fix-planned');
268
271
  initRepo(projectCwd);
269
- registerActiveProject(projectCwd, 'proj-already-fix-planned-slug');
270
272
  writePrd(projectCwd, '30-example', [
271
273
  '# Implementation notes',
272
274
  'Edit `src/thing.js`.',
@@ -301,6 +303,11 @@ test('needs_review row with autoFixAttempted:true is never also stamped looksDon
301
303
  JSON.stringify({ type: 'result', subtype: 'error_during_execution', is_error: true, result: 'still broken' }),
302
304
  ]);
303
305
 
306
+ // Register LAST: the project only becomes discoverable once its PRD and queue row both
307
+ // exist, so a straggling background reconcile from an earlier test can't see the PRD
308
+ // without its row and mint a 'quarantined' adopt row over this needs_review one.
309
+ registerActiveProject(projectCwd, 'proj-already-fix-planned-slug');
310
+
304
311
  commitFile(projectCwd, 'src/thing.js', 'hello', 'later commit touching the declared path');
305
312
 
306
313
  await reverifyNeedsReview();
@@ -118,6 +118,29 @@ test('selectMechanicalRecoveryTarget: kill-switch SM_MECHANICAL_RECOVERY_DISABLE
118
118
  expect(selectMechanicalRecoveryTarget(eligibleJob())).toBeNull();
119
119
  });
120
120
 
121
+ const conflictJob = (o = {}) => eligibleJob({ integrationFailureKind: 'content_conflict', integrationBaseHeadSha: 'aaa111', ...o });
122
+
123
+ test('selectMechanicalRecoveryTarget: content_conflict with unchanged HEAD is skipped', () => {
124
+ expect(selectMechanicalRecoveryTarget(conflictJob(), 'aaa111')).toBeNull();
125
+ });
126
+
127
+ test('selectMechanicalRecoveryTarget: content_conflict with a moved HEAD returns a target', () => {
128
+ expect(selectMechanicalRecoveryTarget(conflictJob(), 'bbb222')).not.toBeNull();
129
+ });
130
+
131
+ test('selectMechanicalRecoveryTarget: blocking_paths with unchanged HEAD still returns a target', () => {
132
+ const job = eligibleJob({ integrationFailureKind: 'blocking_paths', integrationBaseHeadSha: 'aaa111' });
133
+ expect(selectMechanicalRecoveryTarget(job, 'aaa111')).not.toBeNull();
134
+ });
135
+
136
+ test('selectMechanicalRecoveryTarget: a pre-mc-01 row with no integrationFailureKind still returns a target', () => {
137
+ expect(selectMechanicalRecoveryTarget(eligibleJob(), 'aaa111')).not.toBeNull();
138
+ });
139
+
140
+ test('selectMechanicalRecoveryTarget: null currentHeadSha never skips', () => {
141
+ expect(selectMechanicalRecoveryTarget(conflictJob(), null)).not.toBeNull();
142
+ });
143
+
121
144
  test('MECHANICALLY_RESOLVABLE_VERDICTS: closed set contains exactly worktree_integration_failed', () => {
122
145
  expect(Array.from(MECHANICALLY_RESOLVABLE_VERDICTS)).toEqual(['worktree_integration_failed']);
123
146
  });
@@ -26,6 +26,14 @@ const path = require('node:path');
26
26
  const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'needs-review-autoresolve-test-'));
27
27
  process.env.HOME = tmpHome;
28
28
 
29
+ // Fixture cwd must be a REAL, writable dir: every transitionJob out of
30
+ // needs_review fire-and-forgets a history append under <cwd>/session-manager-operations.
31
+ // A fake path ('/home/user/project') made that append fail with EACCES *after*
32
+ // the synchronous test returned, so its console.error landed while vitest was
33
+ // closing the worker ("Closing rpc while onUserConsoleLog was pending" -> exit 1).
34
+ const PROJECT_CWD = path.join(tmpHome, 'project');
35
+ fs.mkdirSync(PROJECT_CWD, { recursive: true });
36
+
29
37
  const {
30
38
  selectExhaustedNeedsReviewTargets,
31
39
  applyNeedsReviewAutoResolve,
@@ -41,7 +49,7 @@ const THRESHOLD_MS = 30 * MIN_MS;
41
49
  function exhaustedJob(overrides = {}) {
42
50
  return {
43
51
  slug: 'exhausted-row',
44
- cwd: '/home/user/project',
52
+ cwd: PROJECT_CWD,
45
53
  status: 'needs_review',
46
54
  autoFixAttempted: true,
47
55
  autoFixOutcome: 'no-plan',
@@ -153,16 +161,16 @@ test('applyNeedsReviewAutoResolve race-guards against a row that moved off needs
153
161
  test('a 3-row dependsOn chain drains: the exhausted middle row auto-skips and the third row becomes eligible', () => {
154
162
  const now = Date.now();
155
163
  const rowA = {
156
- slug: 'a-first', cwd: '/home/user/project', status: 'completed',
164
+ slug: 'a-first', cwd: PROJECT_CWD, status: 'completed',
157
165
  };
158
166
  const rowB = exhaustedJob({
159
167
  slug: 'b-middle',
160
- cwd: '/home/user/project',
168
+ cwd: PROJECT_CWD,
161
169
  exhaustedResolveAttempts: NEEDS_REVIEW_RESOLVE_CAP,
162
170
  dependsOn: ['a-first'],
163
171
  });
164
172
  const rowC = {
165
- slug: 'c-last', cwd: '/home/user/project', status: 'pending', dependsOn: ['b-middle'],
173
+ slug: 'c-last', cwd: PROJECT_CWD, status: 'pending', dependsOn: ['b-middle'],
166
174
  };
167
175
  const jobs = [rowA, rowB, rowC];
168
176
 
@@ -182,8 +190,8 @@ test('a 3-row dependsOn chain drains: the exhausted middle row auto-skips and th
182
190
  });
183
191
 
184
192
  test('a generic (non-auto-resolved) skipped dep still blocks forever — the carve-out is narrow', () => {
185
- const rowB = { slug: 'b-middle', cwd: '/home/user/project', status: 'skipped' }; // no needsReviewAutoResolvedSkip marker
186
- const rowC = { slug: 'c-last', cwd: '/home/user/project', status: 'pending', dependsOn: ['b-middle'] };
193
+ const rowB = { slug: 'b-middle', cwd: PROJECT_CWD, status: 'skipped' }; // no needsReviewAutoResolvedSkip marker
194
+ const rowC = { slug: 'c-last', cwd: PROJECT_CWD, status: 'pending', dependsOn: ['b-middle'] };
187
195
  const { batch } = pickForProject([rowB, rowC], new Set(), 5);
188
196
  assert.equal(batch.length, 0, 'a PRD-source-vanished skip must remain a permanent block, unaffected by this carve-out');
189
197
  });
@@ -103,8 +103,8 @@ test('tickQueue dispatches the healthy project while the torn project is skipped
103
103
  expect(fs.existsSync(path.join(torn.cwd, 'ran-here.marker'))).toBe(false);
104
104
  expect(fs.readFileSync(torn.queueFile).equals(before)).toBe(true);
105
105
 
106
- const health = await require('../health.cjs').check();
106
+ const health = await require('../health.cjs').check({ skipTypecheck: true });
107
107
  expect(health.components.scheduler_queue.ok).toBe(false);
108
108
  expect(health.components.scheduler_queue.quarantinedCwds).toEqual([torn.cwd]);
109
109
  expect(health.issues.some((i) => i.includes(torn.cwd))).toBe(true);
110
- }, 120_000); // health.check() alone can take tens of seconds
110
+ }, 120_000); // skipTypecheck: this asserts scheduler_queue only; tsc was the tens of seconds
@@ -38,6 +38,13 @@ const { execFileSync } = require('node:child_process');
38
38
  const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'stranded-autofix-park-test-'));
39
39
  process.env.HOME = tmpHome;
40
40
 
41
+ // Fixture cwd must be a REAL, writable dir: transitionJob out of needs_review
42
+ // fire-and-forgets a history append under <cwd>/session-manager-operations. A fake
43
+ // path made it fail EACCES AFTER the sync test returned, so its console.error hit
44
+ // vitest mid worker-close ("Closing rpc while onUserConsoleLog was pending").
45
+ const PROJECT_CWD = path.join(tmpHome, 'project');
46
+ fs.mkdirSync(PROJECT_CWD, { recursive: true });
47
+
41
48
  const {
42
49
  isStrandedAutoFixPark,
43
50
  isExhaustedAutoFix,
@@ -72,7 +79,7 @@ function registerActiveProject(cwd, slug) {
72
79
  function strandedJob(overrides = {}) {
73
80
  return {
74
81
  slug: '1218-fo-01-move-scripts-lib-into-src-main-lib',
75
- cwd: '/home/user/project',
82
+ cwd: PROJECT_CWD,
76
83
  status: 'needs_review',
77
84
  exitCode: 0,
78
85
  verifierVerdict: 'transcript_errors',
@@ -137,7 +137,9 @@ test('inode-change rotation invalidates and rebuilds the index — no stale offs
137
137
  // The chokidar watcher on the live sub picks up the rotation and rebuilds
138
138
  // the index via readDelta's inode-change reset — poll (bounded) rather
139
139
  // than assume a fixed debounce delay.
140
- const deadline = Date.now() + 5000;
140
+ // 30s ceiling, not 5s: the watcher event is starved under heavy CPU contention; the loop
141
+ // exits the instant the condition holds so idle cost is unchanged.
142
+ const deadline = Date.now() + 30000;
141
143
  while (Date.now() < deadline) {
142
144
  page = await pageEvents(tabId, 0, 0);
143
145
  if (page.totalLines === 1 && page.events[0]?.data === 'new-a') break;
@@ -146,7 +148,7 @@ test('inode-change rotation invalidates and rebuilds the index — no stale offs
146
148
  expect(page.totalLines).toBe(1);
147
149
  expect(page.events[0].data).toBe('new-a');
148
150
  closeTab(tabId);
149
- }, 10000);
151
+ }, 40000);
150
152
 
151
153
  test('a partial trailing line is not indexed until it completes', async () => {
152
154
  const sessionId = 'sess-partial';
@@ -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
+ });