claude-code-session-manager 0.78.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-13pfo8uY.js → AgentLibrary-zS3jw_1e.js} +1 -1
- package/dist/assets/{DataModel-SUyQbFlg.js → DataModel-Cy_vxTpi.js} +1 -1
- package/dist/assets/{History-2GJMS703.js → History-C6JRuqfT.js} +1 -1
- package/dist/assets/{Hooks-DM2nS3RT.js → Hooks-BafPy9mB.js} +1 -1
- package/dist/assets/{HostBilko-BLeC-lpp.js → HostBilko-BZwhQOFt.js} +1 -1
- package/dist/assets/{Library-BaRkU9m0.js → Library-C8JDDliz.js} +1 -1
- package/dist/assets/{ListDetail-D5scjSKq.js → ListDetail-CqiOdwLc.js} +1 -1
- package/dist/assets/{MarkdownEditor-B1lgAo9T.js → MarkdownEditor-CyLyP67L.js} +1 -1
- package/dist/assets/{McpServers-BzyThQSM.js → McpServers-BzMv-_84.js} +1 -1
- package/dist/assets/{Memory-7UdaOTtl.js → Memory-DSBYQdJR.js} +1 -1
- package/dist/assets/{Panel-JbTMaOPq.js → Panel-CLUhkNNA.js} +1 -1
- package/dist/assets/{Permissions-UBam0bJG.js → Permissions-BfC2-HN4.js} +1 -1
- package/dist/assets/{Plugins-B3gUDkeb.js → Plugins-BKi40jT5.js} +2 -2
- package/dist/assets/{ProvenanceBadge-CeHOub7m.js → ProvenanceBadge-BzFw4KhD.js} +1 -1
- package/dist/assets/{SaveBar-BcvQEq6h.js → SaveBar-avk2p9jv.js} +1 -1
- package/dist/assets/{Scheduler-Dc5qiP24.js → Scheduler-Bf_6MdJo.js} +7 -7
- package/dist/assets/{ScopeSwitcher-BvGQmw4Y.js → ScopeSwitcher-C-RwYUVZ.js} +1 -1
- package/dist/assets/{Settings-C2dEFb-v.js → Settings-Djd8OoBA.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-CIwlosBc.js → SkillReferenceGraph-DuogY6s7.js} +1 -1
- package/dist/assets/{Skills-C0GzzVrQ.js → Skills-D_qAqxZ_.js} +1 -1
- package/dist/assets/{SystemPrompt-mtGPK8zo.js → SystemPrompt-DbHFLQV3.js} +1 -1
- package/dist/assets/{TagLibrary-DX54-mpd.js → TagLibrary-C2y91BT0.js} +1 -1
- package/dist/assets/{TiptapBody-yADC2RWE.js → TiptapBody-D9iz4xQx.js} +1 -1
- package/dist/assets/{Toggle-CRxaCYLI.js → Toggle-BGnFL2E5.js} +1 -1
- package/dist/assets/{index-D6ymGESc.js → index-_2ARyFDj.js} +4 -4
- package/dist/assets/{settingsSchema-TtMvT5Sx.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__/runLogRetention.test.cjs +59 -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-never-stop.test.cjs +157 -0
- package/src/main/__tests__/scheduler-no-orphan-run-dir.test.cjs +81 -0
- package/src/main/__tests__/scheduler-rate-limit-cooldown-freshness.test.cjs +123 -0
- package/src/main/__tests__/scheduler-rate-limit-spin-guard.test.cjs +158 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +30 -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__/reaperHelpers.test.cjs +90 -1
- 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/jobDirtFilter.cjs +54 -0
- 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/rateLimitDetect.cjs +35 -0
- package/src/main/lib/reaperHelpers.cjs +31 -6
- package/src/main/lib/runLogRetention.cjs +82 -4
- 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 +1165 -70
- package/src/preload/api.d.ts +8 -0
|
@@ -10,6 +10,13 @@
|
|
|
10
10
|
* coverage in scheduler-autofix-select.test.cjs (node --test) exercises the
|
|
11
11
|
* same helpers in more granular detail.
|
|
12
12
|
*
|
|
13
|
+
* PRD 1129 added one sub-shape to case (d): "outcome 'plan' WITH a queue
|
|
14
|
+
* row is neither retried nor annotated" only holds while that queue row
|
|
15
|
+
* (the fix-plan child) is still alive. Once the child itself reaches a
|
|
16
|
+
* terminal, non-completed status, isFixPlanDead makes the parent eligible
|
|
17
|
+
* for exactly one further attempt (stamped autoFixReopened: true), after
|
|
18
|
+
* which it is permanently excluded and annotated again.
|
|
19
|
+
*
|
|
13
20
|
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-autofix-outcome.test.cjs
|
|
14
21
|
*/
|
|
15
22
|
|
|
@@ -17,7 +24,7 @@
|
|
|
17
24
|
|
|
18
25
|
import { test, expect } from 'vitest';
|
|
19
26
|
const {
|
|
20
|
-
selectAutoFixTargets, isExhaustedAutoFix, isPlanUnqueued, fixSlugFor,
|
|
27
|
+
selectAutoFixTargets, isExhaustedAutoFix, isPlanUnqueued, isFixPlanDead, fixSlugFor,
|
|
21
28
|
} = require('../scheduler.cjs');
|
|
22
29
|
|
|
23
30
|
const noSiblingOnDisk = () => false;
|
|
@@ -82,6 +89,71 @@ test("(d) outcome 'plan' with the fix slug present in the queue is neither retri
|
|
|
82
89
|
expect(isAnnotatable(job, queuedSlugs)).toBe(false);
|
|
83
90
|
});
|
|
84
91
|
|
|
92
|
+
// (e) dead fix-plan child: outcome 'plan' whose fix-plan row itself died —
|
|
93
|
+
// the parent is eligible again, exactly once.
|
|
94
|
+
test('(e) a dead fix-plan child makes the parent eligible for one further attempt', () => {
|
|
95
|
+
const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'plan', autoFixRetries: 0 });
|
|
96
|
+
const deadChild = { slug: fixSlugFor(job), status: 'needs_review', parallelGroup: job.parallelGroup };
|
|
97
|
+
const jobs = [job, deadChild];
|
|
98
|
+
expect(isFixPlanDead(job, jobs)).toBe(true);
|
|
99
|
+
expect(selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk }).some((j) => j.slug === job.slug)).toBe(true);
|
|
100
|
+
expect(isAnnotatable(job, new Set(jobs.map((j) => j.slug)))).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('(e) a failed, quarantined, or skipped fix-plan child also counts as dead', () => {
|
|
104
|
+
for (const status of ['failed', 'quarantined', 'skipped']) {
|
|
105
|
+
const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'plan', autoFixRetries: 0 });
|
|
106
|
+
const deadChild = { slug: fixSlugFor(job), status, parallelGroup: job.parallelGroup };
|
|
107
|
+
expect(isFixPlanDead(job, [job, deadChild])).toBe(true);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('(e) an already-reopened parent is annotated, never eligible again — even with another dead child', () => {
|
|
112
|
+
const job = makeJob({
|
|
113
|
+
autoFixAttempted: true, autoFixOutcome: 'plan', autoFixRetries: 1, autoFixReopened: true,
|
|
114
|
+
});
|
|
115
|
+
const deadChild = { slug: fixSlugFor(job), status: 'needs_review', parallelGroup: job.parallelGroup };
|
|
116
|
+
const jobs = [job, deadChild];
|
|
117
|
+
expect(isFixPlanDead(job, jobs)).toBe(false);
|
|
118
|
+
expect(selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk }).some((j) => j.slug === job.slug)).toBe(false);
|
|
119
|
+
expect(isExhaustedAutoFix(job)).toBe(true);
|
|
120
|
+
expect(isAnnotatable(job, new Set(jobs.map((j) => j.slug)))).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('(e) a reopened parent whose second attempt is still in flight (outcome cleared) is untouched — not re-eligible, not yet annotated', () => {
|
|
124
|
+
const job = makeJob({ autoFixAttempted: true, autoFixRetries: 1, autoFixReopened: true });
|
|
125
|
+
expect(isFixPlanDead(job, [job])).toBe(false);
|
|
126
|
+
expect(selectAutoFixTargets([job], { fixSlugExists: noSiblingOnDisk }).some((j) => j.slug === job.slug)).toBe(false);
|
|
127
|
+
expect(isExhaustedAutoFix(job)).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('(e) a fix-plan child that is still pending leaves the parent untouched', () => {
|
|
131
|
+
const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'plan', autoFixRetries: 0 });
|
|
132
|
+
const pendingChild = { slug: fixSlugFor(job), status: 'pending', parallelGroup: job.parallelGroup };
|
|
133
|
+
const jobs = [job, pendingChild];
|
|
134
|
+
expect(isFixPlanDead(job, jobs)).toBe(false);
|
|
135
|
+
expect(selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk }).some((j) => j.slug === job.slug)).toBe(false);
|
|
136
|
+
expect(isAnnotatable(job, new Set(jobs.map((j) => j.slug)))).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('(e) a fix-plan child that is still running leaves the parent untouched', () => {
|
|
140
|
+
const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'plan', autoFixRetries: 0 });
|
|
141
|
+
const runningChild = { slug: fixSlugFor(job), status: 'running', parallelGroup: job.parallelGroup };
|
|
142
|
+
const jobs = [job, runningChild];
|
|
143
|
+
expect(isFixPlanDead(job, jobs)).toBe(false);
|
|
144
|
+
expect(selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk }).some((j) => j.slug === job.slug)).toBe(false);
|
|
145
|
+
expect(isAnnotatable(job, new Set(jobs.map((j) => j.slug)))).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('(e) a fix-plan child that completed leaves the parent untouched (unchanged from today)', () => {
|
|
149
|
+
const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'plan', autoFixRetries: 0 });
|
|
150
|
+
const completedChild = { slug: fixSlugFor(job), status: 'completed', parallelGroup: job.parallelGroup };
|
|
151
|
+
const jobs = [job, completedChild];
|
|
152
|
+
expect(isFixPlanDead(job, jobs)).toBe(false);
|
|
153
|
+
expect(selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk }).some((j) => j.slug === job.slug)).toBe(false);
|
|
154
|
+
expect(isAnnotatable(job, new Set(jobs.map((j) => j.slug)))).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
85
157
|
// (invariant) every needs_review job is either an auto-fix target or
|
|
86
158
|
// annotatable (would receive a non-null verifierVerdict) — never both false.
|
|
87
159
|
test('invariant: fresh, retry-eligible, exhausted, and plan-unqueued jobs are always selected or annotatable', () => {
|
|
@@ -308,6 +308,23 @@ test('isFixPlanBeyondDepthCap: fix-plan at depth 1 is not capped, depth 2 is', (
|
|
|
308
308
|
assert.strictEqual(isFixPlanBeyondDepthCap('05-fix-foo', MAX_INVESTIGATION_DEPTH + 1), true);
|
|
309
309
|
});
|
|
310
310
|
|
|
311
|
+
// PRD 1131: classification is now provenance-aware (isFixPlan), not slug-only.
|
|
312
|
+
// A legacy row with no `isFixPlan` argument at all still falls back to the
|
|
313
|
+
// slug-only heuristic above (no re-classification of live history).
|
|
314
|
+
|
|
315
|
+
test('isFixPlanBeyondDepthCap: a scheduler-authored fix plan (isFixPlan:true) is still classified as one and capped at the same depth', () => {
|
|
316
|
+
assert.strictEqual(isFixPlanBeyondDepthCap('05-fix-foo', MAX_INVESTIGATION_DEPTH, true), false);
|
|
317
|
+
assert.strictEqual(isFixPlanBeyondDepthCap('05-fix-foo', MAX_INVESTIGATION_DEPTH + 1, true), true);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('isFixPlanBeyondDepthCap: a human-authored PRD (isFixPlan:false) whose slug merely matches "fix-" is never capped, even with no investigationDepth (PRD 1126 regression)', () => {
|
|
321
|
+
assert.strictEqual(isFixPlanBeyondDepthCap('126-fix-plan-death-reopens-parent', undefined, false), false);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test('isFixPlanBeyondDepthCap: a legacy row (isFixPlan argument omitted/undefined) with a fix- slug and no depth field is still treated as a fix plan via the slug fallback', () => {
|
|
325
|
+
assert.strictEqual(isFixPlanBeyondDepthCap('05-fix-foo', undefined, undefined), true);
|
|
326
|
+
});
|
|
327
|
+
|
|
311
328
|
test('healTargetForFix: depth-2 fix-of-a-fix slug resolves back to the depth-1 fix job (521-fix-recorder-export-footer-redesign)', () => {
|
|
312
329
|
// Depth-1 fix job: originally authored to fix job 521, itself in needs_review.
|
|
313
330
|
const depth1FixJob = {
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import { test, expect } from 'vitest';
|
|
26
26
|
const { commitGuardVerdict } = require('../scheduler.cjs');
|
|
27
|
+
const { resolveIsFixPlan } = require('../lib/fixPlanSlug.cjs');
|
|
27
28
|
|
|
28
29
|
test('commitGuardVerdict: legitimate no-op verdict (pass_no_commit_already_shipped) + concurrent dirt → no flag (655 incident)', () => {
|
|
29
30
|
const verifyResult = {
|
|
@@ -102,6 +103,25 @@ test('commitGuardVerdict: fix-plan job (523-fix-bounded-fix-plan-retry incident)
|
|
|
102
103
|
expect(verdict).toBeNull();
|
|
103
104
|
});
|
|
104
105
|
|
|
106
|
+
test('commitGuardVerdict: a zero-edit run of a human-authored PRD whose slug merely starts with "fix-" still yields silent_no_op (PRD 1131 — not exempted by name alone)', () => {
|
|
107
|
+
// Mirrors PRD 1126: authored via scheduler_create_prd (isFixPlan stamped
|
|
108
|
+
// false at reconcile time), slug happens to read "fix-...". Feeding the
|
|
109
|
+
// job's own persisted isFixPlan through resolveIsFixPlan (the same call
|
|
110
|
+
// spawnJob makes) must NOT exempt this zero-edit run from the guard.
|
|
111
|
+
const job = { slug: '126-fix-plan-death-reopens-parent', isFixPlan: false };
|
|
112
|
+
const verdict = commitGuardVerdict({
|
|
113
|
+
newlyDirty: [],
|
|
114
|
+
siblingRunning: false,
|
|
115
|
+
jobSelfCommitted: false,
|
|
116
|
+
legitimateNoOp: false,
|
|
117
|
+
isFixPlanJob: resolveIsFixPlan(job.slug, job.isFixPlan),
|
|
118
|
+
verifyResult: null,
|
|
119
|
+
});
|
|
120
|
+
expect(verdict).not.toBeNull();
|
|
121
|
+
expect(verdict.verdict).toBe('silent_no_op');
|
|
122
|
+
expect(verdict.downgradeTo).toBe('needs_review');
|
|
123
|
+
});
|
|
124
|
+
|
|
105
125
|
test('commitGuardVerdict: fix-plan job that DID leave real dirt behind is still flagged (isFixPlanJob only exempts the zero-edit case)', () => {
|
|
106
126
|
const verdict = commitGuardVerdict({
|
|
107
127
|
newlyDirty: ['src/main/someFeature.cjs'],
|
|
@@ -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
|
+
});
|