claude-code-session-manager 0.88.1 → 0.89.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-kQF8_wAw.js → AgentLibrary-tuj-j1kQ.js} +1 -1
- package/dist/assets/{DataModel-CY_mMnsr.js → DataModel-DOim5SXW.js} +1 -1
- package/dist/assets/{History-Sji2brb0.js → History-DylcasUk.js} +1 -1
- package/dist/assets/{Hooks-CtqpO-p5.js → Hooks--2xoag9a.js} +1 -1
- package/dist/assets/{HostBilko-DN0ydOCr.js → HostBilko-oaw8paAh.js} +1 -1
- package/dist/assets/{Library-CoiLZTKh.js → Library-DzKA09Iy.js} +1 -1
- package/dist/assets/{ListDetail-CmqiQ_io.js → ListDetail-CEdcO80v.js} +1 -1
- package/dist/assets/{MarkdownEditor-HPoNeKhT.js → MarkdownEditor-BMyo0F7s.js} +1 -1
- package/dist/assets/{McpServers-Bj1X3_NT.js → McpServers-BB7ayW7W.js} +1 -1
- package/dist/assets/{Memory-1PKjsM3b.js → Memory-BOdYvrL4.js} +1 -1
- package/dist/assets/{Panel-I1s6ghPo.js → Panel-Yf3SxsRv.js} +1 -1
- package/dist/assets/{Permissions-BhiiZjHQ.js → Permissions-BYCpHNy5.js} +1 -1
- package/dist/assets/{Plugins-gv3_fkPX.js → Plugins-B0lXiYqV.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BGcgBd8V.js → ProvenanceBadge-T7x8LWcV.js} +1 -1
- package/dist/assets/{SaveBar-BGr9eZ4g.js → SaveBar-BdVb4DbR.js} +1 -1
- package/dist/assets/{Scheduler-Bdo_awPq.js → Scheduler-DlOCWioV.js} +1 -1
- package/dist/assets/{ScopeSwitcher-ukl0qWaA.js → ScopeSwitcher-WVhmNsL5.js} +1 -1
- package/dist/assets/{Settings-DJkFj22G.js → Settings-Bt4dVsl6.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-0t1Qblp6.js → SkillReferenceGraph-R-0YykV8.js} +1 -1
- package/dist/assets/{Skills-CeVdBGE-.js → Skills-DXV_LdJP.js} +1 -1
- package/dist/assets/{SystemPrompt-CfKvNHi3.js → SystemPrompt-DiQRo_WF.js} +1 -1
- package/dist/assets/{TagLibrary-DAzNaUPd.js → TagLibrary-B9OuqU6M.js} +1 -1
- package/dist/assets/{TiptapBody--S_rGXoA.js → TiptapBody-HKKmB0AE.js} +1 -1
- package/dist/assets/{Toggle-Dszm0xnp.js → Toggle-CBggPonH.js} +1 -1
- package/dist/assets/{index-Dp4Rc--q.js → index-BcJqLDiY.js} +3 -3
- package/dist/assets/{settingsSchema-BLbssMYo.js → settingsSchema-BYE9DVw_.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/main/__tests__/scheduler-integration-failure-stamp.test.cjs +41 -0
- package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +23 -0
- package/src/main/build-info.json +4 -4
- package/src/main/lib/__tests__/gitWorktree.test.cjs +172 -0
- package/src/main/lib/gitWorktree.cjs +195 -3
- package/src/main/lib/rcaReport.cjs +8 -1
- package/src/main/scheduler.cjs +89 -14
package/dist/index.html
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
8
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
9
|
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400&family=Geist:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
|
10
|
-
<script type="module" crossorigin src="./assets/index-
|
|
10
|
+
<script type="module" crossorigin src="./assets/index-BcJqLDiY.js"></script>
|
|
11
11
|
<link rel="modulepreload" crossorigin href="./assets/monaco-editor-BW5C4Iv1.js">
|
|
12
12
|
<link rel="stylesheet" crossorigin href="./assets/monaco-editor-BTnBOi8r.css">
|
|
13
13
|
<link rel="stylesheet" crossorigin href="./assets/index-BHTX4OTc.css">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-session-manager",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.89.0",
|
|
4
4
|
"description": "Local cockpit for the Claude Code CLI — multi-tab terminal, full config surface, scheduler, voice dictation, and live observability.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main/index.cjs",
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-integration-failure-stamp.test.cjs — a worktree_integration_failed
|
|
3
|
+
* finalize stamps integrateBranch's failure subtype onto the job row, and the
|
|
4
|
+
* RCA report names it.
|
|
5
|
+
*
|
|
6
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-integration-failure-stamp.test.cjs
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
import { test } from 'vitest';
|
|
12
|
+
const assert = require('node:assert/strict');
|
|
13
|
+
const { stampIntegrationFailure } = require('../scheduler.cjs');
|
|
14
|
+
const { buildRcaMarkdown } = require('../lib/rcaReport.cjs');
|
|
15
|
+
|
|
16
|
+
test('stamps content_conflict kind and conflicted paths', () => {
|
|
17
|
+
const row = { slug: 'x' };
|
|
18
|
+
stampIntegrationFailure(row, { ok: false, failureKind: 'content_conflict', conflictedPaths: ['a.md'] });
|
|
19
|
+
assert.equal(row.integrationFailureKind, 'content_conflict');
|
|
20
|
+
assert.deepEqual(row.integrationConflictPaths, ['a.md']);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('blocking_paths stamps kind only; no failure clears stale fields', () => {
|
|
24
|
+
const row = { integrationConflictPaths: ['old'] };
|
|
25
|
+
stampIntegrationFailure(row, { ok: false, failureKind: 'blocking_paths', blockingPaths: { tracked: ['f'], untracked: [] } });
|
|
26
|
+
assert.equal(row.integrationFailureKind, 'blocking_paths');
|
|
27
|
+
assert.equal('integrationConflictPaths' in row, false);
|
|
28
|
+
stampIntegrationFailure(row, null);
|
|
29
|
+
assert.equal('integrationFailureKind' in row, false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('RCA markdown names the subtype and conflicted paths', () => {
|
|
33
|
+
const job = {
|
|
34
|
+
slug: 'x', runId: 'r', integrationFailureKind: 'content_conflict',
|
|
35
|
+
integrationConflictPaths: ['docs/asset-pipeline.md'],
|
|
36
|
+
};
|
|
37
|
+
const md = buildRcaMarkdown({
|
|
38
|
+
job, verdict: 'worktree_integration_failed', meta: null, logTail: '', acText: '', failureClass: 'unknown',
|
|
39
|
+
});
|
|
40
|
+
assert.ok(md.includes('Integration failure subtype: content_conflict (conflicted paths: docs/asset-pipeline.md)'));
|
|
41
|
+
});
|
|
@@ -118,6 +118,29 @@ test('selectMechanicalRecoveryTarget: kill-switch SM_MECHANICAL_RECOVERY_DISABLE
|
|
|
118
118
|
expect(selectMechanicalRecoveryTarget(eligibleJob())).toBeNull();
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
+
const conflictJob = (o = {}) => eligibleJob({ integrationFailureKind: 'content_conflict', integrationBaseHeadSha: 'aaa111', ...o });
|
|
122
|
+
|
|
123
|
+
test('selectMechanicalRecoveryTarget: content_conflict with unchanged HEAD is skipped', () => {
|
|
124
|
+
expect(selectMechanicalRecoveryTarget(conflictJob(), 'aaa111')).toBeNull();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('selectMechanicalRecoveryTarget: content_conflict with a moved HEAD returns a target', () => {
|
|
128
|
+
expect(selectMechanicalRecoveryTarget(conflictJob(), 'bbb222')).not.toBeNull();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('selectMechanicalRecoveryTarget: blocking_paths with unchanged HEAD still returns a target', () => {
|
|
132
|
+
const job = eligibleJob({ integrationFailureKind: 'blocking_paths', integrationBaseHeadSha: 'aaa111' });
|
|
133
|
+
expect(selectMechanicalRecoveryTarget(job, 'aaa111')).not.toBeNull();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('selectMechanicalRecoveryTarget: a pre-mc-01 row with no integrationFailureKind still returns a target', () => {
|
|
137
|
+
expect(selectMechanicalRecoveryTarget(eligibleJob(), 'aaa111')).not.toBeNull();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('selectMechanicalRecoveryTarget: null currentHeadSha never skips', () => {
|
|
141
|
+
expect(selectMechanicalRecoveryTarget(conflictJob(), null)).not.toBeNull();
|
|
142
|
+
});
|
|
143
|
+
|
|
121
144
|
test('MECHANICALLY_RESOLVABLE_VERDICTS: closed set contains exactly worktree_integration_failed', () => {
|
|
122
145
|
expect(Array.from(MECHANICALLY_RESOLVABLE_VERDICTS)).toEqual(['worktree_integration_failed']);
|
|
123
146
|
});
|
package/src/main/build-info.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
3
|
-
"gitSha": "
|
|
4
|
-
"gitShortSha": "
|
|
2
|
+
"version": "0.89.0",
|
|
3
|
+
"gitSha": "5ec3affd400f9a33189925d332d9c253fee1120c",
|
|
4
|
+
"gitShortSha": "5ec3aff",
|
|
5
5
|
"gitBranch": "HEAD",
|
|
6
|
-
"builtAt": "2026-09-
|
|
6
|
+
"builtAt": "2026-09-19T22:24:34.621Z",
|
|
7
7
|
"dirty": false
|
|
8
8
|
}
|
|
@@ -1310,3 +1310,175 @@ test('[guard][epic] integrateEpicBranch inherits the same guard and still merges
|
|
|
1310
1310
|
|
|
1311
1311
|
await gitWorktree.cleanupEpicWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
|
|
1312
1312
|
});
|
|
1313
|
+
|
|
1314
|
+
// ── integrateBranch failure subtype classification (mc-01) ──
|
|
1315
|
+
|
|
1316
|
+
test('[job] integrateBranch classifies a dirty different tracked file as failureKind blocking_paths', async () => {
|
|
1317
|
+
const slug = 'test-slug-kind-blocking';
|
|
1318
|
+
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
1319
|
+
expect(worktree.ok).toBe(true);
|
|
1320
|
+
|
|
1321
|
+
fs.writeFileSync(path.join(worktree.dir, 'README.md'), 'job edit\n', 'utf8');
|
|
1322
|
+
git(['add', '-A'], worktree.dir);
|
|
1323
|
+
git(['commit', '-q', '-m', 'job edits README'], worktree.dir);
|
|
1324
|
+
// Dirty (uncommitted), byte-DIFFERENT edit in the main tree blocks the merge.
|
|
1325
|
+
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'different local edit\n', 'utf8');
|
|
1326
|
+
|
|
1327
|
+
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
1328
|
+
expect(outcome.ok).toBe(false);
|
|
1329
|
+
expect(outcome.reason).toMatch(/merge failed \(likely a real content conflict\)/);
|
|
1330
|
+
expect(outcome.failureKind).toBe('blocking_paths');
|
|
1331
|
+
expect(outcome.blockingPaths.tracked).toEqual(['README.md']);
|
|
1332
|
+
expect(outcome.blockingPaths.untracked).toEqual([]);
|
|
1333
|
+
|
|
1334
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
test('[job] integrateBranch classifies diverging same-line commits as content_conflict with the conflicted path', async () => {
|
|
1338
|
+
const slug = 'test-slug-kind-content';
|
|
1339
|
+
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
1340
|
+
expect(worktree.ok).toBe(true);
|
|
1341
|
+
|
|
1342
|
+
fs.writeFileSync(path.join(worktree.dir, 'README.md'), 'job edit\n', 'utf8');
|
|
1343
|
+
git(['add', '-A'], worktree.dir);
|
|
1344
|
+
git(['commit', '-q', '-m', 'job edits README'], worktree.dir);
|
|
1345
|
+
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'main tree edit\n', 'utf8');
|
|
1346
|
+
git(['add', '-A'], repoCwd);
|
|
1347
|
+
git(['commit', '-q', '-m', 'main tree edits README'], repoCwd);
|
|
1348
|
+
|
|
1349
|
+
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
1350
|
+
expect(outcome.ok).toBe(false);
|
|
1351
|
+
expect(outcome.failureKind).toBe('content_conflict');
|
|
1352
|
+
expect(outcome.conflictedPaths).toEqual(['README.md']);
|
|
1353
|
+
// The abort still ran.
|
|
1354
|
+
expect(fs.existsSync(path.join(repoCwd, '.git', 'MERGE_HEAD'))).toBe(false);
|
|
1355
|
+
expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
|
|
1356
|
+
|
|
1357
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
1358
|
+
});
|
|
1359
|
+
|
|
1360
|
+
test('[job] integrateBranch classifies disjoint appends to the END of one file as content_conflict when the path is not allowlisted (.js)', async () => {
|
|
1361
|
+
const slug = 'test-slug-kind-append';
|
|
1362
|
+
fs.writeFileSync(path.join(repoCwd, 'src.js'), '// intro\n', 'utf8');
|
|
1363
|
+
git(['add', '-A'], repoCwd);
|
|
1364
|
+
git(['commit', '-q', '-m', 'add src.js'], repoCwd);
|
|
1365
|
+
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
1366
|
+
expect(worktree.ok).toBe(true);
|
|
1367
|
+
|
|
1368
|
+
fs.appendFileSync(path.join(worktree.dir, 'src.js'), '\n// Section Six\nsix body\n', 'utf8');
|
|
1369
|
+
git(['add', '-A'], worktree.dir);
|
|
1370
|
+
git(['commit', '-q', '-m', 'job appends section six'], worktree.dir);
|
|
1371
|
+
fs.appendFileSync(path.join(repoCwd, 'src.js'), '\n## Section Seven\nseven body\n\n## Section Ten\nten body\n', 'utf8');
|
|
1372
|
+
git(['add', '-A'], repoCwd);
|
|
1373
|
+
git(['commit', '-q', '-m', 'main appends sections seven and ten'], repoCwd);
|
|
1374
|
+
|
|
1375
|
+
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
1376
|
+
expect(outcome.ok).toBe(false);
|
|
1377
|
+
expect(outcome.failureKind).toBe('content_conflict');
|
|
1378
|
+
expect(outcome.conflictedPaths).toEqual(['src.js']);
|
|
1379
|
+
|
|
1380
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
1381
|
+
});
|
|
1382
|
+
|
|
1383
|
+
// ── pure-addition merge-conflict auto-resolve (mc-02) ──
|
|
1384
|
+
|
|
1385
|
+
async function conflictScenario(slug, { base, side, main }) {
|
|
1386
|
+
for (const [f, c] of Object.entries(base)) {
|
|
1387
|
+
fs.mkdirSync(path.dirname(path.join(repoCwd, f)), { recursive: true });
|
|
1388
|
+
fs.writeFileSync(path.join(repoCwd, f), c, 'utf8');
|
|
1389
|
+
}
|
|
1390
|
+
git(['add', '-A'], repoCwd);
|
|
1391
|
+
git(['commit', '-q', '-m', 'base files'], repoCwd);
|
|
1392
|
+
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
1393
|
+
expect(worktree.ok).toBe(true);
|
|
1394
|
+
for (const [f, c] of Object.entries(side)) {
|
|
1395
|
+
fs.mkdirSync(path.dirname(path.join(worktree.dir, f)), { recursive: true });
|
|
1396
|
+
fs.writeFileSync(path.join(worktree.dir, f), c, 'utf8');
|
|
1397
|
+
}
|
|
1398
|
+
git(['add', '-A'], worktree.dir);
|
|
1399
|
+
git(['commit', '-q', '-m', 'job side'], worktree.dir);
|
|
1400
|
+
for (const [f, c] of Object.entries(main)) {
|
|
1401
|
+
fs.mkdirSync(path.dirname(path.join(repoCwd, f)), { recursive: true });
|
|
1402
|
+
fs.writeFileSync(path.join(repoCwd, f), c, 'utf8');
|
|
1403
|
+
}
|
|
1404
|
+
git(['add', '-A'], repoCwd);
|
|
1405
|
+
git(['commit', '-q', '-m', 'main side'], repoCwd);
|
|
1406
|
+
return worktree;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
const MARKER_RE = /^(<<<<<<<|\|\|\|\|\|\|\||=======|>>>>>>>)/m;
|
|
1410
|
+
|
|
1411
|
+
test('[job] integrateBranch auto-resolves a pure-addition .md conflict by concatenating ours then theirs', async () => {
|
|
1412
|
+
const slug = 'test-slug-pure-add';
|
|
1413
|
+
const worktree = await conflictScenario(slug, {
|
|
1414
|
+
base: { 'd.md': '# Doc\n\nintro\n' },
|
|
1415
|
+
side: { 'd.md': '# Doc\n\nintro\n\n## Section A\na body\n' },
|
|
1416
|
+
main: { 'd.md': '# Doc\n\nintro\n\n## Section B\nb body\n\n## Section C\nc body\n' },
|
|
1417
|
+
});
|
|
1418
|
+
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
1419
|
+
expect(outcome.ok).toBe(true);
|
|
1420
|
+
expect(outcome.autoResolved).toBe('pure_addition_concat');
|
|
1421
|
+
expect(outcome.resolvedPaths).toEqual(['d.md']);
|
|
1422
|
+
const merged = fs.readFileSync(path.join(repoCwd, 'd.md'), 'utf8');
|
|
1423
|
+
expect(merged).toBe('# Doc\n\nintro\n\n## Section B\nb body\n\n## Section C\nc body\n\n## Section A\na body\n');
|
|
1424
|
+
expect(merged).not.toMatch(MARKER_RE);
|
|
1425
|
+
expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
|
|
1426
|
+
expect(git(['log', '-1', '--format=%s'], repoCwd).trim()).toBe(`merge scheduler job ${slug}`);
|
|
1427
|
+
expect(git(['rev-list', '--parents', '-n', '1', 'HEAD'], repoCwd).trim().split(' ').length).toBe(3);
|
|
1428
|
+
|
|
1429
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
test('[job] integrateBranch does NOT auto-resolve a same-line .md edit and leaves the tree clean', async () => {
|
|
1433
|
+
const slug = 'test-slug-pure-add-neg-sameline';
|
|
1434
|
+
const worktree = await conflictScenario(slug, {
|
|
1435
|
+
base: { 'd.md': 'intro\nORIGINAL LINE\n' },
|
|
1436
|
+
side: { 'd.md': 'intro\nSIDE EDIT\n' },
|
|
1437
|
+
main: { 'd.md': 'intro\nMAIN EDIT\n' },
|
|
1438
|
+
});
|
|
1439
|
+
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
1440
|
+
expect(outcome.ok).toBe(false);
|
|
1441
|
+
expect(outcome.autoResolved).toBeUndefined();
|
|
1442
|
+
expect(outcome.failureKind).toBe('content_conflict');
|
|
1443
|
+
expect(outcome.conflictedPaths).toEqual(['d.md']);
|
|
1444
|
+
expect(fs.readFileSync(path.join(repoCwd, 'd.md'), 'utf8')).toBe('intro\nMAIN EDIT\n');
|
|
1445
|
+
expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
|
|
1446
|
+
|
|
1447
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
test('[job] integrateBranch does NOT auto-resolve a pure-addition conflict on a non-allowlisted path', async () => {
|
|
1451
|
+
const slug = 'test-slug-pure-add-neg-js';
|
|
1452
|
+
const worktree = await conflictScenario(slug, {
|
|
1453
|
+
base: { 'src/thing.js': '// intro\n' },
|
|
1454
|
+
side: { 'src/thing.js': '// intro\n// A\n' },
|
|
1455
|
+
main: { 'src/thing.js': '// intro\n// B\n// C\n' },
|
|
1456
|
+
});
|
|
1457
|
+
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
1458
|
+
expect(outcome.ok).toBe(false);
|
|
1459
|
+
expect(outcome.autoResolved).toBeUndefined();
|
|
1460
|
+
expect(outcome.failureKind).toBe('content_conflict');
|
|
1461
|
+
expect(fs.readFileSync(path.join(repoCwd, 'src/thing.js'), 'utf8')).toBe('// intro\n// B\n// C\n');
|
|
1462
|
+
expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
|
|
1463
|
+
|
|
1464
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
test('[job] integrateBranch bails the whole merge when only some conflicted paths are provable (mixed .md + .js)', async () => {
|
|
1468
|
+
const slug = 'test-slug-pure-add-neg-mixed';
|
|
1469
|
+
const worktree = await conflictScenario(slug, {
|
|
1470
|
+
base: { 'd.md': 'intro\n', 'src/thing.js': 'ORIGINAL\n' },
|
|
1471
|
+
side: { 'd.md': 'intro\n## A\n', 'src/thing.js': 'SIDE\n' },
|
|
1472
|
+
main: { 'd.md': 'intro\n## B\n', 'src/thing.js': 'MAIN\n' },
|
|
1473
|
+
});
|
|
1474
|
+
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
1475
|
+
expect(outcome.ok).toBe(false);
|
|
1476
|
+
expect(outcome.autoResolved).toBeUndefined();
|
|
1477
|
+
expect(outcome.conflictedPaths).toEqual(['d.md', 'src/thing.js']);
|
|
1478
|
+
expect(fs.readFileSync(path.join(repoCwd, 'd.md'), 'utf8')).toBe('intro\n## B\n');
|
|
1479
|
+
expect(fs.readFileSync(path.join(repoCwd, 'src/thing.js'), 'utf8')).toBe('MAIN\n');
|
|
1480
|
+
expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
|
|
1481
|
+
expect(fs.existsSync(path.join(repoCwd, '.git', 'MERGE_HEAD'))).toBe(false);
|
|
1482
|
+
|
|
1483
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
1484
|
+
});
|
|
@@ -940,6 +940,167 @@ function parseBlockingMergePaths(stderrText) {
|
|
|
940
940
|
return { tracked: Array.from(new Set(tracked)), untracked: Array.from(new Set(untracked)) };
|
|
941
941
|
}
|
|
942
942
|
|
|
943
|
+
/**
|
|
944
|
+
* Classifies a failed `git merge` from its stderr — additive metadata for
|
|
945
|
+
* integrateBranch's `ok: false` results (the `reason` string is untouched).
|
|
946
|
+
* 'blocking_paths' = git refused up-front over dirty/untracked paths
|
|
947
|
+
* (auto-resolvable, PRD 1125); 'content_conflict' = a genuine 3-way
|
|
948
|
+
* `CONFLICT (` (deterministic on every retry); 'other' = anything else.
|
|
949
|
+
* For content_conflict, reads the unmerged paths — MUST be called before
|
|
950
|
+
* `git merge --abort`, which clears them. Never throws; a failed read
|
|
951
|
+
* yields `conflictedPaths: []`. O(p log p) in the conflicted-path count.
|
|
952
|
+
*/
|
|
953
|
+
async function classifyMergeFailure({ cwd, stderrText, stdoutText }) {
|
|
954
|
+
const blocking = parseBlockingMergePaths(stderrText);
|
|
955
|
+
if (blocking) return { failureKind: 'blocking_paths', blockingPaths: blocking };
|
|
956
|
+
// git prints `CONFLICT (...)` lines on STDOUT, not stderr.
|
|
957
|
+
if (/CONFLICT \(/m.test(`${stdoutText || ''}\n${stderrText || ''}`)) {
|
|
958
|
+
let conflictedPaths = [];
|
|
959
|
+
try {
|
|
960
|
+
const out = await execGit(['diff', '--name-only', '--diff-filter=U'], { cwd, timeout: 10_000 });
|
|
961
|
+
conflictedPaths = Array.from(new Set(out.split('\n').map((l) => l.trim()).filter(Boolean))).sort();
|
|
962
|
+
} catch { /* best effort — abort must still run */ }
|
|
963
|
+
return { failureKind: 'content_conflict', conflictedPaths };
|
|
964
|
+
}
|
|
965
|
+
return { failureKind: 'other' };
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Extensions eligible for the pure-addition auto-resolve. All-or-nothing across
|
|
969
|
+
// a merge: one conflicted path outside this list means no auto-resolve at all.
|
|
970
|
+
const AUTO_RESOLVE_EXTENSIONS = new Set(['.md', '.markdown', '.txt', '.rst']);
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* Renders `git merge-file -p --diff3` over the index's three conflict stages
|
|
974
|
+
* of `p`. Returns the merged text (with markers), or null when any stage is
|
|
975
|
+
* missing/unreadable, a stage is not valid UTF-8 (the round-trip would not be
|
|
976
|
+
* byte-exact), or git itself errors. `merge-file` exits with the conflict
|
|
977
|
+
* count (1..127) on a clean run — only >127 or a signal is an error. Never
|
|
978
|
+
* throws. O(file size).
|
|
979
|
+
*/
|
|
980
|
+
async function renderStagesDiff3({ cwd, p }) {
|
|
981
|
+
let tmp = null;
|
|
982
|
+
try {
|
|
983
|
+
tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-mergefile-'));
|
|
984
|
+
const files = [];
|
|
985
|
+
for (const stage of [2, 1, 3]) { // ours, base, theirs — merge-file's argument order
|
|
986
|
+
const content = await execGit(['cat-file', 'blob', `:${stage}:${p}`], { cwd, timeout: 10_000 });
|
|
987
|
+
if (content.includes('�')) return null;
|
|
988
|
+
const f = path.join(tmp, `stage${stage}`);
|
|
989
|
+
await fsp.writeFile(f, content, 'utf8');
|
|
990
|
+
files.push(f);
|
|
991
|
+
}
|
|
992
|
+
try {
|
|
993
|
+
return await execGit(['merge-file', '-p', '--diff3', '-L', 'ours', '-L', 'base', '-L', 'theirs', ...files], { cwd, timeout: 10_000 });
|
|
994
|
+
} catch (e) {
|
|
995
|
+
if (e && typeof e.code === 'number' && e.code >= 1 && e.code <= 127 && typeof e.stdoutText === 'string') return e.stdoutText;
|
|
996
|
+
return null;
|
|
997
|
+
}
|
|
998
|
+
} catch {
|
|
999
|
+
return null;
|
|
1000
|
+
} finally {
|
|
1001
|
+
if (tmp) { try { await fsp.rm(tmp, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Parses diff3 merge-file output into segments: `{ text }` for pass-through
|
|
1007
|
+
* regions and `{ ours, base, theirs }` (arrays of lines) for conflict hunks.
|
|
1008
|
+
* Returns null on any out-of-order marker or an unterminated hunk. A marker
|
|
1009
|
+
* prefix is only meaningful in its expected state; `=======` inside the ours
|
|
1010
|
+
* or theirs section (e.g. a markdown setext underline) is treated as
|
|
1011
|
+
* unparseable rather than guessed at. O(n) in output lines.
|
|
1012
|
+
*/
|
|
1013
|
+
function parseDiff3Output(output) {
|
|
1014
|
+
const lines = output.split('\n');
|
|
1015
|
+
const segments = [];
|
|
1016
|
+
let plain = [];
|
|
1017
|
+
let hunk = null;
|
|
1018
|
+
let state = 'plain';
|
|
1019
|
+
for (const line of lines) {
|
|
1020
|
+
const isOpen = line.startsWith('<<<<<<< ');
|
|
1021
|
+
const isBase = line.startsWith('||||||| ');
|
|
1022
|
+
const isSep = line.startsWith('=======');
|
|
1023
|
+
const isClose = line.startsWith('>>>>>>> ');
|
|
1024
|
+
if (state === 'plain') {
|
|
1025
|
+
if (isBase || isClose) return null;
|
|
1026
|
+
if (isOpen) {
|
|
1027
|
+
segments.push({ lines: plain });
|
|
1028
|
+
plain = [];
|
|
1029
|
+
hunk = { ours: [], base: [], theirs: [] };
|
|
1030
|
+
state = 'ours';
|
|
1031
|
+
} else {
|
|
1032
|
+
plain.push(line);
|
|
1033
|
+
}
|
|
1034
|
+
} else if (state === 'ours') {
|
|
1035
|
+
if (isOpen || isSep || isClose) return null;
|
|
1036
|
+
if (isBase) state = 'base'; else hunk.ours.push(line);
|
|
1037
|
+
} else if (state === 'base') {
|
|
1038
|
+
if (isOpen || isBase || isClose) return null;
|
|
1039
|
+
if (isSep && line === '=======') state = 'theirs'; else if (isSep) return null; else hunk.base.push(line);
|
|
1040
|
+
} else {
|
|
1041
|
+
if (isOpen || isBase || isSep) return null;
|
|
1042
|
+
if (isClose) { segments.push({ hunk }); hunk = null; state = 'plain'; } else hunk.theirs.push(line);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
if (state !== 'plain') return null;
|
|
1046
|
+
segments.push({ lines: plain });
|
|
1047
|
+
return segments;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* True ONLY when, for EVERY path, the index holds all three conflict stages
|
|
1052
|
+
* and every diff3 conflict hunk has an EMPTY base section (both sides purely
|
|
1053
|
+
* added text where the merge base had nothing). Proven from the bytes, never
|
|
1054
|
+
* assumed; fails toward false on every error path. Never throws.
|
|
1055
|
+
*/
|
|
1056
|
+
async function isPureAdditionConflict({ cwd, paths }) {
|
|
1057
|
+
try {
|
|
1058
|
+
if (!Array.isArray(paths) || !paths.length) return false;
|
|
1059
|
+
for (const p of paths) {
|
|
1060
|
+
const out = await renderStagesDiff3({ cwd, p });
|
|
1061
|
+
if (out === null) return false;
|
|
1062
|
+
const segments = parseDiff3Output(out);
|
|
1063
|
+
if (!segments) return false;
|
|
1064
|
+
const hunks = segments.filter((s) => s.hunk);
|
|
1065
|
+
if (!hunks.length) return false;
|
|
1066
|
+
if (hunks.some((s) => s.hunk.base.length !== 0)) return false;
|
|
1067
|
+
}
|
|
1068
|
+
return true;
|
|
1069
|
+
} catch {
|
|
1070
|
+
return false;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* Rewrites each conflicted path in the working tree with every conflict hunk
|
|
1076
|
+
* replaced by OURS then THEIRS (no markers, base, reordering, or dedup);
|
|
1077
|
+
* non-conflicted regions pass through unchanged. Renders ALL paths before
|
|
1078
|
+
* writing any so a late failure leaves nothing half-rewritten. Throws on any
|
|
1079
|
+
* failure — the caller aborts the merge.
|
|
1080
|
+
*/
|
|
1081
|
+
async function resolveByConcatenation({ cwd, paths }) {
|
|
1082
|
+
const rendered = [];
|
|
1083
|
+
for (const p of paths) {
|
|
1084
|
+
const out = await renderStagesDiff3({ cwd, p });
|
|
1085
|
+
const segments = out === null ? null : parseDiff3Output(out);
|
|
1086
|
+
if (!segments) throw new Error(`cannot render conflict for ${p}`);
|
|
1087
|
+
rendered.push({ p, text: joinSegments(segments) });
|
|
1088
|
+
}
|
|
1089
|
+
for (const { p, text } of rendered) {
|
|
1090
|
+
await fsp.writeFile(path.join(cwd, p), text, 'utf8');
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/** Rebuilds file text from parsed segments, hunks contributing ours+theirs lines. */
|
|
1095
|
+
function joinSegments(segments) {
|
|
1096
|
+
const out = [];
|
|
1097
|
+
for (const seg of segments) {
|
|
1098
|
+
if (seg.hunk) out.push(...seg.hunk.ours, ...seg.hunk.theirs);
|
|
1099
|
+
else out.push(...seg.lines);
|
|
1100
|
+
}
|
|
1101
|
+
return out.join('\n');
|
|
1102
|
+
}
|
|
1103
|
+
|
|
943
1104
|
/**
|
|
944
1105
|
* Resolves `cwd`'s default branch, without ever hardcoding `main`: prefers
|
|
945
1106
|
* the remote-tracked default (`origin/HEAD`, set by `git clone`/`git remote
|
|
@@ -949,6 +1110,16 @@ function parseBlockingMergePaths(stderrText) {
|
|
|
949
1110
|
* this chain (no remote, no config, no matching local branch) simply falls
|
|
950
1111
|
* through to the next source.
|
|
951
1112
|
*/
|
|
1113
|
+
// HEAD sha of `cwd` (null on any failure) — stamped on a failed integration so
|
|
1114
|
+
// mechanical recovery can tell whether the base tree has moved since.
|
|
1115
|
+
async function readHeadSha(cwd) {
|
|
1116
|
+
try {
|
|
1117
|
+
return (await execGit(['rev-parse', 'HEAD'], { cwd, timeout: 10_000 })).trim() || null;
|
|
1118
|
+
} catch {
|
|
1119
|
+
return null;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
952
1123
|
async function resolveDefaultBranch(cwd) {
|
|
953
1124
|
try {
|
|
954
1125
|
const out = (await execGit(['symbolic-ref', 'refs/remotes/origin/HEAD'], { cwd, timeout: 10_000 })).trim();
|
|
@@ -1112,15 +1283,36 @@ async function integrateBranch({ cwd, branch, key, kind, carriedPaths }) {
|
|
|
1112
1283
|
resolvedPaths: allPaths,
|
|
1113
1284
|
};
|
|
1114
1285
|
} catch (retryErr) {
|
|
1115
|
-
try { await execGit(['merge', '--abort'], { cwd, timeout: 10_000 }); } catch { /* nothing to abort */ }
|
|
1116
1286
|
const retryText = (retryErr && (retryErr.stderrText || retryErr.message)) || String(retryErr);
|
|
1117
|
-
|
|
1287
|
+
const retryClass = await classifyMergeFailure({ cwd, stderrText: retryText, stdoutText: retryErr && retryErr.stdoutText });
|
|
1288
|
+
try { await execGit(['merge', '--abort'], { cwd, timeout: 10_000 }); } catch { /* nothing to abort */ }
|
|
1289
|
+
return { ok: false, reason: `merge failed (likely a real content conflict): ${retryText}`, ...retryClass, baseHeadSha: await readHeadSha(cwd) };
|
|
1118
1290
|
}
|
|
1119
1291
|
}
|
|
1120
1292
|
}
|
|
1293
|
+
// Read the conflicted paths BEFORE the abort — it clears the index stages.
|
|
1294
|
+
const failureClass = await classifyMergeFailure({ cwd, stderrText, stdoutText: e && e.stdoutText });
|
|
1295
|
+
// Pure-addition auto-resolve: two jobs appended different text to the same
|
|
1296
|
+
// doc. All-or-nothing; any doubt or failure falls through to the abort.
|
|
1297
|
+
if (
|
|
1298
|
+
process.env.SM_PURE_ADDITION_MERGE_DISABLE !== '1'
|
|
1299
|
+
&& failureClass.failureKind === 'content_conflict'
|
|
1300
|
+
&& failureClass.conflictedPaths.length
|
|
1301
|
+
&& failureClass.conflictedPaths.every((p) => AUTO_RESOLVE_EXTENSIONS.has(path.extname(p).toLowerCase()))
|
|
1302
|
+
) {
|
|
1303
|
+
try {
|
|
1304
|
+
const paths = failureClass.conflictedPaths;
|
|
1305
|
+
if (await isPureAdditionConflict({ cwd, paths })) {
|
|
1306
|
+
await resolveByConcatenation({ cwd, paths });
|
|
1307
|
+
await execGit(['add', ...paths], { cwd, timeout: 30_000 });
|
|
1308
|
+
await execGit(['commit', '-m', mergeMessage], { cwd, timeout: 30_000 });
|
|
1309
|
+
return { ok: true, integrated: true, mergeCommit: true, autoResolved: 'pure_addition_concat', resolvedPaths: paths };
|
|
1310
|
+
}
|
|
1311
|
+
} catch { /* fall through to the abort */ }
|
|
1312
|
+
}
|
|
1121
1313
|
// Abort a half-applied merge so `cwd` isn't left in a mid-merge state.
|
|
1122
1314
|
try { await execGit(['merge', '--abort'], { cwd, timeout: 10_000 }); } catch { /* nothing to abort */ }
|
|
1123
|
-
return { ok: false, reason: `merge failed (likely a real content conflict): ${stderrText}
|
|
1315
|
+
return { ok: false, reason: `merge failed (likely a real content conflict): ${stderrText}`, ...failureClass, baseHeadSha: await readHeadSha(cwd) };
|
|
1124
1316
|
}
|
|
1125
1317
|
}
|
|
1126
1318
|
|
|
@@ -295,9 +295,16 @@ function buildRcaMarkdown({ job, verdict, meta, logTail, acText, failureClass, i
|
|
|
295
295
|
const durationMs = meta?.durationMs != null ? `${meta.durationMs}ms` : 'unknown';
|
|
296
296
|
const prevention = PREVENTION_HINTS[failureClass] ?? PREVENTION_HINTS[FAILURE_CLASSES.UNKNOWN];
|
|
297
297
|
|
|
298
|
+
const integrationLine = job.integrationFailureKind
|
|
299
|
+
? `\n\nIntegration failure subtype: ${job.integrationFailureKind}` +
|
|
300
|
+
(job.integrationFailureKind === 'content_conflict' && Array.isArray(job.integrationConflictPaths)
|
|
301
|
+
? ` (conflicted paths: ${job.integrationConflictPaths.join(', ') || 'unknown'})`
|
|
302
|
+
: '')
|
|
303
|
+
: '';
|
|
304
|
+
|
|
298
305
|
const sections = [
|
|
299
306
|
`# What happened\n\nJob \`${job.slug}\` was parked in \`needs_review\` with verifier verdict ` +
|
|
300
|
-
`\`${verdict}\` (${humanVerdict(verdict)}).${job.error ? `\n\n${job.error}` : ''}`,
|
|
307
|
+
`\`${verdict}\` (${humanVerdict(verdict)}).${job.error ? `\n\n${job.error}` : ''}${integrationLine}`,
|
|
301
308
|
`# Evidence\n\n- Exit code: ${exitCode}\n- Duration: ${durationMs}\n\nLast 60 log lines:\n\n\`\`\`\n${last60 || '(no log available)'}\n\`\`\``,
|
|
302
309
|
`# The PRD's acceptance criteria\n\n${acText || '(acceptance criteria not found — original PRD may have been archived or removed)'}`,
|
|
303
310
|
`# Likely failure class\n\n**${failureClass}**\n\nPrevention: ${prevention}`,
|