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
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-resume-recovery.test.cjs — PRD 1111: resume-first recovery for a
|
|
3
|
+
* job parked in needs_review with verdict 'uncommitted_changes'. Before this
|
|
4
|
+
* feature, every such job went straight to spawnInvestigation (a cold-read
|
|
5
|
+
* fix-plan PRD authored by a FRESH session with no memory of the run it's
|
|
6
|
+
* diagnosing). This suite covers selectResumeRecoveryTarget's eligibility
|
|
7
|
+
* table, the --resume vs --session-id argv split (buildClaudeSpawnArgs), the
|
|
8
|
+
* preamble builder, the one-attempt bound, the SM_RESUME_RECOVERY_DISABLE
|
|
9
|
+
* kill-switch, and the two skip-gates (spawnInvestigation, selectAutoFixTargets).
|
|
10
|
+
*
|
|
11
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-resume-recovery.test.cjs
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
import { test, expect, afterEach } from 'vitest';
|
|
17
|
+
const {
|
|
18
|
+
selectResumeRecoveryTarget,
|
|
19
|
+
buildResumeRecoveryPreamble,
|
|
20
|
+
buildClaudeSpawnArgs,
|
|
21
|
+
selectAutoFixTargets,
|
|
22
|
+
spawnInvestigation,
|
|
23
|
+
} = require('../scheduler.cjs');
|
|
24
|
+
|
|
25
|
+
const savedDisable = process.env.SM_RESUME_RECOVERY_DISABLE;
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
if (savedDisable === undefined) delete process.env.SM_RESUME_RECOVERY_DISABLE;
|
|
28
|
+
else process.env.SM_RESUME_RECOVERY_DISABLE = savedDisable;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function baseJob(overrides = {}) {
|
|
32
|
+
return {
|
|
33
|
+
slug: '900-example',
|
|
34
|
+
status: 'needs_review',
|
|
35
|
+
verifierVerdict: 'uncommitted_changes',
|
|
36
|
+
sessionId: 'sess-abc-123',
|
|
37
|
+
uncommittedPaths: ['src/foo.js', 'src/bar.js'],
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------- selectResumeRecoveryTarget eligibility table ----------
|
|
43
|
+
|
|
44
|
+
test('selectResumeRecoveryTarget: eligible shape returns a resume decision', () => {
|
|
45
|
+
const target = selectResumeRecoveryTarget(baseJob());
|
|
46
|
+
expect(target).toEqual({
|
|
47
|
+
slug: '900-example',
|
|
48
|
+
sessionId: 'sess-abc-123',
|
|
49
|
+
dirtyPaths: ['src/foo.js', 'src/bar.js'],
|
|
50
|
+
salvagePatch: null,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('selectResumeRecoveryTarget: carries salvagePatch through when present on the row', () => {
|
|
55
|
+
const target = selectResumeRecoveryTarget(baseJob({ salvagePatch: '/tmp/runs/x/900-example.uncommitted.patch' }));
|
|
56
|
+
expect(target.salvagePatch).toBe('/tmp/runs/x/900-example.uncommitted.patch');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const notNeedsReviewStatuses = ['pending', 'running', 'investigating', 'completed', 'failed', 'skipped'];
|
|
60
|
+
for (const status of notNeedsReviewStatuses) {
|
|
61
|
+
test(`selectResumeRecoveryTarget: status=${status} → null`, () => {
|
|
62
|
+
expect(selectResumeRecoveryTarget(baseJob({ status }))).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const nonRecoverableVerdicts = [
|
|
67
|
+
'silent_no_op',
|
|
68
|
+
'pass_no_commit_already_shipped',
|
|
69
|
+
'pass_no_commit_prior_run_verified',
|
|
70
|
+
'transcript_errors',
|
|
71
|
+
'deps_unmet',
|
|
72
|
+
'halt',
|
|
73
|
+
'verify_unavailable',
|
|
74
|
+
'worktree_integration_failed',
|
|
75
|
+
'shared_tree_reverted',
|
|
76
|
+
undefined,
|
|
77
|
+
];
|
|
78
|
+
for (const verdict of nonRecoverableVerdicts) {
|
|
79
|
+
test(`selectResumeRecoveryTarget: verdict=${JSON.stringify(verdict)} → null`, () => {
|
|
80
|
+
expect(selectResumeRecoveryTarget(baseJob({ verifierVerdict: verdict }))).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
test('selectResumeRecoveryTarget: missing sessionId → null', () => {
|
|
85
|
+
expect(selectResumeRecoveryTarget(baseJob({ sessionId: undefined }))).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('selectResumeRecoveryTarget: empty-string sessionId → null', () => {
|
|
89
|
+
expect(selectResumeRecoveryTarget(baseJob({ sessionId: '' }))).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('selectResumeRecoveryTarget: non-string sessionId → null', () => {
|
|
93
|
+
expect(selectResumeRecoveryTarget(baseJob({ sessionId: 12345 }))).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('selectResumeRecoveryTarget: resumeRecoveryAttempted === true → null (bounded to one attempt)', () => {
|
|
97
|
+
expect(selectResumeRecoveryTarget(baseJob({ resumeRecoveryAttempted: true }))).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('selectResumeRecoveryTarget: resumeRecoveryAttempted absent/false → still eligible', () => {
|
|
101
|
+
expect(selectResumeRecoveryTarget(baseJob({ resumeRecoveryAttempted: false }))).not.toBeNull();
|
|
102
|
+
expect(selectResumeRecoveryTarget(baseJob())).not.toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('selectResumeRecoveryTarget: no recorded dirty paths → null', () => {
|
|
106
|
+
expect(selectResumeRecoveryTarget(baseJob({ uncommittedPaths: [] }))).toBeNull();
|
|
107
|
+
expect(selectResumeRecoveryTarget(baseJob({ uncommittedPaths: undefined }))).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('selectResumeRecoveryTarget: null/undefined job → null', () => {
|
|
111
|
+
expect(selectResumeRecoveryTarget(null)).toBeNull();
|
|
112
|
+
expect(selectResumeRecoveryTarget(undefined)).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('selectResumeRecoveryTarget: SM_RESUME_RECOVERY_DISABLE=1 → null even for an otherwise-eligible job', () => {
|
|
116
|
+
process.env.SM_RESUME_RECOVERY_DISABLE = '1';
|
|
117
|
+
expect(selectResumeRecoveryTarget(baseJob())).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ---------- --resume vs --session-id argv (buildClaudeSpawnArgs) ----------
|
|
121
|
+
|
|
122
|
+
test('buildClaudeSpawnArgs: resume=true passes --resume <sessionId>, never --session-id', () => {
|
|
123
|
+
const args = buildClaudeSpawnArgs({ prompt: 'preamble text', model: 'sonnet', sessionId: 'sess-abc-123', resume: true });
|
|
124
|
+
expect(args).toContain('--resume');
|
|
125
|
+
expect(args[args.indexOf('--resume') + 1]).toBe('sess-abc-123');
|
|
126
|
+
expect(args).not.toContain('--session-id');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('buildClaudeSpawnArgs: resume=false passes --session-id <sessionId>, never --resume', () => {
|
|
130
|
+
const args = buildClaudeSpawnArgs({ prompt: 'prd body', model: 'sonnet', sessionId: 'sess-fresh-456', resume: false });
|
|
131
|
+
expect(args).toContain('--session-id');
|
|
132
|
+
expect(args[args.indexOf('--session-id') + 1]).toBe('sess-fresh-456');
|
|
133
|
+
expect(args).not.toContain('--resume');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('buildClaudeSpawnArgs: --model is always explicitly pinned, both modes', () => {
|
|
137
|
+
for (const resume of [true, false]) {
|
|
138
|
+
const args = buildClaudeSpawnArgs({ prompt: 'x', model: 'sonnet', sessionId: 'sid', resume });
|
|
139
|
+
const modelIdx = args.indexOf('--model');
|
|
140
|
+
expect(modelIdx).toBeGreaterThanOrEqual(0);
|
|
141
|
+
expect(args[modelIdx + 1]).toBe('sonnet');
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ---------- preamble content (buildResumeRecoveryPreamble) ----------
|
|
146
|
+
|
|
147
|
+
test('buildResumeRecoveryPreamble: contains every recorded dirty path', () => {
|
|
148
|
+
const dirtyPaths = ['src/main/scheduler.cjs', 'session-manager-operations/scheduler/prds/900-example.md'];
|
|
149
|
+
const preamble = buildResumeRecoveryPreamble({ dirtyPaths, salvagePatch: null });
|
|
150
|
+
for (const p of dirtyPaths) {
|
|
151
|
+
expect(preamble).toContain(p);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('buildResumeRecoveryPreamble: does NOT re-embed a PRD body passed in as an unrelated string', () => {
|
|
156
|
+
const prdBody = '# Goal\nSome long PRD body that must never appear in a resume preamble.\n## Acceptance criteria\n- [ ] thing';
|
|
157
|
+
const preamble = buildResumeRecoveryPreamble({ dirtyPaths: ['a.js'], salvagePatch: null });
|
|
158
|
+
expect(preamble).not.toContain(prdBody);
|
|
159
|
+
expect(preamble).not.toContain('Acceptance criteria');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('buildResumeRecoveryPreamble: instructs verifying paths on disk, foreground gate, and commit', () => {
|
|
163
|
+
const preamble = buildResumeRecoveryPreamble({ dirtyPaths: ['a.js'], salvagePatch: null });
|
|
164
|
+
expect(preamble).toMatch(/verify/i);
|
|
165
|
+
expect(preamble).toMatch(/FOREGROUND/);
|
|
166
|
+
expect(preamble).toMatch(/commit/i);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('buildResumeRecoveryPreamble: mentions the salvage patch path when present', () => {
|
|
170
|
+
const preamble = buildResumeRecoveryPreamble({ dirtyPaths: ['a.js'], salvagePatch: '/tmp/runs/x/900-example.uncommitted.patch' });
|
|
171
|
+
expect(preamble).toContain('/tmp/runs/x/900-example.uncommitted.patch');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ---------- selectAutoFixTargets skips resume-eligible jobs ----------
|
|
175
|
+
|
|
176
|
+
test('selectAutoFixTargets: excludes a job selectResumeRecoveryTarget accepts', () => {
|
|
177
|
+
const job = baseJob({ runId: 'run-1' });
|
|
178
|
+
const targets = selectAutoFixTargets([job], {
|
|
179
|
+
fixSlugExists: () => false,
|
|
180
|
+
resolveJobRunId: () => job.runId,
|
|
181
|
+
});
|
|
182
|
+
expect(targets).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('selectAutoFixTargets: still selects a needs_review job that is NOT resume-eligible (different verdict)', () => {
|
|
186
|
+
const job = baseJob({ runId: 'run-1', verifierVerdict: 'transcript_errors' });
|
|
187
|
+
const targets = selectAutoFixTargets([job], {
|
|
188
|
+
fixSlugExists: () => false,
|
|
189
|
+
resolveJobRunId: () => job.runId,
|
|
190
|
+
});
|
|
191
|
+
expect(targets.map((t) => t.slug)).toEqual([job.slug]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('selectAutoFixTargets: a resume-recovery-exhausted job (resumeRecoveryAttempted=true) falls through to normal auto-fix eligibility', () => {
|
|
195
|
+
const job = baseJob({ runId: 'run-1', resumeRecoveryAttempted: true });
|
|
196
|
+
const targets = selectAutoFixTargets([job], {
|
|
197
|
+
fixSlugExists: () => false,
|
|
198
|
+
resolveJobRunId: () => job.runId,
|
|
199
|
+
});
|
|
200
|
+
expect(targets.map((t) => t.slug)).toEqual([job.slug]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// ---------- resume-then-fail: never a dead end ----------
|
|
204
|
+
|
|
205
|
+
test('resume-then-fail: a job whose one resume attempt is spent falls through to the normal fix-plan path, not a dead end', () => {
|
|
206
|
+
// spawnResumeRecovery reuses spawnJob wholesale, so a resume run that
|
|
207
|
+
// itself re-parks in needs_review runs through the SAME finalize mutate as
|
|
208
|
+
// any other run — the only difference is resumeRecoveryAttempted is now
|
|
209
|
+
// true on the row (stamped atomically with the 'running' transition
|
|
210
|
+
// before the resume child ever spawned).
|
|
211
|
+
const jobAfterFailedResume = baseJob({ runId: 'run-2', resumeRecoveryAttempted: true });
|
|
212
|
+
// A second resume is refused...
|
|
213
|
+
expect(selectResumeRecoveryTarget(jobAfterFailedResume)).toBeNull();
|
|
214
|
+
// ...but the job is NOT stranded: it is offered for a fix-plan exactly
|
|
215
|
+
// like any other needs_review job, via the same gate spawnInvestigation's
|
|
216
|
+
// periodic caller (reverifyNeedsReview) consults.
|
|
217
|
+
const targets = selectAutoFixTargets([jobAfterFailedResume], {
|
|
218
|
+
fixSlugExists: () => false,
|
|
219
|
+
resolveJobRunId: () => jobAfterFailedResume.runId,
|
|
220
|
+
});
|
|
221
|
+
expect(targets.map((t) => t.slug)).toEqual([jobAfterFailedResume.slug]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ---------- spawnInvestigation's own resume-first guard ----------
|
|
225
|
+
|
|
226
|
+
test('spawnInvestigation: a resume-eligible job is skipped before any fix-plan work (returns deferred:false immediately)', async () => {
|
|
227
|
+
const job = baseJob();
|
|
228
|
+
// A resume-eligible job trips the guard before spawnInvestigation ever
|
|
229
|
+
// touches the filesystem for runDir/investigationDepth lookups, so a
|
|
230
|
+
// nonexistent runDir is safe to pass here — proves the guard runs first.
|
|
231
|
+
const result = await spawnInvestigation(job, '/nonexistent/run/dir/for/this/test');
|
|
232
|
+
expect(result).toEqual({ deferred: false });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('spawnInvestigation: resume-recovery-exhausted job (resumeRecoveryAttempted=true) is NOT skipped by the resume guard', async () => {
|
|
236
|
+
// slug matches the fix-plan pattern + investigationDepth beyond the cap so
|
|
237
|
+
// the NEXT gate (isFixPlanBeyondDepthCap) trips deterministically and
|
|
238
|
+
// returns before any fs/spawn work — proving the resume guard was passed
|
|
239
|
+
// through, not that this particular job happens to be safe to fully run.
|
|
240
|
+
const job = baseJob({ slug: '01-fix-example', resumeRecoveryAttempted: true, investigationDepth: 99 });
|
|
241
|
+
const result = await spawnInvestigation(job, '/nonexistent/run/dir/for/this/test');
|
|
242
|
+
expect(result).toEqual({ deferred: false });
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('SM_RESUME_RECOVERY_DISABLE=1: spawnInvestigation is NOT skipped by the resume guard for an otherwise-eligible job', async () => {
|
|
246
|
+
process.env.SM_RESUME_RECOVERY_DISABLE = '1';
|
|
247
|
+
const job = baseJob({ slug: '01-fix-example', investigationDepth: 99 });
|
|
248
|
+
// With the kill-switch on, selectResumeRecoveryTarget returns null, so the
|
|
249
|
+
// resume guard never fires; the job falls through to the NEXT gate
|
|
250
|
+
// (isFixPlanBeyondDepthCap, tripped here via investigationDepth: 99) —
|
|
251
|
+
// proving today's pre-feature behaviour is restored exactly.
|
|
252
|
+
const result = await spawnInvestigation(job, '/nonexistent/run/dir/for/this/test');
|
|
253
|
+
expect(result).toEqual({ deferred: false });
|
|
254
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// PRD 1123 — a single row parked in `needs_review` (or `failed`/`skipped`)
|
|
4
|
+
// several dependsOn hops down used to read as generic starvation: the picker
|
|
5
|
+
// only ever named a held job's IMMEDIATE dep, which is very often just
|
|
6
|
+
// another `pending` row, not the actual cause. These tests pin the root-
|
|
7
|
+
// blocker walk: the reason must name the transitive ancestor that is itself
|
|
8
|
+
// not blocked by anything, plus the count of jobs held behind it and its
|
|
9
|
+
// status — and must terminate (not hang) on a dependsOn cycle.
|
|
10
|
+
//
|
|
11
|
+
// Run: timeout 120 npx vitest run src/main/__tests__/schedulerBatchRootBlocker.test.cjs
|
|
12
|
+
|
|
13
|
+
const assert = require('node:assert/strict');
|
|
14
|
+
const { pickForProject, DEFAULT_PROJECT_CWD } = require('../lib/schedulerBatch.cjs');
|
|
15
|
+
|
|
16
|
+
const CWD = DEFAULT_PROJECT_CWD;
|
|
17
|
+
|
|
18
|
+
function job(slug, status, extra = {}) {
|
|
19
|
+
return { slug, status, cwd: CWD, dependsOn: [], ...extra };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test('a 3-deep chain rooted at a needs_review row names the ROOT, not the middle link', () => {
|
|
23
|
+
const jobs = [
|
|
24
|
+
job('100-root', 'needs_review'),
|
|
25
|
+
job('101-middle', 'pending', { dependsOn: ['100-root'] }),
|
|
26
|
+
job('102-leaf', 'pending', { dependsOn: ['101-middle'] }),
|
|
27
|
+
];
|
|
28
|
+
const { batch, reason, holds } = pickForProject(jobs, new Set(), 3);
|
|
29
|
+
assert.deepEqual(batch, []);
|
|
30
|
+
assert.match(reason, /depends-gate/);
|
|
31
|
+
// Names the root, not the immediate (middle) dep as the cause.
|
|
32
|
+
assert.match(reason, /root blocker 100-root/);
|
|
33
|
+
assert.match(reason, /needs_review/);
|
|
34
|
+
assert.match(reason, /is a QUESTION awaiting a human/);
|
|
35
|
+
// Both downstream jobs are counted as held behind the root.
|
|
36
|
+
assert.match(reason, /holding 2 job\(s\)/);
|
|
37
|
+
assert.match(reason, /101-middle/);
|
|
38
|
+
assert.match(reason, /102-leaf/);
|
|
39
|
+
|
|
40
|
+
const leafHold = holds.find((h) => h.slug === '102-leaf');
|
|
41
|
+
assert.equal(leafHold.dep, '101-middle'); // immediate dep still recorded
|
|
42
|
+
assert.equal(leafHold.rootSlug, '100-root'); // but the row also knows the root
|
|
43
|
+
assert.equal(leafHold.rootStatus, 'needs_review');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('the same 3-deep chain rooted at a FAILED row names the root, not the middle link', () => {
|
|
47
|
+
const jobs = [
|
|
48
|
+
job('200-root', 'failed'),
|
|
49
|
+
job('201-middle', 'pending', { dependsOn: ['200-root'] }),
|
|
50
|
+
job('202-leaf', 'pending', { dependsOn: ['201-middle'] }),
|
|
51
|
+
];
|
|
52
|
+
const { batch, reason, holds } = pickForProject(jobs, new Set(), 3);
|
|
53
|
+
assert.deepEqual(batch, []);
|
|
54
|
+
assert.match(reason, /root blocker 200-root/);
|
|
55
|
+
assert.match(reason, /\(failed\)/);
|
|
56
|
+
assert.match(reason, /holding 1 job\(s\)/); // only 202-leaf: 201-middle is caught by the shallow failed-dep bucket
|
|
57
|
+
assert.match(reason, /Reset or archive 200-root/);
|
|
58
|
+
|
|
59
|
+
const leafHold = holds.find((h) => h.slug === '202-leaf');
|
|
60
|
+
assert.equal(leafHold.rootSlug, '200-root');
|
|
61
|
+
assert.equal(leafHold.rootStatus, 'failed');
|
|
62
|
+
const middleHold = holds.find((h) => h.slug === '201-middle');
|
|
63
|
+
assert.equal(middleHold.rootSlug, '200-root');
|
|
64
|
+
assert.equal(middleHold.rootStatus, 'failed');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('a 2-node dependsOn cycle is reported as its own reason, not a hang', () => {
|
|
68
|
+
const jobs = [
|
|
69
|
+
job('300-a', 'pending', { dependsOn: ['301-b'] }),
|
|
70
|
+
job('301-b', 'pending', { dependsOn: ['300-a'] }),
|
|
71
|
+
];
|
|
72
|
+
const start = Date.now();
|
|
73
|
+
const { batch, reason } = pickForProject(jobs, new Set(), 3);
|
|
74
|
+
assert.ok(Date.now() - start < 5000, 'must terminate quickly, not hang');
|
|
75
|
+
assert.deepEqual(batch, []);
|
|
76
|
+
assert.match(reason, /CYCLE/);
|
|
77
|
+
assert.match(reason, /300-a/);
|
|
78
|
+
assert.match(reason, /301-b/);
|
|
79
|
+
// Reported exactly once, not once per member.
|
|
80
|
+
assert.equal((reason.match(/CYCLE/g) || []).length, 1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('no false stall reported when the gate is not the reason the batch is empty (slots exhausted)', () => {
|
|
84
|
+
const jobs = [job('400-a', 'pending'), job('401-b', 'pending')];
|
|
85
|
+
const { batch, reason } = pickForProject(jobs, new Set(), 0);
|
|
86
|
+
assert.deepEqual(batch, []);
|
|
87
|
+
assert.match(reason, /no slots free/);
|
|
88
|
+
assert.doesNotMatch(reason, /root blocker/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('no false stall reported when the gate is not the reason the batch is empty (project cap)', () => {
|
|
92
|
+
const prevCap = process.env.SM_PROJECT_JOB_CAP;
|
|
93
|
+
process.env.SM_PROJECT_JOB_CAP = '1';
|
|
94
|
+
try {
|
|
95
|
+
const jobs = [
|
|
96
|
+
job('402-running', 'running'),
|
|
97
|
+
job('403-pending', 'pending'),
|
|
98
|
+
];
|
|
99
|
+
const { batch, reason } = pickForProject(jobs, new Set(['402-running']), 3);
|
|
100
|
+
assert.deepEqual(batch, []);
|
|
101
|
+
assert.match(reason, /project-cap/);
|
|
102
|
+
assert.doesNotMatch(reason, /root blocker/);
|
|
103
|
+
} finally {
|
|
104
|
+
if (prevCap === undefined) delete process.env.SM_PROJECT_JOB_CAP;
|
|
105
|
+
else process.env.SM_PROJECT_JOB_CAP = prevCap;
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('a running/pending-rooted chain reports no stall reason (expected to clear on its own)', () => {
|
|
110
|
+
const jobs = [
|
|
111
|
+
job('500-root', 'running'),
|
|
112
|
+
job('501-dependent', 'pending', { dependsOn: ['500-root'] }),
|
|
113
|
+
];
|
|
114
|
+
const { batch, reason } = pickForProject(jobs, new Set(['500-root']), 3);
|
|
115
|
+
assert.deepEqual(batch, []);
|
|
116
|
+
assert.equal(reason, null);
|
|
117
|
+
});
|
|
@@ -79,12 +79,24 @@ describe('pickForProject dependsOn eligibility (PRD 832)', () => {
|
|
|
79
79
|
expect(res.batch.map((j) => j.slug)).toContain('851-next');
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
-
it('a dep slug with NO queue row counts as done
|
|
82
|
+
it('a dep slug with NO queue row counts as done when found in history/archive (PRD 1122)', () => {
|
|
83
83
|
const dependent = job('853-next', 'pending', 853, { dependsOn: ['852-archived-long-ago'] });
|
|
84
|
-
|
|
84
|
+
// Caller (scheduler.cjs's computeDepHistorySatisfaction) precomputes this
|
|
85
|
+
// Set from history.jsonl/prds-archived — pickForProject never does I/O.
|
|
86
|
+
const satisfiedSlugs = new Set(['852-archived-long-ago']);
|
|
87
|
+
const res = pickForProject([dependent], new Set(), 3, new Map(), satisfiedSlugs);
|
|
85
88
|
expect(res.batch.map((j) => j.slug)).toContain('853-next');
|
|
86
89
|
});
|
|
87
90
|
|
|
91
|
+
it('a dep slug with NO queue row and NO history/archive record HOLDS as unresolved (PRD 1122)', () => {
|
|
92
|
+
const dependent = job('853-next', 'pending', 853, { dependsOn: ['852-archived-long-ago'] });
|
|
93
|
+
const res = pickForProject([dependent], new Set(), 3);
|
|
94
|
+
expect(res.batch).toEqual([]);
|
|
95
|
+
expect(res.reason).toMatch(/unresolvable/);
|
|
96
|
+
expect(res.reason).toContain('853-next');
|
|
97
|
+
expect(res.reason).toContain('852-archived-long-ago');
|
|
98
|
+
});
|
|
99
|
+
|
|
88
100
|
it('a FAILED dep holds the dependent with an explicit depends-gate reason', () => {
|
|
89
101
|
const dep = job('854-base', 'failed', 854);
|
|
90
102
|
const dependent = job('855-next', 'pending', 855, { dependsOn: ['854-base'] });
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -313,6 +313,13 @@ const NO_NEWLINE_RE = /^[^\r\n]*$/;
|
|
|
313
313
|
// prdCreate.cjs's createPrd via prdAgentType.cjs's assertAgentTypeWritable —
|
|
314
314
|
// this schema only enforces the name SHAPE.
|
|
315
315
|
const PrdAgentTypeSchema = z.string().regex(PERSONA_NAME_RE, 'agent name must be lowercase, hyphenated (e.g. "dev-lead")');
|
|
316
|
+
// Shared shape for a `dependsOn` slug entry — schedulerCreatePrd and
|
|
317
|
+
// adminUpdatePrd (scheduler_update_prd, PRD 1124) must accept the exact same
|
|
318
|
+
// slug shape or an update could write a dependsOn value create would have
|
|
319
|
+
// rejected. Array-level constraints (`.max(20)`, `.optional()`) stay at each
|
|
320
|
+
// call site since update also allows an explicit empty array to CLEAR the
|
|
321
|
+
// dependency, which create has no reason to accept.
|
|
322
|
+
const DepSlugSchema = z.string().min(1).max(160).regex(/^[A-Za-z0-9][\w.-]*$/);
|
|
316
323
|
const schedulerCreatePrd = z.object({
|
|
317
324
|
title: z.string().min(1).max(200).regex(NO_NEWLINE_RE, 'must not contain newlines'),
|
|
318
325
|
// Optional (PRD: worktree-cwd Epic-lookup hazard) — when omitted, prdCreate.cjs's
|
|
@@ -332,7 +339,7 @@ const schedulerCreatePrd = z.object({
|
|
|
332
339
|
parallelGroup: z.number().int().min(1).max(999999).optional(),
|
|
333
340
|
// Explicit ordering (PRD 832): slugs that must complete before this PRD
|
|
334
341
|
// becomes eligible. Written to frontmatter as `dependsOn: [a, b]`.
|
|
335
|
-
dependsOn: z.array(
|
|
342
|
+
dependsOn: z.array(DepSlugSchema).max(20).optional(),
|
|
336
343
|
// An EXISTING Epic's promptSessionId (PRD 748) — NOT a PromptTicket.id.
|
|
337
344
|
// ensureEpic() (epicMint.cjs) joins the Epic whose active-index.json
|
|
338
345
|
// sessions key literally equals this value; the renderer only ever sends
|
|
@@ -455,6 +462,13 @@ const adminPrdFrontmatterPatch = z.object({
|
|
|
455
462
|
// Opt-in exclusive-lease flag (PRD 1107) — patchable after creation too,
|
|
456
463
|
// same as every other recognized key here.
|
|
457
464
|
quietMachine: z.boolean().optional(),
|
|
465
|
+
// Explicit ordering (PRD 832), promoted to patchable by PRD 1124 — see
|
|
466
|
+
// prdFrontmatter.cjs's header. An explicit `[]` CLEARS the dependency
|
|
467
|
+
// (updatePrd's array-emptiness check skips writing the key at all); a
|
|
468
|
+
// non-empty array is validated write-time against the SAME resolver
|
|
469
|
+
// (depSlugResolve.cjs) scheduler_create_prd uses, so update and create
|
|
470
|
+
// can never disagree about what a dependsOn entry resolves to.
|
|
471
|
+
dependsOn: z.array(DepSlugSchema).max(20).optional(),
|
|
458
472
|
});
|
|
459
473
|
|
|
460
474
|
const adminUpdatePrd = z.object({
|
|
@@ -186,7 +186,7 @@ test('[job] integration is skipped (carried-wip-only) when the branch only commi
|
|
|
186
186
|
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: !outcome.ok });
|
|
187
187
|
});
|
|
188
188
|
|
|
189
|
-
test('[job] a branch that commits carried paths ALONGSIDE its own work
|
|
189
|
+
test('[job] a branch that commits carried paths ALONGSIDE its own work auto-resolves the merge (PRD 1125: carried path is byte-identical duplicate, real work still lands)', async () => {
|
|
190
190
|
const slug = 'test-slug-carried-plus-own-work';
|
|
191
191
|
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
|
|
192
192
|
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
@@ -198,16 +198,20 @@ test('[job] a branch that commits carried paths ALONGSIDE its own work still att
|
|
|
198
198
|
git(['commit', '-q', '-m', 'job commit touching carried path + own work'], worktree.dir);
|
|
199
199
|
|
|
200
200
|
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug, carriedPaths: worktree.carriedPaths });
|
|
201
|
-
// The base tree still holds README.md dirty,
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
expect(
|
|
201
|
+
// The base tree still holds README.md dirty, but its content is BYTE
|
|
202
|
+
// IDENTICAL to the branch's committed copy (the branch never edited it,
|
|
203
|
+
// only carried it through) — this is exactly the shape PRD 1125 teaches
|
|
204
|
+
// integrateBranch to auto-resolve: discard the duplicate, retry once, land
|
|
205
|
+
// the job's real new-file.txt commit too. Not the carried-wip-only
|
|
206
|
+
// shortcut (that only fires when the branch's ONLY changes are carried
|
|
207
|
+
// paths) — here the merge itself is what resolves.
|
|
208
|
+
expect(outcome.ok).toBe(true);
|
|
209
|
+
expect(outcome.integrated).toBe(true);
|
|
210
|
+
expect(outcome.autoResolved).toBe('identical_working_tree_duplicates');
|
|
211
|
+
expect(outcome.resolvedPaths).toEqual(['README.md']);
|
|
212
|
+
expect(fs.existsSync(path.join(repoCwd, 'new-file.txt'))).toBe(true);
|
|
209
213
|
|
|
210
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch:
|
|
214
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: !outcome.ok });
|
|
211
215
|
});
|
|
212
216
|
|
|
213
217
|
test('[job] a branch that ADDS its own edit on top of the carried content on the SAME path is never classified carried-wip-only (content-verify, not path-only)', async () => {
|
|
@@ -683,3 +687,118 @@ test('[job] reconcileWorktreesOnBoot logs a one-shot report line with reclaimed
|
|
|
683
687
|
logSpy.mockRestore();
|
|
684
688
|
}
|
|
685
689
|
});
|
|
690
|
+
|
|
691
|
+
// ──────────────────────────────────────────── PRD 1125: auto-resolve identical-duplicate merge blocks
|
|
692
|
+
|
|
693
|
+
test('[job] integrateBranch auto-resolves when every blocking path (tracked + untracked) is byte-identical to the branch', async () => {
|
|
694
|
+
const slug = 'test-slug-identical-duplicates';
|
|
695
|
+
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
696
|
+
expect(worktree.ok).toBe(true);
|
|
697
|
+
|
|
698
|
+
// The job's own real work, committed on its branch.
|
|
699
|
+
fs.writeFileSync(path.join(worktree.dir, 'fileA'), 'shared-value\n', 'utf8');
|
|
700
|
+
fs.mkdirSync(path.join(worktree.dir, 'newdir'), { recursive: true });
|
|
701
|
+
fs.writeFileSync(path.join(worktree.dir, 'newdir', 'fileU'), 'untracked-shared-value\n', 'utf8');
|
|
702
|
+
git(['add', '-A'], worktree.dir);
|
|
703
|
+
git(['commit', '-q', '-m', 'job commit'], worktree.dir);
|
|
704
|
+
|
|
705
|
+
// The shared main tree independently ends up with the SAME bytes on both
|
|
706
|
+
// a tracked path (dirty, uncommitted) and an untracked path — exactly the
|
|
707
|
+
// 2026-09-06 starry-night-ships incident shape: a prior in-place run left
|
|
708
|
+
// these duplicates behind uncommitted.
|
|
709
|
+
fs.writeFileSync(path.join(repoCwd, 'fileA'), 'shared-value\n', 'utf8');
|
|
710
|
+
fs.mkdirSync(path.join(repoCwd, 'newdir'), { recursive: true });
|
|
711
|
+
fs.writeFileSync(path.join(repoCwd, 'newdir', 'fileU'), 'untracked-shared-value\n', 'utf8');
|
|
712
|
+
|
|
713
|
+
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
714
|
+
expect(outcome.ok).toBe(true);
|
|
715
|
+
expect(outcome.integrated).toBe(true);
|
|
716
|
+
expect(outcome.mergeCommit ?? outcome.fastForward).toBeTruthy();
|
|
717
|
+
expect(outcome.autoResolved).toBe('identical_working_tree_duplicates');
|
|
718
|
+
expect(outcome.resolvedPaths.sort()).toEqual(['fileA', 'newdir/fileU'].sort());
|
|
719
|
+
|
|
720
|
+
expect(fs.readFileSync(path.join(repoCwd, 'fileA'), 'utf8')).toBe('shared-value\n');
|
|
721
|
+
expect(fs.readFileSync(path.join(repoCwd, 'newdir', 'fileU'), 'utf8')).toBe('untracked-shared-value\n');
|
|
722
|
+
expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
|
|
723
|
+
|
|
724
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: !outcome.ok });
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
test('[job] integrateBranch does NOT auto-resolve when one blocking path differs from the branch — same reason as today', async () => {
|
|
728
|
+
const slug = 'test-slug-one-differing-path';
|
|
729
|
+
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
730
|
+
expect(worktree.ok).toBe(true);
|
|
731
|
+
|
|
732
|
+
fs.writeFileSync(path.join(worktree.dir, 'fileA'), 'branch-value\n', 'utf8');
|
|
733
|
+
git(['add', '-A'], worktree.dir);
|
|
734
|
+
git(['commit', '-q', '-m', 'job commit'], worktree.dir);
|
|
735
|
+
|
|
736
|
+
// Main tree's dirty copy diverges from the branch's committed content —
|
|
737
|
+
// real information is at risk, so this must NOT be discarded.
|
|
738
|
+
fs.writeFileSync(path.join(repoCwd, 'fileA'), 'different-local-value\n', 'utf8');
|
|
739
|
+
|
|
740
|
+
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
741
|
+
expect(outcome.ok).toBe(false);
|
|
742
|
+
expect(outcome.reason).toMatch(/merge failed \(likely a real content conflict\)/);
|
|
743
|
+
expect(fs.readFileSync(path.join(repoCwd, 'fileA'), 'utf8')).toBe('different-local-value\n');
|
|
744
|
+
expect(git(['status', '--porcelain'], repoCwd).trim()).not.toBe('');
|
|
745
|
+
|
|
746
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
test('[job] integrateBranch falls through to today\'s failure when stderr has no parseable blocking-path block (a genuine content conflict)', async () => {
|
|
750
|
+
const slug = 'test-slug-unparseable-stderr';
|
|
751
|
+
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
752
|
+
expect(worktree.ok).toBe(true);
|
|
753
|
+
|
|
754
|
+
fs.writeFileSync(path.join(worktree.dir, 'README.md'), 'job edit\n', 'utf8');
|
|
755
|
+
git(['add', '-A'], worktree.dir);
|
|
756
|
+
git(['commit', '-q', '-m', 'job edits README'], worktree.dir);
|
|
757
|
+
|
|
758
|
+
// A committed (not dirty) diverging edit on the main tree produces git's
|
|
759
|
+
// ordinary "CONFLICT (content)" stderr shape, not the "would be
|
|
760
|
+
// overwritten" block parseBlockingMergePaths looks for.
|
|
761
|
+
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'main tree edit\n', 'utf8');
|
|
762
|
+
git(['add', '-A'], repoCwd);
|
|
763
|
+
git(['commit', '-q', '-m', 'main tree edits README'], repoCwd);
|
|
764
|
+
|
|
765
|
+
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
766
|
+
expect(outcome.ok).toBe(false);
|
|
767
|
+
expect(outcome.reason).toMatch(/merge failed \(likely a real content conflict\)/);
|
|
768
|
+
expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
|
|
769
|
+
|
|
770
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
test('[job] integrateBranch aborts cleanly when the single retry also fails on a genuine conflict elsewhere', async () => {
|
|
774
|
+
const slug = 'test-slug-retry-fails';
|
|
775
|
+
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
776
|
+
expect(worktree.ok).toBe(true);
|
|
777
|
+
|
|
778
|
+
// The branch's own real work: extends fileA (existing, tracked since
|
|
779
|
+
// initRepo's README.md commit doesn't cover it — add it fresh here) and
|
|
780
|
+
// ADDS a brand-new fileB.txt.
|
|
781
|
+
fs.writeFileSync(path.join(worktree.dir, 'fileA'), 'shared-value\n', 'utf8');
|
|
782
|
+
fs.writeFileSync(path.join(worktree.dir, 'fileB.txt'), 'branch B change\n', 'utf8');
|
|
783
|
+
git(['add', '-A'], worktree.dir);
|
|
784
|
+
git(['commit', '-q', '-m', 'job commit: fileA + fileB'], worktree.dir);
|
|
785
|
+
|
|
786
|
+
// Main tree independently ADDS the SAME path with DIFFERENT content — a
|
|
787
|
+
// genuine "both added, diverging content" conflict on fileB.txt.
|
|
788
|
+
fs.writeFileSync(path.join(repoCwd, 'fileB.txt'), 'base B change\n', 'utf8');
|
|
789
|
+
git(['add', '-A'], repoCwd);
|
|
790
|
+
git(['commit', '-q', '-m', 'main tree diverges fileB'], repoCwd);
|
|
791
|
+
// ...and ALSO leaves fileA dirty with content byte-identical to the branch
|
|
792
|
+
// — the one blocking path the initial merge attempt refuses on, and the
|
|
793
|
+
// one the auto-resolve retry successfully discards.
|
|
794
|
+
fs.writeFileSync(path.join(repoCwd, 'fileA'), 'shared-value\n', 'utf8');
|
|
795
|
+
|
|
796
|
+
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
797
|
+
expect(outcome.ok).toBe(false);
|
|
798
|
+
expect(outcome.reason).toMatch(/merge failed \(likely a real content conflict\)/);
|
|
799
|
+
// The retry's half-applied merge must be aborted, not left mid-merge.
|
|
800
|
+
expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
|
|
801
|
+
expect(fs.existsSync(path.join(repoCwd, '.git', 'MERGE_HEAD'))).toBe(false);
|
|
802
|
+
|
|
803
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
804
|
+
});
|