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
|
@@ -32,11 +32,34 @@ const assert = require('node:assert/strict');
|
|
|
32
32
|
const fs = require('node:fs');
|
|
33
33
|
const os = require('node:os');
|
|
34
34
|
const path = require('node:path');
|
|
35
|
+
const { execFileSync } = require('node:child_process');
|
|
35
36
|
|
|
36
37
|
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'reap-dead-running-jobs-test-'));
|
|
37
38
|
process.env.HOME = tmpHome;
|
|
38
39
|
|
|
39
|
-
const { reapDeadRunningJobs } = require('../scheduler.cjs');
|
|
40
|
+
const { reapDeadRunningJobs, PIDLESS_SPAWN_GRACE_MS } = require('../scheduler.cjs');
|
|
41
|
+
const { AUDIT_LOG_PATH } = require('../lib/auditLog.cjs');
|
|
42
|
+
// queueStore's cwd discovery is cached for 30s (queueStore.cjs's CACHE_MS) —
|
|
43
|
+
// a project registered by THIS test after an earlier test already populated
|
|
44
|
+
// that cache (and didn't itself bust it, e.g. because it found nothing
|
|
45
|
+
// reapable and returned before ever calling writeQueue) would otherwise be
|
|
46
|
+
// invisible to readQueue() for the rest of that window. Bust explicitly
|
|
47
|
+
// after registering a new project so each test sees its own fixture.
|
|
48
|
+
const { bustCwdCache } = require('../lib/queueStore.cjs');
|
|
49
|
+
|
|
50
|
+
function git(args, cwd) {
|
|
51
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8' });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function initRepo(dir) {
|
|
55
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
56
|
+
git(['init', '-q'], dir);
|
|
57
|
+
git(['config', 'user.email', 'test@example.com'], dir);
|
|
58
|
+
git(['config', 'user.name', 'Test'], dir);
|
|
59
|
+
fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
|
|
60
|
+
git(['add', '-A'], dir);
|
|
61
|
+
git(['commit', '-q', '-m', 'initial'], dir);
|
|
62
|
+
}
|
|
40
63
|
|
|
41
64
|
function registerActiveProject(cwd) {
|
|
42
65
|
const projectsDir = path.join(tmpHome, '.claude', 'projects');
|
|
@@ -85,6 +108,66 @@ test('reapDeadRunningJobs reconciles a queue.json row stuck at status:running wi
|
|
|
85
108
|
assert.equal(jobs[0].status, 'completed', 'job must be reconciled from disk state, not skipped via the runningSet gate');
|
|
86
109
|
assert.equal(jobs[0].exitCode, 0);
|
|
87
110
|
assert.equal(jobs[0].runtime, undefined);
|
|
111
|
+
assert.equal(jobs[0].gateOutcome, 'passed', 'a success result event maps to gateOutcome:passed');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('reapDeadRunningJobs reaps a pidless row older than PIDLESS_SPAWN_GRACE_MS with an empty run dir as failed, not completed, and audits it', async () => {
|
|
115
|
+
const projectCwd = path.join(tmpHome, 'c-project');
|
|
116
|
+
fs.mkdirSync(projectCwd, { recursive: true });
|
|
117
|
+
registerActiveProject(projectCwd);
|
|
118
|
+
|
|
119
|
+
const staleStartedAt = new Date(Date.now() - PIDLESS_SPAWN_GRACE_MS - 60_000).toISOString();
|
|
120
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
121
|
+
{
|
|
122
|
+
slug: 'pidless-zombie',
|
|
123
|
+
status: 'running',
|
|
124
|
+
cwd: projectCwd,
|
|
125
|
+
runId: 'run-pidless-zombie',
|
|
126
|
+
startedAt: staleStartedAt,
|
|
127
|
+
estimateMinutes: 24,
|
|
128
|
+
// no runtime key at all — the spawn never got far enough to record one
|
|
129
|
+
},
|
|
130
|
+
]);
|
|
131
|
+
// Empty run dir: created, but never written to (the exact 2026-09-01 repro).
|
|
132
|
+
fs.mkdirSync(path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', 'run-pidless-zombie'), { recursive: true });
|
|
133
|
+
|
|
134
|
+
const auditSizeBefore = fs.existsSync(AUDIT_LOG_PATH) ? fs.statSync(AUDIT_LOG_PATH).size : 0;
|
|
135
|
+
|
|
136
|
+
await reapDeadRunningJobs();
|
|
137
|
+
|
|
138
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
139
|
+
assert.equal(jobs.length, 1);
|
|
140
|
+
assert.equal(jobs[0].status, 'failed', 'an empty run dir must never be reaped as completed');
|
|
141
|
+
assert.match(jobs[0].error, /no runtime\.pid recorded/);
|
|
142
|
+
assert.equal(jobs[0].runtime, undefined);
|
|
143
|
+
assert.equal(jobs[0].gateOutcome, 'never_ran', 'a pidless reap means the gate never had a chance to run');
|
|
144
|
+
|
|
145
|
+
const auditText = fs.readFileSync(AUDIT_LOG_PATH, 'utf8').slice(auditSizeBefore);
|
|
146
|
+
const auditLines = auditText.trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
147
|
+
const pidlessEvent = auditLines.find((e) => e.kind === 'job_reaped_pidless' && e.slug === 'pidless-zombie');
|
|
148
|
+
assert.ok(pidlessEvent, 'reaping a pidless row must leave an audit trace');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('reapDeadRunningJobs leaves a pidless row alone while it is still within the grace window', async () => {
|
|
152
|
+
const projectCwd = path.join(tmpHome, 'd-project');
|
|
153
|
+
fs.mkdirSync(projectCwd, { recursive: true });
|
|
154
|
+
registerActiveProject(projectCwd);
|
|
155
|
+
|
|
156
|
+
const freshStartedAt = new Date(Date.now() - 60_000).toISOString(); // 1 minute ago
|
|
157
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
158
|
+
{
|
|
159
|
+
slug: 'mid-flight-spawn',
|
|
160
|
+
status: 'running',
|
|
161
|
+
cwd: projectCwd,
|
|
162
|
+
runId: 'run-mid-flight',
|
|
163
|
+
startedAt: freshStartedAt,
|
|
164
|
+
},
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
await reapDeadRunningJobs();
|
|
168
|
+
|
|
169
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
170
|
+
assert.equal(jobs[0].status, 'running', 'a genuinely mid-flight spawn must not be reaped');
|
|
88
171
|
});
|
|
89
172
|
|
|
90
173
|
test('reapDeadRunningJobs is a no-op when no job is actually running in queue.json', async () => {
|
|
@@ -101,3 +184,126 @@ test('reapDeadRunningJobs is a no-op when no job is actually running in queue.js
|
|
|
101
184
|
assert.equal(jobs.length, 1);
|
|
102
185
|
assert.equal(jobs[0].status, 'completed');
|
|
103
186
|
});
|
|
187
|
+
|
|
188
|
+
test('reapDeadRunningJobs salvages a delta-scoped in-place patch (PRD 1098) when the row carries a persisted guardBaseline', async () => {
|
|
189
|
+
const projectCwd = path.join(tmpHome, 'e-project');
|
|
190
|
+
initRepo(projectCwd);
|
|
191
|
+
registerActiveProject(projectCwd);
|
|
192
|
+
|
|
193
|
+
// Pre-existing WIP present at the recorded pre-run baseline — must never
|
|
194
|
+
// appear in the salvaged patch.
|
|
195
|
+
fs.writeFileSync(path.join(projectCwd, 'human-wip.txt'), 'human wip\n', 'utf8');
|
|
196
|
+
|
|
197
|
+
const runId = 'run-vanished-inplace';
|
|
198
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
199
|
+
{
|
|
200
|
+
slug: 'vanished-inplace',
|
|
201
|
+
status: 'running',
|
|
202
|
+
cwd: projectCwd,
|
|
203
|
+
runId,
|
|
204
|
+
runtime: { pid: 999999 }, // guaranteed-dead pid
|
|
205
|
+
// Includes the untracked session-manager-operations/ dir itself (git
|
|
206
|
+
// status --porcelain reports an untracked DIRECTORY, not the file
|
|
207
|
+
// inside it) — writeProjectQueue below writes queue.json INSIDE this
|
|
208
|
+
// same git repo, so it's part of the true pre-run baseline exactly
|
|
209
|
+
// like human-wip.txt.
|
|
210
|
+
guardBaseline: ['human-wip.txt', 'session-manager-operations/'],
|
|
211
|
+
},
|
|
212
|
+
]);
|
|
213
|
+
const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
|
|
214
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
215
|
+
fs.writeFileSync(path.join(runDir, 'vanished-inplace.log'), '{"type":"result","subtype":"success","result":"done","is_error":false}\n');
|
|
216
|
+
|
|
217
|
+
// The job's own delta, dirtied before its process vanished.
|
|
218
|
+
fs.writeFileSync(path.join(projectCwd, 'README.md'), 'hello\nedited by job\n', 'utf8');
|
|
219
|
+
fs.writeFileSync(path.join(projectCwd, 'job-output.txt'), 'job work\n', 'utf8');
|
|
220
|
+
|
|
221
|
+
bustCwdCache();
|
|
222
|
+
await reapDeadRunningJobs();
|
|
223
|
+
|
|
224
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
225
|
+
const row = jobs.find((j) => j.slug === 'vanished-inplace');
|
|
226
|
+
assert.equal(row.status, 'completed');
|
|
227
|
+
assert.ok(row.salvagePatch, 'a salvage patch must be recorded on the row');
|
|
228
|
+
assert.ok(fs.existsSync(row.salvagePatch));
|
|
229
|
+
const patch = fs.readFileSync(row.salvagePatch, 'utf8');
|
|
230
|
+
assert.match(patch, /README\.md/);
|
|
231
|
+
assert.match(patch, /edited by job/);
|
|
232
|
+
assert.match(patch, /job-output\.txt/);
|
|
233
|
+
assert.doesNotMatch(patch, /human wip/, 'baseline WIP must never leak into the salvaged patch');
|
|
234
|
+
assert.equal(row.leftoverCount, 2, 'the reaper must attribute the job\'s own delta (README.md + job-output.txt), never the baseline WIP');
|
|
235
|
+
assert.deepEqual(new Set(row.leftoverPaths), new Set(['README.md', 'job-output.txt']));
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('reapDeadRunningJobs: a failed reap with a persisted guardBaseline names the leftover count in its transition reason and error', async () => {
|
|
239
|
+
const projectCwd = path.join(tmpHome, 'g-project');
|
|
240
|
+
initRepo(projectCwd);
|
|
241
|
+
registerActiveProject(projectCwd);
|
|
242
|
+
|
|
243
|
+
const runId = 'run-vanished-failed';
|
|
244
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
245
|
+
{
|
|
246
|
+
slug: 'vanished-failed',
|
|
247
|
+
status: 'running',
|
|
248
|
+
cwd: projectCwd,
|
|
249
|
+
runId,
|
|
250
|
+
runtime: { pid: 999999 },
|
|
251
|
+
// Includes the untracked session-manager-operations/ dir itself — see
|
|
252
|
+
// the comment on the salvage test above for why.
|
|
253
|
+
guardBaseline: ['session-manager-operations/'],
|
|
254
|
+
},
|
|
255
|
+
]);
|
|
256
|
+
// Empty run dir → classifyRunOutcome finds no result event → 'no_result' → failed.
|
|
257
|
+
fs.mkdirSync(path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId), { recursive: true });
|
|
258
|
+
|
|
259
|
+
fs.writeFileSync(path.join(projectCwd, 'partial-work.txt'), 'unfinished\n', 'utf8');
|
|
260
|
+
|
|
261
|
+
bustCwdCache();
|
|
262
|
+
await reapDeadRunningJobs();
|
|
263
|
+
|
|
264
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
265
|
+
const row = jobs.find((j) => j.slug === 'vanished-failed');
|
|
266
|
+
assert.equal(row.status, 'failed');
|
|
267
|
+
assert.equal(row.leftoverCount, 1);
|
|
268
|
+
assert.deepEqual(row.leftoverPaths, ['partial-work.txt']);
|
|
269
|
+
assert.match(row.error, /left 1 files uncommitted/, 'the leftover count must be visible in the error string, not just a separate field');
|
|
270
|
+
const lastTransition = row.statusHistory[row.statusHistory.length - 1];
|
|
271
|
+
assert.match(lastTransition.reason, /left 1 files uncommitted/);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('reapDeadRunningJobs skips in-place salvage (no whole-tree dump) when the row has no persisted guardBaseline', async () => {
|
|
275
|
+
const projectCwd = path.join(tmpHome, 'f-project');
|
|
276
|
+
initRepo(projectCwd);
|
|
277
|
+
registerActiveProject(projectCwd);
|
|
278
|
+
|
|
279
|
+
fs.writeFileSync(path.join(projectCwd, 'human-wip.txt'), 'human wip\n', 'utf8');
|
|
280
|
+
fs.writeFileSync(path.join(projectCwd, 'README.md'), 'hello\nedited by job\n', 'utf8');
|
|
281
|
+
|
|
282
|
+
const runId = 'run-vanished-no-baseline';
|
|
283
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
284
|
+
{
|
|
285
|
+
slug: 'vanished-no-baseline',
|
|
286
|
+
status: 'running',
|
|
287
|
+
cwd: projectCwd,
|
|
288
|
+
runId,
|
|
289
|
+
runtime: { pid: 999999 },
|
|
290
|
+
// No guardBaseline field — simulates a row whose owning process
|
|
291
|
+
// vanished before spawnJob's dispatch-time persist-mutate ever ran, so
|
|
292
|
+
// there's no safe way to distinguish this job's own dirt from the
|
|
293
|
+
// human's pre-existing WIP above.
|
|
294
|
+
},
|
|
295
|
+
]);
|
|
296
|
+
const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
|
|
297
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
298
|
+
fs.writeFileSync(path.join(runDir, 'vanished-no-baseline.log'), '{"type":"result","subtype":"success","result":"done","is_error":false}\n');
|
|
299
|
+
|
|
300
|
+
bustCwdCache();
|
|
301
|
+
await reapDeadRunningJobs();
|
|
302
|
+
|
|
303
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
304
|
+
const row = jobs.find((j) => j.slug === 'vanished-no-baseline');
|
|
305
|
+
assert.equal(row.status, 'completed');
|
|
306
|
+
assert.equal(row.salvagePatch, undefined, 'must skip salvage entirely rather than ever dumping the whole tree');
|
|
307
|
+
assert.equal(row.leftoverPaths, undefined, 'no baseline means no safe attribution — must not guess');
|
|
308
|
+
assert.equal(row.leftoverCount, undefined);
|
|
309
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-shared-tree-guard.test.cjs — the shared-tree stash guard added
|
|
3
|
+
* for the 2026-09-01 social-signals-trader incident: a headless PRD executor
|
|
4
|
+
* ran a blanket `git stash` in a working tree shared with a live trading
|
|
5
|
+
* service, silently reverting an uncommitted operator config edit with no
|
|
6
|
+
* error anywhere.
|
|
7
|
+
*
|
|
8
|
+
* evaluateSharedTreeGuard is the pure diff (no I/O); checkSharedTreeGuard is
|
|
9
|
+
* the orchestrator that also restores a single unambiguous stash, using the
|
|
10
|
+
* same module.exports spy pattern computeCommittedDuringRun/committedInWindow
|
|
11
|
+
* use so the underlying git calls never actually run.
|
|
12
|
+
*
|
|
13
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-shared-tree-guard.test.cjs
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
import { test, expect, vi, afterEach } from 'vitest';
|
|
19
|
+
const scheduler = require('../scheduler.cjs');
|
|
20
|
+
const { evaluateSharedTreeGuard, checkSharedTreeGuard } = scheduler;
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
vi.restoreAllMocks();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('evaluateSharedTreeGuard: no-op when nothing changed', () => {
|
|
27
|
+
const result = evaluateSharedTreeGuard({
|
|
28
|
+
stashBefore: ['aaa stash@{0} WIP on main: aaa old'],
|
|
29
|
+
stashAfter: ['aaa stash@{0} WIP on main: aaa old'],
|
|
30
|
+
dirtyBefore: ['data/spread_config.json'],
|
|
31
|
+
dirtyAfter: ['data/spread_config.json'],
|
|
32
|
+
pathsCommittedDuringRun: [],
|
|
33
|
+
});
|
|
34
|
+
expect(result.newStashes).toEqual([]);
|
|
35
|
+
expect(result.reverted).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('evaluateSharedTreeGuard: flags one new stash entry not present in the baseline', () => {
|
|
39
|
+
const result = evaluateSharedTreeGuard({
|
|
40
|
+
stashBefore: [],
|
|
41
|
+
stashAfter: ['bbb stash@{0} WIP on fix/x: bbb f5ae98f'],
|
|
42
|
+
dirtyBefore: [],
|
|
43
|
+
dirtyAfter: [],
|
|
44
|
+
pathsCommittedDuringRun: [],
|
|
45
|
+
});
|
|
46
|
+
expect(result.newStashes).toEqual([{ hash: 'bbb', ref: 'stash@{0}', subject: 'WIP on fix/x: bbb f5ae98f' }]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('evaluateSharedTreeGuard: two new stash entries both surface (caller decides ambiguity, not this function)', () => {
|
|
50
|
+
const result = evaluateSharedTreeGuard({
|
|
51
|
+
stashBefore: [],
|
|
52
|
+
stashAfter: [
|
|
53
|
+
'bbb stash@{0} WIP on fix/x: bbb newest',
|
|
54
|
+
'ccc stash@{1} WIP on fix/x: ccc older',
|
|
55
|
+
],
|
|
56
|
+
dirtyBefore: [],
|
|
57
|
+
dirtyAfter: [],
|
|
58
|
+
pathsCommittedDuringRun: [],
|
|
59
|
+
});
|
|
60
|
+
expect(result.newStashes).toHaveLength(2);
|
|
61
|
+
expect(result.newStashes.map((e) => e.ref)).toEqual(['stash@{0}', 'stash@{1}']);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('evaluateSharedTreeGuard: baseline-dirty path clean after the run with no commit to explain it is flagged reverted', () => {
|
|
65
|
+
const result = evaluateSharedTreeGuard({
|
|
66
|
+
stashBefore: [],
|
|
67
|
+
stashAfter: [],
|
|
68
|
+
dirtyBefore: ['data/spread_config.json'],
|
|
69
|
+
dirtyAfter: [],
|
|
70
|
+
pathsCommittedDuringRun: [],
|
|
71
|
+
});
|
|
72
|
+
expect(result.reverted).toEqual(['data/spread_config.json']);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('evaluateSharedTreeGuard: baseline-dirty path clean after the run but touched by a commit made during the run is NOT flagged', () => {
|
|
76
|
+
const result = evaluateSharedTreeGuard({
|
|
77
|
+
stashBefore: [],
|
|
78
|
+
stashAfter: [],
|
|
79
|
+
dirtyBefore: ['src/foo.js'],
|
|
80
|
+
dirtyAfter: [],
|
|
81
|
+
pathsCommittedDuringRun: ['src/foo.js'],
|
|
82
|
+
});
|
|
83
|
+
expect(result.reverted).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('checkSharedTreeGuard: no-op when nothing changed returns null', async () => {
|
|
87
|
+
vi.spyOn(scheduler, 'stashList').mockResolvedValue([]);
|
|
88
|
+
vi.spyOn(scheduler, 'uncommittedChanges').mockResolvedValue([]);
|
|
89
|
+
vi.spyOn(scheduler, 'gitHead').mockResolvedValue('sha1');
|
|
90
|
+
vi.spyOn(scheduler, 'pathsChangedSince').mockResolvedValue([]);
|
|
91
|
+
const restoreSpy = vi.spyOn(scheduler, 'restoreSpecificStash');
|
|
92
|
+
|
|
93
|
+
const result = await checkSharedTreeGuard({
|
|
94
|
+
cwd: '/repo',
|
|
95
|
+
stashBaseline: [],
|
|
96
|
+
dirtyBaseline: [],
|
|
97
|
+
headBefore: 'sha1',
|
|
98
|
+
slug: 'job-1',
|
|
99
|
+
});
|
|
100
|
+
expect(result).toBeNull();
|
|
101
|
+
expect(restoreSpy).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('checkSharedTreeGuard: a single new stash is restored via apply+drop of that specific ref', async () => {
|
|
105
|
+
vi.spyOn(scheduler, 'stashList').mockResolvedValue(['newhash stash@{0} WIP on main: f5ae98f']);
|
|
106
|
+
vi.spyOn(scheduler, 'uncommittedChanges').mockResolvedValue([]);
|
|
107
|
+
vi.spyOn(scheduler, 'gitHead').mockResolvedValue('sha1');
|
|
108
|
+
vi.spyOn(scheduler, 'pathsChangedSince').mockResolvedValue([]);
|
|
109
|
+
const restoreSpy = vi.spyOn(scheduler, 'restoreSpecificStash').mockResolvedValue({ ok: true });
|
|
110
|
+
|
|
111
|
+
const result = await checkSharedTreeGuard({
|
|
112
|
+
cwd: '/repo',
|
|
113
|
+
stashBaseline: [],
|
|
114
|
+
dirtyBaseline: [],
|
|
115
|
+
headBefore: 'sha1',
|
|
116
|
+
slug: 'job-1',
|
|
117
|
+
});
|
|
118
|
+
expect(restoreSpy).toHaveBeenCalledWith('/repo', 'stash@{0}');
|
|
119
|
+
expect(result).toEqual({ restoredStash: 'stash@{0}' });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('checkSharedTreeGuard: a baseline-dirty path stashed by the job and successfully restored is not ALSO flagged reverted', async () => {
|
|
123
|
+
vi.spyOn(scheduler, 'stashList').mockResolvedValue(['newhash stash@{0} WIP on main: f5ae98f']);
|
|
124
|
+
vi.spyOn(scheduler, 'gitHead').mockResolvedValue('sha1');
|
|
125
|
+
vi.spyOn(scheduler, 'pathsChangedSince').mockResolvedValue([]);
|
|
126
|
+
vi.spyOn(scheduler, 'restoreSpecificStash').mockResolvedValue({ ok: true });
|
|
127
|
+
// uncommittedChanges is queried AFTER the restore attempt in the fixed
|
|
128
|
+
// implementation — simulate the stashed file reappearing dirty once the
|
|
129
|
+
// apply+drop succeeds (this is the exact 2026-09-01 incident shape: an
|
|
130
|
+
// operator's pre-existing dirty file gets swept into the job's stash).
|
|
131
|
+
vi.spyOn(scheduler, 'uncommittedChanges').mockResolvedValue(['data/spread_config.json']);
|
|
132
|
+
|
|
133
|
+
const result = await checkSharedTreeGuard({
|
|
134
|
+
cwd: '/repo',
|
|
135
|
+
stashBaseline: [],
|
|
136
|
+
dirtyBaseline: ['data/spread_config.json'],
|
|
137
|
+
headBefore: 'sha1',
|
|
138
|
+
slug: 'job-1',
|
|
139
|
+
});
|
|
140
|
+
expect(result).toEqual({ restoredStash: 'stash@{0}' });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('checkSharedTreeGuard: two new stashes are reported, not restored (ambiguous)', async () => {
|
|
144
|
+
vi.spyOn(scheduler, 'stashList').mockResolvedValue([
|
|
145
|
+
'aaa stash@{0} WIP on main: newest',
|
|
146
|
+
'bbb stash@{1} WIP on main: older',
|
|
147
|
+
]);
|
|
148
|
+
vi.spyOn(scheduler, 'uncommittedChanges').mockResolvedValue([]);
|
|
149
|
+
vi.spyOn(scheduler, 'gitHead').mockResolvedValue('sha1');
|
|
150
|
+
vi.spyOn(scheduler, 'pathsChangedSince').mockResolvedValue([]);
|
|
151
|
+
const restoreSpy = vi.spyOn(scheduler, 'restoreSpecificStash');
|
|
152
|
+
|
|
153
|
+
const result = await checkSharedTreeGuard({
|
|
154
|
+
cwd: '/repo',
|
|
155
|
+
stashBaseline: [],
|
|
156
|
+
dirtyBaseline: [],
|
|
157
|
+
headBefore: 'sha1',
|
|
158
|
+
slug: 'job-1',
|
|
159
|
+
});
|
|
160
|
+
expect(restoreSpy).not.toHaveBeenCalled();
|
|
161
|
+
expect(result.ambiguousStashes).toEqual(['stash@{0}', 'stash@{1}']);
|
|
162
|
+
expect(result.restoredStash).toBeUndefined();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('checkSharedTreeGuard: a baseline-dirty path reverted with no stash and no commit is flagged reverted', async () => {
|
|
166
|
+
vi.spyOn(scheduler, 'stashList').mockResolvedValue([]);
|
|
167
|
+
vi.spyOn(scheduler, 'uncommittedChanges').mockResolvedValue([]);
|
|
168
|
+
vi.spyOn(scheduler, 'gitHead').mockResolvedValue('sha1');
|
|
169
|
+
vi.spyOn(scheduler, 'pathsChangedSince').mockResolvedValue([]);
|
|
170
|
+
|
|
171
|
+
const result = await checkSharedTreeGuard({
|
|
172
|
+
cwd: '/repo',
|
|
173
|
+
stashBaseline: [],
|
|
174
|
+
dirtyBaseline: ['data/spread_config.json'],
|
|
175
|
+
headBefore: 'sha1',
|
|
176
|
+
slug: 'job-1',
|
|
177
|
+
});
|
|
178
|
+
expect(result).toEqual({ reverted: ['data/spread_config.json'] });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('checkSharedTreeGuard: a failed restore is reported as restoreFailed, entry left in place', async () => {
|
|
182
|
+
vi.spyOn(scheduler, 'stashList').mockResolvedValue(['newhash stash@{0} WIP on main: f5ae98f']);
|
|
183
|
+
vi.spyOn(scheduler, 'uncommittedChanges').mockResolvedValue([]);
|
|
184
|
+
vi.spyOn(scheduler, 'gitHead').mockResolvedValue('sha1');
|
|
185
|
+
vi.spyOn(scheduler, 'pathsChangedSince').mockResolvedValue([]);
|
|
186
|
+
vi.spyOn(scheduler, 'restoreSpecificStash').mockResolvedValue({ ok: false, error: 'conflict applying stash' });
|
|
187
|
+
|
|
188
|
+
const result = await checkSharedTreeGuard({
|
|
189
|
+
cwd: '/repo',
|
|
190
|
+
stashBaseline: [],
|
|
191
|
+
dirtyBaseline: [],
|
|
192
|
+
headBefore: 'sha1',
|
|
193
|
+
slug: 'job-1',
|
|
194
|
+
});
|
|
195
|
+
expect(result.restoreFailed).toMatch(/stash@\{0\}.*conflict applying stash/);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('checkSharedTreeGuard never throws when the underlying git calls reject', async () => {
|
|
199
|
+
vi.spyOn(scheduler, 'stashList').mockRejectedValue(new Error('boom'));
|
|
200
|
+
vi.spyOn(scheduler, 'uncommittedChanges').mockResolvedValue([]);
|
|
201
|
+
vi.spyOn(scheduler, 'gitHead').mockResolvedValue('sha1');
|
|
202
|
+
vi.spyOn(scheduler, 'pathsChangedSince').mockResolvedValue([]);
|
|
203
|
+
|
|
204
|
+
const result = await checkSharedTreeGuard({
|
|
205
|
+
cwd: '/repo',
|
|
206
|
+
stashBaseline: [],
|
|
207
|
+
dirtyBaseline: [],
|
|
208
|
+
headBefore: 'sha1',
|
|
209
|
+
slug: 'job-1',
|
|
210
|
+
});
|
|
211
|
+
expect(result).toBeNull();
|
|
212
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-stranded-investigation.test.cjs — findStrandedInvestigations: a
|
|
3
|
+
* job left 'investigating' with nothing to restore it once the process that
|
|
4
|
+
* spawned its probe is gone.
|
|
5
|
+
*
|
|
6
|
+
* spawnInvestigation's own restore (onExit / synchronous-throw catch) runs
|
|
7
|
+
* ONLY inside the live process that spawned the probe. An app restart (or
|
|
8
|
+
* crash) mid-probe leaves the row frozen at 'investigating' forever — the
|
|
9
|
+
* comment at spawnInvestigation's onExit already asserts "'investigating'
|
|
10
|
+
* must never be the job's resting state", but nothing enforced that across a
|
|
11
|
+
* restart. Live repro (burrow, 2026-09-01): 834-session-summary-... sat
|
|
12
|
+
* 'investigating' for 1653 minutes even though finishedAt+exitCode were
|
|
13
|
+
* already recorded 27h earlier.
|
|
14
|
+
*
|
|
15
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-stranded-investigation.test.cjs
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
// vitest, NOT node:test — this repo's suite is vitest-only (CLAUDE.md).
|
|
21
|
+
import { test } from 'vitest';
|
|
22
|
+
const assert = require('node:assert/strict');
|
|
23
|
+
const { findStrandedInvestigations, INVESTIGATION_MAX_MS } = require('../scheduler.cjs');
|
|
24
|
+
|
|
25
|
+
const NOW = Date.parse('2026-09-01T12:00:00.000Z');
|
|
26
|
+
const agoMin = (m) => new Date(NOW - m * 60_000).toISOString();
|
|
27
|
+
const neverAlive = () => false;
|
|
28
|
+
const alwaysAlive = () => true;
|
|
29
|
+
|
|
30
|
+
test('stranded past the window is selected, with the pre-probe status as restoreStatus', () => {
|
|
31
|
+
const jobs = [
|
|
32
|
+
{
|
|
33
|
+
slug: 'a',
|
|
34
|
+
cwd: '/p1',
|
|
35
|
+
status: 'investigating',
|
|
36
|
+
statusHistory: [
|
|
37
|
+
{ from: 'failed', to: 'investigating', at: agoMin(120), reason: 'spawning investigation probe' },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
const out = findStrandedInvestigations(jobs, NOW, INVESTIGATION_MAX_MS, neverAlive);
|
|
42
|
+
assert.strictEqual(out.length, 1);
|
|
43
|
+
assert.strictEqual(out[0].slug, 'a');
|
|
44
|
+
assert.strictEqual(out[0].restoreStatus, 'failed');
|
|
45
|
+
assert.ok(out[0].ageMs >= 119 * 60_000);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('within the window is left alone — a live probe must not be yanked mid-flight', () => {
|
|
49
|
+
const jobs = [
|
|
50
|
+
{
|
|
51
|
+
slug: 'b',
|
|
52
|
+
cwd: '/p1',
|
|
53
|
+
status: 'investigating',
|
|
54
|
+
statusHistory: [{ from: 'needs_review', to: 'investigating', at: agoMin(10) }],
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
assert.deepStrictEqual(findStrandedInvestigations(jobs, NOW, INVESTIGATION_MAX_MS, neverAlive), []);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('a live probe process (pid alive) is never restored, even past the window', () => {
|
|
61
|
+
const jobs = [
|
|
62
|
+
{
|
|
63
|
+
slug: 'c',
|
|
64
|
+
cwd: '/p1',
|
|
65
|
+
status: 'investigating',
|
|
66
|
+
runtime: { pid: 4242 },
|
|
67
|
+
statusHistory: [{ from: 'failed', to: 'investigating', at: agoMin(120) }],
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
assert.deepStrictEqual(findStrandedInvestigations(jobs, NOW, INVESTIGATION_MAX_MS, alwaysAlive), []);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('a row with no runtime.pid past the window is still selected (dead-process default)', () => {
|
|
74
|
+
const jobs = [
|
|
75
|
+
{
|
|
76
|
+
slug: 'd',
|
|
77
|
+
cwd: '/p1',
|
|
78
|
+
status: 'investigating',
|
|
79
|
+
statusHistory: [{ from: 'failed', to: 'investigating', at: agoMin(120) }],
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
const out = findStrandedInvestigations(jobs, NOW, INVESTIGATION_MAX_MS, alwaysAlive);
|
|
83
|
+
assert.strictEqual(out.length, 1);
|
|
84
|
+
assert.strictEqual(out[0].slug, 'd');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('missing statusHistory transition is warn-logged and not restored — cannot prove age', () => {
|
|
88
|
+
const jobs = [{ slug: 'e', cwd: '/p1', status: 'investigating', statusHistory: [] }];
|
|
89
|
+
const warnSpy = [];
|
|
90
|
+
const origWarn = console.warn;
|
|
91
|
+
console.warn = (...args) => warnSpy.push(args.join(' '));
|
|
92
|
+
try {
|
|
93
|
+
const out = findStrandedInvestigations(jobs, NOW, INVESTIGATION_MAX_MS, neverAlive);
|
|
94
|
+
assert.deepStrictEqual(out, []);
|
|
95
|
+
assert.ok(warnSpy.some((line) => line.includes('e') && line.includes('no statusHistory entry')), `expected a warn mentioning slug 'e', got: ${JSON.stringify(warnSpy)}`);
|
|
96
|
+
} finally {
|
|
97
|
+
console.warn = origWarn;
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('unparseable transition timestamp is warn-logged and not restored', () => {
|
|
102
|
+
const jobs = [
|
|
103
|
+
{
|
|
104
|
+
slug: 'f',
|
|
105
|
+
cwd: '/p1',
|
|
106
|
+
status: 'investigating',
|
|
107
|
+
statusHistory: [{ from: 'failed', to: 'investigating', at: 'not-a-date' }],
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
const warnSpy = [];
|
|
111
|
+
const origWarn = console.warn;
|
|
112
|
+
console.warn = (...args) => warnSpy.push(args.join(' '));
|
|
113
|
+
try {
|
|
114
|
+
const out = findStrandedInvestigations(jobs, NOW, INVESTIGATION_MAX_MS, neverAlive);
|
|
115
|
+
assert.deepStrictEqual(out, []);
|
|
116
|
+
assert.ok(warnSpy.some((line) => line.includes('f') && line.includes('unparseable')), `expected a warn mentioning slug 'f', got: ${JSON.stringify(warnSpy)}`);
|
|
117
|
+
} finally {
|
|
118
|
+
console.warn = origWarn;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('the burrow-834 shape: finishedAt + non-null exitCode already recorded is restored using that recorded outcome, not re-run', () => {
|
|
123
|
+
const jobs = [
|
|
124
|
+
{
|
|
125
|
+
slug: '834-session-summary-reports-planner-intent-as-outcome',
|
|
126
|
+
cwd: '/home/bilko/Projects/burrow',
|
|
127
|
+
status: 'investigating',
|
|
128
|
+
finishedAt: '2026-08-31T15:13:15.181Z',
|
|
129
|
+
exitCode: 0,
|
|
130
|
+
error: 'is_error=true in final 20% of transcript (event 91/101)',
|
|
131
|
+
estimateMinutes: 60,
|
|
132
|
+
statusHistory: [
|
|
133
|
+
{ from: 'needs_review', to: 'investigating', at: '2026-08-31T15:20:00.000Z', reason: 'spawning investigation probe' },
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
|
+
];
|
|
137
|
+
const out = findStrandedInvestigations(jobs, NOW, INVESTIGATION_MAX_MS, neverAlive);
|
|
138
|
+
assert.strictEqual(out.length, 1);
|
|
139
|
+
assert.strictEqual(out[0].restoreStatus, 'needs_review');
|
|
140
|
+
assert.ok(out[0].ageMs / 60_000 > 1200, `expected well past the 60m threshold, got ${out[0].ageMs / 60_000}m`);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('only the most recent investigating transition is used when a job was investigated more than once', () => {
|
|
144
|
+
const jobs = [
|
|
145
|
+
{
|
|
146
|
+
slug: 'g',
|
|
147
|
+
cwd: '/p1',
|
|
148
|
+
status: 'investigating',
|
|
149
|
+
statusHistory: [
|
|
150
|
+
{ from: 'failed', to: 'investigating', at: agoMin(500) },
|
|
151
|
+
{ from: 'investigating', to: 'failed', at: agoMin(400) },
|
|
152
|
+
{ from: 'failed', to: 'investigating', at: agoMin(10) }, // recent retry — still within the window
|
|
153
|
+
],
|
|
154
|
+
},
|
|
155
|
+
];
|
|
156
|
+
assert.deepStrictEqual(findStrandedInvestigations(jobs, NOW, INVESTIGATION_MAX_MS, neverAlive), []);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('non-investigating rows are never considered', () => {
|
|
160
|
+
const base = { cwd: '/p1', statusHistory: [{ from: 'x', to: 'investigating', at: agoMin(500) }] };
|
|
161
|
+
const jobs = [
|
|
162
|
+
{ ...base, slug: 'h', status: 'failed' },
|
|
163
|
+
{ ...base, slug: 'i', status: 'running' },
|
|
164
|
+
{ ...base, slug: 'j', status: 'needs_review' },
|
|
165
|
+
];
|
|
166
|
+
assert.deepStrictEqual(findStrandedInvestigations(jobs, NOW, INVESTIGATION_MAX_MS, neverAlive), []);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('a corrupted/illegal statusHistory `from` value falls back to failed rather than being handed to transitionJob unvalidated', () => {
|
|
170
|
+
const jobs = [
|
|
171
|
+
{
|
|
172
|
+
slug: 'k',
|
|
173
|
+
cwd: '/p1',
|
|
174
|
+
status: 'investigating',
|
|
175
|
+
statusHistory: [{ from: 'quarantined', to: 'investigating', at: agoMin(120) }],
|
|
176
|
+
},
|
|
177
|
+
];
|
|
178
|
+
const out = findStrandedInvestigations(jobs, NOW, INVESTIGATION_MAX_MS, neverAlive);
|
|
179
|
+
assert.strictEqual(out.length, 1);
|
|
180
|
+
assert.strictEqual(out[0].restoreStatus, 'failed');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('the shipped default is the documented one (60 minutes)', () => {
|
|
184
|
+
assert.strictEqual(INVESTIGATION_MAX_MS, 60 * 60_000);
|
|
185
|
+
});
|