claude-code-session-manager 0.76.0 → 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-CBx9l4zN.js → AgentLibrary-B2ie8bbw.js} +2 -2
- package/dist/assets/{DataModel-Bf0EIE_t.js → DataModel-BIJPYw32.js} +1 -1
- package/dist/assets/{History-CpdtWhC8.js → History-CeY6dk9S.js} +2 -2
- package/dist/assets/{Hooks-DyUbMDmg.js → Hooks-BFH2ocKg.js} +2 -2
- package/dist/assets/{HostBilko-By-wIpry.js → HostBilko-36gj9wLz.js} +1 -1
- package/dist/assets/{Library-CQmo4QVC.js → Library-C-hBct39.js} +1 -1
- package/dist/assets/{ListDetail-BQMd6NOm.js → ListDetail-CNq64VWV.js} +1 -1
- package/dist/assets/{MarkdownEditor-DEp43FXX.js → MarkdownEditor-Bh3qt5-1.js} +1 -1
- package/dist/assets/{McpServers-CLarzwqA.js → McpServers-DpGN0oyz.js} +1 -1
- package/dist/assets/{Memory-B0sCdIy1.js → Memory-D59hUjC4.js} +6 -6
- package/dist/assets/{Panel-BhWPVOCD.js → Panel-DCgbaoci.js} +1 -1
- package/dist/assets/{Permissions-Ddlq8T_O.js → Permissions-DAmQ0DYV.js} +2 -2
- package/dist/assets/{Plugins-D2oA_2Jl.js → Plugins-Dyfgn6Is.js} +2 -2
- package/dist/assets/{ProvenanceBadge-DgAgavUM.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-C_zWEtIl.js → ScopeSwitcher-P4mdLGNU.js} +1 -1
- package/dist/assets/{Settings-2Vx3X5SI.js → Settings-BL4vf5aX.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-BDEUjlTQ.js → SkillReferenceGraph-BRBDyi1_.js} +1 -1
- package/dist/assets/{Skills-Cmrz_LeN.js → Skills-BV08gDUH.js} +2 -2
- package/dist/assets/{SystemPrompt-DVA1eYDP.js → SystemPrompt-CLftSsDw.js} +1 -1
- package/dist/assets/TagLibrary-Bp8jGsd5.js +1 -0
- package/dist/assets/{TiptapBody-DmPc3amD.js → TiptapBody-jCpuB6E5.js} +1 -1
- package/dist/assets/{Toggle-zfd5LJkK.js → Toggle-D2paA1xf.js} +1 -1
- package/dist/assets/{index-B_4PNh9T.js → index-BDRSqBl3.js} +175 -175
- package/dist/assets/{index-DIjnPkRN.css → index-CYhdtisq.css} +1 -1
- package/dist/assets/{settingsSchema-B9es6fdA.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/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 +115 -1
- package/scripts/validate-project-pages-summary.cjs +62 -0
- package/src/main/__tests__/agentModelResolve.test.cjs +66 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
- package/src/main/__tests__/prdAgentType.test.cjs +103 -0
- package/src/main/__tests__/prdCreate.test.cjs +138 -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 +43 -0
- package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +103 -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 +147 -0
- package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +212 -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/config.cjs +4 -1
- package/src/main/index.cjs +8 -1
- package/src/main/ipcSchemas.cjs +51 -0
- package/src/main/lib/__tests__/childWithLog.test.cjs +78 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +152 -2
- package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +4 -2
- package/src/main/lib/__tests__/fixChainDepth.test.cjs +40 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +277 -4
- package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +153 -0
- package/src/main/lib/__tests__/jobWorktree.test.cjs +5 -3
- 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__/mcpToolCatalog.test.cjs +1 -0
- package/src/main/lib/__tests__/opsOwnership.test.cjs +7 -0
- package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +82 -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 +22 -1
- package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +125 -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__/schedulerMcpServerProjectHome.test.cjs +350 -0
- package/src/main/lib/agentModelResolve.cjs +58 -0
- package/src/main/lib/childWithLog.cjs +40 -5
- package/src/main/lib/claudeBin.cjs +54 -1
- package/src/main/lib/definitionOfDone.cjs +3 -2
- package/src/main/lib/delegationReadiness.cjs +115 -9
- package/src/main/lib/epicWorktreeMint.cjs +5 -2
- package/src/main/lib/fixChainDepth.cjs +45 -0
- package/src/main/lib/gitWorktree.cjs +464 -19
- package/src/main/lib/jobWorktree.cjs +1 -0
- package/src/main/lib/landedSinceRun.cjs +55 -0
- package/src/main/lib/launchFailure.cjs +357 -0
- package/src/main/lib/mcpToolCatalog.cjs +87 -2
- package/src/main/lib/opsOwnership.cjs +12 -0
- package/src/main/lib/prdAgentType.cjs +84 -0
- package/src/main/lib/prdCreate.cjs +57 -1
- package/src/main/lib/prdDeclaredPaths.cjs +70 -0
- package/src/main/lib/prdFrontmatter.cjs +17 -3
- package/src/main/lib/projectHomeAdminRoutes.cjs +402 -0
- package/src/main/lib/projectPageSummarySchema.cjs +181 -0
- package/src/main/lib/queueHealth.cjs +38 -0
- package/src/main/lib/queueStore.cjs +9 -2
- package/src/main/lib/quietMachineLease.cjs +48 -0
- package/src/main/lib/rcaReport.cjs +53 -3
- package/src/main/lib/reaperHelpers.cjs +18 -1
- package/src/main/lib/scheduleJobSchema.cjs +31 -0
- package/src/main/lib/scheduleJobTransitions.cjs +6 -2
- package/src/main/lib/schedulerBatch.cjs +133 -29
- package/src/main/lib/schedulerConfig.cjs +19 -0
- package/src/main/projectPages.cjs +160 -2
- package/src/main/runVerify.cjs +50 -9
- package/src/main/scheduler/prdParser.cjs +18 -1
- package/src/main/scheduler.cjs +1371 -97
- package/src/main/seedAgentPersonas.cjs +62 -21
- 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 +118 -2
- package/src/preload/index.cjs +7 -0
- package/src/seed/agents/project-home-builder.md +59 -0
- package/dist/assets/SaveBar-Qvc4Ek-H.js +0 -1
- package/dist/assets/Scheduler-BmYJvNzK.js +0 -14
- package/dist/assets/TagLibrary-DYJGAKZu.js +0 -1
|
@@ -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
|
+
});
|
|
@@ -32,12 +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
40
|
const { reapDeadRunningJobs, PIDLESS_SPAWN_GRACE_MS } = require('../scheduler.cjs');
|
|
40
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
|
+
}
|
|
41
63
|
|
|
42
64
|
function registerActiveProject(cwd) {
|
|
43
65
|
const projectsDir = path.join(tmpHome, '.claude', 'projects');
|
|
@@ -86,6 +108,7 @@ test('reapDeadRunningJobs reconciles a queue.json row stuck at status:running wi
|
|
|
86
108
|
assert.equal(jobs[0].status, 'completed', 'job must be reconciled from disk state, not skipped via the runningSet gate');
|
|
87
109
|
assert.equal(jobs[0].exitCode, 0);
|
|
88
110
|
assert.equal(jobs[0].runtime, undefined);
|
|
111
|
+
assert.equal(jobs[0].gateOutcome, 'passed', 'a success result event maps to gateOutcome:passed');
|
|
89
112
|
});
|
|
90
113
|
|
|
91
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 () => {
|
|
@@ -117,6 +140,7 @@ test('reapDeadRunningJobs reaps a pidless row older than PIDLESS_SPAWN_GRACE_MS
|
|
|
117
140
|
assert.equal(jobs[0].status, 'failed', 'an empty run dir must never be reaped as completed');
|
|
118
141
|
assert.match(jobs[0].error, /no runtime\.pid recorded/);
|
|
119
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');
|
|
120
144
|
|
|
121
145
|
const auditText = fs.readFileSync(AUDIT_LOG_PATH, 'utf8').slice(auditSizeBefore);
|
|
122
146
|
const auditLines = auditText.trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
@@ -160,3 +184,126 @@ test('reapDeadRunningJobs is a no-op when no job is actually running in queue.js
|
|
|
160
184
|
assert.equal(jobs.length, 1);
|
|
161
185
|
assert.equal(jobs[0].status, 'completed');
|
|
162
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
|
+
});
|