claude-code-session-manager 0.79.0 → 0.81.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-psZYVM2w.js} +1 -1
- package/dist/assets/{DataModel-CSEKw_OR.js → DataModel-BMied5pg.js} +1 -1
- package/dist/assets/{History-CHHovrAO.js → History-BFC0oaKc.js} +1 -1
- package/dist/assets/{Hooks-BZU6C3x6.js → Hooks-CTLfO9G8.js} +1 -1
- package/dist/assets/{HostBilko-CqTUoq37.js → HostBilko-D4I0Cpwn.js} +1 -1
- package/dist/assets/{Library-BtxdyTLz.js → Library-BTzS8KsS.js} +1 -1
- package/dist/assets/{ListDetail-qZc7Zm-6.js → ListDetail-CuRmT008.js} +1 -1
- package/dist/assets/{MarkdownEditor-BHe_4fJR.js → MarkdownEditor-BgQGtvWo.js} +1 -1
- package/dist/assets/{McpServers-7Z98HLNo.js → McpServers-DiVQUK57.js} +1 -1
- package/dist/assets/{Memory-CR72KoyP.js → Memory-DSu15JmL.js} +1 -1
- package/dist/assets/{Panel-pL6H3dpQ.js → Panel-CD5wxGSR.js} +1 -1
- package/dist/assets/{Permissions-CWSWjyXM.js → Permissions-C_tAE6yJ.js} +1 -1
- package/dist/assets/{Plugins-CN6lX2lt.js → Plugins-kwI7W-eK.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BXSXwIsk.js → ProvenanceBadge-CQceOgsH.js} +1 -1
- package/dist/assets/{SaveBar-BlB5TGpR.js → SaveBar-BDk5e3Pp.js} +1 -1
- package/dist/assets/{Scheduler-DRciWUmR.js → Scheduler-DRnvEYzv.js} +7 -7
- package/dist/assets/{ScopeSwitcher-kFrXtjpr.js → ScopeSwitcher-CeifaOlq.js} +1 -1
- package/dist/assets/{Settings-BXuyf4lJ.js → Settings-BWQ1Utop.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-Dfacb0PE.js → SkillReferenceGraph-CW6e1SW1.js} +1 -1
- package/dist/assets/{Skills-CHqcpiyt.js → Skills-BLwFB0E4.js} +1 -1
- package/dist/assets/{SystemPrompt-fxXm0BZr.js → SystemPrompt-DC4ZTArJ.js} +1 -1
- package/dist/assets/{TagLibrary-DOz65ZTz.js → TagLibrary-pMeGfjUb.js} +1 -1
- package/dist/assets/{TiptapBody-D0bWx_9o.js → TiptapBody-BHFid2pZ.js} +1 -1
- package/dist/assets/{Toggle-C9jBwGSx.js → Toggle-D9eYoZh4.js} +1 -1
- package/dist/assets/{index-DPYa6jbM.js → index-CuyM9vAP.js} +5 -5
- package/dist/assets/{settingsSchema-BTPw1bR3.js → settingsSchema-DrxC67uZ.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/plugins/session-manager-dev/skills/develop/standards.md +1 -0
- 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__/rcaReport.test.cjs +24 -0
- package/src/main/__tests__/runVerify-blocked-by-foreign-wip.test.cjs +58 -0
- package/src/main/__tests__/runVerify-policy-denial.test.cjs +89 -0
- package/src/main/__tests__/scheduleJobTransitions.test.cjs +1 -0
- package/src/main/__tests__/scheduler-already-satisfied-on-main.test.cjs +105 -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-blocked-by-foreign-wip.test.cjs +107 -0
- package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +20 -0
- package/src/main/__tests__/scheduler-finalize-dispatch-guards.test.cjs +229 -0
- package/src/main/__tests__/scheduler-leftover-quarantine.test.cjs +199 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +141 -1
- package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +222 -0
- package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +81 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +205 -2
- 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__/branchSweep.test.cjs +164 -0
- package/src/main/lib/__tests__/fixtures/204-mercury-steam-horse.log.txt +13 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +129 -10
- package/src/main/lib/__tests__/landedSinceRun.test.cjs +61 -1
- package/src/main/lib/__tests__/rateLimitWindow.test.cjs +88 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +120 -1
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +59 -7
- package/src/main/lib/branchSweep.cjs +127 -0
- 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 +117 -12
- package/src/main/lib/landedSinceRun.cjs +41 -1
- 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/rateLimitWindow.cjs +62 -0
- package/src/main/lib/rcaReport.cjs +18 -3
- package/src/main/lib/reaperHelpers.cjs +169 -3
- package/src/main/lib/scheduleJobTransitions.cjs +28 -5
- package/src/main/lib/schedulerBatch.cjs +181 -23
- package/src/main/runVerify.cjs +71 -3
- package/src/main/scheduler/prdParser.cjs +7 -0
- package/src/main/scheduler.cjs +1554 -71
- 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
|
+
});
|
|
@@ -33,7 +33,7 @@ const { execFileSync } = require('node:child_process');
|
|
|
33
33
|
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'looks-done-test-'));
|
|
34
34
|
process.env.HOME = tmpHome;
|
|
35
35
|
|
|
36
|
-
const { reverifyNeedsReview, computeLooksDone } = require('../scheduler.cjs');
|
|
36
|
+
const { reverifyNeedsReview, computeLooksDone, findSatisfyingCommitOnMain } = require('../scheduler.cjs');
|
|
37
37
|
const { resolvePrdWriteDir } = require('../lib/prdLocations.cjs');
|
|
38
38
|
const { bustCwdCache } = require('../lib/queueStore.cjs');
|
|
39
39
|
|
|
@@ -51,6 +51,18 @@ function initRepo(dir) {
|
|
|
51
51
|
git(['commit', '-q', '-m', 'initial'], dir);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// -b main pins the branch name regardless of the host's init.defaultBranch
|
|
55
|
+
// config — findSatisfyingCommitOnMain's tests need a deterministic 'main' ref.
|
|
56
|
+
function initRepoOnMain(dir) {
|
|
57
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
58
|
+
git(['init', '-q', '-b', 'main'], dir);
|
|
59
|
+
git(['config', 'user.email', 'test@example.com'], dir);
|
|
60
|
+
git(['config', 'user.name', 'Test'], dir);
|
|
61
|
+
fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
|
|
62
|
+
git(['add', '-A'], dir);
|
|
63
|
+
git(['commit', '-q', '-m', 'initial'], dir);
|
|
64
|
+
}
|
|
65
|
+
|
|
54
66
|
function commitFile(dir, relPath, content, message) {
|
|
55
67
|
const abs = path.join(dir, relPath);
|
|
56
68
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
@@ -228,6 +240,134 @@ test('computeLooksDone: no declared paths on the PRD → null, never fabricates
|
|
|
228
240
|
assert.equal(looksDone, null);
|
|
229
241
|
});
|
|
230
242
|
|
|
243
|
+
test('needs_review row with autoFixAttempted:true is never also stamped looksDone (PRD 1136 — mutually exclusive)', async () => {
|
|
244
|
+
const projectCwd = path.join(tmpHome, 'proj-already-fix-planned');
|
|
245
|
+
initRepo(projectCwd);
|
|
246
|
+
registerActiveProject(projectCwd, 'proj-already-fix-planned-slug');
|
|
247
|
+
writePrd(projectCwd, '30-example', [
|
|
248
|
+
'# Implementation notes',
|
|
249
|
+
'Edit `src/thing.js`.',
|
|
250
|
+
].join('\n'));
|
|
251
|
+
|
|
252
|
+
// startedAt/finishedAt are fixed well in the past so the real-time commit
|
|
253
|
+
// below lands outside committedInWindow's [startedAt, finishedAt+60s]
|
|
254
|
+
// window — otherwise the pre-existing heal pass would auto-complete the
|
|
255
|
+
// row before ever reaching the stillOpen/looksDone branch this test
|
|
256
|
+
// targets (committedInWindow, unlike computeLooksDone's landedSinceRun,
|
|
257
|
+
// has no upper bound skip).
|
|
258
|
+
const startedAt = '2020-01-01T00:00:00.000Z';
|
|
259
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
260
|
+
{
|
|
261
|
+
slug: '30-example',
|
|
262
|
+
status: 'needs_review',
|
|
263
|
+
cwd: projectCwd,
|
|
264
|
+
runId: 'run-30',
|
|
265
|
+
startedAt,
|
|
266
|
+
finishedAt: '2020-01-01T00:05:00.000Z',
|
|
267
|
+
verifierVerdict: 'transcript_errors',
|
|
268
|
+
error: 'transcript_errors: stale verdict',
|
|
269
|
+
// A fix-plan child was already minted for this row at finalize time —
|
|
270
|
+
// the exact ordering that always precedes a later reverifyNeedsReview
|
|
271
|
+
// pass, since autoFixAttempted is stamped synchronously in the same
|
|
272
|
+
// mutate() as the needs_review transition itself.
|
|
273
|
+
autoFixAttempted: true,
|
|
274
|
+
},
|
|
275
|
+
]);
|
|
276
|
+
writeRunLog('run-30', '30-example', [
|
|
277
|
+
'[scheduler] starting 30-example',
|
|
278
|
+
JSON.stringify({ type: 'result', subtype: 'error_during_execution', is_error: true, result: 'still broken' }),
|
|
279
|
+
]);
|
|
280
|
+
|
|
281
|
+
commitFile(projectCwd, 'src/thing.js', 'hello', 'later commit touching the declared path');
|
|
282
|
+
|
|
283
|
+
await reverifyNeedsReview();
|
|
284
|
+
|
|
285
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
286
|
+
assert.equal(jobs.length, 1);
|
|
287
|
+
assert.equal(jobs[0].status, 'needs_review');
|
|
288
|
+
assert.equal(jobs[0].looksDone, undefined, 'a row already fix-planned must never also be stamped looksDone');
|
|
289
|
+
assert.equal(jobs[0].error, 'transcript_errors: stale verdict', 'the looksDone annotation must not overwrite the original error');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// findSatisfyingCommitOnMain — PRD 1136's already-satisfied-on-main evidence
|
|
293
|
+
// query. Requires EVERY declared path to have landed on main since
|
|
294
|
+
// queuedAt, not just one — a code-review finding (2026-09-07) confirmed
|
|
295
|
+
// that a PRD citing both its real target file and a second file purely as
|
|
296
|
+
// context (e.g. a `path/to/file.js:123` reference) would otherwise
|
|
297
|
+
// auto-complete unattended off any unrelated commit touching only that
|
|
298
|
+
// second, incidental file.
|
|
299
|
+
|
|
300
|
+
test('findSatisfyingCommitOnMain: single declared path, satisfying commit lands on main → returns the sha', async () => {
|
|
301
|
+
const projectCwd = path.join(tmpHome, 'proj-satisfied-single-path');
|
|
302
|
+
initRepoOnMain(projectCwd);
|
|
303
|
+
registerActiveProject(projectCwd, 'proj-satisfied-single-path-slug');
|
|
304
|
+
writePrd(projectCwd, '40-single-path', [
|
|
305
|
+
'# Implementation notes',
|
|
306
|
+
'Edit `src/real-work.js`.',
|
|
307
|
+
].join('\n'));
|
|
308
|
+
|
|
309
|
+
const queuedAt = new Date().toISOString();
|
|
310
|
+
await wait(1100);
|
|
311
|
+
commitFile(projectCwd, 'src/real-work.js', 'hello', 'fix real-work');
|
|
312
|
+
|
|
313
|
+
const job = { slug: '40-single-path', cwd: projectCwd, queuedAt };
|
|
314
|
+
const commits = await findSatisfyingCommitOnMain(job);
|
|
315
|
+
assert.equal(commits.length, 1);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test('findSatisfyingCommitOnMain: two declared paths but only ONE lands → [] (not satisfied — no false completion off an incidental context citation)', async () => {
|
|
319
|
+
const projectCwd = path.join(tmpHome, 'proj-satisfied-partial-path');
|
|
320
|
+
initRepoOnMain(projectCwd);
|
|
321
|
+
registerActiveProject(projectCwd, 'proj-satisfied-partial-path-slug');
|
|
322
|
+
writePrd(projectCwd, '41-partial-path', [
|
|
323
|
+
'# Implementation notes',
|
|
324
|
+
'Edit `src/real-work.js`. See `src/main/hotfile.js:123` for context.',
|
|
325
|
+
].join('\n'));
|
|
326
|
+
|
|
327
|
+
const queuedAt = new Date().toISOString();
|
|
328
|
+
await wait(1100);
|
|
329
|
+
// Only the incidentally-cited context file gets a later, UNRELATED commit
|
|
330
|
+
// — the actual target path (src/real-work.js) never lands.
|
|
331
|
+
commitFile(projectCwd, 'src/main/hotfile.js', 'unrelated change', 'unrelated commit');
|
|
332
|
+
|
|
333
|
+
const job = { slug: '41-partial-path', cwd: projectCwd, queuedAt };
|
|
334
|
+
const commits = await findSatisfyingCommitOnMain(job);
|
|
335
|
+
assert.deepEqual(commits, [], 'partial path coverage must never be treated as satisfied');
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test('findSatisfyingCommitOnMain: two declared paths, BOTH land → returns satisfying commits', async () => {
|
|
339
|
+
const projectCwd = path.join(tmpHome, 'proj-satisfied-both-paths');
|
|
340
|
+
initRepoOnMain(projectCwd);
|
|
341
|
+
registerActiveProject(projectCwd, 'proj-satisfied-both-paths-slug');
|
|
342
|
+
writePrd(projectCwd, '42-both-paths', [
|
|
343
|
+
'# Implementation notes',
|
|
344
|
+
'Edit `src/real-work.js` and `src/main/hotfile.js`.',
|
|
345
|
+
].join('\n'));
|
|
346
|
+
|
|
347
|
+
const queuedAt = new Date().toISOString();
|
|
348
|
+
await wait(1100);
|
|
349
|
+
commitFile(projectCwd, 'src/real-work.js', 'hello', 'fix real-work');
|
|
350
|
+
commitFile(projectCwd, 'src/main/hotfile.js', 'hello', 'fix hotfile too');
|
|
351
|
+
|
|
352
|
+
const job = { slug: '42-both-paths', cwd: projectCwd, queuedAt };
|
|
353
|
+
const commits = await findSatisfyingCommitOnMain(job);
|
|
354
|
+
assert.ok(commits.length >= 1, 'full path coverage must be treated as satisfied');
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test('findSatisfyingCommitOnMain: no queuedAt on the job → [] without querying git', async () => {
|
|
358
|
+
const projectCwd = path.join(tmpHome, 'proj-no-queued-at');
|
|
359
|
+
initRepoOnMain(projectCwd);
|
|
360
|
+
registerActiveProject(projectCwd, 'proj-no-queued-at-slug');
|
|
361
|
+
writePrd(projectCwd, '43-no-queued-at', [
|
|
362
|
+
'# Implementation notes',
|
|
363
|
+
'Edit `src/thing.js`.',
|
|
364
|
+
].join('\n'));
|
|
365
|
+
|
|
366
|
+
const job = { slug: '43-no-queued-at', cwd: projectCwd };
|
|
367
|
+
const commits = await findSatisfyingCommitOnMain(job);
|
|
368
|
+
assert.deepEqual(commits, []);
|
|
369
|
+
});
|
|
370
|
+
|
|
231
371
|
test('computeLooksDone: non-git cwd → null without throwing (git-unavailable)', async () => {
|
|
232
372
|
const notARepo = fs.mkdtempSync(path.join(tmpHome, 'not-a-repo-'));
|
|
233
373
|
writePrd(notARepo, '11-no-repo', [
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-rate-limit-pause.test.cjs — PRD 1118: pause against the BINDING
|
|
3
|
+
* rate-limit window read off a run's own log, not refreshNextReset()'s
|
|
4
|
+
* five_hour-only assumption, and never leave an indefinite pause with no
|
|
5
|
+
* resume timer when both the log and the billing endpoint come up empty
|
|
6
|
+
* (the live failure mode: the usage endpoint itself 429s).
|
|
7
|
+
*
|
|
8
|
+
* These test the PURE decision functions scheduler.cjs's setPaused() and the
|
|
9
|
+
* spawnJob() rateLimited branch delegate to — never the real setPaused/mutate
|
|
10
|
+
* path, which writes the machine's live scheduler queue.json at a fixed
|
|
11
|
+
* homedir path (~/.claude/session-manager/scheduled-plans) and must not be
|
|
12
|
+
* touched by a test run.
|
|
13
|
+
*
|
|
14
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-rate-limit-pause.test.cjs
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
import { test } from 'vitest';
|
|
20
|
+
const assert = require('node:assert/strict');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
const {
|
|
23
|
+
resolveRateLimitPauseReset,
|
|
24
|
+
computeEffectiveResumeAt,
|
|
25
|
+
computeResumeDelay,
|
|
26
|
+
} = require('../scheduler.cjs');
|
|
27
|
+
|
|
28
|
+
// Repo-tracked, byte-for-byte copy of the real 2026-09-05 starry-night-ships
|
|
29
|
+
// incident log (see src/main/lib/__tests__/rateLimitWindow.test.cjs for the
|
|
30
|
+
// live-path parity check) — kept hermetic rather than pointed at the
|
|
31
|
+
// machine-local live path directly.
|
|
32
|
+
const FIXTURE = path.join(__dirname, '..', 'lib', '__tests__', 'fixtures', '204-mercury-steam-horse.log.txt');
|
|
33
|
+
const FIVE_HOUR_ISO = new Date(1788643800 * 1000).toISOString(); // what refreshNextReset() would have returned
|
|
34
|
+
const SEVEN_DAY_ISO = new Date(1789059600 * 1000).toISOString(); // the actually-binding window
|
|
35
|
+
|
|
36
|
+
test('resolveRateLimitPauseReset: prefers the log-derived seven-day reset over the billing five_hour reset', () => {
|
|
37
|
+
const resetIso = resolveRateLimitPauseReset(FIXTURE, FIVE_HOUR_ISO);
|
|
38
|
+
assert.strictEqual(resetIso, SEVEN_DAY_ISO);
|
|
39
|
+
assert.notStrictEqual(resetIso, FIVE_HOUR_ISO);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('resolveRateLimitPauseReset: falls back to the billing reset when the log yields none', () => {
|
|
43
|
+
const resetIso = resolveRateLimitPauseReset('/nonexistent/path/job.log', FIVE_HOUR_ISO);
|
|
44
|
+
assert.strictEqual(resetIso, FIVE_HOUR_ISO);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('resolveRateLimitPauseReset: null when BOTH the log and the billing endpoint are unavailable', () => {
|
|
48
|
+
assert.strictEqual(resolveRateLimitPauseReset('/nonexistent/path/job.log', null), null);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('computeEffectiveResumeAt: rate_limit with no resumeAt gets the same bounded 30-minute fallback as network', () => {
|
|
52
|
+
const now = Date.parse('2026-09-05T16:32:00.000Z');
|
|
53
|
+
const resumeAt = computeEffectiveResumeAt('rate_limit', null, now);
|
|
54
|
+
assert.strictEqual(resumeAt, new Date(now + 30 * 60_000).toISOString());
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('computeEffectiveResumeAt: an explicit resumeAt (the log-derived reset) is never overridden', () => {
|
|
58
|
+
const resumeAt = computeEffectiveResumeAt('rate_limit', SEVEN_DAY_ISO, Date.now());
|
|
59
|
+
assert.strictEqual(resumeAt, SEVEN_DAY_ISO);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('computeEffectiveResumeAt: other reasons (e.g. auth) get no fallback — stays indefinite', () => {
|
|
63
|
+
assert.strictEqual(computeEffectiveResumeAt('auth', null, Date.now()), null);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('computeResumeDelay: a seven-day-out reset (~5 days from now) is armed, not treated as too-far', () => {
|
|
67
|
+
const now = Date.parse('2026-09-05T16:32:00.000Z');
|
|
68
|
+
// ~5 days out — comfortably under the 0x7fffffff (~24.8 day) setTimeout cap.
|
|
69
|
+
const fiveDaysOut = new Date(now + 5 * 24 * 60 * 60_000).toISOString();
|
|
70
|
+
const { delayMs, tooFar } = computeResumeDelay(fiveDaysOut, now);
|
|
71
|
+
assert.strictEqual(tooFar, false);
|
|
72
|
+
assert.ok(delayMs < 0x7fffffff, `expected delay under setTimeout cap, got ${delayMs}`);
|
|
73
|
+
assert.ok(delayMs > 400_000_000, `expected ~5 days in ms, got ${delayMs}`);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('computeResumeDelay: a reset far enough out DOES trip the overflow guard', () => {
|
|
77
|
+
const now = Date.parse('2026-09-05T16:32:00.000Z');
|
|
78
|
+
const thirtyDaysOut = new Date(now + 30 * 24 * 60 * 60_000).toISOString();
|
|
79
|
+
const { tooFar } = computeResumeDelay(thirtyDaysOut, now);
|
|
80
|
+
assert.strictEqual(tooFar, true);
|
|
81
|
+
});
|