claude-code-session-manager 0.79.0 → 0.80.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-COtVRqBR.js → AgentLibrary-zS3jw_1e.js} +1 -1
- package/dist/assets/{DataModel-CSEKw_OR.js → DataModel-Cy_vxTpi.js} +1 -1
- package/dist/assets/{History-CHHovrAO.js → History-C6JRuqfT.js} +1 -1
- package/dist/assets/{Hooks-BZU6C3x6.js → Hooks-BafPy9mB.js} +1 -1
- package/dist/assets/{HostBilko-CqTUoq37.js → HostBilko-BZwhQOFt.js} +1 -1
- package/dist/assets/{Library-BtxdyTLz.js → Library-C8JDDliz.js} +1 -1
- package/dist/assets/{ListDetail-qZc7Zm-6.js → ListDetail-CqiOdwLc.js} +1 -1
- package/dist/assets/{MarkdownEditor-BHe_4fJR.js → MarkdownEditor-CyLyP67L.js} +1 -1
- package/dist/assets/{McpServers-7Z98HLNo.js → McpServers-BzMv-_84.js} +1 -1
- package/dist/assets/{Memory-CR72KoyP.js → Memory-DSBYQdJR.js} +1 -1
- package/dist/assets/{Panel-pL6H3dpQ.js → Panel-CLUhkNNA.js} +1 -1
- package/dist/assets/{Permissions-CWSWjyXM.js → Permissions-BfC2-HN4.js} +1 -1
- package/dist/assets/{Plugins-CN6lX2lt.js → Plugins-BKi40jT5.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BXSXwIsk.js → ProvenanceBadge-BzFw4KhD.js} +1 -1
- package/dist/assets/{SaveBar-BlB5TGpR.js → SaveBar-avk2p9jv.js} +1 -1
- package/dist/assets/{Scheduler-DRciWUmR.js → Scheduler-Bf_6MdJo.js} +7 -7
- package/dist/assets/{ScopeSwitcher-kFrXtjpr.js → ScopeSwitcher-C-RwYUVZ.js} +1 -1
- package/dist/assets/{Settings-BXuyf4lJ.js → Settings-Djd8OoBA.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-Dfacb0PE.js → SkillReferenceGraph-DuogY6s7.js} +1 -1
- package/dist/assets/{Skills-CHqcpiyt.js → Skills-D_qAqxZ_.js} +1 -1
- package/dist/assets/{SystemPrompt-fxXm0BZr.js → SystemPrompt-DbHFLQV3.js} +1 -1
- package/dist/assets/{TagLibrary-DOz65ZTz.js → TagLibrary-C2y91BT0.js} +1 -1
- package/dist/assets/{TiptapBody-D0bWx_9o.js → TiptapBody-D9iz4xQx.js} +1 -1
- package/dist/assets/{Toggle-C9jBwGSx.js → Toggle-BGnFL2E5.js} +1 -1
- package/dist/assets/{index-DPYa6jbM.js → index-_2ARyFDj.js} +4 -4
- package/dist/assets/{settingsSchema-BTPw1bR3.js → settingsSchema-JK15eJU8.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/builder/3-publish/SKILL.md +10 -0
- package/scripts/project-pages-logic/dist/logic.cjs +12 -12
- package/scripts/render-project-pages/dist/renderer.cjs +22 -22
- package/src/main/__tests__/computeDepHistorySatisfaction.test.cjs +66 -0
- package/src/main/__tests__/prdCreate.test.cjs +133 -8
- package/src/main/__tests__/prdFrontmatterDependsOn.test.cjs +136 -0
- package/src/main/__tests__/prdUpdateDependsOn.test.cjs +160 -0
- package/src/main/__tests__/queueHistory.test.cjs +33 -0
- package/src/main/__tests__/scheduleJobTransitions.test.cjs +1 -0
- package/src/main/__tests__/scheduler-autofix-outcome.test.cjs +73 -1
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +17 -0
- package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +20 -0
- package/src/main/__tests__/scheduler-leftover-quarantine.test.cjs +199 -0
- package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +222 -0
- package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +51 -0
- package/src/main/__tests__/scheduler-resume-recovery.test.cjs +254 -0
- package/src/main/__tests__/schedulerBatchRootBlocker.test.cjs +117 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -2
- package/src/main/ipcSchemas.cjs +15 -1
- package/src/main/lib/__tests__/gitWorktree.test.cjs +129 -10
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +59 -7
- package/src/main/lib/depSlugResolve.cjs +72 -0
- package/src/main/lib/epicWorktreeMerge.cjs +3 -3
- package/src/main/lib/epicWorktreeMint.cjs +17 -5
- package/src/main/lib/fixPlanSlug.cjs +62 -0
- package/src/main/lib/gitWorktree.cjs +97 -12
- package/src/main/lib/mcpToolCatalog.cjs +4 -1
- package/src/main/lib/prdCreate.cjs +84 -5
- package/src/main/lib/prdFrontmatter.cjs +56 -8
- package/src/main/lib/queueHistory.cjs +50 -5
- package/src/main/lib/scheduleJobTransitions.cjs +12 -2
- package/src/main/lib/schedulerBatch.cjs +181 -23
- package/src/main/scheduler/prdParser.cjs +7 -0
- package/src/main/scheduler.cjs +813 -40
- package/src/preload/api.d.ts +8 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-leftover-quarantine.test.cjs — PRD 1128: once resume-first
|
|
3
|
+
* recovery (PRD 1111) is spent and a job STILL parks needs_review with
|
|
4
|
+
* 'uncommitted_changes', its recorded leftover paths must be quarantined off
|
|
5
|
+
* the shared tree (onto a dedicated `sm-salvage/<slug>` ref) rather than left
|
|
6
|
+
* dirty forever, poisoning every later worktree merge for that project (the
|
|
7
|
+
* 216-jupiter-sand-kazekage incident, 2026-09-06).
|
|
8
|
+
*
|
|
9
|
+
* selectLeftoverQuarantineTarget is the pure eligibility rule (no I/O);
|
|
10
|
+
* quarantineLeftovers is the git plumbing, exercised against a real
|
|
11
|
+
* throwaway repo under os.tmpdir() (same pattern as gitWorktree.test.cjs).
|
|
12
|
+
*
|
|
13
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-leftover-quarantine.test.cjs
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
import { test, expect, beforeEach, 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
|
+
const scheduler = require('../scheduler.cjs');
|
|
24
|
+
const { selectLeftoverQuarantineTarget, quarantineLeftovers } = scheduler;
|
|
25
|
+
|
|
26
|
+
let tmpRoot;
|
|
27
|
+
let repoCwd;
|
|
28
|
+
let originalDisable;
|
|
29
|
+
|
|
30
|
+
function git(args, cwd) {
|
|
31
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8' });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function initRepo(dir) {
|
|
35
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
36
|
+
git(['init', '-q'], dir);
|
|
37
|
+
git(['config', 'user.email', 'test@example.com'], dir);
|
|
38
|
+
git(['config', 'user.name', 'Test'], dir);
|
|
39
|
+
fs.writeFileSync(path.join(dir, 'a.txt'), 'original a\n', 'utf8');
|
|
40
|
+
fs.writeFileSync(path.join(dir, 'c.txt'), 'original c\n', 'utf8');
|
|
41
|
+
git(['add', '-A'], dir);
|
|
42
|
+
git(['commit', '-q', '-m', 'initial'], dir);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-leftover-quarantine-'));
|
|
47
|
+
repoCwd = path.join(tmpRoot, 'repo');
|
|
48
|
+
initRepo(repoCwd);
|
|
49
|
+
originalDisable = process.env.SM_LEFTOVER_QUARANTINE_DISABLE;
|
|
50
|
+
delete process.env.SM_LEFTOVER_QUARANTINE_DISABLE;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
if (originalDisable === undefined) delete process.env.SM_LEFTOVER_QUARANTINE_DISABLE;
|
|
55
|
+
else process.env.SM_LEFTOVER_QUARANTINE_DISABLE = originalDisable;
|
|
56
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ──────────────────────────────────────────── selectLeftoverQuarantineTarget (pure)
|
|
60
|
+
|
|
61
|
+
function eligibleJob(overrides = {}) {
|
|
62
|
+
return {
|
|
63
|
+
slug: 'my-slug',
|
|
64
|
+
cwd: repoCwd,
|
|
65
|
+
status: 'needs_review',
|
|
66
|
+
verifierVerdict: 'uncommitted_changes',
|
|
67
|
+
resumeRecoveryAttempted: true,
|
|
68
|
+
uncommittedPaths: ['a.txt', 'b.txt'],
|
|
69
|
+
...overrides,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
test('selectLeftoverQuarantineTarget: eligible job with no foreign WIP returns all uncommitted paths', () => {
|
|
74
|
+
const target = selectLeftoverQuarantineTarget(eligibleJob());
|
|
75
|
+
expect(target).toEqual({ slug: 'my-slug', cwd: repoCwd, paths: ['a.txt', 'b.txt'] });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('selectLeftoverQuarantineTarget: a path present in preRunDirtyPaths (foreign WIP) is excluded', () => {
|
|
79
|
+
const target = selectLeftoverQuarantineTarget(eligibleJob({ preRunDirtyPaths: ['a.txt'] }));
|
|
80
|
+
expect(target).toEqual({ slug: 'my-slug', cwd: repoCwd, paths: ['b.txt'] });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('selectLeftoverQuarantineTarget: every uncommitted path being foreign WIP returns null', () => {
|
|
84
|
+
const target = selectLeftoverQuarantineTarget(eligibleJob({ preRunDirtyPaths: ['a.txt', 'b.txt'] }));
|
|
85
|
+
expect(target).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('selectLeftoverQuarantineTarget: not eligible unless resume recovery was already attempted', () => {
|
|
89
|
+
expect(selectLeftoverQuarantineTarget(eligibleJob({ resumeRecoveryAttempted: false }))).toBeNull();
|
|
90
|
+
expect(selectLeftoverQuarantineTarget(eligibleJob({ resumeRecoveryAttempted: undefined }))).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('selectLeftoverQuarantineTarget: wrong status or verdict is ineligible', () => {
|
|
94
|
+
expect(selectLeftoverQuarantineTarget(eligibleJob({ status: 'completed' }))).toBeNull();
|
|
95
|
+
expect(selectLeftoverQuarantineTarget(eligibleJob({ verifierVerdict: 'transcript_errors' }))).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('selectLeftoverQuarantineTarget: a second call is a no-op once leftoverQuarantineAttempted is true', () => {
|
|
99
|
+
const job = eligibleJob();
|
|
100
|
+
expect(selectLeftoverQuarantineTarget(job)).not.toBeNull();
|
|
101
|
+
job.leftoverQuarantineAttempted = true;
|
|
102
|
+
expect(selectLeftoverQuarantineTarget(job)).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('selectLeftoverQuarantineTarget: kill-switch SM_LEFTOVER_QUARANTINE_DISABLE=1 always returns null', () => {
|
|
106
|
+
process.env.SM_LEFTOVER_QUARANTINE_DISABLE = '1';
|
|
107
|
+
expect(selectLeftoverQuarantineTarget(eligibleJob())).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ──────────────────────────────────────────── quarantineLeftovers (real git plumbing)
|
|
111
|
+
|
|
112
|
+
test('quarantineLeftovers: commits leftovers onto sm-salvage/<slug> and restores the tree to baseline', async () => {
|
|
113
|
+
const baseline = git(['rev-parse', 'HEAD'], repoCwd).trim();
|
|
114
|
+
fs.writeFileSync(path.join(repoCwd, 'a.txt'), 'modified a\n', 'utf8');
|
|
115
|
+
fs.writeFileSync(path.join(repoCwd, 'b.txt'), 'new file b\n', 'utf8');
|
|
116
|
+
|
|
117
|
+
const result = await quarantineLeftovers({ cwd: repoCwd, slug: 'my-slug', paths: ['a.txt', 'b.txt'], headBefore: baseline });
|
|
118
|
+
|
|
119
|
+
expect(result.ok).toBe(true);
|
|
120
|
+
expect(result.ref).toBe('sm-salvage/my-slug');
|
|
121
|
+
expect(result.commit).toBeTruthy();
|
|
122
|
+
expect(result.quarantinedPaths.sort()).toEqual(['a.txt', 'b.txt']);
|
|
123
|
+
|
|
124
|
+
// The checked-out branch never moved.
|
|
125
|
+
expect(git(['rev-parse', 'HEAD'], repoCwd).trim()).toBe(baseline);
|
|
126
|
+
// The salvage ref's parent is the baseline commit.
|
|
127
|
+
expect(git(['rev-parse', `${result.ref}^`], repoCwd).trim()).toBe(baseline);
|
|
128
|
+
// The salvage commit carries the leftover content.
|
|
129
|
+
expect(git(['show', `${result.ref}:a.txt`], repoCwd)).toBe('modified a\n');
|
|
130
|
+
expect(git(['show', `${result.ref}:b.txt`], repoCwd)).toBe('new file b\n');
|
|
131
|
+
|
|
132
|
+
// The working tree is restored to the pre-run baseline.
|
|
133
|
+
expect(fs.readFileSync(path.join(repoCwd, 'a.txt'), 'utf8')).toBe('original a\n');
|
|
134
|
+
expect(fs.existsSync(path.join(repoCwd, 'b.txt'))).toBe(false);
|
|
135
|
+
expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('quarantineLeftovers: a path dirty at dispatch time (foreign WIP, filtered out by the caller) is left exactly as-is', async () => {
|
|
139
|
+
const baseline = git(['rev-parse', 'HEAD'], repoCwd).trim();
|
|
140
|
+
fs.writeFileSync(path.join(repoCwd, 'a.txt'), 'modified a\n', 'utf8');
|
|
141
|
+
fs.writeFileSync(path.join(repoCwd, 'c.txt'), 'someone elses edit\n', 'utf8');
|
|
142
|
+
|
|
143
|
+
// Simulate the caller having already excluded 'c.txt' via preRunDirtyPaths
|
|
144
|
+
// by never passing it in `paths`.
|
|
145
|
+
const result = await quarantineLeftovers({ cwd: repoCwd, slug: 'my-slug', paths: ['a.txt'], headBefore: baseline });
|
|
146
|
+
|
|
147
|
+
expect(result.ok).toBe(true);
|
|
148
|
+
expect(result.quarantinedPaths).toEqual(['a.txt']);
|
|
149
|
+
// The foreign path is completely untouched.
|
|
150
|
+
expect(fs.readFileSync(path.join(repoCwd, 'c.txt'), 'utf8')).toBe('someone elses edit\n');
|
|
151
|
+
expect(git(['status', '--porcelain', '--', 'c.txt'], repoCwd).trim()).not.toBe('');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('quarantineLeftovers: a path no longer dirty on disk is skipped, not force-restored', async () => {
|
|
155
|
+
const baseline = git(['rev-parse', 'HEAD'], repoCwd).trim();
|
|
156
|
+
fs.writeFileSync(path.join(repoCwd, 'a.txt'), 'modified a\n', 'utf8');
|
|
157
|
+
fs.writeFileSync(path.join(repoCwd, 'b.txt'), 'new file b\n', 'utf8');
|
|
158
|
+
// a.txt gets committed by something else between parking and quarantine —
|
|
159
|
+
// it is no longer dirty relative to the live tree.
|
|
160
|
+
git(['add', 'a.txt'], repoCwd);
|
|
161
|
+
git(['commit', '-q', '-m', 'a.txt landed separately'], repoCwd);
|
|
162
|
+
|
|
163
|
+
const result = await quarantineLeftovers({ cwd: repoCwd, slug: 'my-slug', paths: ['a.txt', 'b.txt'], headBefore: baseline });
|
|
164
|
+
|
|
165
|
+
expect(result.ok).toBe(true);
|
|
166
|
+
expect(result.quarantinedPaths).toEqual(['b.txt']);
|
|
167
|
+
expect(result.skippedPaths).toEqual(['a.txt']);
|
|
168
|
+
// a.txt was never touched — still whatever the later commit left it as.
|
|
169
|
+
expect(fs.readFileSync(path.join(repoCwd, 'a.txt'), 'utf8')).toBe('modified a\n');
|
|
170
|
+
expect(fs.existsSync(path.join(repoCwd, 'b.txt'))).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('quarantineLeftovers: a git command failure abandons the whole attempt with the tree untouched', async () => {
|
|
174
|
+
fs.writeFileSync(path.join(repoCwd, 'a.txt'), 'modified a\n', 'utf8');
|
|
175
|
+
|
|
176
|
+
const result = await quarantineLeftovers({
|
|
177
|
+
cwd: repoCwd,
|
|
178
|
+
slug: 'my-slug',
|
|
179
|
+
paths: ['a.txt'],
|
|
180
|
+
headBefore: '0000000000000000000000000000000000000000', // never resolvable
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
expect(result.ok).toBe(false);
|
|
184
|
+
expect(result.reason).toBeTruthy();
|
|
185
|
+
// Nothing was quarantined or restored.
|
|
186
|
+
expect(fs.readFileSync(path.join(repoCwd, 'a.txt'), 'utf8')).toBe('modified a\n');
|
|
187
|
+
expect(git(['status', '--porcelain'], repoCwd).trim()).not.toBe('');
|
|
188
|
+
expect(() => git(['rev-parse', '--verify', 'refs/heads/sm-salvage/my-slug'], repoCwd)).toThrow();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('quarantineLeftovers: a non-git cwd is abandoned cleanly with a reason', async () => {
|
|
192
|
+
const notARepo = path.join(tmpRoot, 'not-a-repo');
|
|
193
|
+
fs.mkdirSync(notARepo, { recursive: true });
|
|
194
|
+
fs.writeFileSync(path.join(notARepo, 'a.txt'), 'x\n', 'utf8');
|
|
195
|
+
|
|
196
|
+
const result = await quarantineLeftovers({ cwd: notARepo, slug: 'my-slug', paths: ['a.txt'] });
|
|
197
|
+
expect(result.ok).toBe(false);
|
|
198
|
+
expect(result.reason).toBeTruthy();
|
|
199
|
+
});
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-mechanical-recovery.test.cjs — PRD 1130: a fix-plan job parked
|
|
3
|
+
* needs_review at investigationDepth >= 2 (isFixPlanBeyondDepthCap,
|
|
4
|
+
* scheduler.cjs) gets no further investigation, and resume-first recovery
|
|
5
|
+
* (selectResumeRecoveryTarget) is hard-gated on verdict 'uncommitted_changes'
|
|
6
|
+
* — so a depth-capped job whose failure is fully mechanical (verdict
|
|
7
|
+
* 'worktree_integration_failed') was previously stranded needs_review
|
|
8
|
+
* forever (216-jupiter-sand-kazekage, 2026-09-06). This adds one bounded,
|
|
9
|
+
* model-free recovery rung, evaluated independently of depth.
|
|
10
|
+
*
|
|
11
|
+
* selectMechanicalRecoveryTarget is the pure eligibility rule (no I/O, no
|
|
12
|
+
* depth check); performMechanicalRecovery is the git plumbing + queue
|
|
13
|
+
* transition, exercised against a real throwaway repo under os.tmpdir()
|
|
14
|
+
* (same pattern as scheduler-leftover-quarantine.test.cjs and
|
|
15
|
+
* scheduler-reap-dead-running-jobs.test.cjs).
|
|
16
|
+
*
|
|
17
|
+
* HOME is overridden to a tmp dir BEFORE requiring scheduler.cjs, since
|
|
18
|
+
* queue.json I/O (mutate/readQueue/writeQueue) is baked into a top-level
|
|
19
|
+
* ROOT const derived from os.homedir() at require time — this test must
|
|
20
|
+
* never touch real state.
|
|
21
|
+
*
|
|
22
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-mechanical-recovery.test.cjs
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
import { test, expect, beforeEach, afterEach } from 'vitest';
|
|
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(), 'sm-mechanical-recovery-test-'));
|
|
34
|
+
process.env.HOME = tmpHome;
|
|
35
|
+
|
|
36
|
+
const scheduler = require('../scheduler.cjs');
|
|
37
|
+
const {
|
|
38
|
+
selectMechanicalRecoveryTarget, performMechanicalRecovery, MECHANICALLY_RESOLVABLE_VERDICTS,
|
|
39
|
+
} = scheduler;
|
|
40
|
+
const jobWorktree = require('../lib/jobWorktree.cjs');
|
|
41
|
+
|
|
42
|
+
function git(args, cwd) {
|
|
43
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8' });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function initRepo(dir) {
|
|
47
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
48
|
+
git(['init', '-q'], dir);
|
|
49
|
+
git(['config', 'user.email', 'test@example.com'], dir);
|
|
50
|
+
git(['config', 'user.name', 'Test'], dir);
|
|
51
|
+
fs.writeFileSync(path.join(dir, 'a.txt'), 'original a\n', 'utf8');
|
|
52
|
+
git(['add', '-A'], dir);
|
|
53
|
+
git(['commit', '-q', '-m', 'initial'], dir);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function writeProjectQueue(cwd, jobs) {
|
|
57
|
+
const stateDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
|
|
58
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
59
|
+
fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs }, null, 2));
|
|
60
|
+
return path.join(stateDir, 'queue.json');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function registerActiveProject(cwd, slugDirName) {
|
|
64
|
+
const projectsDir = path.join(tmpHome, '.claude', 'projects');
|
|
65
|
+
const slugDir = path.join(projectsDir, slugDirName);
|
|
66
|
+
fs.mkdirSync(slugDir, { recursive: true });
|
|
67
|
+
fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let originalDisable;
|
|
71
|
+
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
originalDisable = process.env.SM_MECHANICAL_RECOVERY_DISABLE;
|
|
74
|
+
delete process.env.SM_MECHANICAL_RECOVERY_DISABLE;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
if (originalDisable === undefined) delete process.env.SM_MECHANICAL_RECOVERY_DISABLE;
|
|
79
|
+
else process.env.SM_MECHANICAL_RECOVERY_DISABLE = originalDisable;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ──────────────────────────────────────────── selectMechanicalRecoveryTarget (pure)
|
|
83
|
+
|
|
84
|
+
function eligibleJob(overrides = {}) {
|
|
85
|
+
return {
|
|
86
|
+
slug: 'depth-capped-fix-plan',
|
|
87
|
+
cwd: '/tmp/whatever-not-read-by-the-selector',
|
|
88
|
+
status: 'needs_review',
|
|
89
|
+
verifierVerdict: 'worktree_integration_failed',
|
|
90
|
+
investigationDepth: 2,
|
|
91
|
+
...overrides,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
test('selectMechanicalRecoveryTarget: eligible depth-2 worktree_integration_failed job returns a target, depth never disqualifies it', () => {
|
|
96
|
+
const target = selectMechanicalRecoveryTarget(eligibleJob({ investigationDepth: 5 }));
|
|
97
|
+
expect(target).not.toBeNull();
|
|
98
|
+
expect(target.slug).toBe('depth-capped-fix-plan');
|
|
99
|
+
expect(target.branch).toBe(jobWorktree.branchNameFor('depth-capped-fix-plan'));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('selectMechanicalRecoveryTarget: already-attempted job is not eligible', () => {
|
|
103
|
+
expect(selectMechanicalRecoveryTarget(eligibleJob({ mechanicalRecoveryAttempted: true }))).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('selectMechanicalRecoveryTarget: a verdict outside the closed set is not eligible', () => {
|
|
107
|
+
expect(selectMechanicalRecoveryTarget(eligibleJob({ verifierVerdict: 'uncommitted_changes' }))).toBeNull();
|
|
108
|
+
expect(selectMechanicalRecoveryTarget(eligibleJob({ verifierVerdict: 'transcript_errors' }))).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('selectMechanicalRecoveryTarget: wrong status is not eligible', () => {
|
|
112
|
+
expect(selectMechanicalRecoveryTarget(eligibleJob({ status: 'failed' }))).toBeNull();
|
|
113
|
+
expect(selectMechanicalRecoveryTarget(eligibleJob({ status: 'completed' }))).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('selectMechanicalRecoveryTarget: kill-switch SM_MECHANICAL_RECOVERY_DISABLE=1 always returns null', () => {
|
|
117
|
+
process.env.SM_MECHANICAL_RECOVERY_DISABLE = '1';
|
|
118
|
+
expect(selectMechanicalRecoveryTarget(eligibleJob())).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('MECHANICALLY_RESOLVABLE_VERDICTS: closed set contains exactly worktree_integration_failed', () => {
|
|
122
|
+
expect(Array.from(MECHANICALLY_RESOLVABLE_VERDICTS)).toEqual(['worktree_integration_failed']);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ──────────────────────────────────────────── performMechanicalRecovery (real git + queue)
|
|
126
|
+
|
|
127
|
+
test('performMechanicalRecovery: successful re-integration transitions needs_review -> completed, clears verifierVerdict, deletes the branch', async () => {
|
|
128
|
+
const projectCwd = path.join(tmpHome, 'proj-success');
|
|
129
|
+
initRepo(projectCwd);
|
|
130
|
+
registerActiveProject(projectCwd, 'proj-success-slug');
|
|
131
|
+
|
|
132
|
+
const slug = 'fix-plan-depth2';
|
|
133
|
+
const branch = jobWorktree.branchNameFor(slug);
|
|
134
|
+
// Simulate a job whose worktree branch has one committed change, preserved
|
|
135
|
+
// (never deleted) because its earlier integration attempt failed.
|
|
136
|
+
git(['checkout', '-q', '-b', branch], projectCwd);
|
|
137
|
+
fs.writeFileSync(path.join(projectCwd, 'b.txt'), 'from the fix plan\n', 'utf8');
|
|
138
|
+
git(['add', '-A'], projectCwd);
|
|
139
|
+
git(['commit', '-q', '-m', 'fix plan work'], projectCwd);
|
|
140
|
+
git(['checkout', '-q', 'master'], projectCwd);
|
|
141
|
+
|
|
142
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
143
|
+
{
|
|
144
|
+
slug,
|
|
145
|
+
status: 'needs_review',
|
|
146
|
+
cwd: projectCwd,
|
|
147
|
+
verifierVerdict: 'worktree_integration_failed',
|
|
148
|
+
investigationDepth: 2,
|
|
149
|
+
error: 'worktree branch integration FAILED (merge failed) — branch preserved for manual recovery',
|
|
150
|
+
},
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
const target = selectMechanicalRecoveryTarget({ slug, cwd: projectCwd, status: 'needs_review', verifierVerdict: 'worktree_integration_failed' });
|
|
154
|
+
expect(target).not.toBeNull();
|
|
155
|
+
|
|
156
|
+
await performMechanicalRecovery({ slug, cwd: projectCwd }, target);
|
|
157
|
+
|
|
158
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
159
|
+
expect(jobs).toHaveLength(1);
|
|
160
|
+
expect(jobs[0].status).toBe('completed');
|
|
161
|
+
expect(jobs[0].verifierVerdict).toBeUndefined();
|
|
162
|
+
expect(jobs[0].mechanicalRecoveryAttempted).toBe(true);
|
|
163
|
+
expect(jobs[0].statusHistory.at(-1).source).toBe('scheduler:mechanicalRecovery');
|
|
164
|
+
expect(fs.readFileSync(path.join(projectCwd, 'b.txt'), 'utf8')).toBe('from the fix plan\n');
|
|
165
|
+
// The branch, now merged, is cleaned up.
|
|
166
|
+
const branchList = git(['branch', '--list', branch], projectCwd);
|
|
167
|
+
expect(branchList.trim()).toBe('');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('performMechanicalRecovery: a real merge conflict keeps the job needs_review, stamps mechanicalRecoveryAttempted, appends the failure text', async () => {
|
|
171
|
+
const projectCwd = path.join(tmpHome, 'proj-conflict');
|
|
172
|
+
initRepo(projectCwd);
|
|
173
|
+
registerActiveProject(projectCwd, 'proj-conflict-slug');
|
|
174
|
+
|
|
175
|
+
const slug = 'fix-plan-conflict';
|
|
176
|
+
const branch = jobWorktree.branchNameFor(slug);
|
|
177
|
+
git(['checkout', '-q', '-b', branch], projectCwd);
|
|
178
|
+
fs.writeFileSync(path.join(projectCwd, 'a.txt'), 'branch version\n', 'utf8');
|
|
179
|
+
git(['add', '-A'], projectCwd);
|
|
180
|
+
git(['commit', '-q', '-m', 'branch edits a.txt'], projectCwd);
|
|
181
|
+
git(['checkout', '-q', 'master'], projectCwd);
|
|
182
|
+
// Main tree diverges on the SAME line — a genuine, unresolvable conflict.
|
|
183
|
+
fs.writeFileSync(path.join(projectCwd, 'a.txt'), 'main version\n', 'utf8');
|
|
184
|
+
git(['add', '-A'], projectCwd);
|
|
185
|
+
git(['commit', '-q', '-m', 'main edits a.txt'], projectCwd);
|
|
186
|
+
|
|
187
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
188
|
+
{ slug, status: 'needs_review', cwd: projectCwd, verifierVerdict: 'worktree_integration_failed', investigationDepth: 3, error: 'prior failure note' },
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
const target = selectMechanicalRecoveryTarget({ slug, cwd: projectCwd, status: 'needs_review', verifierVerdict: 'worktree_integration_failed' });
|
|
192
|
+
await performMechanicalRecovery({ slug, cwd: projectCwd }, target);
|
|
193
|
+
|
|
194
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
195
|
+
expect(jobs[0].status).toBe('needs_review');
|
|
196
|
+
expect(jobs[0].mechanicalRecoveryAttempted).toBe(true);
|
|
197
|
+
expect(jobs[0].error).toMatch(/prior failure note/);
|
|
198
|
+
expect(jobs[0].error).toMatch(/Mechanical recovery retry failed/);
|
|
199
|
+
// The branch is preserved for manual recovery, exactly like the original failure.
|
|
200
|
+
const branchList = git(['branch', '--list', branch], projectCwd);
|
|
201
|
+
expect(branchList.trim()).not.toBe('');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('performMechanicalRecovery: a branch that no longer exists is handled without throwing, and stamps an explicit reason', async () => {
|
|
205
|
+
const projectCwd = path.join(tmpHome, 'proj-missing-branch');
|
|
206
|
+
initRepo(projectCwd);
|
|
207
|
+
registerActiveProject(projectCwd, 'proj-missing-branch-slug');
|
|
208
|
+
|
|
209
|
+
const slug = 'fix-plan-already-merged';
|
|
210
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
211
|
+
{ slug, status: 'needs_review', cwd: projectCwd, verifierVerdict: 'worktree_integration_failed', investigationDepth: 2 },
|
|
212
|
+
]);
|
|
213
|
+
|
|
214
|
+
const target = selectMechanicalRecoveryTarget({ slug, cwd: projectCwd, status: 'needs_review', verifierVerdict: 'worktree_integration_failed' });
|
|
215
|
+
|
|
216
|
+
await expect(performMechanicalRecovery({ slug, cwd: projectCwd }, target)).resolves.not.toThrow();
|
|
217
|
+
|
|
218
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
219
|
+
expect(jobs[0].status).toBe('needs_review');
|
|
220
|
+
expect(jobs[0].mechanicalRecoveryAttempted).toBe(true);
|
|
221
|
+
expect(jobs[0].error).toMatch(/not found/);
|
|
222
|
+
});
|
|
@@ -146,6 +146,57 @@ test('reconcile() exempts a fix-plan PRD (NN-fix-*) from quarantine even with no
|
|
|
146
146
|
}
|
|
147
147
|
});
|
|
148
148
|
|
|
149
|
+
test('reconcile() does NOT stamp investigationDepth for a createdVia-stamped PRD whose slug merely starts with "fix-" (PRD 1126/1131 regression)', async () => {
|
|
150
|
+
const { cwd, prdsDir, stateDir } = makeFixtureProject('sm-quarantine-fake-fixplan-');
|
|
151
|
+
try {
|
|
152
|
+
// Mirrors PRD 1126: authored via scheduler_create_prd (createdVia stamped),
|
|
153
|
+
// slug happens to kebab-case into "fix-..." — must NOT be treated as a
|
|
154
|
+
// genuine scheduler-authored fix plan just because the name matches.
|
|
155
|
+
fs.writeFileSync(
|
|
156
|
+
path.join(prdsDir, '9005-fix-plan-death-reopens-parent.md'),
|
|
157
|
+
`---\ntitle: Fix plan death reopens parent\ncwd: ${cwd}\nestimateMinutes: 15\ncreatedVia: scheduler-api\nissuedAt: 2026-08-07T00:00:00.000Z\n---\n\n# Goal\nDo the thing.\n`,
|
|
158
|
+
'utf8',
|
|
159
|
+
);
|
|
160
|
+
fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs: [] }, null, 2), 'utf8');
|
|
161
|
+
|
|
162
|
+
queueStore.bustCwdCache();
|
|
163
|
+
const state = { jobs: [], invalidJobs: [], paused: null };
|
|
164
|
+
await scheduler.reconcile(state);
|
|
165
|
+
|
|
166
|
+
const row = state.jobs.find((j) => j.slug === '9005-fix-plan-death-reopens-parent');
|
|
167
|
+
expect(row).toBeDefined();
|
|
168
|
+
expect(row.status).toBe('pending');
|
|
169
|
+
expect(row.isFixPlan).toBe(false);
|
|
170
|
+
expect(row.investigationDepth).toBeUndefined();
|
|
171
|
+
} finally {
|
|
172
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('reconcile() stamps isFixPlan + investigationDepth for a genuine spawnInvestigation-authored fix plan (explicit isFixPlan:true, no createdVia)', async () => {
|
|
177
|
+
const { cwd, prdsDir, stateDir } = makeFixtureProject('sm-quarantine-genuine-fixplan-');
|
|
178
|
+
try {
|
|
179
|
+
fs.writeFileSync(
|
|
180
|
+
path.join(prdsDir, '9006-fix-something.md'),
|
|
181
|
+
`---\ntitle: Fix something\ncwd: ${cwd}\nestimateMinutes: 15\nisFixPlan: true\n---\n\n# Goal\nHeal it.\n`,
|
|
182
|
+
'utf8',
|
|
183
|
+
);
|
|
184
|
+
fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs: [] }, null, 2), 'utf8');
|
|
185
|
+
|
|
186
|
+
queueStore.bustCwdCache();
|
|
187
|
+
const state = { jobs: [], invalidJobs: [], paused: null };
|
|
188
|
+
await scheduler.reconcile(state);
|
|
189
|
+
|
|
190
|
+
const row = state.jobs.find((j) => j.slug === '9006-fix-something');
|
|
191
|
+
expect(row).toBeDefined();
|
|
192
|
+
expect(row.status).toBe('pending');
|
|
193
|
+
expect(row.isFixPlan).toBe(true);
|
|
194
|
+
expect(row.investigationDepth).toBe(2);
|
|
195
|
+
} finally {
|
|
196
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
149
200
|
test('reconcile() adopts a quarantined row to pending once its PRD file is stamped', async () => {
|
|
150
201
|
const { cwd, prdsDir, stateDir } = makeFixtureProject('sm-quarantine-adopt-');
|
|
151
202
|
try {
|