claude-code-session-manager 0.57.1 → 0.59.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/README.md +2 -2
- package/dist/assets/{TiptapBody-CJDxVF7X.js → TiptapBody-PUx4oZTh.js} +1 -1
- package/dist/assets/index-BPnfPdLW.js +3222 -0
- package/dist/assets/{index-WUc_GHMf.css → index-CPMP2XZ_.css} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -2
- package/plugins/session-manager-dev/skills/develop/SKILL.md +7 -6
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
- package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +1 -1
- package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +1 -1
- package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +16 -25
- package/plugins/session-manager-dev/skills/project-status/SKILL.md +5 -5
- package/scripts/lib/watchdogHelpers.cjs +0 -434
- package/scripts/mint-epic.cjs +6 -6
- package/src/main/__tests__/activeIndexMerge.test.cjs +2 -2
- package/src/main/__tests__/agentModelResolve.test.cjs +119 -0
- package/src/main/__tests__/classifyTranscriptLine.test.cjs +128 -17
- package/src/main/__tests__/epicContextDigest.test.cjs +100 -0
- package/src/main/__tests__/epicMint.test.cjs +165 -207
- package/src/main/__tests__/epicValidationHook.test.cjs +291 -0
- package/src/main/__tests__/prdMigration.test.cjs +160 -0
- package/src/main/__tests__/projectPages.test.cjs +151 -0
- package/src/main/__tests__/promptSessionEvents.test.cjs +74 -0
- package/src/main/__tests__/promptSessionSchema.test.cjs +101 -0
- package/src/main/__tests__/promptSessionsCreateEpicHandler.test.cjs +139 -0
- package/src/main/__tests__/rcaReport.test.cjs +188 -0
- package/src/main/__tests__/runVerify.test.cjs +109 -6
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +32 -12
- package/src/main/__tests__/scheduler-epic-digest.test.cjs +214 -0
- package/src/main/__tests__/scheduler-heal-refusal.test.cjs +61 -0
- package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +74 -0
- package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +5 -5
- package/src/main/__tests__/transcripts-doFlush-array.test.cjs +118 -0
- package/src/main/__tests__/transcripts-paged-reads.test.cjs +233 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +7 -2
- package/src/main/chatRunner.cjs +10 -1
- package/src/main/health.cjs +5 -1
- package/src/main/index.cjs +3 -5
- package/src/main/ipcSchemas.cjs +72 -4
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +130 -0
- package/src/main/lib/agentModelResolve.cjs +97 -0
- package/src/main/lib/auditLog.cjs +2 -2
- package/src/main/lib/classifyTranscriptLine.cjs +131 -48
- package/src/main/lib/epicContextDigest.cjs +78 -0
- package/src/main/lib/epicMint.cjs +86 -164
- package/src/main/lib/epicValidationHook.cjs +192 -0
- package/src/main/lib/prdMigration.cjs +76 -5
- package/src/main/lib/promptSessionSchema.cjs +95 -0
- package/src/main/lib/promptSessionsCreateEpic.cjs +70 -0
- package/src/main/lib/{rcaFeedbackHook.cjs → rcaReport.cjs} +47 -114
- package/src/main/lib/schedulerBatch.cjs +70 -111
- package/src/main/lib/schedulerConfig.cjs +0 -1
- package/src/main/otel.cjs +3 -1
- package/src/main/projectPages.cjs +60 -14
- package/src/main/promptSessionEvents.cjs +16 -1
- package/src/main/runVerify.cjs +17 -2
- package/src/main/scheduler.cjs +302 -68
- package/src/main/templates/project-pages-default-home.html +123 -0
- package/src/main/transcripts.cjs +191 -32
- package/src/main/webRemote.cjs +8 -7
- package/src/preload/api.d.ts +100 -57
- package/src/preload/index.cjs +9 -7
- package/dist/assets/index-DCa3QvAi.js +0 -3227
- package/plugins/session-manager-dev/skills/propose-epic/SKILL.md +0 -68
- package/scripts/propose-epic.cjs +0 -56
- package/src/main/__tests__/rcaFeedbackHook.test.cjs +0 -280
- package/src/main/repoAnalyzer.cjs +0 -346
- package/src/main/search.cjs +0 -332
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* promptSessionSchema.test.cjs — unit tests for the canonical PromptSession
|
|
3
|
+
* zod schema and its assertValidPromptSession fail-closed helper.
|
|
4
|
+
*
|
|
5
|
+
* Run: timeout 300 npx vitest run src/main/__tests__/promptSessionSchema.test.cjs
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
import { test, expect } from 'vitest';
|
|
11
|
+
const { PromptSessionSchema, assertValidPromptSession } = require('../lib/promptSessionSchema.cjs');
|
|
12
|
+
|
|
13
|
+
test('a valid full record parses', () => {
|
|
14
|
+
const full = {
|
|
15
|
+
id: 'epic-abc12345',
|
|
16
|
+
cwd: '/home/bilko/Projects/session-manager',
|
|
17
|
+
goalText: 'do the thing',
|
|
18
|
+
claudeSessionId: 'c1b2a3d4-0000-0000-0000-000000000000',
|
|
19
|
+
status: 'proposed',
|
|
20
|
+
createdAt: '2026-08-02T00:00:00.000Z',
|
|
21
|
+
completedAt: null,
|
|
22
|
+
resumedFromId: 'old-epic-id',
|
|
23
|
+
tag: 'feature',
|
|
24
|
+
openingPrompt: 'full body of the first prompt',
|
|
25
|
+
source: {
|
|
26
|
+
producer: 'new-epic-ui',
|
|
27
|
+
prdSlug: '123-fix-thing',
|
|
28
|
+
runId: 'run-1',
|
|
29
|
+
sourceTabId: 'tab-1',
|
|
30
|
+
},
|
|
31
|
+
agentType: 'architect',
|
|
32
|
+
};
|
|
33
|
+
expect(() => assertValidPromptSession(full)).not.toThrow();
|
|
34
|
+
expect(PromptSessionSchema.safeParse(full).success).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('a valid minimal record (only required fields) parses', () => {
|
|
38
|
+
const minimal = {
|
|
39
|
+
id: 'epic-abc12345',
|
|
40
|
+
cwd: '/home/bilko/Projects/session-manager',
|
|
41
|
+
goalText: 'do the thing',
|
|
42
|
+
claudeSessionId: 'c1b2a3d4-0000-0000-0000-000000000000',
|
|
43
|
+
status: 'active',
|
|
44
|
+
createdAt: '2026-08-02T00:00:00.000Z',
|
|
45
|
+
completedAt: null,
|
|
46
|
+
};
|
|
47
|
+
expect(() => assertValidPromptSession(minimal)).not.toThrow();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('missing a required field fails', () => {
|
|
51
|
+
const missingGoalText = {
|
|
52
|
+
id: 'epic-abc12345',
|
|
53
|
+
cwd: '/home/bilko/Projects/session-manager',
|
|
54
|
+
claudeSessionId: 'c1b2a3d4-0000-0000-0000-000000000000',
|
|
55
|
+
status: 'active',
|
|
56
|
+
createdAt: '2026-08-02T00:00:00.000Z',
|
|
57
|
+
completedAt: null,
|
|
58
|
+
};
|
|
59
|
+
expect(() => assertValidPromptSession(missingGoalText)).toThrow(/goalText/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('an invalid status value fails', () => {
|
|
63
|
+
const badStatus = {
|
|
64
|
+
id: 'epic-abc12345',
|
|
65
|
+
cwd: '/home/bilko/Projects/session-manager',
|
|
66
|
+
goalText: 'do the thing',
|
|
67
|
+
claudeSessionId: 'c1b2a3d4-0000-0000-0000-000000000000',
|
|
68
|
+
status: 'in-progress',
|
|
69
|
+
createdAt: '2026-08-02T00:00:00.000Z',
|
|
70
|
+
completedAt: null,
|
|
71
|
+
};
|
|
72
|
+
expect(() => assertValidPromptSession(badStatus)).toThrow(/status/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('an invalid tag value fails', () => {
|
|
76
|
+
const badTag = {
|
|
77
|
+
id: 'epic-abc12345',
|
|
78
|
+
cwd: '/home/bilko/Projects/session-manager',
|
|
79
|
+
goalText: 'do the thing',
|
|
80
|
+
claudeSessionId: 'c1b2a3d4-0000-0000-0000-000000000000',
|
|
81
|
+
status: 'active',
|
|
82
|
+
createdAt: '2026-08-02T00:00:00.000Z',
|
|
83
|
+
completedAt: null,
|
|
84
|
+
tag: 'not-a-real-tag',
|
|
85
|
+
};
|
|
86
|
+
expect(() => assertValidPromptSession(badTag)).toThrow(/tag/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('a source object missing its required producer fails', () => {
|
|
90
|
+
const badSource = {
|
|
91
|
+
id: 'epic-abc12345',
|
|
92
|
+
cwd: '/home/bilko/Projects/session-manager',
|
|
93
|
+
goalText: 'do the thing',
|
|
94
|
+
claudeSessionId: 'c1b2a3d4-0000-0000-0000-000000000000',
|
|
95
|
+
status: 'active',
|
|
96
|
+
createdAt: '2026-08-02T00:00:00.000Z',
|
|
97
|
+
completedAt: null,
|
|
98
|
+
source: { prdSlug: '123-fix-thing' },
|
|
99
|
+
};
|
|
100
|
+
expect(() => assertValidPromptSession(badSource)).toThrow(/producer/);
|
|
101
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* promptSessionsCreateEpicHandler.test.cjs — unit tests for the
|
|
3
|
+
* 'promptSessions:create-epic' IPC handler (lib/promptSessionsCreateEpic.cjs),
|
|
4
|
+
* PRD 953: an additive-only IPC channel letting the renderer mint an Epic
|
|
5
|
+
* through main's own ensureEpic() instead of hand-constructing the
|
|
6
|
+
* PromptSession record itself. Nothing in the app calls this yet.
|
|
7
|
+
*
|
|
8
|
+
* Exercises the handler the same way index.cjs wires it — schema validation
|
|
9
|
+
* (ipcSchemas.cjs's `validated` wrapper) in front of the business logic —
|
|
10
|
+
* without touching Electron's `ipcMain`, mirroring activeIndexMerge.test.cjs's
|
|
11
|
+
* pattern of testing the underlying function directly.
|
|
12
|
+
*
|
|
13
|
+
* Run: timeout 300 npx vitest run src/main/__tests__/promptSessionsCreateEpicHandler.test.cjs
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
import { test, expect, afterEach } from 'vitest';
|
|
19
|
+
const fsp = require('node:fs/promises');
|
|
20
|
+
const os = require('node:os');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
const { schemas, validated } = require('../ipcSchemas.cjs');
|
|
23
|
+
const { createEpicViaIpc } = require('../lib/promptSessionsCreateEpic.cjs');
|
|
24
|
+
const { readActiveIndex } = require('../lib/epicMint.cjs');
|
|
25
|
+
const { PromptSessionSchema } = require('../lib/promptSessionSchema.cjs');
|
|
26
|
+
const config = require('../config.cjs');
|
|
27
|
+
|
|
28
|
+
const handler = validated(schemas.promptSessionsCreateEpic, ({ cwd, goalText, tag, agentType, source }) =>
|
|
29
|
+
createEpicViaIpc(cwd, { goalText, tag, agentType, source }));
|
|
30
|
+
|
|
31
|
+
const tmpDirs = [];
|
|
32
|
+
afterEach(async () => {
|
|
33
|
+
while (tmpDirs.length) {
|
|
34
|
+
const d = tmpDirs.pop();
|
|
35
|
+
await fsp.rm(d, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
async function mkCwd() {
|
|
40
|
+
const cwd = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-promptsessions-create-epic-'));
|
|
41
|
+
config.addAllowedRoot(cwd);
|
|
42
|
+
tmpDirs.push(cwd);
|
|
43
|
+
return cwd;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test('a valid request mints a new proposed Epic and returns a schema-valid session read back from disk', async () => {
|
|
47
|
+
const cwd = await mkCwd();
|
|
48
|
+
const result = await handler(null, {
|
|
49
|
+
cwd,
|
|
50
|
+
goalText: 'Ship the widget',
|
|
51
|
+
tag: 'feature',
|
|
52
|
+
agentType: 'architect',
|
|
53
|
+
source: { producer: 'scheduler-dispatch' },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(result.epicId).toBeTruthy();
|
|
57
|
+
expect(result.session.id).toBe(result.epicId);
|
|
58
|
+
expect(result.session.status).toBe('proposed');
|
|
59
|
+
expect(result.session.goalText).toBe('Ship the widget');
|
|
60
|
+
expect(result.session.tag).toBe('feature');
|
|
61
|
+
expect(result.session.agentType).toBe('architect');
|
|
62
|
+
expect(result.session.source).toEqual({ producer: 'scheduler-dispatch' });
|
|
63
|
+
|
|
64
|
+
// Response is the just-written record read back from disk, not a second
|
|
65
|
+
// independently re-derived copy.
|
|
66
|
+
const index = readActiveIndex(cwd);
|
|
67
|
+
expect(index.sessions[result.epicId]).toEqual(result.session);
|
|
68
|
+
|
|
69
|
+
const parsed = PromptSessionSchema.safeParse(result.session);
|
|
70
|
+
expect(parsed.success).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('a request with no tag/agentType/source still mints and validates cleanly', async () => {
|
|
74
|
+
const cwd = await mkCwd();
|
|
75
|
+
const result = await handler(null, { cwd, goalText: 'Minimal request' });
|
|
76
|
+
|
|
77
|
+
expect(result.session.goalText).toBe('Minimal request');
|
|
78
|
+
expect(PromptSessionSchema.safeParse(result.session).success).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ipcSchemas.cjs's `validated` wrapper calls schema.parse synchronously
|
|
82
|
+
// before invoking the handler — a ZodError throws synchronously from the
|
|
83
|
+
// call itself (not a rejected promise), which is exactly the "rejected
|
|
84
|
+
// before ensureEpic ever runs" guarantee this test asserts.
|
|
85
|
+
test('a request missing cwd is rejected by ipcSchemas validation before ensureEpic ever runs', () => {
|
|
86
|
+
expect(() => handler(null, { goalText: 'No cwd here' })).toThrow();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('a request missing goalText is rejected by ipcSchemas validation before ensureEpic ever runs', async () => {
|
|
90
|
+
const cwd = await mkCwd();
|
|
91
|
+
expect(() => handler(null, { cwd })).toThrow();
|
|
92
|
+
|
|
93
|
+
const index = readActiveIndex(cwd);
|
|
94
|
+
expect(Object.keys(index.sessions)).toHaveLength(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('an unknown tag value is rejected by ipcSchemas validation', () => {
|
|
98
|
+
expect(() => handler(null, { cwd: '/tmp/whatever', goalText: 'x', tag: 'not-a-real-tag' })).toThrow();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('an unknown source.producer value is rejected by ipcSchemas validation', () => {
|
|
102
|
+
expect(() => handler(null, {
|
|
103
|
+
cwd: '/tmp/whatever',
|
|
104
|
+
goalText: 'x',
|
|
105
|
+
source: { producer: 'bogus-producer' },
|
|
106
|
+
})).toThrow();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// createEpicViaIpc's whole point is forceNewEpic: true — it must never join
|
|
110
|
+
// an existing Epic via ensureEpic's goalText-similarity join branch, even
|
|
111
|
+
// when called twice with the identical goalText for the same cwd.
|
|
112
|
+
test('two requests with identical goalText for the same cwd mint two distinct Epics, never joining', async () => {
|
|
113
|
+
const cwd = await mkCwd();
|
|
114
|
+
const first = await handler(null, { cwd, goalText: 'Same title both times' });
|
|
115
|
+
const second = await handler(null, { cwd, goalText: 'Same title both times' });
|
|
116
|
+
|
|
117
|
+
expect(first.epicId).not.toBe(second.epicId);
|
|
118
|
+
const index = readActiveIndex(cwd);
|
|
119
|
+
expect(index.sessions[first.epicId]).toBeDefined();
|
|
120
|
+
expect(index.sessions[second.epicId]).toBeDefined();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// The renderer can send an arbitrary `cwd` string over IPC — config.cjs's
|
|
124
|
+
// validatePath (allowedRoots = home dir + every cwd a TAB has opened) is the
|
|
125
|
+
// boundary every other renderer-facing path IPC already passes through
|
|
126
|
+
// (config:read-json/write-json, lib/activeIndexMerge.cjs's own merge); this
|
|
127
|
+
// handler must be gated by the same boundary rather than handing an
|
|
128
|
+
// unvalidated path straight to epicMint.cjs's raw `fs` writes.
|
|
129
|
+
test('a cwd outside every allowed root is rejected before anything is minted', async () => {
|
|
130
|
+
const unregistered = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-promptsessions-create-epic-unregistered-'));
|
|
131
|
+
tmpDirs.push(unregistered);
|
|
132
|
+
|
|
133
|
+
await expect(handler(null, { cwd: unregistered, goalText: 'Should never mint' })).rejects.toThrow(
|
|
134
|
+
/outside allowed boundaries/,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const index = readActiveIndex(unregistered);
|
|
138
|
+
expect(Object.keys(index.sessions)).toHaveLength(0);
|
|
139
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rcaReport.test.cjs — unit tests for src/main/lib/rcaReport.cjs.
|
|
3
|
+
*
|
|
4
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/rcaReport.test.cjs
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
import { test, expect, beforeEach, afterEach } from 'vitest';
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const os = require('node:os');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
|
|
14
|
+
let tmpHome;
|
|
15
|
+
let realHome;
|
|
16
|
+
let rcaReport;
|
|
17
|
+
|
|
18
|
+
// config.cjs bakes os.homedir() into top-level consts at first require
|
|
19
|
+
// (allowedRoots/WRITE_PREFIXES). Node's require cache must be purged per test
|
|
20
|
+
// so each test's tmpHome takes effect — vi.resetModules() only resets vitest's
|
|
21
|
+
// ESM graph, not require().
|
|
22
|
+
const MODULES_TO_RELOAD = ['../lib/rcaReport.cjs', '../config.cjs'];
|
|
23
|
+
|
|
24
|
+
function purgeRequireCache() {
|
|
25
|
+
for (const m of MODULES_TO_RELOAD) {
|
|
26
|
+
try { delete require.cache[require.resolve(m)]; } catch { /* not loaded yet */ }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
realHome = process.env.HOME;
|
|
32
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'rca-report-test-'));
|
|
33
|
+
process.env.HOME = tmpHome;
|
|
34
|
+
delete process.env.SM_RCA_DISABLE;
|
|
35
|
+
purgeRequireCache();
|
|
36
|
+
rcaReport = require('../lib/rcaReport.cjs');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
process.env.HOME = realHome;
|
|
41
|
+
delete process.env.SM_RCA_DISABLE;
|
|
42
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
43
|
+
purgeRequireCache();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
function makeProject(name = 'proj') {
|
|
49
|
+
const cwd = path.join(tmpHome, name);
|
|
50
|
+
fs.mkdirSync(cwd, { recursive: true });
|
|
51
|
+
return cwd;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function writeRun(runDir, slug, { logLines = ['line1', 'line2'], exitCode = 1, durationMs = 1234 } = {}) {
|
|
55
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
56
|
+
fs.writeFileSync(path.join(runDir, `${slug}.log`), logLines.join('\n') + '\n');
|
|
57
|
+
fs.writeFileSync(path.join(runDir, `${slug}.meta.json`), JSON.stringify({ exitCode, durationMs }));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function writePrd(cwd, slug, { acBody = '- [ ] `timeout 10 echo ok` passes.' } = {}) {
|
|
61
|
+
const prdsDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'prds');
|
|
62
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
63
|
+
const body = ['---', `title: ${slug}`, '---', '', '# Goal', '', 'Do the thing.', '', '# Acceptance criteria', '', acBody, '', '# Out of scope', '', '- N/A'].join('\n');
|
|
64
|
+
fs.writeFileSync(path.join(prdsDir, `${slug}.md`), body);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function baseJob(overrides = {}) {
|
|
68
|
+
return { slug: 'testslug', runId: '2026-07-24T00-00-00-000Z', exitCode: 1, error: 'something broke', ...overrides };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readSessions(cwd) {
|
|
72
|
+
const p = path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json');
|
|
73
|
+
if (!fs.existsSync(p)) return {};
|
|
74
|
+
return JSON.parse(fs.readFileSync(p, 'utf8')).sessions ?? {};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ─── report writing ──────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
test('writeRcaReport: writes root-cause-<slug>.md into the run directory', async () => {
|
|
80
|
+
const cwd = makeProject();
|
|
81
|
+
const runDir = path.join(tmpHome, 'run1');
|
|
82
|
+
writeRun(runDir, 'testslug');
|
|
83
|
+
writePrd(cwd, 'testslug');
|
|
84
|
+
|
|
85
|
+
const res = await rcaReport.writeRcaReport({ job: baseJob({ cwd }), runDir, verdict: 'transcript_errors' });
|
|
86
|
+
|
|
87
|
+
expect(res.filed).toBe(true);
|
|
88
|
+
expect(res.path).toBe(path.join(runDir, 'root-cause-testslug.md'));
|
|
89
|
+
const body = fs.readFileSync(res.path, 'utf8');
|
|
90
|
+
expect(body).toContain('prdSlug: testslug');
|
|
91
|
+
expect(body).toContain('# What happened');
|
|
92
|
+
expect(body).toContain('# The PRD\'s acceptance criteria');
|
|
93
|
+
expect(body).toContain('timeout 10 echo ok');
|
|
94
|
+
expect(res.summary).toContain('testslug');
|
|
95
|
+
expect(res.summary.split('\n')).toHaveLength(1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('writeRcaReport: creates no Epic — a parked job never opens work on its own', async () => {
|
|
99
|
+
const cwd = makeProject();
|
|
100
|
+
const runDir = path.join(tmpHome, 'run1');
|
|
101
|
+
writeRun(runDir, 'testslug');
|
|
102
|
+
writePrd(cwd, 'testslug');
|
|
103
|
+
|
|
104
|
+
await rcaReport.writeRcaReport({ job: baseJob({ cwd }), runDir, verdict: 'transcript_errors' });
|
|
105
|
+
|
|
106
|
+
expect(readSessions(cwd)).toEqual({});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('writeRcaReport: a second call for the same run overwrites in place, never duplicates', async () => {
|
|
110
|
+
const cwd = makeProject();
|
|
111
|
+
const runDir = path.join(tmpHome, 'run1');
|
|
112
|
+
writeRun(runDir, 'testslug');
|
|
113
|
+
writePrd(cwd, 'testslug');
|
|
114
|
+
const job = baseJob({ cwd });
|
|
115
|
+
|
|
116
|
+
const first = await rcaReport.writeRcaReport({ job, runDir, verdict: 'transcript_errors' });
|
|
117
|
+
const second = await rcaReport.writeRcaReport({ job, runDir, verdict: 'transcript_errors' });
|
|
118
|
+
|
|
119
|
+
expect(second.path).toBe(first.path);
|
|
120
|
+
expect(fs.readdirSync(runDir).filter((f) => f.startsWith('root-cause-'))).toHaveLength(1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('writeRcaReport: investigationText enriches the report in place', async () => {
|
|
124
|
+
const cwd = makeProject();
|
|
125
|
+
const runDir = path.join(tmpHome, 'run1');
|
|
126
|
+
writeRun(runDir, 'testslug');
|
|
127
|
+
writePrd(cwd, 'testslug');
|
|
128
|
+
const job = baseJob({ cwd });
|
|
129
|
+
|
|
130
|
+
const first = await rcaReport.writeRcaReport({ job, runDir, verdict: 'transcript_errors' });
|
|
131
|
+
expect(fs.readFileSync(first.path, 'utf8')).not.toContain('## Investigation analysis');
|
|
132
|
+
|
|
133
|
+
const second = await rcaReport.writeRcaReport({
|
|
134
|
+
job, runDir, verdict: 'transcript_errors', investigationText: 'Root cause: forgot to bound the poll loop.',
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
expect(second.path).toBe(first.path);
|
|
138
|
+
const enriched = fs.readFileSync(second.path, 'utf8');
|
|
139
|
+
expect(enriched).toContain('## Investigation analysis');
|
|
140
|
+
expect(enriched).toContain('forgot to bound the poll loop');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('writeRcaReport: works for a job whose PRD cannot be read (no acceptance criteria on record)', async () => {
|
|
144
|
+
const cwd = makeProject('bare-proj');
|
|
145
|
+
const runDir = path.join(tmpHome, 'run1');
|
|
146
|
+
writeRun(runDir, 'testslug');
|
|
147
|
+
|
|
148
|
+
const res = await rcaReport.writeRcaReport({ job: baseJob({ cwd }), runDir, verdict: 'transcript_errors' });
|
|
149
|
+
|
|
150
|
+
expect(res.filed).toBe(true);
|
|
151
|
+
expect(fs.readFileSync(res.path, 'utf8')).toContain('acceptance criteria not found');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ─── skip paths ──────────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
test('writeRcaReport: rejects a slug containing path-traversal segments', async () => {
|
|
157
|
+
const cwd = makeProject();
|
|
158
|
+
const runDir = path.join(tmpHome, 'run1');
|
|
159
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
160
|
+
|
|
161
|
+
const res = await rcaReport.writeRcaReport({ job: baseJob({ cwd, slug: '../../etc/passwd' }), runDir, verdict: 'transcript_errors' });
|
|
162
|
+
|
|
163
|
+
expect(res.filed).toBe(false);
|
|
164
|
+
expect(res.reason).toBe('unsafe-slug');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('writeRcaReport: skips when there is no run directory to write into', async () => {
|
|
168
|
+
const cwd = makeProject();
|
|
169
|
+
|
|
170
|
+
const res = await rcaReport.writeRcaReport({ job: baseJob({ cwd }), verdict: 'transcript_errors' });
|
|
171
|
+
|
|
172
|
+
expect(res.filed).toBe(false);
|
|
173
|
+
expect(res.reason).toBe('no-run-dir');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('writeRcaReport: SM_RCA_DISABLE=1 skips without writing', async () => {
|
|
177
|
+
process.env.SM_RCA_DISABLE = '1';
|
|
178
|
+
const cwd = makeProject();
|
|
179
|
+
const runDir = path.join(tmpHome, 'run1');
|
|
180
|
+
writeRun(runDir, 'testslug');
|
|
181
|
+
writePrd(cwd, 'testslug');
|
|
182
|
+
|
|
183
|
+
const res = await rcaReport.writeRcaReport({ job: baseJob({ cwd }), runDir, verdict: 'transcript_errors' });
|
|
184
|
+
|
|
185
|
+
expect(res.filed).toBe(false);
|
|
186
|
+
expect(res.reason).toBe('disabled');
|
|
187
|
+
expect(fs.readdirSync(runDir).filter((f) => f.startsWith('root-cause-'))).toHaveLength(0);
|
|
188
|
+
});
|
|
@@ -25,7 +25,7 @@ const os = require('node:os');
|
|
|
25
25
|
const fs = require('node:fs');
|
|
26
26
|
const path = require('node:path');
|
|
27
27
|
const { execFileSync } = require('node:child_process');
|
|
28
|
-
const { verifyRun } = require('../runVerify.cjs');
|
|
28
|
+
const { verifyRun, parseLog } = require('../runVerify.cjs');
|
|
29
29
|
|
|
30
30
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
31
31
|
|
|
@@ -792,6 +792,109 @@ test('harness tool error (<tool_use_error>) in final 20% → clean', async () =>
|
|
|
792
792
|
} finally { rmdir(tmp); }
|
|
793
793
|
});
|
|
794
794
|
|
|
795
|
+
// ─── subagent-internal tool errors (incident: 955-route-epic-create-through-ipc, 2026-08-03) ──
|
|
796
|
+
|
|
797
|
+
test('subagent-internal is_error (parent_tool_use_id set) in final 20% → clean', async () => {
|
|
798
|
+
const tmp = makeTmpDir();
|
|
799
|
+
try {
|
|
800
|
+
const slug = '955-route-epic-create-through-ipc';
|
|
801
|
+
const events = [];
|
|
802
|
+
for (let k = 0; k < 8; k++) {
|
|
803
|
+
events.push({ type: 'assistant', message: { role: 'assistant', content: [
|
|
804
|
+
{ type: 'tool_use', id: `t${k}`, name: 'Read', input: { description: `read ${k}` } }] } });
|
|
805
|
+
events.push({ type: 'user', message: { role: 'user', content: [
|
|
806
|
+
{ type: 'tool_result', tool_use_id: `t${k}`, content: 'ok', is_error: false }] } });
|
|
807
|
+
}
|
|
808
|
+
// A code-reviewer Task subagent runs its own exploratory grep, which exits
|
|
809
|
+
// 1 (no matches) — this happened INSIDE the subagent, not the main agent.
|
|
810
|
+
events.push({
|
|
811
|
+
type: 'assistant',
|
|
812
|
+
parent_tool_use_id: 'toolu_task_001',
|
|
813
|
+
message: { role: 'assistant', content: [
|
|
814
|
+
{ type: 'tool_use', id: 'tgrep', name: 'Bash', input: { description: 'grep for buildPromptSession' } }] },
|
|
815
|
+
});
|
|
816
|
+
events.push({
|
|
817
|
+
type: 'user',
|
|
818
|
+
parent_tool_use_id: 'toolu_task_001',
|
|
819
|
+
message: { role: 'user', content: [
|
|
820
|
+
{ type: 'tool_result', tool_use_id: 'tgrep', content: '', is_error: true }] },
|
|
821
|
+
});
|
|
822
|
+
events.push({ type: 'result', subtype: 'success', result: 'All acceptance criteria verified.' });
|
|
823
|
+
|
|
824
|
+
writeLog(tmp, slug, events);
|
|
825
|
+
const prdPath = writePrd(tmp, slug, '# Route Epic create through IPC');
|
|
826
|
+
const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [], committedDuringRun: true });
|
|
827
|
+
assert.equal(verdict.verdict, 'clean', `subagent-internal is_error must not flag, got ${verdict.verdict}: ${verdict.reason}`);
|
|
828
|
+
} finally { rmdir(tmp); }
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
test('main-agent is_error (no parent_tool_use_id) in final 20% → still transcript_errors', async () => {
|
|
832
|
+
const tmp = makeTmpDir();
|
|
833
|
+
try {
|
|
834
|
+
const slug = '955-main-agent-error-guard';
|
|
835
|
+
const events = [];
|
|
836
|
+
for (let k = 0; k < 8; k++) {
|
|
837
|
+
events.push({ type: 'assistant', message: { role: 'assistant', content: [
|
|
838
|
+
{ type: 'tool_use', id: `t${k}`, name: 'Read', input: { description: `read ${k}` } }] } });
|
|
839
|
+
events.push({ type: 'user', message: { role: 'user', content: [
|
|
840
|
+
{ type: 'tool_result', tool_use_id: `t${k}`, content: 'ok', is_error: false }] } });
|
|
841
|
+
}
|
|
842
|
+
events.push({ type: 'assistant', message: { role: 'assistant', content: [
|
|
843
|
+
{ type: 'tool_use', id: 'tbad', name: 'Bash', input: { description: 'run tests' } }] } });
|
|
844
|
+
events.push({ type: 'user', message: { role: 'user', content: [
|
|
845
|
+
{ type: 'tool_result', tool_use_id: 'tbad', content: 'FAILED: 1 test failed', is_error: true }] } });
|
|
846
|
+
events.push({ type: 'result', subtype: 'success', result: 'All acceptance criteria verified.' });
|
|
847
|
+
|
|
848
|
+
writeLog(tmp, slug, events);
|
|
849
|
+
const prdPath = writePrd(tmp, slug, '# Guard: main-agent errors still flag');
|
|
850
|
+
const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [], committedDuringRun: true });
|
|
851
|
+
assert.equal(verdict.verdict, 'transcript_errors', `main-agent is_error must still flag, got ${verdict.verdict}: ${verdict.reason}`);
|
|
852
|
+
} finally { rmdir(tmp); }
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
test('parseLog populates parentToolUseId on tool_use and tool_result events, defaulting to null', () => {
|
|
856
|
+
const tmp = makeTmpDir();
|
|
857
|
+
try {
|
|
858
|
+
const slug = '955-parselog-parent-id';
|
|
859
|
+
const events = [
|
|
860
|
+
{
|
|
861
|
+
type: 'assistant',
|
|
862
|
+
parent_tool_use_id: 'toolu_parent_001',
|
|
863
|
+
message: { role: 'assistant', content: [
|
|
864
|
+
{ type: 'tool_use', id: 'tchild', name: 'Bash', input: { description: 'grep in subagent' } }] },
|
|
865
|
+
},
|
|
866
|
+
{
|
|
867
|
+
type: 'user',
|
|
868
|
+
parent_tool_use_id: 'toolu_parent_001',
|
|
869
|
+
message: { role: 'user', content: [
|
|
870
|
+
{ type: 'tool_result', tool_use_id: 'tchild', content: '', is_error: true }] },
|
|
871
|
+
},
|
|
872
|
+
{
|
|
873
|
+
type: 'assistant',
|
|
874
|
+
message: { role: 'assistant', content: [
|
|
875
|
+
{ type: 'tool_use', id: 'ttop', name: 'Bash', input: { description: 'run in main agent' } }] },
|
|
876
|
+
},
|
|
877
|
+
{
|
|
878
|
+
type: 'user',
|
|
879
|
+
message: { role: 'user', content: [
|
|
880
|
+
{ type: 'tool_result', tool_use_id: 'ttop', content: 'ok', is_error: false }] },
|
|
881
|
+
},
|
|
882
|
+
];
|
|
883
|
+
writeLog(tmp, slug, events);
|
|
884
|
+
const { events: parsed } = parseLog(path.join(tmp, `${slug}.log`));
|
|
885
|
+
|
|
886
|
+
const childUse = parsed.find((ev) => ev.kind === 'tool_use' && ev.toolUseId === 'tchild');
|
|
887
|
+
const childResult = parsed.find((ev) => ev.kind === 'tool_result' && ev.toolUseId === 'tchild');
|
|
888
|
+
const topUse = parsed.find((ev) => ev.kind === 'tool_use' && ev.toolUseId === 'ttop');
|
|
889
|
+
const topResult = parsed.find((ev) => ev.kind === 'tool_result' && ev.toolUseId === 'ttop');
|
|
890
|
+
|
|
891
|
+
assert.equal(childUse.parentToolUseId, 'toolu_parent_001');
|
|
892
|
+
assert.equal(childResult.parentToolUseId, 'toolu_parent_001');
|
|
893
|
+
assert.equal(topUse.parentToolUseId, null);
|
|
894
|
+
assert.equal(topResult.parentToolUseId, null);
|
|
895
|
+
} finally { rmdir(tmp); }
|
|
896
|
+
});
|
|
897
|
+
|
|
795
898
|
test('isHarnessToolError detects wrapper and "No such tool available"', () => {
|
|
796
899
|
const { isHarnessToolError } = require('../runVerify.cjs');
|
|
797
900
|
assert.equal(isHarnessToolError('<tool_use_error>Error: No such tool available: bash</tool_use_error>'), true);
|
|
@@ -1145,8 +1248,8 @@ test('pass_no_commit: original PRD, PASS no commit, all deliverable paths alread
|
|
|
1145
1248
|
const tmp = makeTmpDir();
|
|
1146
1249
|
try {
|
|
1147
1250
|
const slug = '655-needs-review-rca-feedback-hook';
|
|
1148
|
-
writeLog(tmp, slug, noOpRunEvents('Verified
|
|
1149
|
-
const prdPath = writePrd(tmp, slug, 'Deliverables: `src/main/lib/
|
|
1251
|
+
writeLog(tmp, slug, noOpRunEvents('Verified rcaReport.cjs already shipped in e13168d; typecheck + tests green; no commit needed.\nSCHEDULER_VERDICT: PASS'));
|
|
1252
|
+
const prdPath = writePrd(tmp, slug, 'Deliverables: `src/main/lib/rcaReport.cjs` and `src/main/__tests__/rcaReport.test.cjs`.');
|
|
1150
1253
|
const verdict = await verifyRun({
|
|
1151
1254
|
runDir: tmp,
|
|
1152
1255
|
prdPath,
|
|
@@ -1166,7 +1269,7 @@ test('pass_no_commit: original PRD, PASS no commit, one deliverable path untrack
|
|
|
1166
1269
|
try {
|
|
1167
1270
|
const slug = '655-needs-review-rca-feedback-hook';
|
|
1168
1271
|
writeLog(tmp, slug, noOpRunEvents('Nothing to do here.\nSCHEDULER_VERDICT: PASS'));
|
|
1169
|
-
const prdPath = writePrd(tmp, slug, 'Deliverables: `src/main/lib/
|
|
1272
|
+
const prdPath = writePrd(tmp, slug, 'Deliverables: `src/main/lib/rcaReport.cjs` and `src/main/lib/does-not-exist-xyz.cjs`.');
|
|
1170
1273
|
const verdict = await verifyRun({
|
|
1171
1274
|
runDir: tmp,
|
|
1172
1275
|
prdPath,
|
|
@@ -1227,9 +1330,9 @@ test('pass_no_commit: commit-guard-retry PRD naming scheduler.cjs + its committe
|
|
|
1227
1330
|
});
|
|
1228
1331
|
|
|
1229
1332
|
test('extractPrdDeliverablePaths: extracts backticked repo-relative paths, ignores prose', () => {
|
|
1230
|
-
const body = 'Deliverables: `src/main/lib/
|
|
1333
|
+
const body = 'Deliverables: `src/main/lib/rcaReport.cjs` and `src/main/__tests__/rcaReport.test.cjs`. Also see node_modules/foo/bar.js which should be excluded.';
|
|
1231
1334
|
const paths = extractPrdDeliverablePaths(body);
|
|
1232
|
-
assert.deepEqual(paths, ['src/main/lib/
|
|
1335
|
+
assert.deepEqual(paths, ['src/main/lib/rcaReport.cjs', 'src/main/__tests__/rcaReport.test.cjs']);
|
|
1233
1336
|
assert.deepEqual(extractPrdDeliverablePaths(''), []);
|
|
1234
1337
|
assert.deepEqual(extractPrdDeliverablePaths(undefined), []);
|
|
1235
1338
|
});
|
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
* scheduler-effective-concurrency.test.cjs — unit tests for the
|
|
3
3
|
* effectiveConcurrency field on buildScheduleStatePayload.
|
|
4
4
|
*
|
|
5
|
+
* The scheduler's private `concurrencyCap` is retired: the machine-wide
|
|
6
|
+
* sessionSlots pool is the ONLY concurrency limit, so effectiveConcurrency
|
|
7
|
+
* now reports the pool (and its SM_SESSION_SLOTS override), not a config
|
|
8
|
+
* field. See lib/sessionSlots.cjs's charter — caps belong to Session-Manager,
|
|
9
|
+
* not to each consumer.
|
|
10
|
+
*
|
|
5
11
|
* Run: timeout 300 npx vitest run src/main/__tests__/scheduler-effective-concurrency.test.cjs
|
|
6
12
|
*/
|
|
7
13
|
|
|
@@ -9,23 +15,26 @@
|
|
|
9
15
|
|
|
10
16
|
import { test, expect, beforeEach, afterEach } from 'vitest';
|
|
11
17
|
const { buildScheduleStatePayload } = require('../scheduler.cjs');
|
|
18
|
+
const sessionSlots = require('../lib/sessionSlots.cjs');
|
|
12
19
|
|
|
13
|
-
const ENV_KEY = '
|
|
20
|
+
const ENV_KEY = 'SM_SESSION_SLOTS';
|
|
14
21
|
let savedEnv;
|
|
15
22
|
|
|
16
23
|
beforeEach(() => {
|
|
17
24
|
savedEnv = process.env[ENV_KEY];
|
|
18
25
|
delete process.env[ENV_KEY];
|
|
26
|
+
sessionSlots.__resetForTests();
|
|
19
27
|
});
|
|
20
28
|
|
|
21
29
|
afterEach(() => {
|
|
22
30
|
if (savedEnv === undefined) delete process.env[ENV_KEY];
|
|
23
31
|
else process.env[ENV_KEY] = savedEnv;
|
|
32
|
+
sessionSlots.__resetForTests();
|
|
24
33
|
});
|
|
25
34
|
|
|
26
|
-
function fakeState(
|
|
35
|
+
function fakeState() {
|
|
27
36
|
return {
|
|
28
|
-
config: {
|
|
37
|
+
config: {},
|
|
29
38
|
jobs: [],
|
|
30
39
|
scheduledFor: null,
|
|
31
40
|
lastRunAt: null,
|
|
@@ -33,19 +42,30 @@ function fakeState(concurrencyCap) {
|
|
|
33
42
|
};
|
|
34
43
|
}
|
|
35
44
|
|
|
36
|
-
test('effectiveConcurrency
|
|
37
|
-
const payload = buildScheduleStatePayload(fakeState(
|
|
38
|
-
expect(payload.effectiveConcurrency).
|
|
45
|
+
test('effectiveConcurrency reports the pool when no env override is set', () => {
|
|
46
|
+
const payload = buildScheduleStatePayload(fakeState());
|
|
47
|
+
expect(payload.effectiveConcurrency.source).toBe('pool');
|
|
48
|
+
expect(payload.effectiveConcurrency.cap).toBe(sessionSlots.totalSlots());
|
|
49
|
+
expect(payload.effectiveConcurrency.free).toBe(sessionSlots.available());
|
|
39
50
|
});
|
|
40
51
|
|
|
41
|
-
test('effectiveConcurrency is env-sourced
|
|
52
|
+
test('effectiveConcurrency is env-sourced when SM_SESSION_SLOTS is set', () => {
|
|
42
53
|
process.env[ENV_KEY] = '7';
|
|
43
|
-
const payload = buildScheduleStatePayload(fakeState(
|
|
44
|
-
expect(payload.effectiveConcurrency).toEqual({ cap: 7, source: 'env' });
|
|
54
|
+
const payload = buildScheduleStatePayload(fakeState());
|
|
55
|
+
expect(payload.effectiveConcurrency).toEqual({ cap: 7, free: 7, source: 'env' });
|
|
45
56
|
});
|
|
46
57
|
|
|
47
|
-
test('effectiveConcurrency clamps env var into [
|
|
58
|
+
test('effectiveConcurrency clamps the env var into the pool range [0, 10]', () => {
|
|
48
59
|
process.env[ENV_KEY] = '999';
|
|
49
|
-
const payload = buildScheduleStatePayload(fakeState(
|
|
50
|
-
expect(payload.effectiveConcurrency).toEqual({ cap:
|
|
60
|
+
const payload = buildScheduleStatePayload(fakeState());
|
|
61
|
+
expect(payload.effectiveConcurrency).toEqual({ cap: 10, free: 10, source: 'env' });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('free is net of held slots — a chat run shrinks it without changing cap', () => {
|
|
65
|
+
process.env[ENV_KEY] = '5';
|
|
66
|
+
const token = sessionSlots.acquire('chat:test-tab');
|
|
67
|
+
expect(token).toBeTruthy();
|
|
68
|
+
const payload = buildScheduleStatePayload(fakeState());
|
|
69
|
+
expect(payload.effectiveConcurrency).toEqual({ cap: 5, free: 4, source: 'env' });
|
|
70
|
+
sessionSlots.release(token);
|
|
51
71
|
});
|