claude-code-session-manager 0.60.0 → 0.62.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/{TiptapBody-BtVPGBaq.js → TiptapBody-Bn-LMce4.js} +42 -42
- package/dist/assets/{index-DtNip-Zx.js → index-BfiMRkln.js} +763 -760
- package/dist/assets/{index-CPMP2XZ_.css → index-DOXvph-Z.css} +1 -1
- package/dist/index.html +2 -2
- package/package.json +4 -2
- package/src/main/__tests__/chat-preamble-anchors.test.cjs +100 -0
- package/src/main/__tests__/epicContextDigest.test.cjs +34 -0
- package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +58 -1
- package/src/main/__tests__/scheduler-epic-digest.test.cjs +12 -4
- package/src/main/__tests__/scheduler-verify-prd-path.test.cjs +109 -0
- package/src/main/__tests__/scheduler-worktree-exec-cwd.test.cjs +122 -0
- package/src/main/chatRunner.cjs +4 -0
- package/src/main/config.cjs +29 -1
- package/src/main/lib/__tests__/coldBootPromptSessionsWrite.test.cjs +87 -0
- package/src/main/lib/__tests__/jobWorktree.test.cjs +197 -0
- package/src/main/lib/activeIndexMerge.cjs +9 -0
- package/src/main/lib/epicContextDigest.cjs +23 -9
- package/src/main/lib/jobWorktree.cjs +327 -0
- package/src/main/lib/rcaReport.cjs +3 -2
- package/src/main/scheduler.cjs +254 -72
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-verify-prd-path.test.cjs — regression test for PRD 991: the
|
|
3
|
+
* verifier's `prdPath` must resolve to a REAL, existing file for an
|
|
4
|
+
* Epic-scoped PRD, both live and archived.
|
|
5
|
+
*
|
|
6
|
+
* Before this PRD, `verifyRun`'s two call sites (the post-run verify in
|
|
7
|
+
* spawnJob and reverifyNeedsReview) built `prdPath` via `prdPathForJob`,
|
|
8
|
+
* which resolves to the RETIRED flat
|
|
9
|
+
* `<cwd>/session-manager-operations/scheduler/prds/` dir — a dir that holds
|
|
10
|
+
* only zero-byte `.reserved-NNN` stubs for every project that has migrated
|
|
11
|
+
* to Epic-scoped PRDs (i.e. every project today). The verifier therefore
|
|
12
|
+
* silently lost access to the PRD body (dependsOn checks, soak-floor dates,
|
|
13
|
+
* deliverable-path checks, the merge-main PR-number exemption) on every
|
|
14
|
+
* single run.
|
|
15
|
+
*
|
|
16
|
+
* This test exercises actual path resolution against a temp tree — no
|
|
17
|
+
* resolver-stubbing — the same class of gap that let this bug (and PRD
|
|
18
|
+
* 985's sibling notify-path bug) ship green twice.
|
|
19
|
+
*
|
|
20
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-verify-prd-path.test.cjs
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
import { test, expect, beforeAll, afterAll } from 'vitest';
|
|
26
|
+
const fs = require('node:fs');
|
|
27
|
+
const os = require('node:os');
|
|
28
|
+
const path = require('node:path');
|
|
29
|
+
|
|
30
|
+
let tmpHome;
|
|
31
|
+
let originalHome;
|
|
32
|
+
let resolveVerifyPrdPath;
|
|
33
|
+
let archivedPrdPathForJob;
|
|
34
|
+
let PRDS_DIR;
|
|
35
|
+
|
|
36
|
+
beforeAll(() => {
|
|
37
|
+
originalHome = process.env.HOME;
|
|
38
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-verify-prd-path-'));
|
|
39
|
+
process.env.HOME = tmpHome;
|
|
40
|
+
|
|
41
|
+
// Load AFTER HOME is stubbed — scheduler.cjs snapshots os.homedir() into
|
|
42
|
+
// PRDS_DIR at module load, not lazily (see scheduler-find-prd-dir.test.cjs).
|
|
43
|
+
({ resolveVerifyPrdPath, archivedPrdPathForJob, PRDS_DIR } = require('../scheduler.cjs'));
|
|
44
|
+
|
|
45
|
+
if (!PRDS_DIR.startsWith(tmpHome)) {
|
|
46
|
+
throw new Error(`refusing to run: PRDS_DIR (${PRDS_DIR}) is not under the temp HOME (${tmpHome})`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// allProjectCwds()/activeProjectCwds() discover project cwds by scanning
|
|
50
|
+
// ~/.claude/projects/*/*.jsonl for a `cwd` field — fake one project with a
|
|
51
|
+
// transcript pointing at a real on-disk cwd.
|
|
52
|
+
const projectsDir = path.join(tmpHome, '.claude', 'projects');
|
|
53
|
+
const projDir = path.join(projectsDir, 'fake-project-slug');
|
|
54
|
+
fs.mkdirSync(projDir, { recursive: true });
|
|
55
|
+
fs.writeFileSync(
|
|
56
|
+
path.join(projDir, 'session.jsonl'),
|
|
57
|
+
`${JSON.stringify({ cwd: projectCwd() })}\n`,
|
|
58
|
+
'utf8',
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterAll(() => {
|
|
63
|
+
process.env.HOME = originalHome;
|
|
64
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
function projectCwd() {
|
|
68
|
+
return path.join(tmpHome, 'fake-project-cwd');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
test('resolveVerifyPrdPath resolves a LIVE Epic-scoped PRD to a real existing path', async () => {
|
|
72
|
+
const cwd = projectCwd();
|
|
73
|
+
const epicId = 'test-epic-live';
|
|
74
|
+
const slug = `991-test-live-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
75
|
+
const prdsDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'epics', epicId, 'prds');
|
|
76
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
77
|
+
const prdPath = path.join(prdsDir, `${slug}.md`);
|
|
78
|
+
fs.writeFileSync(prdPath, '---\ntitle: live fixture\n---\n\n# Goal\n\ntest\n', 'utf8');
|
|
79
|
+
|
|
80
|
+
const resolved = await resolveVerifyPrdPath({ slug, cwd });
|
|
81
|
+
expect(resolved).toBe(prdPath);
|
|
82
|
+
expect(fs.existsSync(resolved)).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('resolveVerifyPrdPath returns null for an ARCHIVED-only PRD; archivedPrdPathForJob fills the gap', async () => {
|
|
86
|
+
const cwd = projectCwd();
|
|
87
|
+
const epicId = 'test-epic-archived';
|
|
88
|
+
const slug = `991-test-archived-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
89
|
+
const epicDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'epics', epicId);
|
|
90
|
+
const archiveDir = path.join(epicDir, 'prds-archived');
|
|
91
|
+
fs.mkdirSync(archiveDir, { recursive: true });
|
|
92
|
+
const archivedPath = path.join(archiveDir, `${slug}.md`);
|
|
93
|
+
fs.writeFileSync(archivedPath, '---\ntitle: archived fixture\n---\n\n# Goal\n\ntest\n', 'utf8');
|
|
94
|
+
|
|
95
|
+
const job = { slug, cwd };
|
|
96
|
+
const live = await resolveVerifyPrdPath(job);
|
|
97
|
+
expect(live).toBe(null);
|
|
98
|
+
|
|
99
|
+
const fallback = live ?? archivedPrdPathForJob(job);
|
|
100
|
+
expect(fallback).toBe(archivedPath);
|
|
101
|
+
expect(fs.existsSync(fallback)).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('resolveVerifyPrdPath returns null for a slug that exists nowhere (never falls back to the retired flat dir)', async () => {
|
|
105
|
+
const cwd = projectCwd();
|
|
106
|
+
const slug = `991-test-nonexistent-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
107
|
+
const resolved = await resolveVerifyPrdPath({ slug, cwd });
|
|
108
|
+
expect(resolved).toBe(null);
|
|
109
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-worktree-exec-cwd.test.cjs — PRD 994, LOAD-BEARING acceptance
|
|
3
|
+
* criterion: when a job is isolated into a worktree, its PRD file, queue
|
|
4
|
+
* state, and run logs must still resolve to the MAIN tree, never the
|
|
5
|
+
* worktree — `session-manager-operations/` is resolved from a `cwd` param
|
|
6
|
+
* (lib/prdLocations.cjs), so a naive cwd swap would give the job a
|
|
7
|
+
* different, empty ops root and orphan its own PRD.
|
|
8
|
+
*
|
|
9
|
+
* Drives executeJob's real `execCwd` parameter (the 5th arg scheduler.cjs
|
|
10
|
+
* passes when a worktree was created) with a stub `claude` binary, proving:
|
|
11
|
+
* 1. the child process's OWN cwd is the worktree dir (execCwd) — verified
|
|
12
|
+
* by having the stub write a marker file to `process.cwd()`;
|
|
13
|
+
* 2. the PRD source is still found via job.cwd (the main tree) even though
|
|
14
|
+
* the worktree dir has no PRD file in it at all;
|
|
15
|
+
* 3. the run's meta.json still records job.cwd, not the worktree dir.
|
|
16
|
+
*
|
|
17
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-worktree-exec-cwd.test.cjs
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
import { test, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
|
23
|
+
const fs = require('node:fs');
|
|
24
|
+
const os = require('node:os');
|
|
25
|
+
const path = require('node:path');
|
|
26
|
+
|
|
27
|
+
let tmpHome;
|
|
28
|
+
let originalHome;
|
|
29
|
+
let executeJob;
|
|
30
|
+
|
|
31
|
+
beforeAll(() => {
|
|
32
|
+
originalHome = process.env.HOME;
|
|
33
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-worktree-execcwd-'));
|
|
34
|
+
process.env.HOME = tmpHome;
|
|
35
|
+
({ executeJob } = require('../scheduler.cjs'));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterAll(() => {
|
|
39
|
+
process.env.HOME = originalHome;
|
|
40
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
delete process.env.SM_CLAUDE_BIN;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Stub `claude` binary: writes a marker file into its OWN process.cwd() (proving
|
|
48
|
+
// where it actually ran), captures the '-p' prompt, then emits a stream-json
|
|
49
|
+
// success result and exits 0.
|
|
50
|
+
function writeClaudeStub() {
|
|
51
|
+
const stubPath = path.join(os.tmpdir(), `sm-claude-stub-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
|
|
52
|
+
const body = `
|
|
53
|
+
const fs = require('fs');
|
|
54
|
+
const path = require('path');
|
|
55
|
+
fs.writeFileSync(path.join(process.cwd(), 'ran-here.marker'), 'yes', 'utf8');
|
|
56
|
+
process.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', result: 'ok' }) + '\\n');
|
|
57
|
+
process.exit(0);
|
|
58
|
+
`;
|
|
59
|
+
fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
|
|
60
|
+
return stubPath;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
test('executeJob spawns the child in execCwd (worktree) while resolving the PRD from job.cwd (main tree)', async () => {
|
|
64
|
+
const mainCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-worktree-main-'));
|
|
65
|
+
const worktreeCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-worktree-exec-'));
|
|
66
|
+
fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
|
|
67
|
+
const runDir = fs.mkdtempSync(path.join(tmpHome, '.claude', 'sm-worktree-run-'));
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const slug = `994-test-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
71
|
+
const prdsDir = path.join(mainCwd, 'session-manager-operations', 'scheduler', 'prds');
|
|
72
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
73
|
+
const bodyText = 'Do the isolated-worktree thing.';
|
|
74
|
+
fs.writeFileSync(path.join(prdsDir, `${slug}.md`), bodyText, 'utf8');
|
|
75
|
+
|
|
76
|
+
process.env.SM_CLAUDE_BIN = writeClaudeStub();
|
|
77
|
+
|
|
78
|
+
const job = { slug, cwd: mainCwd };
|
|
79
|
+
const result = await executeJob(job, runDir, mainCwd, () => Promise.resolve(), worktreeCwd);
|
|
80
|
+
|
|
81
|
+
expect(result.exitCode).toBe(0);
|
|
82
|
+
|
|
83
|
+
// 1. The child process really ran with cwd = worktreeCwd, not mainCwd.
|
|
84
|
+
expect(fs.existsSync(path.join(worktreeCwd, 'ran-here.marker'))).toBe(true);
|
|
85
|
+
expect(fs.existsSync(path.join(mainCwd, 'ran-here.marker'))).toBe(false);
|
|
86
|
+
|
|
87
|
+
// 2. The PRD source itself is untouched — resolved via job.cwd, not execCwd.
|
|
88
|
+
const onDiskPrd = fs.readFileSync(path.join(prdsDir, `${slug}.md`), 'utf8');
|
|
89
|
+
expect(onDiskPrd).toBe(bodyText);
|
|
90
|
+
|
|
91
|
+
// 3. The run's meta.json records job.cwd (the main tree), not the worktree.
|
|
92
|
+
const metaPath = path.join(runDir, `${slug}.meta.json`);
|
|
93
|
+
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
|
|
94
|
+
expect(meta.cwd).toBe(mainCwd);
|
|
95
|
+
} finally {
|
|
96
|
+
fs.rmSync(mainCwd, { recursive: true, force: true });
|
|
97
|
+
fs.rmSync(worktreeCwd, { recursive: true, force: true });
|
|
98
|
+
fs.rmSync(runDir, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('executeJob falls back to job.cwd for the spawn cwd when no execCwd is supplied (unchanged behaviour)', async () => {
|
|
103
|
+
const mainCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-worktree-fallback-'));
|
|
104
|
+
fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
|
|
105
|
+
const runDir = fs.mkdtempSync(path.join(tmpHome, '.claude', 'sm-worktree-run2-'));
|
|
106
|
+
try {
|
|
107
|
+
const slug = `994-test-fallback-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
108
|
+
const prdsDir = path.join(mainCwd, 'session-manager-operations', 'scheduler', 'prds');
|
|
109
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
110
|
+
fs.writeFileSync(path.join(prdsDir, `${slug}.md`), 'no worktree here', 'utf8');
|
|
111
|
+
process.env.SM_CLAUDE_BIN = writeClaudeStub();
|
|
112
|
+
|
|
113
|
+
const job = { slug, cwd: mainCwd };
|
|
114
|
+
const result = await executeJob(job, runDir, mainCwd, () => Promise.resolve());
|
|
115
|
+
|
|
116
|
+
expect(result.exitCode).toBe(0);
|
|
117
|
+
expect(fs.existsSync(path.join(mainCwd, 'ran-here.marker'))).toBe(true);
|
|
118
|
+
} finally {
|
|
119
|
+
fs.rmSync(mainCwd, { recursive: true, force: true });
|
|
120
|
+
fs.rmSync(runDir, { recursive: true, force: true });
|
|
121
|
+
}
|
|
122
|
+
});
|
package/src/main/chatRunner.cjs
CHANGED
|
@@ -891,6 +891,10 @@ module.exports = {
|
|
|
891
891
|
splitStopSignal,
|
|
892
892
|
parseContextUsageMarkdown,
|
|
893
893
|
STOP_SENTINEL,
|
|
894
|
+
// Both instruction blocks are exported so the renderer's preamble splitter
|
|
895
|
+
// (lib/promptPreamble.ts) can be drift-tested against the REAL text rather
|
|
896
|
+
// than a copy — see chat-preamble-anchors.test.cjs.
|
|
897
|
+
STOP_SIGNAL_INSTRUCTION,
|
|
894
898
|
CHAT_MODE_TRUTH_INSTRUCTION,
|
|
895
899
|
KILL_CEILING_MS,
|
|
896
900
|
KILL_CEILING_MIN,
|
package/src/main/config.cjs
CHANGED
|
@@ -70,6 +70,30 @@ function addAllowedRoot(dir) {
|
|
|
70
70
|
if (dir) allowedRoots.add(path.resolve(dir));
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// PromptSession archive writes (state/promptSessions.ts's markCompleted ->
|
|
74
|
+
// window.api.config.writeJson(promptSessionArchivePath(...))) reach this
|
|
75
|
+
// generic IPC handler with no pty ever having spawned for their cwd (a
|
|
76
|
+
// chat-only Epic on a cold boot), same hazard prdCreate.cjs:152-155 already
|
|
77
|
+
// documents for PRD creation. Registering the root here — narrowly, only
|
|
78
|
+
// when the resolved path is actually under a project's
|
|
79
|
+
// session-manager-operations/prompt-sessions/ — mirrors that precedent for
|
|
80
|
+
// the one other write path (besides mergeActiveIndex's own) that needs it,
|
|
81
|
+
// without widening addAllowedRoot's use to every config:write-json call.
|
|
82
|
+
const PROMPT_SESSIONS_MARKER = `${path.sep}session-manager-operations${path.sep}prompt-sessions${path.sep}`;
|
|
83
|
+
|
|
84
|
+
function registerPromptSessionsRoot(p) {
|
|
85
|
+
if (!p || typeof p !== 'string') return;
|
|
86
|
+
let abs;
|
|
87
|
+
try {
|
|
88
|
+
abs = path.resolve(expandHome(p));
|
|
89
|
+
} catch {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const idx = abs.indexOf(PROMPT_SESSIONS_MARKER);
|
|
93
|
+
if (idx === -1) return;
|
|
94
|
+
addAllowedRoot(abs.slice(0, idx));
|
|
95
|
+
}
|
|
96
|
+
|
|
73
97
|
function validatePath(abs) {
|
|
74
98
|
const real = realResolve(abs);
|
|
75
99
|
for (const root of allowedRoots) {
|
|
@@ -438,7 +462,10 @@ function registerConfigHandlers() {
|
|
|
438
462
|
ipcMain.handle('config:read-text', v(s.configPath, ({ path: p }) => readText(p)));
|
|
439
463
|
// `writer` is the renderer's declared owner id for the single-writer law
|
|
440
464
|
// (lib/opsOwnership.cjs). Ignored outside the ops root; required inside it.
|
|
441
|
-
ipcMain.handle('config:write-json', v(s.configWriteJson, ({ path: p, data, writer }) =>
|
|
465
|
+
ipcMain.handle('config:write-json', v(s.configWriteJson, ({ path: p, data, writer }) => {
|
|
466
|
+
registerPromptSessionsRoot(p);
|
|
467
|
+
return writeJson(p, data, { writer });
|
|
468
|
+
}));
|
|
442
469
|
ipcMain.handle('config:write-text', v(s.configWriteText, ({ path: p, text, writer }) => writeTextAtomic(p, text, { writer })));
|
|
443
470
|
ipcMain.handle('config:list-dir', v(s.configListDir, ({ path: p, opts }) => listDir(p, opts || {})));
|
|
444
471
|
ipcMain.handle('config:exists', v(s.configPath, ({ path: p }) => exists(p)));
|
|
@@ -469,6 +496,7 @@ module.exports = {
|
|
|
469
496
|
listDir,
|
|
470
497
|
exists,
|
|
471
498
|
addAllowedRoot,
|
|
499
|
+
registerPromptSessionsRoot,
|
|
472
500
|
validatePath,
|
|
473
501
|
validateWrite,
|
|
474
502
|
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* coldBootPromptSessionsWrite.test.cjs — regression coverage for the
|
|
3
|
+
* "Epics marked Completed come back Open" bug.
|
|
4
|
+
*
|
|
5
|
+
* config.cjs's allowedRoots starts as just {os.homedir()}, and
|
|
6
|
+
* validateWrite() explicitly skips the homedir root (a project cwd must be
|
|
7
|
+
* registered via addAllowedRoot() before writeJson()/writeTextAtomic() will
|
|
8
|
+
* accept a write under it). addAllowedRoot's only callers used to be pty.cjs
|
|
9
|
+
* (Terminal spawn) and lib/prdCreate.cjs — never the Epic persistence path —
|
|
10
|
+
* so on a cold boot, before any Terminal/PRD activity for a project, both
|
|
11
|
+
* mergeActiveIndex's own write and the renderer's Epic-archive write
|
|
12
|
+
* (config:write-json of promptSessionArchivePath) were silently rejected.
|
|
13
|
+
*
|
|
14
|
+
* Both cases below use a tmp dir INSIDE the homedir so validatePath (the
|
|
15
|
+
* READ boundary, satisfied by the homedir root alone) succeeds while
|
|
16
|
+
* validateWrite (which skips the homedir root, per config.cjs:113) still
|
|
17
|
+
* requires an explicit per-project root registration — reproducing the
|
|
18
|
+
* exact cold-boot gap without needing an out-of-home tmp root.
|
|
19
|
+
*
|
|
20
|
+
* Run: timeout 300 npx vitest run src/main/lib/__tests__/coldBootPromptSessionsWrite.test.cjs
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
import { test, expect, afterEach } from 'vitest';
|
|
26
|
+
const fsp = require('node:fs/promises');
|
|
27
|
+
const os = require('node:os');
|
|
28
|
+
const path = require('node:path');
|
|
29
|
+
const { mergeActiveIndex } = require('../activeIndexMerge.cjs');
|
|
30
|
+
const { readActiveIndex } = require('../epicMint.cjs');
|
|
31
|
+
const config = require('../../config.cjs');
|
|
32
|
+
|
|
33
|
+
const tmpDirs = [];
|
|
34
|
+
afterEach(async () => {
|
|
35
|
+
while (tmpDirs.length) {
|
|
36
|
+
const d = tmpDirs.pop();
|
|
37
|
+
await fsp.rm(d, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
async function mkColdBootCwd(prefix) {
|
|
42
|
+
// Deliberately under homedir (so validatePath's read boundary passes via
|
|
43
|
+
// the default homedir root) and deliberately NOT registered via
|
|
44
|
+
// config.addAllowedRoot() — simulates the exact state of allowedRoots
|
|
45
|
+
// right after app boot, before any Terminal/PRD activity for this project.
|
|
46
|
+
const cwd = await fsp.mkdtemp(path.join(os.homedir(), prefix));
|
|
47
|
+
tmpDirs.push(cwd);
|
|
48
|
+
return cwd;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function session(id, overrides = {}) {
|
|
52
|
+
return {
|
|
53
|
+
id,
|
|
54
|
+
cwd: overrides.cwd || '/proj',
|
|
55
|
+
goalText: overrides.goalText || `goal for ${id}`,
|
|
56
|
+
claudeSessionId: overrides.claudeSessionId || `claude-${id}`,
|
|
57
|
+
status: overrides.status || 'active',
|
|
58
|
+
createdAt: overrides.createdAt || '2026-08-06T13:48:00.000Z',
|
|
59
|
+
completedAt: overrides.completedAt ?? null,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
test('mergeActiveIndex writes active-index.json on a cold boot with only the homedir registered', async () => {
|
|
64
|
+
const cwd = await mkColdBootCwd('.sm-test-activeindex-coldboot-');
|
|
65
|
+
const s = session('psess-coldboot-1');
|
|
66
|
+
|
|
67
|
+
await mergeActiveIndex(cwd, { sessions: { [s.id]: s }, events: {} });
|
|
68
|
+
|
|
69
|
+
const index = readActiveIndex(cwd);
|
|
70
|
+
expect(index.sessions[s.id]).toEqual(s);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Mirrors what the 'config:write-json' IPC handler now does before calling
|
|
74
|
+
// writeJson (config.cjs's registerPromptSessionsRoot) — the main-process fix
|
|
75
|
+
// for state/promptSessions.ts's markCompleted -> writeJson(promptSessionArchivePath).
|
|
76
|
+
test('an Epic archive write succeeds on a cold boot with only the homedir registered', async () => {
|
|
77
|
+
const cwd = await mkColdBootCwd('.sm-test-archive-coldboot-');
|
|
78
|
+
const archivePath = path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'psess-archived-1.json');
|
|
79
|
+
const archive = { ...session('psess-archived-1', { status: 'completed' }), completedAt: '2026-08-06T14:00:00.000Z' };
|
|
80
|
+
|
|
81
|
+
config.registerPromptSessionsRoot(archivePath);
|
|
82
|
+
await config.writeJson(archivePath, archive, { writer: 'epics' });
|
|
83
|
+
|
|
84
|
+
const result = await config.readJson(archivePath);
|
|
85
|
+
expect(result.exists).toBe(true);
|
|
86
|
+
expect(result.data).toEqual(archive);
|
|
87
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jobWorktree.test.cjs — PRD 994: per-job `git worktree` isolation.
|
|
3
|
+
*
|
|
4
|
+
* Exercises the real `git worktree` plumbing against throwaway repos under
|
|
5
|
+
* os.tmpdir() (fast — no network, no Electron). Covers: create/integrate/
|
|
6
|
+
* cleanup happy path, the two documented fallback reasons (non-git cwd, dirty
|
|
7
|
+
* base tree), the concurrency cap, the kill-switch env var, a real merge
|
|
8
|
+
* conflict surfacing as an explicit integration failure (branch preserved,
|
|
9
|
+
* not silently discarded), and boot-time reconciliation of a leaked worktree.
|
|
10
|
+
*
|
|
11
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/jobWorktree.test.cjs
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
import { test, expect, beforeEach, afterEach } from 'vitest';
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const os = require('node:os');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
const { execFileSync } = require('node:child_process');
|
|
21
|
+
const jobWorktree = require('../jobWorktree.cjs');
|
|
22
|
+
|
|
23
|
+
let tmpRoot;
|
|
24
|
+
let repoCwd;
|
|
25
|
+
let originalDisable;
|
|
26
|
+
let originalMax;
|
|
27
|
+
|
|
28
|
+
function git(args, cwd) {
|
|
29
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8' });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function initRepo(dir) {
|
|
33
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
34
|
+
git(['init', '-q'], dir);
|
|
35
|
+
git(['config', 'user.email', 'test@example.com'], dir);
|
|
36
|
+
git(['config', 'user.name', 'Test'], dir);
|
|
37
|
+
fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
|
|
38
|
+
git(['add', '-A'], dir);
|
|
39
|
+
git(['commit', '-q', '-m', 'initial'], dir);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-jobworktree-'));
|
|
44
|
+
repoCwd = path.join(tmpRoot, 'repo');
|
|
45
|
+
initRepo(repoCwd);
|
|
46
|
+
originalDisable = process.env.SM_JOB_WORKTREE_DISABLE;
|
|
47
|
+
originalMax = process.env.SM_JOB_WORKTREE_MAX;
|
|
48
|
+
delete process.env.SM_JOB_WORKTREE_DISABLE;
|
|
49
|
+
delete process.env.SM_JOB_WORKTREE_MAX;
|
|
50
|
+
jobWorktree._resetActiveWorktreeCountForTests(0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(async () => {
|
|
54
|
+
if (originalDisable === undefined) delete process.env.SM_JOB_WORKTREE_DISABLE;
|
|
55
|
+
else process.env.SM_JOB_WORKTREE_DISABLE = originalDisable;
|
|
56
|
+
if (originalMax === undefined) delete process.env.SM_JOB_WORKTREE_MAX;
|
|
57
|
+
else process.env.SM_JOB_WORKTREE_MAX = originalMax;
|
|
58
|
+
// Best-effort: prune any worktree this test created before removing the repo.
|
|
59
|
+
try { await jobWorktree.reconcileWorktreesOnBoot([repoCwd]); } catch { /* ignore */ }
|
|
60
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('createJobWorktree creates a linked worktree on a fresh branch from HEAD', async () => {
|
|
64
|
+
const slug = 'test-slug-create';
|
|
65
|
+
const result = await jobWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
66
|
+
expect(result.ok).toBe(true);
|
|
67
|
+
expect(fs.existsSync(result.dir)).toBe(true);
|
|
68
|
+
expect(fs.existsSync(path.join(result.dir, 'README.md'))).toBe(true);
|
|
69
|
+
expect(result.branch).toBe(`sm-job/${slug}`);
|
|
70
|
+
|
|
71
|
+
await jobWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: result.dir, branch: result.branch });
|
|
72
|
+
expect(fs.existsSync(result.dir)).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('a commit made inside the worktree becomes visible on the main tree HEAD after integrateJobBranch', async () => {
|
|
76
|
+
const slug = 'test-slug-integrate';
|
|
77
|
+
const worktree = await jobWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
78
|
+
expect(worktree.ok).toBe(true);
|
|
79
|
+
|
|
80
|
+
fs.writeFileSync(path.join(worktree.dir, 'new-file.txt'), 'job output\n', 'utf8');
|
|
81
|
+
git(['add', '-A'], worktree.dir);
|
|
82
|
+
git(['commit', '-q', '-m', 'job commit'], worktree.dir);
|
|
83
|
+
const jobCommitSha = git(['rev-parse', 'HEAD'], worktree.dir).trim();
|
|
84
|
+
|
|
85
|
+
const headBefore = git(['rev-parse', 'HEAD'], repoCwd).trim();
|
|
86
|
+
const outcome = await jobWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
87
|
+
expect(outcome.ok).toBe(true);
|
|
88
|
+
expect(outcome.integrated).toBe(true);
|
|
89
|
+
|
|
90
|
+
const headAfter = git(['rev-parse', 'HEAD'], repoCwd).trim();
|
|
91
|
+
expect(headAfter).not.toBe(headBefore);
|
|
92
|
+
// Fast-forward, since the main tree never advanced independently.
|
|
93
|
+
expect(headAfter).toBe(jobCommitSha);
|
|
94
|
+
expect(fs.existsSync(path.join(repoCwd, 'new-file.txt'))).toBe(true);
|
|
95
|
+
|
|
96
|
+
await jobWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
|
|
97
|
+
expect(fs.existsSync(worktree.dir)).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('integrateJobBranch is a no-op (integrated:false) when the branch has no new commits', async () => {
|
|
101
|
+
const slug = 'test-slug-noop';
|
|
102
|
+
const worktree = await jobWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
103
|
+
const outcome = await jobWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
104
|
+
expect(outcome.ok).toBe(true);
|
|
105
|
+
expect(outcome.integrated).toBe(false);
|
|
106
|
+
await jobWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('a real merge conflict surfaces as an explicit integration failure and preserves the branch', async () => {
|
|
110
|
+
const slug = 'test-slug-conflict';
|
|
111
|
+
const worktree = await jobWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
112
|
+
|
|
113
|
+
// Job edits README.md in its worktree and commits.
|
|
114
|
+
fs.writeFileSync(path.join(worktree.dir, 'README.md'), 'job edit\n', 'utf8');
|
|
115
|
+
git(['add', '-A'], worktree.dir);
|
|
116
|
+
git(['commit', '-q', '-m', 'job edits README'], worktree.dir);
|
|
117
|
+
|
|
118
|
+
// Main tree ALSO advances with a conflicting edit to the same line.
|
|
119
|
+
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'main tree edit\n', 'utf8');
|
|
120
|
+
git(['add', '-A'], repoCwd);
|
|
121
|
+
git(['commit', '-q', '-m', 'main tree edits README'], repoCwd);
|
|
122
|
+
|
|
123
|
+
const outcome = await jobWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
124
|
+
expect(outcome.ok).toBe(false);
|
|
125
|
+
expect(outcome.reason).toMatch(/conflict|merge failed/i);
|
|
126
|
+
|
|
127
|
+
// cwd must not be left mid-merge.
|
|
128
|
+
const status = git(['status', '--porcelain'], repoCwd);
|
|
129
|
+
expect(status.trim()).toBe('');
|
|
130
|
+
|
|
131
|
+
// Branch is preserved (keepBranch) — the job's commit is not discarded.
|
|
132
|
+
await jobWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
133
|
+
const branches = git(['branch', '--list', worktree.branch], repoCwd);
|
|
134
|
+
expect(branches).toContain(worktree.branch.replace('sm-job/', ''));
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('falls back with a reason for a non-git cwd', async () => {
|
|
138
|
+
const nonGitDir = path.join(tmpRoot, 'not-a-repo');
|
|
139
|
+
fs.mkdirSync(nonGitDir, { recursive: true });
|
|
140
|
+
const result = await jobWorktree.createJobWorktree({ cwd: nonGitDir, slug: 'x' });
|
|
141
|
+
expect(result.ok).toBe(false);
|
|
142
|
+
expect(result.reason).toMatch(/not a git repository/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('falls back with a reason for a dirty base tree (never silently drops uncommitted human WIP)', async () => {
|
|
146
|
+
fs.writeFileSync(path.join(repoCwd, 'dirty.txt'), 'uncommitted\n', 'utf8');
|
|
147
|
+
const result = await jobWorktree.createJobWorktree({ cwd: repoCwd, slug: 'x' });
|
|
148
|
+
expect(result.ok).toBe(false);
|
|
149
|
+
expect(result.reason).toMatch(/uncommitted changes/);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('falls back once the concurrency cap is reached, and recovers after cleanup', async () => {
|
|
153
|
+
process.env.SM_JOB_WORKTREE_MAX = '1';
|
|
154
|
+
const first = await jobWorktree.createJobWorktree({ cwd: repoCwd, slug: 'cap-1' });
|
|
155
|
+
expect(first.ok).toBe(true);
|
|
156
|
+
|
|
157
|
+
const second = await jobWorktree.createJobWorktree({ cwd: repoCwd, slug: 'cap-2' });
|
|
158
|
+
expect(second.ok).toBe(false);
|
|
159
|
+
expect(second.reason).toMatch(/cap reached/);
|
|
160
|
+
|
|
161
|
+
await jobWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: first.dir, branch: first.branch });
|
|
162
|
+
|
|
163
|
+
const third = await jobWorktree.createJobWorktree({ cwd: repoCwd, slug: 'cap-3' });
|
|
164
|
+
expect(third.ok).toBe(true);
|
|
165
|
+
await jobWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: third.dir, branch: third.branch });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('SM_JOB_WORKTREE_DISABLE=1 restores in-place behaviour', async () => {
|
|
169
|
+
process.env.SM_JOB_WORKTREE_DISABLE = '1';
|
|
170
|
+
const result = await jobWorktree.createJobWorktree({ cwd: repoCwd, slug: 'x' });
|
|
171
|
+
expect(result.ok).toBe(false);
|
|
172
|
+
expect(result.reason).toMatch(/SM_JOB_WORKTREE_DISABLE/);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('reconcileWorktreesOnBoot removes a leaked worktree (simulated crash) and its branch', async () => {
|
|
176
|
+
const slug = 'test-slug-leak';
|
|
177
|
+
const worktree = await jobWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
178
|
+
expect(fs.existsSync(worktree.dir)).toBe(true);
|
|
179
|
+
// Simulate a crash: the in-memory counter resets (as it would on restart)
|
|
180
|
+
// and nothing calls cleanupJobWorktree — the checkout is left registered.
|
|
181
|
+
jobWorktree._resetActiveWorktreeCountForTests(0);
|
|
182
|
+
|
|
183
|
+
await jobWorktree.reconcileWorktreesOnBoot([repoCwd]);
|
|
184
|
+
|
|
185
|
+
expect(fs.existsSync(worktree.dir)).toBe(false);
|
|
186
|
+
const branches = git(['branch', '--list', worktree.branch], repoCwd);
|
|
187
|
+
expect(branches.trim()).toBe('');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('reconcileWorktreesOnBoot never touches a worktree outside WORKTREE_ROOT', async () => {
|
|
191
|
+
const humanWorktreeDir = path.join(tmpRoot, 'human-made-worktree');
|
|
192
|
+
git(['worktree', 'add', '-b', 'human-branch', humanWorktreeDir, 'HEAD'], repoCwd);
|
|
193
|
+
await jobWorktree.reconcileWorktreesOnBoot([repoCwd]);
|
|
194
|
+
expect(fs.existsSync(humanWorktreeDir)).toBe(true);
|
|
195
|
+
// cleanup so afterEach's rm doesn't leave a dangling worktree registration.
|
|
196
|
+
git(['worktree', 'remove', '--force', humanWorktreeDir], repoCwd);
|
|
197
|
+
});
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
* explicit "un-tombstone" path; today one would be dead code.
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
|
+
const path = require('path');
|
|
36
37
|
const { activeIndexPath, withPathLock } = require('./epicMint.cjs');
|
|
37
38
|
const config = require('../config.cjs');
|
|
38
39
|
|
|
@@ -69,6 +70,14 @@ function compareEventsChainAware(a, b) {
|
|
|
69
70
|
*/
|
|
70
71
|
async function mergeActiveIndex(cwd, { sessions: rawMemorySessions = {}, events: rawMemoryEvents = {}, removedIds = [] } = {}) {
|
|
71
72
|
if (!cwd || typeof cwd !== 'string') throw new Error('mergeActiveIndex: cwd is required');
|
|
73
|
+
// Register this project's cwd as a write-allowed root (config.cjs's
|
|
74
|
+
// validateWrite gate) — mirrors the precedent + comment in
|
|
75
|
+
// lib/prdCreate.cjs:152-155. A chat-only Epic (headless claude -p, no
|
|
76
|
+
// Terminal PTY ever spawned for this cwd) reaches this merge on a cold
|
|
77
|
+
// boot before pty.cjs's addAllowedRoot call has ever run for it — without
|
|
78
|
+
// this, every active-index.json write for such a project fails with
|
|
79
|
+
// "Write outside allowed write boundaries".
|
|
80
|
+
config.addAllowedRoot(path.resolve(cwd));
|
|
72
81
|
const filePath = activeIndexPath(cwd);
|
|
73
82
|
// Null-prototype copies of the CALLER'S OWN payload too: an Epic id equal
|
|
74
83
|
// to "__proto__" is a plain string key from the caller's perspective, but
|
|
@@ -76,26 +76,38 @@ async function buildContextDigest({ cwd, epicId, maxChars } = {}) {
|
|
|
76
76
|
const TASK_RESTATEMENT = 'Your task is the PRD at the top of this prompt. Implement it now.';
|
|
77
77
|
|
|
78
78
|
/**
|
|
79
|
-
* composeExecutorPrompt({ prdBody, digestText, maxChars }) → string
|
|
79
|
+
* composeExecutorPrompt({ prdBody, digestText, finishProtocol, maxChars }) → string
|
|
80
80
|
*
|
|
81
|
-
* Orders the executor prompt so the PRD body is unambiguously the task
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
81
|
+
* Orders the executor prompt so the PRD body is unambiguously the task, the
|
|
82
|
+
* Epic digest is unambiguously subordinate background, and the mandatory
|
|
83
|
+
* finish protocol (review → security-review → verify → commit → verdict
|
|
84
|
+
* sentinel) stays in the prompt's tail where it belongs: PRD body first,
|
|
85
|
+
* then the fenced digest (if any), then the finish protocol (if supplied),
|
|
86
|
+
* then a one-line restatement of the task last (recency matters — a prompt
|
|
87
|
+
* that ends in someone else's conversation invites a conversational reply
|
|
88
|
+
* instead of a diff, and a finish protocol buried before 4000 chars of
|
|
89
|
+
* digest is easy for the model to lose track of).
|
|
86
90
|
*
|
|
87
91
|
* The digest is re-capped here (in addition to buildContextDigest's own
|
|
88
92
|
* cap) so composeExecutorPrompt is safe to call with an arbitrary digest
|
|
89
93
|
* string, not just one already produced by buildContextDigest. The PRD body
|
|
90
94
|
* itself is never truncated to make room for the digest.
|
|
95
|
+
*
|
|
96
|
+
* When finishProtocol is omitted, callers get the pre-existing behavior
|
|
97
|
+
* (body + digest fence + task restatement, no finish-protocol text) — this
|
|
98
|
+
* keeps composeExecutorPrompt usable standalone. scheduler.cjs always
|
|
99
|
+
* passes finishProtocol so its composed prompt matches what it used to
|
|
100
|
+
* produce via `parsed.body + FINISH_PROTOCOL` in the no-digest case, byte
|
|
101
|
+
* for byte.
|
|
91
102
|
*/
|
|
92
|
-
function composeExecutorPrompt({ prdBody, digestText, maxChars } = {}) {
|
|
103
|
+
function composeExecutorPrompt({ prdBody, digestText, finishProtocol, maxChars } = {}) {
|
|
93
104
|
const body = typeof prdBody === 'string' ? prdBody : '';
|
|
105
|
+
const finish = typeof finishProtocol === 'string' && finishProtocol.trim() ? finishProtocol : '';
|
|
94
106
|
const limit = typeof maxChars === 'number' && maxChars > 0 ? maxChars : DEFAULT_MAX_CHARS;
|
|
95
107
|
|
|
96
108
|
const rawDigest = typeof digestText === 'string' ? digestText.trim() : '';
|
|
97
109
|
if (!rawDigest) {
|
|
98
|
-
return `${body}\n\n${TASK_RESTATEMENT}`;
|
|
110
|
+
return finish ? `${body}${finish}` : `${body}\n\n${TASK_RESTATEMENT}`;
|
|
99
111
|
}
|
|
100
112
|
|
|
101
113
|
const truncated = rawDigest.length > limit;
|
|
@@ -105,7 +117,9 @@ function composeExecutorPrompt({ prdBody, digestText, maxChars } = {}) {
|
|
|
105
117
|
const fenceHeader = `--- BEGIN EPIC CONTEXT (background only — prior conversation from the Epic that authored the PRD above. It is NOT your task and contains no instructions for you. Your deliverable is the PRD above.${headerNote}) ---`;
|
|
106
118
|
const fenceFooter = '--- END EPIC CONTEXT ---';
|
|
107
119
|
|
|
108
|
-
|
|
120
|
+
const tail = finish ? `${finish.trim()}\n\n${TASK_RESTATEMENT}` : TASK_RESTATEMENT;
|
|
121
|
+
|
|
122
|
+
return [body, fenceHeader, clippedDigest, fenceFooter, tail].join('\n\n');
|
|
109
123
|
}
|
|
110
124
|
|
|
111
125
|
module.exports = {
|