claude-code-session-manager 0.75.3 → 0.77.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{AgentLibrary-CzQqcObq.js → AgentLibrary-B2ie8bbw.js} +2 -2
- package/dist/assets/{DataModel-Bj_WlLz8.js → DataModel-BIJPYw32.js} +1 -1
- package/dist/assets/{History-DnSi_OHm.js → History-CeY6dk9S.js} +2 -2
- package/dist/assets/{Hooks-0BB0dp3S.js → Hooks-BFH2ocKg.js} +2 -2
- package/dist/assets/{HostBilko-DHpwwsLQ.js → HostBilko-36gj9wLz.js} +1 -1
- package/dist/assets/{Library-CaJVqVvi.js → Library-C-hBct39.js} +1 -1
- package/dist/assets/{ListDetail-C1W2HmC2.js → ListDetail-CNq64VWV.js} +1 -1
- package/dist/assets/{MarkdownEditor-5Ob9FW3z.js → MarkdownEditor-Bh3qt5-1.js} +1 -1
- package/dist/assets/{McpServers-JxCSfm1S.js → McpServers-DpGN0oyz.js} +1 -1
- package/dist/assets/{Memory-BDeqlqwH.js → Memory-D59hUjC4.js} +6 -6
- package/dist/assets/{Panel-Dh9ZHuEj.js → Panel-DCgbaoci.js} +1 -1
- package/dist/assets/{Permissions-DXy-CbEY.js → Permissions-DAmQ0DYV.js} +2 -2
- package/dist/assets/{Plugins-_n1Iuc8T.js → Plugins-Dyfgn6Is.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BP_evfxE.js → ProvenanceBadge-BiYhPO1U.js} +1 -1
- package/dist/assets/SaveBar-RV7B6sOh.js +1 -0
- package/dist/assets/Scheduler-BPaNqx1b.js +14 -0
- package/dist/assets/{ScopeSwitcher-CAWzM6RI.js → ScopeSwitcher-P4mdLGNU.js} +1 -1
- package/dist/assets/{Settings-DRRozLyT.js → Settings-BL4vf5aX.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-DGHDWlz4.js → SkillReferenceGraph-BRBDyi1_.js} +1 -1
- package/dist/assets/{Skills-D8L66eiX.js → Skills-BV08gDUH.js} +2 -2
- package/dist/assets/{SystemPrompt-CYtUsonD.js → SystemPrompt-CLftSsDw.js} +1 -1
- package/dist/assets/TagLibrary-Bp8jGsd5.js +1 -0
- package/dist/assets/{TiptapBody-B2hRgbPE.js → TiptapBody-jCpuB6E5.js} +1 -1
- package/dist/assets/{Toggle-BTwsbxam.js → Toggle-D2paA1xf.js} +1 -1
- package/dist/assets/{index-DijufvkJ.js → index-BDRSqBl3.js} +704 -704
- package/dist/assets/{index-CMLnzdZC.css → index-CYhdtisq.css} +1 -1
- package/dist/assets/{settingsSchema-D6wzxAi6.js → settingsSchema-6IOLjZZN.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +8 -2
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
- package/scripts/lib/activeSessions.cjs +116 -6
- package/scripts/project-pages-logic/dist/logic.cjs +4709 -0
- package/scripts/render-project-pages/dist/renderer.cjs +18900 -0
- package/scripts/render-project-pages.cjs +70 -0
- package/scripts/scheduler-mcp-server.cjs +269 -96
- package/scripts/validate-project-pages-summary.cjs +62 -0
- package/src/main/__tests__/agentModelResolve.test.cjs +66 -0
- package/src/main/__tests__/epicStatusMirror.test.cjs +110 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +106 -0
- package/src/main/__tests__/prdAdminRoutes.test.cjs +295 -0
- package/src/main/__tests__/prdAgentType.test.cjs +103 -0
- package/src/main/__tests__/prdCreate.test.cjs +247 -0
- package/src/main/__tests__/prdFrontmatterAgentType.test.cjs +117 -0
- package/src/main/__tests__/prdFrontmatterQuietMachine.test.cjs +108 -0
- package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +485 -0
- package/src/main/__tests__/projectPages.test.cjs +73 -1
- package/src/main/__tests__/rcaReport.test.cjs +54 -0
- package/src/main/__tests__/runVerify.test.cjs +94 -0
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +58 -3
- package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +103 -0
- package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +41 -0
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +10 -0
- package/src/main/__tests__/scheduler-foreign-wip-manifest.test.cjs +78 -0
- package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +242 -0
- package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +31 -0
- package/src/main/__tests__/scheduler-launch-failure.test.cjs +201 -0
- package/src/main/__tests__/scheduler-leftover-fields.test.cjs +52 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +241 -0
- package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +135 -0
- package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +222 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +207 -1
- package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +212 -0
- package/src/main/__tests__/scheduler-stranded-investigation.test.cjs +185 -0
- package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +194 -0
- package/src/main/__tests__/seedAgentPersonas.test.cjs +75 -14
- package/src/main/__tests__/seedSchedulerMcp.test.cjs +66 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -5
- package/src/main/bilkoHost.cjs +4 -3
- package/src/main/chatRunner.cjs +6 -1
- package/src/main/config.cjs +25 -33
- package/src/main/health.cjs +153 -2
- package/src/main/index.cjs +64 -5
- package/src/main/ipcSchemas.cjs +69 -1
- package/src/main/lib/__tests__/activeIndexRebuild.test.cjs +179 -0
- package/src/main/lib/__tests__/childWithLog.test.cjs +141 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +391 -42
- package/src/main/lib/__tests__/ephemeralCwd.test.cjs +91 -0
- package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +5 -3
- package/src/main/lib/__tests__/fixChainDepth.test.cjs +40 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +290 -5
- package/src/main/lib/__tests__/gitWorktreeSalvage.test.cjs +107 -0
- package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +153 -0
- package/src/main/lib/__tests__/jobWorktree.test.cjs +6 -4
- package/src/main/lib/__tests__/landedSinceRun.test.cjs +73 -0
- package/src/main/lib/__tests__/launchFailure.test.cjs +220 -0
- package/src/main/lib/__tests__/loadGate.test.cjs +159 -0
- package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +102 -0
- package/src/main/lib/__tests__/opsOwnership.test.cjs +7 -0
- package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +151 -0
- package/src/main/lib/__tests__/opsRootResolve.test.cjs +149 -0
- package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +82 -0
- package/src/main/lib/__tests__/projectRootResolve.test.cjs +148 -0
- package/src/main/lib/__tests__/queueHealth.test.cjs +58 -0
- package/src/main/lib/__tests__/quietMachineLease.test.cjs +39 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +133 -0
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +19 -9
- package/src/main/lib/__tests__/schedulerBatchFairness.test.cjs +213 -0
- package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +125 -0
- package/src/main/lib/__tests__/schedulerBatchProjectCap.test.cjs +127 -0
- package/src/main/lib/__tests__/schedulerBatchQuietMachine.test.cjs +109 -0
- package/src/main/lib/__tests__/schedulerMcpServerHeadlessRefusal.test.cjs +71 -0
- package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +217 -0
- package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +350 -0
- package/src/main/lib/activeIndexMerge.cjs +15 -0
- package/src/main/lib/activeIndexRebuild.cjs +133 -0
- package/src/main/lib/agentModelResolve.cjs +58 -0
- package/src/main/lib/buildTarget.cjs +3 -2
- package/src/main/lib/childWithLog.cjs +69 -2
- package/src/main/lib/claudeBin.cjs +54 -1
- package/src/main/lib/crossProjectFeedback.cjs +8 -1
- package/src/main/lib/definitionOfDone.cjs +3 -2
- package/src/main/lib/delegationReadiness.cjs +514 -26
- package/src/main/lib/ephemeralCwd.cjs +78 -0
- package/src/main/lib/epicDelegationStats.cjs +2 -1
- package/src/main/lib/epicMint.cjs +17 -1
- package/src/main/lib/epicStatusMirror.cjs +95 -0
- package/src/main/lib/epicValidationHook.cjs +2 -1
- package/src/main/lib/epicWorktreeMint.cjs +5 -2
- package/src/main/lib/fixChainDepth.cjs +45 -0
- package/src/main/lib/gitWorktree.cjs +520 -21
- package/src/main/lib/jobWorktree.cjs +2 -0
- package/src/main/lib/landedSinceRun.cjs +55 -0
- package/src/main/lib/launchFailure.cjs +357 -0
- package/src/main/lib/loadGate.cjs +134 -0
- package/src/main/lib/mcpToolCatalog.cjs +370 -0
- package/src/main/lib/opsErrorLog.cjs +12 -1
- package/src/main/lib/opsOwnership.cjs +106 -0
- package/src/main/lib/prdAdminRoutes.cjs +43 -3
- package/src/main/lib/prdAgentType.cjs +84 -0
- package/src/main/lib/prdCreate.cjs +103 -15
- package/src/main/lib/prdDeclaredPaths.cjs +70 -0
- package/src/main/lib/prdFrontmatter.cjs +17 -3
- package/src/main/lib/prdLocations.cjs +13 -6
- package/src/main/lib/projectHomeAdminRoutes.cjs +402 -0
- package/src/main/lib/projectPageSummarySchema.cjs +181 -0
- package/src/main/lib/projectRootResolve.cjs +134 -0
- package/src/main/lib/promptSessionSchema.cjs +7 -0
- package/src/main/lib/queueHealth.cjs +38 -0
- package/src/main/lib/queueStore.cjs +40 -7
- package/src/main/lib/quietMachineLease.cjs +48 -0
- package/src/main/lib/rcaReport.cjs +54 -4
- package/src/main/lib/reaperHelpers.cjs +64 -1
- package/src/main/lib/scheduleJobSchema.cjs +31 -0
- package/src/main/lib/scheduleJobTransitions.cjs +6 -2
- package/src/main/lib/schedulerBatch.cjs +301 -55
- package/src/main/lib/schedulerConfig.cjs +99 -0
- package/src/main/projectBrief.cjs +3 -2
- package/src/main/projectPages.cjs +162 -3
- package/src/main/promptSessionTranscript.cjs +0 -0
- package/src/main/pty.cjs +5 -0
- package/src/main/queueOps.cjs +15 -8
- package/src/main/runVerify.cjs +50 -9
- package/src/main/scheduler/prdParser.cjs +18 -1
- package/src/main/scheduler.cjs +1701 -130
- package/src/main/seedAgentPersonas.cjs +62 -21
- package/src/main/seedSchedulerMcp.cjs +58 -4
- package/src/main/templates/project-pages-catalog.json +741 -0
- package/src/main/templates/project-pages-pipeline.md +417 -0
- package/src/preload/api.d.ts +187 -3
- package/src/preload/index.cjs +9 -0
- package/src/seed/agents/project-home-builder.md +59 -0
- package/dist/assets/SaveBar-D-gCUx4n.js +0 -1
- package/dist/assets/Scheduler-Bpd4OGju.js +0 -14
- package/dist/assets/TagLibrary-E5CLeuVk.js +0 -1
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-looks-done.test.cjs — PRD 1102.
|
|
3
|
+
*
|
|
4
|
+
* reverifyNeedsReview() used to have two hard limits that left real, finished
|
|
5
|
+
* work parked red forever: (1) isRescanCandidate required status ===
|
|
6
|
+
* 'needs_review' — a `failed` row was never revisited at all; (2)
|
|
7
|
+
* committedInWindow only looked inside [startedAt, finishedAt+60s], missing a
|
|
8
|
+
* commit that landed later (a retry, a sibling run, a human). This tests the
|
|
9
|
+
* widened pass: a `failed` row whose failure is unverified-shaped (no result
|
|
10
|
+
* event at all — classifyRunOutcome === 'no_result') is now a rescan
|
|
11
|
+
* candidate, and any candidate with a POST-window commit touching its own
|
|
12
|
+
* declared paths gets annotated `looksDone` for a human — never auto-
|
|
13
|
+
* completed. A `failed` row with a genuine result event (a real red gate) must
|
|
14
|
+
* stay excluded, and the pre-existing needs_review heal path must be
|
|
15
|
+
* unaffected.
|
|
16
|
+
*
|
|
17
|
+
* HOME is overridden to a tmp dir BEFORE requiring scheduler.cjs — see
|
|
18
|
+
* scheduler-reap-dead-running-jobs.test.cjs's comment for why (every path
|
|
19
|
+
* this code touches is baked into a top-level const from os.homedir() at
|
|
20
|
+
* require time).
|
|
21
|
+
*
|
|
22
|
+
* Run: timeout 180 npx vitest run src/main/__tests__/scheduler-looks-done.test.cjs
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
const assert = require('node:assert/strict');
|
|
28
|
+
const fs = require('node:fs');
|
|
29
|
+
const os = require('node:os');
|
|
30
|
+
const path = require('node:path');
|
|
31
|
+
const { execFileSync } = require('node:child_process');
|
|
32
|
+
|
|
33
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'looks-done-test-'));
|
|
34
|
+
process.env.HOME = tmpHome;
|
|
35
|
+
|
|
36
|
+
const { reverifyNeedsReview, computeLooksDone } = require('../scheduler.cjs');
|
|
37
|
+
const { resolvePrdWriteDir } = require('../lib/prdLocations.cjs');
|
|
38
|
+
const { bustCwdCache } = require('../lib/queueStore.cjs');
|
|
39
|
+
|
|
40
|
+
function git(args, cwd) {
|
|
41
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8' });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function initRepo(dir) {
|
|
45
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
46
|
+
git(['init', '-q'], dir);
|
|
47
|
+
git(['config', 'user.email', 'test@example.com'], dir);
|
|
48
|
+
git(['config', 'user.name', 'Test'], dir);
|
|
49
|
+
fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
|
|
50
|
+
git(['add', '-A'], dir);
|
|
51
|
+
git(['commit', '-q', '-m', 'initial'], dir);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function commitFile(dir, relPath, content, message) {
|
|
55
|
+
const abs = path.join(dir, relPath);
|
|
56
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
57
|
+
fs.writeFileSync(abs, content);
|
|
58
|
+
git(['add', relPath], dir);
|
|
59
|
+
git(['commit', '-q', '-m', message], dir);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function registerActiveProject(cwd, slug) {
|
|
63
|
+
const projectsDir = path.join(tmpHome, '.claude', 'projects');
|
|
64
|
+
const slugDir = path.join(projectsDir, slug);
|
|
65
|
+
fs.mkdirSync(slugDir, { recursive: true });
|
|
66
|
+
fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
|
|
67
|
+
bustCwdCache();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function writeProjectQueue(cwd, jobs) {
|
|
71
|
+
const stateDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
|
|
72
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
73
|
+
fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs }, null, 2));
|
|
74
|
+
return path.join(stateDir, 'queue.json');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function writeRunLog(runId, slug, lines) {
|
|
78
|
+
const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
|
|
79
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
80
|
+
fs.writeFileSync(path.join(runDir, `${slug}.log`), lines.join('\n') + '\n');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function writePrd(cwd, slug, body) {
|
|
84
|
+
const dir = resolvePrdWriteDir(cwd);
|
|
85
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
86
|
+
const text = `---\ntitle: Test PRD\ncwd: ${cwd}\nestimateMinutes: 30\n---\n${body}\n`;
|
|
87
|
+
fs.writeFileSync(path.join(dir, `${slug}.md`), text);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function wait(ms) {
|
|
91
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
test('failed + no result event (unverified-shaped) + later commit touching declared path → looksDone, transitions to needs_review', async () => {
|
|
95
|
+
const projectCwd = path.join(tmpHome, 'proj-171');
|
|
96
|
+
initRepo(projectCwd);
|
|
97
|
+
registerActiveProject(projectCwd, 'proj-171-slug');
|
|
98
|
+
writePrd(projectCwd, '171-example', [
|
|
99
|
+
'# Implementation notes',
|
|
100
|
+
'Edit `src/foo.js`.',
|
|
101
|
+
].join('\n'));
|
|
102
|
+
|
|
103
|
+
const startedAt = new Date().toISOString();
|
|
104
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
105
|
+
{
|
|
106
|
+
slug: '171-example',
|
|
107
|
+
status: 'failed',
|
|
108
|
+
cwd: projectCwd,
|
|
109
|
+
runId: 'run-171',
|
|
110
|
+
startedAt,
|
|
111
|
+
finishedAt: new Date().toISOString(),
|
|
112
|
+
error: 'reaped: process gone (outcome=no_result) (outcome=no_result)',
|
|
113
|
+
},
|
|
114
|
+
]);
|
|
115
|
+
writeRunLog('run-171', '171-example', ['[scheduler] starting 171-example']); // no result event
|
|
116
|
+
|
|
117
|
+
await wait(1100); // git --since has 1s resolution
|
|
118
|
+
commitFile(projectCwd, 'src/foo.js', 'hello', 'fix 171');
|
|
119
|
+
|
|
120
|
+
await reverifyNeedsReview();
|
|
121
|
+
|
|
122
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
123
|
+
assert.equal(jobs.length, 1);
|
|
124
|
+
assert.equal(jobs[0].status, 'needs_review', 'looksDone never auto-completes — only surfaces for a human');
|
|
125
|
+
assert.ok(jobs[0].looksDone, 'expected a looksDone annotation');
|
|
126
|
+
assert.equal(jobs[0].looksDone.commits.length, 1);
|
|
127
|
+
assert.deepEqual(jobs[0].looksDone.paths, ['src/foo.js']);
|
|
128
|
+
assert.match(jobs[0].error, /looks done — 1 commit/);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('failed + a real result event (genuine gate failure) → not a candidate, stays failed untouched', async () => {
|
|
132
|
+
const projectCwd = path.join(tmpHome, 'proj-real-fail');
|
|
133
|
+
initRepo(projectCwd);
|
|
134
|
+
registerActiveProject(projectCwd, 'proj-real-fail-slug');
|
|
135
|
+
writePrd(projectCwd, '05-real-fail', [
|
|
136
|
+
'# Implementation notes',
|
|
137
|
+
'Edit `src/bar.js`.',
|
|
138
|
+
].join('\n'));
|
|
139
|
+
|
|
140
|
+
const startedAt = new Date().toISOString();
|
|
141
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
142
|
+
{
|
|
143
|
+
slug: '05-real-fail',
|
|
144
|
+
status: 'failed',
|
|
145
|
+
cwd: projectCwd,
|
|
146
|
+
runId: 'run-real-fail',
|
|
147
|
+
startedAt,
|
|
148
|
+
finishedAt: new Date().toISOString(),
|
|
149
|
+
error: 'AC gate red: tests failed',
|
|
150
|
+
},
|
|
151
|
+
]);
|
|
152
|
+
writeRunLog('run-real-fail', '05-real-fail', [
|
|
153
|
+
'[scheduler] starting 05-real-fail',
|
|
154
|
+
JSON.stringify({ type: 'result', subtype: 'error_during_execution', is_error: true, result: 'tests failed' }),
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
await wait(1100);
|
|
158
|
+
commitFile(projectCwd, 'src/bar.js', 'hello', 'unrelated later commit touching the declared path');
|
|
159
|
+
|
|
160
|
+
await reverifyNeedsReview();
|
|
161
|
+
|
|
162
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
163
|
+
assert.equal(jobs.length, 1);
|
|
164
|
+
assert.equal(jobs[0].status, 'failed', 'a genuine red gate must never become a heal candidate');
|
|
165
|
+
assert.equal(jobs[0].looksDone, undefined);
|
|
166
|
+
assert.equal(jobs[0].error, 'AC gate red: tests failed');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('needs_review with an in-window commit heals exactly as today (regression — unchanged heal semantics)', async () => {
|
|
170
|
+
const projectCwd = path.join(tmpHome, 'proj-heals-today');
|
|
171
|
+
initRepo(projectCwd);
|
|
172
|
+
registerActiveProject(projectCwd, 'proj-heals-today-slug');
|
|
173
|
+
writePrd(projectCwd, '20-clean-feature', [
|
|
174
|
+
'# Acceptance criteria',
|
|
175
|
+
'- [ ] `npm test` passes',
|
|
176
|
+
].join('\n'));
|
|
177
|
+
|
|
178
|
+
const startedAt = new Date().toISOString();
|
|
179
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
180
|
+
{
|
|
181
|
+
slug: '20-clean-feature',
|
|
182
|
+
status: 'needs_review',
|
|
183
|
+
cwd: projectCwd,
|
|
184
|
+
runId: 'run-clean',
|
|
185
|
+
startedAt,
|
|
186
|
+
finishedAt: new Date().toISOString(),
|
|
187
|
+
verifierVerdict: 'transcript_errors',
|
|
188
|
+
error: 'transcript_errors: stale verdict',
|
|
189
|
+
},
|
|
190
|
+
]);
|
|
191
|
+
writeRunLog('run-clean', '20-clean-feature', [
|
|
192
|
+
'[scheduler] starting 20-clean-feature',
|
|
193
|
+
JSON.stringify({
|
|
194
|
+
type: 'assistant',
|
|
195
|
+
message: { role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'npm test' } }] },
|
|
196
|
+
}),
|
|
197
|
+
JSON.stringify({
|
|
198
|
+
type: 'user',
|
|
199
|
+
message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'all tests passed' }] },
|
|
200
|
+
}),
|
|
201
|
+
'SCHEDULER_VERDICT: PASS',
|
|
202
|
+
'[scheduler] exit code=0 (raw code=0 signal=null) duration=10s',
|
|
203
|
+
]);
|
|
204
|
+
|
|
205
|
+
await reverifyNeedsReview();
|
|
206
|
+
|
|
207
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
208
|
+
assert.equal(jobs.length, 1);
|
|
209
|
+
assert.equal(jobs[0].status, 'completed', 'a clean re-verify on a RESCANNABLE verdict must still heal');
|
|
210
|
+
assert.equal(jobs[0].verifierVerdict, undefined);
|
|
211
|
+
assert.equal(jobs[0].error, null);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('computeLooksDone: no declared paths on the PRD → null, never fabricates evidence', async () => {
|
|
215
|
+
const projectCwd = path.join(tmpHome, 'proj-no-paths');
|
|
216
|
+
initRepo(projectCwd);
|
|
217
|
+
writePrd(projectCwd, '09-no-paths', [
|
|
218
|
+
'# Implementation notes',
|
|
219
|
+
'Just prose here, no backticked file paths.',
|
|
220
|
+
].join('\n'));
|
|
221
|
+
|
|
222
|
+
const startedAt = new Date().toISOString();
|
|
223
|
+
await wait(1100);
|
|
224
|
+
commitFile(projectCwd, 'src/whatever.js', 'x', 'some later commit');
|
|
225
|
+
|
|
226
|
+
const job = { slug: '09-no-paths', cwd: projectCwd, startedAt };
|
|
227
|
+
const looksDone = await computeLooksDone(job);
|
|
228
|
+
assert.equal(looksDone, null);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('computeLooksDone: non-git cwd → null without throwing (git-unavailable)', async () => {
|
|
232
|
+
const notARepo = fs.mkdtempSync(path.join(tmpHome, 'not-a-repo-'));
|
|
233
|
+
writePrd(notARepo, '11-no-repo', [
|
|
234
|
+
'# Implementation notes',
|
|
235
|
+
'Edit `src/thing.js`.',
|
|
236
|
+
].join('\n'));
|
|
237
|
+
|
|
238
|
+
const job = { slug: '11-no-repo', cwd: notARepo, startedAt: new Date().toISOString() };
|
|
239
|
+
const looksDone = await computeLooksDone(job);
|
|
240
|
+
assert.equal(looksDone, null);
|
|
241
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-prd-persona-spawn.test.cjs — PRD 1115: a PRD's `agentType`
|
|
3
|
+
* frontmatter must actually drive the headless spawn (--append-system-prompt
|
|
4
|
+
* with the persona's frontmatter-stripped, capped body, and --model with the
|
|
5
|
+
* persona's own model), not just sit in the prompt as prose the executor may
|
|
6
|
+
* or may not obey. Covers buildClaudeSpawnArgs' pure argv shape plus a real
|
|
7
|
+
* executeJob spawn (stub `claude` binary) proving the resolved persona
|
|
8
|
+
* actually reaches the child process, and that a dangling agentType still
|
|
9
|
+
* runs the job to completion (never parks or fails it).
|
|
10
|
+
*
|
|
11
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-prd-persona-spawn.test.cjs
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
import { test, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const os = require('node:os');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
|
|
21
|
+
let tmpHome;
|
|
22
|
+
let originalHome;
|
|
23
|
+
let executeJob;
|
|
24
|
+
let buildClaudeSpawnArgs;
|
|
25
|
+
|
|
26
|
+
beforeAll(() => {
|
|
27
|
+
originalHome = process.env.HOME;
|
|
28
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-prd-persona-spawn-'));
|
|
29
|
+
process.env.HOME = tmpHome;
|
|
30
|
+
({ executeJob, buildClaudeSpawnArgs } = require('../scheduler.cjs'));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterAll(() => {
|
|
34
|
+
process.env.HOME = originalHome;
|
|
35
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
delete process.env.SM_CLAUDE_BIN;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ---------- buildClaudeSpawnArgs: systemPrompt -> --append-system-prompt ----------
|
|
43
|
+
|
|
44
|
+
test('buildClaudeSpawnArgs: a given systemPrompt is passed via --append-system-prompt', () => {
|
|
45
|
+
const args = buildClaudeSpawnArgs({ prompt: 'prd body', model: 'opus', sessionId: 'sid', resume: false, systemPrompt: 'You are dev-lead.' });
|
|
46
|
+
const idx = args.indexOf('--append-system-prompt');
|
|
47
|
+
expect(idx).toBeGreaterThanOrEqual(0);
|
|
48
|
+
expect(args[idx + 1]).toBe('You are dev-lead.');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('buildClaudeSpawnArgs: omits --append-system-prompt entirely when systemPrompt is absent', () => {
|
|
52
|
+
const args = buildClaudeSpawnArgs({ prompt: 'prd body', model: 'sonnet', sessionId: 'sid', resume: false });
|
|
53
|
+
expect(args).not.toContain('--append-system-prompt');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('buildClaudeSpawnArgs: model is never omitted even without a systemPrompt', () => {
|
|
57
|
+
const args = buildClaudeSpawnArgs({ prompt: 'x', model: 'sonnet', sessionId: 'sid', resume: false });
|
|
58
|
+
const idx = args.indexOf('--model');
|
|
59
|
+
expect(idx).toBeGreaterThanOrEqual(0);
|
|
60
|
+
expect(args[idx + 1]).toBe('sonnet');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ---------- real spawn: persona actually reaches the child process ----------
|
|
64
|
+
|
|
65
|
+
// Stub `claude` binary: dumps its own argv into a marker file, then emits a
|
|
66
|
+
// stream-json success result and exits 0 — same technique as
|
|
67
|
+
// scheduler-bash-timeout-env.test.cjs's env-var proof, applied to argv.
|
|
68
|
+
function writeClaudeStub() {
|
|
69
|
+
const stubPath = path.join(os.tmpdir(), `sm-claude-stub-argv-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
|
|
70
|
+
const body = `
|
|
71
|
+
const fs = require('fs');
|
|
72
|
+
const path = require('path');
|
|
73
|
+
fs.writeFileSync(path.join(process.cwd(), 'argv.marker'), JSON.stringify(process.argv.slice(2)), 'utf8');
|
|
74
|
+
process.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', result: 'ok' }) + '\\n');
|
|
75
|
+
process.exit(0);
|
|
76
|
+
`;
|
|
77
|
+
fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
|
|
78
|
+
return stubPath;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function writePersona(dir, name, frontmatterLines, bodyText) {
|
|
82
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
83
|
+
const lines = ['---', `name: ${name}`, ...frontmatterLines, '---', bodyText];
|
|
84
|
+
fs.writeFileSync(path.join(dir, `${name}.md`), lines.join('\n'));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function runJobWithAgentType({ agentType }) {
|
|
88
|
+
const mainCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-prd-persona-main-'));
|
|
89
|
+
fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
|
|
90
|
+
const runDir = fs.mkdtempSync(path.join(tmpHome, '.claude', 'sm-prd-persona-run-'));
|
|
91
|
+
const slug = `1115-test-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
92
|
+
const prdsDir = path.join(mainCwd, 'session-manager-operations', 'scheduler', 'prds');
|
|
93
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
94
|
+
fs.writeFileSync(path.join(prdsDir, `${slug}.md`), 'Do the thing.', 'utf8');
|
|
95
|
+
process.env.SM_CLAUDE_BIN = writeClaudeStub();
|
|
96
|
+
|
|
97
|
+
const job = { slug, cwd: mainCwd, agentType };
|
|
98
|
+
const result = await executeJob(job, runDir, mainCwd, () => Promise.resolve());
|
|
99
|
+
const argv = JSON.parse(fs.readFileSync(path.join(mainCwd, 'argv.marker'), 'utf8'));
|
|
100
|
+
fs.rmSync(mainCwd, { recursive: true, force: true });
|
|
101
|
+
fs.rmSync(runDir, { recursive: true, force: true });
|
|
102
|
+
return { result, argv };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
test('executeJob: a resolvable agentType reaches the spawned child as --append-system-prompt + --model', async () => {
|
|
106
|
+
const globalAgentsDir = path.join(tmpHome, '.claude', 'agents');
|
|
107
|
+
writePersona(globalAgentsDir, 'dev-lead', ['model: opus'], 'You are dev-lead. Ship it.');
|
|
108
|
+
|
|
109
|
+
const { result, argv } = await runJobWithAgentType({ agentType: 'dev-lead' });
|
|
110
|
+
|
|
111
|
+
expect(result.exitCode).toBe(0);
|
|
112
|
+
const spIdx = argv.indexOf('--append-system-prompt');
|
|
113
|
+
expect(spIdx).toBeGreaterThanOrEqual(0);
|
|
114
|
+
expect(argv[spIdx + 1]).toBe('You are dev-lead. Ship it.');
|
|
115
|
+
const modelIdx = argv.indexOf('--model');
|
|
116
|
+
expect(argv[modelIdx + 1]).toBe('opus');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('executeJob: a dangling agentType (no resolvable persona file) still runs the job to completion, falling back to sonnet with no --append-system-prompt', async () => {
|
|
120
|
+
const { result, argv } = await runJobWithAgentType({ agentType: 'renamed-or-deleted-persona' });
|
|
121
|
+
|
|
122
|
+
expect(result.exitCode).toBe(0);
|
|
123
|
+
expect(argv).not.toContain('--append-system-prompt');
|
|
124
|
+
const modelIdx = argv.indexOf('--model');
|
|
125
|
+
expect(argv[modelIdx + 1]).toBe('sonnet');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('executeJob: a job with no agentType at all runs unaffected (no persona, sonnet fallback)', async () => {
|
|
129
|
+
const { result, argv } = await runJobWithAgentType({ agentType: null });
|
|
130
|
+
|
|
131
|
+
expect(result.exitCode).toBe(0);
|
|
132
|
+
expect(argv).not.toContain('--append-system-prompt');
|
|
133
|
+
const modelIdx = argv.indexOf('--model');
|
|
134
|
+
expect(argv[modelIdx + 1]).toBe('sonnet');
|
|
135
|
+
});
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-quiet-machine-lease.test.cjs — PRD 1107: the exclusive
|
|
3
|
+
* quiet-machine lease must be released on EVERY spawnJob exit path,
|
|
4
|
+
* including a killed job (SIGKILL/exit 137), not just a clean exit — the
|
|
5
|
+
* same finally block that already releases the sessionSlots token. Drives
|
|
6
|
+
* spawnJob end-to-end against a real git fixture repo with a stub `claude`
|
|
7
|
+
* binary that exits 137 mid-run (same pattern as
|
|
8
|
+
* scheduler-inplace-salvage.test.cjs), then asserts:
|
|
9
|
+
* 1. the lease is released once the job settles, and
|
|
10
|
+
* 2. an ordinary pending job — held behind the lease while it was held —
|
|
11
|
+
* is now eligible to dispatch.
|
|
12
|
+
*
|
|
13
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-quiet-machine-lease.test.cjs
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
import { test, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const os = require('node:os');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
const { execFileSync } = require('node:child_process');
|
|
23
|
+
|
|
24
|
+
let tmpHome;
|
|
25
|
+
let originalHome;
|
|
26
|
+
let originalDisable;
|
|
27
|
+
let spawnJob;
|
|
28
|
+
let quietMachineLease;
|
|
29
|
+
let pickNextBatch;
|
|
30
|
+
|
|
31
|
+
function git(args, cwd) {
|
|
32
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8' });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function initRepo(dir) {
|
|
36
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
37
|
+
git(['init', '-q'], dir);
|
|
38
|
+
git(['config', 'user.email', 'test@example.com'], dir);
|
|
39
|
+
git(['config', 'user.name', 'Test'], dir);
|
|
40
|
+
fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
|
|
41
|
+
git(['add', '-A'], dir);
|
|
42
|
+
git(['commit', '-q', '-m', 'initial'], dir);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// See scheduler-inplace-salvage.test.cjs's identical helper: readQueue()'s
|
|
46
|
+
// per-project discovery walks ~/.claude/projects/*/*.jsonl transcripts for a
|
|
47
|
+
// `cwd` field to find known project queue.json shards.
|
|
48
|
+
function registerActiveProject(cwd) {
|
|
49
|
+
const projectsDir = path.join(tmpHome, '.claude', 'projects');
|
|
50
|
+
const slugDir = path.join(projectsDir, `fake-project-slug-${path.basename(cwd)}`);
|
|
51
|
+
fs.mkdirSync(slugDir, { recursive: true });
|
|
52
|
+
fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeProjectQueue(cwd, jobs) {
|
|
56
|
+
const stateDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
|
|
57
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
58
|
+
fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs }, null, 2));
|
|
59
|
+
return path.join(stateDir, 'queue.json');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Stub `claude` binary that exits 137 (SIGKILL) with no result:success event,
|
|
63
|
+
// simulating a leased job killed mid-run before it could finish and release
|
|
64
|
+
// naturally through its own clean-exit path. Dirties the tree first (like
|
|
65
|
+
// scheduler-inplace-salvage.test.cjs's stub) so the scheduler classifies this
|
|
66
|
+
// as a genuine kill-with-partial-work, not an auto-retryable transient blip
|
|
67
|
+
// with no evidence of real execution.
|
|
68
|
+
function writeKilledClaudeStub() {
|
|
69
|
+
const stubPath = path.join(os.tmpdir(), `sm-claude-stub-quiet-killed-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
|
|
70
|
+
const body = `
|
|
71
|
+
const fs = require('fs');
|
|
72
|
+
const path = require('path');
|
|
73
|
+
fs.writeFileSync(path.join(process.cwd(), 'job-output.txt'), 'work in progress when killed\\n', 'utf8');
|
|
74
|
+
process.exit(137);
|
|
75
|
+
`;
|
|
76
|
+
fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
|
|
77
|
+
return stubPath;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let reapDeadRunningJobs;
|
|
81
|
+
|
|
82
|
+
beforeAll(() => {
|
|
83
|
+
originalHome = process.env.HOME;
|
|
84
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-quiet-lease-'));
|
|
85
|
+
process.env.HOME = tmpHome;
|
|
86
|
+
originalDisable = process.env.SM_JOB_WORKTREE_DISABLE;
|
|
87
|
+
process.env.SM_JOB_WORKTREE_DISABLE = '1';
|
|
88
|
+
({ spawnJob, reapDeadRunningJobs } = require('../scheduler.cjs'));
|
|
89
|
+
({ pickNextBatch } = require('../lib/schedulerBatch.cjs'));
|
|
90
|
+
quietMachineLease = require('../lib/quietMachineLease.cjs');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
afterAll(() => {
|
|
94
|
+
process.env.HOME = originalHome;
|
|
95
|
+
if (originalDisable === undefined) delete process.env.SM_JOB_WORKTREE_DISABLE;
|
|
96
|
+
else process.env.SM_JOB_WORKTREE_DISABLE = originalDisable;
|
|
97
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
afterEach(() => {
|
|
101
|
+
delete process.env.SM_CLAUDE_BIN;
|
|
102
|
+
quietMachineLease.__resetForTests();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('a killed quietMachine job releases the exclusive lease, and an ordinary job dispatches on the next pick', async () => {
|
|
106
|
+
const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-quiet-lease-project-'));
|
|
107
|
+
initRepo(projectCwd);
|
|
108
|
+
registerActiveProject(projectCwd);
|
|
109
|
+
|
|
110
|
+
const quietSlug = `1107-test-quiet-killed-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
111
|
+
const ordinarySlug = `1107-test-ordinary-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
112
|
+
const prdsDir = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'prds');
|
|
113
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
114
|
+
fs.writeFileSync(path.join(prdsDir, `${quietSlug}.md`), '---\nquietMachine: true\n---\nMeasure timing without contention, then get killed.', 'utf8');
|
|
115
|
+
fs.writeFileSync(path.join(prdsDir, `${ordinarySlug}.md`), 'An ordinary PRD queued behind the quiet job.', 'utf8');
|
|
116
|
+
|
|
117
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
118
|
+
{ slug: quietSlug, status: 'pending', cwd: projectCwd, quietMachine: true },
|
|
119
|
+
{ slug: ordinarySlug, status: 'pending', cwd: projectCwd, createdAt: new Date().toISOString() },
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
process.env.SM_CLAUDE_BIN = writeKilledClaudeStub();
|
|
123
|
+
|
|
124
|
+
const runId = `run-${quietSlug}`;
|
|
125
|
+
const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
|
|
126
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
// Before spawnJob ever runs, demonstrate that a held lease (as it would
|
|
130
|
+
// be while quietSlug is genuinely running) blocks EVERY dispatch,
|
|
131
|
+
// including the ordinary sibling job sitting right behind it in the same
|
|
132
|
+
// project. Uses a standalone dummy holder so it doesn't interact with
|
|
133
|
+
// spawnJob's own acquire/release of the real lease below.
|
|
134
|
+
expect(quietMachineLease.acquire('dummy-holder-simulating-in-flight-run')).toBe(true);
|
|
135
|
+
const heldPick = pickNextBatch(
|
|
136
|
+
[
|
|
137
|
+
{ slug: quietSlug, status: 'running', cwd: projectCwd, quietMachine: true },
|
|
138
|
+
{ slug: ordinarySlug, status: 'pending', cwd: projectCwd, createdAt: new Date().toISOString() },
|
|
139
|
+
],
|
|
140
|
+
new Set([quietSlug]),
|
|
141
|
+
5,
|
|
142
|
+
{ leaseHeld: quietMachineLease.isHeld(), machineInUse: 1, now: Date.now() },
|
|
143
|
+
);
|
|
144
|
+
expect(heldPick.batch).toEqual([]);
|
|
145
|
+
quietMachineLease.release('dummy-holder-simulating-in-flight-run');
|
|
146
|
+
|
|
147
|
+
// Now the real thing: spawnJob acquires the lease itself for quietSlug,
|
|
148
|
+
// runs the killed stub, and must release it on this exit path.
|
|
149
|
+
await spawnJob({ slug: quietSlug, cwd: projectCwd, quietMachine: true }, runId, runDir, projectCwd);
|
|
150
|
+
|
|
151
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
152
|
+
const quietRow = jobs.find((j) => j.slug === quietSlug);
|
|
153
|
+
expect(quietRow).toBeTruthy();
|
|
154
|
+
expect(quietRow.exitCode).toBe(137);
|
|
155
|
+
expect(quietRow.quietMachine).toBe(true);
|
|
156
|
+
|
|
157
|
+
// The lease was released on this killed-job exit path — spawnJob's own
|
|
158
|
+
// finally block, the same one that releases the sessionSlots token.
|
|
159
|
+
expect(quietMachineLease.isHeld()).toBe(false);
|
|
160
|
+
|
|
161
|
+
// With the lease free again, the ordinary sibling job is now eligible.
|
|
162
|
+
const nextPick = pickNextBatch(
|
|
163
|
+
[
|
|
164
|
+
{ slug: quietSlug, status: 'failed', cwd: projectCwd, quietMachine: true },
|
|
165
|
+
{ slug: ordinarySlug, status: 'pending', cwd: projectCwd, createdAt: new Date().toISOString() },
|
|
166
|
+
],
|
|
167
|
+
new Set(),
|
|
168
|
+
5,
|
|
169
|
+
{ leaseHeld: quietMachineLease.isHeld(), machineInUse: 0, now: Date.now() },
|
|
170
|
+
);
|
|
171
|
+
expect(nextPick.batch.map((j) => j.slug)).toEqual([ordinarySlug]);
|
|
172
|
+
} finally {
|
|
173
|
+
fs.rmSync(projectCwd, { recursive: true, force: true });
|
|
174
|
+
fs.rmSync(runDir, { recursive: true, force: true });
|
|
175
|
+
}
|
|
176
|
+
}, 30_000);
|
|
177
|
+
|
|
178
|
+
test('reapDeadRunningJobs releases a quietMachine job\'s lease too — the process vanished without spawnJob\'s own finally ever running', async () => {
|
|
179
|
+
// This reaper exists exactly for the case spawnJob's own finally never
|
|
180
|
+
// runs (the process dies without the completion mutate() finishing) — see
|
|
181
|
+
// reapDeadRunningJobs's header comment. A quietMachine row reaped through
|
|
182
|
+
// THIS path must release the lease itself, or it stays wedged forever.
|
|
183
|
+
const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-quiet-lease-reap-project-'));
|
|
184
|
+
registerActiveProject(projectCwd);
|
|
185
|
+
|
|
186
|
+
const slug = `1107-test-reaped-quiet-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
187
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
188
|
+
{
|
|
189
|
+
slug,
|
|
190
|
+
status: 'running',
|
|
191
|
+
cwd: projectCwd,
|
|
192
|
+
quietMachine: true,
|
|
193
|
+
runId: `run-${slug}`,
|
|
194
|
+
runtime: { pid: 999999 }, // guaranteed-dead pid (see reaperHelpers tests)
|
|
195
|
+
},
|
|
196
|
+
]);
|
|
197
|
+
const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', `run-${slug}`);
|
|
198
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
199
|
+
fs.writeFileSync(
|
|
200
|
+
path.join(runDir, `${slug}.log`),
|
|
201
|
+
'{"type":"result","subtype":"success","result":"done","is_error":false}\n',
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
// Simulate the lease as held by this row, exactly as it would be if
|
|
205
|
+
// spawnJob's own acquire ran but its finally never got the chance to.
|
|
206
|
+
expect(quietMachineLease.acquire(slug)).toBe(true);
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
await reapDeadRunningJobs();
|
|
210
|
+
|
|
211
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
212
|
+
const row = jobs.find((j) => j.slug === slug);
|
|
213
|
+
expect(row).toBeTruthy();
|
|
214
|
+
expect(row.status).toBe('completed');
|
|
215
|
+
|
|
216
|
+
// The lease must be released here too, not just in spawnJob's finally.
|
|
217
|
+
expect(quietMachineLease.isHeld()).toBe(false);
|
|
218
|
+
} finally {
|
|
219
|
+
fs.rmSync(projectCwd, { recursive: true, force: true });
|
|
220
|
+
fs.rmSync(runDir, { recursive: true, force: true });
|
|
221
|
+
}
|
|
222
|
+
});
|