@adhdev/daemon-core 0.9.82-rc.522 → 0.9.82-rc.524
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/commands/router-refine.d.ts +86 -5
- package/dist/commands/router.d.ts +9 -0
- package/dist/git/git-status.d.ts +14 -1
- package/dist/index.js +641 -159
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +641 -159
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-queue-assignment.d.ts +13 -0
- package/dist/mesh/mesh-refine-gates.d.ts +5 -0
- package/dist/mesh/refine-config.d.ts +36 -0
- package/package.json +3 -3
- package/src/commands/router-refine.ts +756 -164
- package/src/commands/router.ts +9 -0
- package/src/git/git-status.ts +38 -6
- package/src/mesh/mesh-queue-assignment.ts +64 -2
- package/src/mesh/mesh-reconcile-loop.ts +18 -1
- package/src/mesh/mesh-refine-gates.ts +49 -1
- package/src/mesh/refine-config.ts +44 -0
|
@@ -14,6 +14,8 @@ import { LOG } from '../logging/logger.js';
|
|
|
14
14
|
import { createInteractionId } from '../logging/debug-trace.js';
|
|
15
15
|
import { meshNodeIdMatches } from '@adhdev/mesh-shared';
|
|
16
16
|
import { handleMeshForwardEvent, queuePendingMeshCoordinatorEvent } from '../mesh/mesh-events.js';
|
|
17
|
+
import { resolveCoordinatorSelfIds, daemonIdListIncludes } from '../mesh/mesh-reconcile-identity.js';
|
|
18
|
+
import { fastForwardMeshNode } from '../mesh/mesh-fast-forward.js';
|
|
17
19
|
import { analyzeMeshRefineNodeChangeArea, orderMeshRefineBatchNodes } from '../mesh/mesh-refine-batch.js';
|
|
18
20
|
import type { WorktreeBootstrapState } from '../mesh/worktree-bootstrap-config.js';
|
|
19
21
|
import { DEFAULT_MESH_POLICY } from '../repo-mesh-types.js';
|
|
@@ -82,6 +84,49 @@ export function buildRefineJobHandle(self: DaemonCommandRouter, args: {
|
|
|
82
84
|
};
|
|
83
85
|
}
|
|
84
86
|
|
|
87
|
+
/**
|
|
88
|
+
* QW2: extract a compact failure diagnostic from a validation summary — the first
|
|
89
|
+
* failing command's name, its exit code, its failureKind, and a bounded output tail.
|
|
90
|
+
* Surfaced in BOTH the slim coordinator event and the ledger blockerContext so a
|
|
91
|
+
* coordinator can decide next-step without pulling and parsing the full ledger record.
|
|
92
|
+
*
|
|
93
|
+
* A command record carries `passed` (boolean), never `success` (see QW1) — the first
|
|
94
|
+
* record with passed===false is the gate's failing command. Returns undefined when the
|
|
95
|
+
* summary did not fail on a command (e.g. bootstrap-stage failure with no commandsRun
|
|
96
|
+
* entry), in which case the top-level failureCode/failureKind still describe the cause.
|
|
97
|
+
*/
|
|
98
|
+
export function extractValidationFailureDiagnostics(
|
|
99
|
+
validationSummary: Record<string, unknown> | undefined,
|
|
100
|
+
): { firstFailedCommand?: string; exitCode?: unknown; failureKind?: unknown; outputTail?: string } | undefined {
|
|
101
|
+
if (!validationSummary || typeof validationSummary !== 'object') return undefined;
|
|
102
|
+
const commandsRun = Array.isArray(validationSummary.commandsRun)
|
|
103
|
+
? (validationSummary.commandsRun as Array<Record<string, unknown>>)
|
|
104
|
+
: [];
|
|
105
|
+
const failed = commandsRun.find(c => c.passed === false);
|
|
106
|
+
const summaryFailureKind = validationSummary.failureKind;
|
|
107
|
+
if (!failed) {
|
|
108
|
+
// No per-command failure (bootstrap failure, spawn resolution before any
|
|
109
|
+
// command ran, etc.) — still surface the summary-level failureKind so the
|
|
110
|
+
// event isn't blank.
|
|
111
|
+
return summaryFailureKind !== undefined ? { failureKind: summaryFailureKind } : undefined;
|
|
112
|
+
}
|
|
113
|
+
const firstFailedCommand = typeof failed.displayCommand === 'string' ? failed.displayCommand
|
|
114
|
+
: typeof failed.command === 'string'
|
|
115
|
+
? [failed.command, ...(Array.isArray(failed.args) ? failed.args : [])].join(' ').trim()
|
|
116
|
+
: undefined;
|
|
117
|
+
const rawOutput = [failed.stderr, failed.stdout, failed.output]
|
|
118
|
+
.filter(s => typeof s === 'string' && (s as string).length > 0)
|
|
119
|
+
.join('\n');
|
|
120
|
+
const outputTail = rawOutput.length > 600 ? rawOutput.slice(-600) : rawOutput;
|
|
121
|
+
return {
|
|
122
|
+
...(firstFailedCommand ? { firstFailedCommand } : {}),
|
|
123
|
+
...(failed.exitCode !== undefined ? { exitCode: failed.exitCode } : {}),
|
|
124
|
+
...(failed.failureKind !== undefined ? { failureKind: failed.failureKind }
|
|
125
|
+
: summaryFailureKind !== undefined ? { failureKind: summaryFailureKind } : {}),
|
|
126
|
+
...(outputTail ? { outputTail } : {}),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
85
130
|
/**
|
|
86
131
|
* Slim the terminal-stage refine result down to the fields a coordinator needs to
|
|
87
132
|
* decide next-step, dropping the heavy per-command / per-entry detail.
|
|
@@ -101,6 +146,8 @@ export function slimRefineEventResult(result: Record<string, unknown>): Record<s
|
|
|
101
146
|
for (const key of [
|
|
102
147
|
'success', 'code', 'error', 'convergenceStatus', 'blockedReason',
|
|
103
148
|
'branch', 'into', 'terminalKind', 'nextStep', 'finalBranchConvergenceState',
|
|
149
|
+
// QW4: merge conflict paths; QW5: cleanup branch-ref / residue warnings.
|
|
150
|
+
'conflictPaths', 'branchRefWarning', 'residueWarning', 'branchRefDeleted',
|
|
104
151
|
] as const) {
|
|
105
152
|
if (result[key] !== undefined) slim[key] = result[key];
|
|
106
153
|
}
|
|
@@ -115,12 +162,20 @@ export function slimRefineEventResult(result: Record<string, unknown>): Record<s
|
|
|
115
162
|
// suggestions/suggestedConfig detail).
|
|
116
163
|
if (result.validationSummary && typeof result.validationSummary === 'object') {
|
|
117
164
|
const vs = result.validationSummary as Record<string, unknown>;
|
|
165
|
+
// QW2: attach compact failure diagnostics (first failing command + exit code
|
|
166
|
+
// + failureKind + bounded output tail) so a coordinator can decide next-step
|
|
167
|
+
// straight from the event without pulling the full ledger record.
|
|
168
|
+
const diagnostics = vs.status === 'failed'
|
|
169
|
+
? extractValidationFailureDiagnostics(vs)
|
|
170
|
+
: undefined;
|
|
118
171
|
slim.validationSummary = {
|
|
119
172
|
status: vs.status,
|
|
120
173
|
failureCode: vs.failureCode,
|
|
174
|
+
failureKind: vs.failureKind,
|
|
121
175
|
configSource: vs.configSource,
|
|
122
176
|
configSourceType: vs.configSourceType,
|
|
123
177
|
commandsRunCount: Array.isArray(vs.commandsRun) ? vs.commandsRun.length : undefined,
|
|
178
|
+
...(diagnostics ? { failure: diagnostics } : {}),
|
|
124
179
|
};
|
|
125
180
|
}
|
|
126
181
|
// Reduce patch-equivalence to just its verdict.
|
|
@@ -291,6 +346,13 @@ export async function executeMeshRefineNodeSynchronously(self: DaemonCommandRout
|
|
|
291
346
|
if (resolved.kind === 'terminal') return resolved.result;
|
|
292
347
|
const ctx = resolved.ctx;
|
|
293
348
|
|
|
349
|
+
// DS2: sync_base runs BEFORE validation. A branch that is behind base — whether
|
|
350
|
+
// strictly behind (fast-forwardable) or DIVERGED (ahead>0 AND behind>0, the
|
|
351
|
+
// laggard the old ancestor-only rebase missed) — is auto-rebased onto the pinned
|
|
352
|
+
// baseHead here, so validation and every later gate see the FINAL rebased tree.
|
|
353
|
+
const syncBase = await refineSyncBaseStage(self, ctx);
|
|
354
|
+
if (syncBase.kind === 'terminal') return syncBase.result;
|
|
355
|
+
|
|
294
356
|
const validation = await refineValidationStage(self, ctx);
|
|
295
357
|
if (validation.kind === 'terminal') return validation.result;
|
|
296
358
|
|
|
@@ -415,6 +477,213 @@ export async function refineResolveRefsStage(self: DaemonCommandRouter,
|
|
|
415
477
|
};
|
|
416
478
|
}
|
|
417
479
|
|
|
480
|
+
/**
|
|
481
|
+
* DS2: compute the branch↔base divergence explicitly via merge-base + rev-list, so a
|
|
482
|
+
* DIVERGED laggard (ahead>0 AND behind>0) is identified — not just the strict-ancestor
|
|
483
|
+
* "simply behind" case the old auto-rebase handled. Returns ahead/behind counts and the
|
|
484
|
+
* merge-base; behind>0 means base has commits the branch lacks (rebase target), ahead>0
|
|
485
|
+
* means the branch has its own commits. All counts are best-effort (0 on any git error).
|
|
486
|
+
*/
|
|
487
|
+
async function computeBranchBaseDivergence(
|
|
488
|
+
execFileAsync: RefineExecFileAsync,
|
|
489
|
+
cwd: string,
|
|
490
|
+
baseHead: string,
|
|
491
|
+
branchHead: string,
|
|
492
|
+
): Promise<{ mergeBase?: string; ahead: number; behind: number; diverged: boolean; isStrictlyBehind: boolean }> {
|
|
493
|
+
let mergeBase: string | undefined;
|
|
494
|
+
try {
|
|
495
|
+
const { stdout } = await execFileAsync('git', ['merge-base', baseHead, branchHead], { cwd, encoding: 'utf8' });
|
|
496
|
+
mergeBase = stdout.trim() || undefined;
|
|
497
|
+
} catch { /* unresolved base/branch — treat as no shared history */ }
|
|
498
|
+
let ahead = 0;
|
|
499
|
+
let behind = 0;
|
|
500
|
+
try {
|
|
501
|
+
// `--left-right --count base...branch` → "<behind>\t<ahead>": left (base-only) =
|
|
502
|
+
// commits the branch is BEHIND; right (branch-only) = commits the branch is AHEAD.
|
|
503
|
+
const { stdout } = await execFileAsync('git', ['rev-list', '--left-right', '--count', `${baseHead}...${branchHead}`], { cwd, encoding: 'utf8' });
|
|
504
|
+
const [left, right] = stdout.trim().split(/\s+/).map(n => Number.parseInt(n, 10));
|
|
505
|
+
behind = Number.isFinite(left) ? left : 0;
|
|
506
|
+
ahead = Number.isFinite(right) ? right : 0;
|
|
507
|
+
} catch { /* keep zero counts on error */ }
|
|
508
|
+
return {
|
|
509
|
+
mergeBase,
|
|
510
|
+
ahead,
|
|
511
|
+
behind,
|
|
512
|
+
diverged: ahead > 0 && behind > 0,
|
|
513
|
+
// Strictly behind = base is a descendant of branch (branch is an ancestor of base):
|
|
514
|
+
// behind>0 with ahead===0.
|
|
515
|
+
isStrictlyBehind: behind > 0 && ahead === 0,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* DS2 sync_base stage: bring the worktree branch up to the pinned baseHead BEFORE
|
|
521
|
+
* validation, so every later gate (validation, patch_equivalence, merge) sees the
|
|
522
|
+
* final rebased tree rather than a stale pre-rebase one.
|
|
523
|
+
*
|
|
524
|
+
* The old auto-rebase lived inside patch_equivalence and only fired when branchHead
|
|
525
|
+
* was a STRICT ANCESTOR of baseHead (`merge-base --is-ancestor`). A diverged laggard
|
|
526
|
+
* — the branch has its own commits AND base moved underneath it (ahead>0 AND behind>0)
|
|
527
|
+
* — failed that ancestor check, so it was never rebased and fell straight to
|
|
528
|
+
* patch_equivalence_failed / blocked_review even though a clean rebase would have
|
|
529
|
+
* converged it. Here we compute ahead/behind explicitly and rebase whenever behind>0
|
|
530
|
+
* (strictly-behind OR diverged), aborting to blocked_review only on a real conflict.
|
|
531
|
+
*
|
|
532
|
+
* On a successful rebase we recompute branchHead and re-derive changeImpact against
|
|
533
|
+
* the rebased tree (its baseHead..branchHead diff changed), and record the
|
|
534
|
+
* `patch_equivalence_after_auto_rebase` stage so the batch/ancestry assertions can see
|
|
535
|
+
* the rebase happened. When the branch is already up to date (behind===0), this is a
|
|
536
|
+
* no-op passed stage.
|
|
537
|
+
*/
|
|
538
|
+
export async function refineSyncBaseStage(self: DaemonCommandRouter, ctx: RefineContext): Promise<RefineStageOutcome> {
|
|
539
|
+
const { repoRoot, baseHead, node, branch, baseBranch, refineStages, execFileAsync } = ctx;
|
|
540
|
+
let branchHead = ctx.branchHead;
|
|
541
|
+
const syncStarted = Date.now();
|
|
542
|
+
const divergence = await computeBranchBaseDivergence(execFileAsync, node.workspace, baseHead, branchHead);
|
|
543
|
+
|
|
544
|
+
if (divergence.behind === 0) {
|
|
545
|
+
// Branch already contains baseHead — nothing to sync. (ahead>0 is fine; that
|
|
546
|
+
// is the normal "branch is ahead, ready to merge" case.)
|
|
547
|
+
recordMeshRefineStage(refineStages, 'sync_base', 'passed', syncStarted, {
|
|
548
|
+
ahead: divergence.ahead,
|
|
549
|
+
behind: divergence.behind,
|
|
550
|
+
rebased: false,
|
|
551
|
+
reason: 'branch_up_to_date_with_base',
|
|
552
|
+
});
|
|
553
|
+
return { kind: 'continue', ctx };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Pre-rebase gate probe (MUST precede the rebase) — two cases where rebasing is
|
|
557
|
+
// the wrong move and we defer to the patch_equivalence stage with the branch
|
|
558
|
+
// intact:
|
|
559
|
+
// (1) already-merged-via-another-path — the branch's changes are already in
|
|
560
|
+
// base (merge-tree produces no diff: actualPatchId empty, expectedPatchId
|
|
561
|
+
// non-empty). A rebase would drop every commit as empty and leave a
|
|
562
|
+
// degenerate no-commit branch patch_equivalence can no longer recognize.
|
|
563
|
+
// (2) submodule gitlink conflict — base and branch advanced the SAME submodule
|
|
564
|
+
// to divergent commits. A blind root rebase would silently take the
|
|
565
|
+
// branch-side gitlink and hide the conflict (surfacing it later, without
|
|
566
|
+
// the actionable hint); the patch_equivalence gate instead describes it
|
|
567
|
+
// richly (which submodule, base vs branch commit, how to resolve).
|
|
568
|
+
// In both cases skip the rebase and continue → patch_equivalence handles it.
|
|
569
|
+
try {
|
|
570
|
+
const preRebasePe = await runMeshRefinePatchEquivalenceGate(repoRoot, baseHead, branchHead);
|
|
571
|
+
const alreadyMerged = !preRebasePe.actualPatchId && !!preRebasePe.expectedPatchId;
|
|
572
|
+
const submoduleConflict = preRebasePe.actionableHint?.kind === 'submodule_conflict';
|
|
573
|
+
if (alreadyMerged || submoduleConflict) {
|
|
574
|
+
recordMeshRefineStage(refineStages, 'sync_base', 'passed', syncStarted, {
|
|
575
|
+
ahead: divergence.ahead,
|
|
576
|
+
behind: divergence.behind,
|
|
577
|
+
rebased: false,
|
|
578
|
+
reason: alreadyMerged ? 'already_merged_via_other_path_skip_rebase' : 'submodule_conflict_defer_to_patch_equivalence',
|
|
579
|
+
});
|
|
580
|
+
return { kind: 'continue', ctx };
|
|
581
|
+
}
|
|
582
|
+
} catch { /* fail-open: on gate error, fall through to the rebase */ }
|
|
583
|
+
|
|
584
|
+
// behind>0: strictly-behind OR diverged. Rebase the branch onto the pinned
|
|
585
|
+
// baseHead. A conflict aborts and terminates blocked_review (retryable=false —
|
|
586
|
+
// a real content conflict needs human resolution, not a base-movement retry).
|
|
587
|
+
const rebaseStarted = Date.now();
|
|
588
|
+
try {
|
|
589
|
+
execFileSync('git', ['rebase', baseHead], { cwd: node.workspace, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
590
|
+
} catch (rebaseErr: any) {
|
|
591
|
+
try { execFileSync('git', ['rebase', '--abort'], { cwd: node.workspace, stdio: 'ignore' }); } catch { /* ignore */ }
|
|
592
|
+
// A rebase conflict on a submodule/gitlink divergence is a SPECIAL case the
|
|
593
|
+
// patch-equivalence gate describes with a rich actionable hint (which
|
|
594
|
+
// submodule, base vs branch commit, how to resolve). Run that gate against
|
|
595
|
+
// the original branchHead to recover the hint; when it IS a submodule
|
|
596
|
+
// conflict, surface the richer patch_equivalence_failed result (preserving
|
|
597
|
+
// the pre-DS2 UX) instead of the generic needs_rebase_with_conflicts.
|
|
598
|
+
let submoduleHintPatchEquivalence: Awaited<ReturnType<typeof runMeshRefinePatchEquivalenceGate>> | undefined;
|
|
599
|
+
try {
|
|
600
|
+
submoduleHintPatchEquivalence = await runMeshRefinePatchEquivalenceGate(repoRoot, baseHead, ctx.branchHead);
|
|
601
|
+
} catch { /* hint is best-effort */ }
|
|
602
|
+
const submoduleConflict = submoduleHintPatchEquivalence?.actionableHint?.kind === 'submodule_conflict';
|
|
603
|
+
recordMeshRefineStage(refineStages, 'sync_base', 'failed', syncStarted, {
|
|
604
|
+
ahead: divergence.ahead,
|
|
605
|
+
behind: divergence.behind,
|
|
606
|
+
diverged: divergence.diverged,
|
|
607
|
+
error: rebaseErr?.message || String(rebaseErr),
|
|
608
|
+
...(submoduleConflict ? { submoduleConflict: true } : {}),
|
|
609
|
+
});
|
|
610
|
+
if (submoduleConflict && submoduleHintPatchEquivalence) {
|
|
611
|
+
// Mirror the pre-DS2 patch_equivalence_failed shape (code, hint, stage).
|
|
612
|
+
recordMeshRefineStage(refineStages, 'patch_equivalence', 'failed', rebaseStarted, {
|
|
613
|
+
equivalent: submoduleHintPatchEquivalence.equivalent,
|
|
614
|
+
expectedPatchId: submoduleHintPatchEquivalence.expectedPatchId,
|
|
615
|
+
actualPatchId: submoduleHintPatchEquivalence.actualPatchId,
|
|
616
|
+
error: submoduleHintPatchEquivalence.error,
|
|
617
|
+
actionableHint: submoduleHintPatchEquivalence.actionableHint,
|
|
618
|
+
});
|
|
619
|
+
return { kind: 'terminal', result: {
|
|
620
|
+
success: false,
|
|
621
|
+
code: 'patch_equivalence_failed',
|
|
622
|
+
convergenceStatus: 'blocked_review',
|
|
623
|
+
error: 'Refinery patch-equivalence preflight failed (submodule gitlink conflict); merge/refine was not attempted.',
|
|
624
|
+
branch,
|
|
625
|
+
into: baseBranch,
|
|
626
|
+
patchEquivalence: submoduleHintPatchEquivalence,
|
|
627
|
+
refineStages,
|
|
628
|
+
finalBranchConvergenceState: {
|
|
629
|
+
branch, baseBranch, merged: false, removed: false, patchEquivalence: 'failed', status: 'blocked_review',
|
|
630
|
+
},
|
|
631
|
+
} };
|
|
632
|
+
}
|
|
633
|
+
// Generic content conflict → record patch_equivalence_after_auto_rebase failed
|
|
634
|
+
// so the failing-stage classification and the ancestry regression see the
|
|
635
|
+
// rebase attempt.
|
|
636
|
+
recordMeshRefineStage(refineStages, 'patch_equivalence_after_auto_rebase', 'failed', rebaseStarted, {
|
|
637
|
+
error: rebaseErr?.message || String(rebaseErr),
|
|
638
|
+
});
|
|
639
|
+
return { kind: 'terminal', result: {
|
|
640
|
+
success: false,
|
|
641
|
+
code: 'needs_rebase_with_conflicts',
|
|
642
|
+
convergenceStatus: 'blocked_review',
|
|
643
|
+
error: divergence.diverged
|
|
644
|
+
? `Branch has diverged from ${baseBranch} (ahead ${divergence.ahead}, behind ${divergence.behind}) and auto-rebase onto the fetched base hit conflicts; resolve conflicts manually and retry.`
|
|
645
|
+
: `Branch is behind ${baseBranch} and auto-rebase failed due to conflicts; resolve conflicts manually and retry.`,
|
|
646
|
+
branch,
|
|
647
|
+
into: baseBranch,
|
|
648
|
+
refineStages,
|
|
649
|
+
finalBranchConvergenceState: {
|
|
650
|
+
branch,
|
|
651
|
+
baseBranch,
|
|
652
|
+
merged: false,
|
|
653
|
+
removed: false,
|
|
654
|
+
status: 'blocked_review',
|
|
655
|
+
},
|
|
656
|
+
} };
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Rebase succeeded — recompute branchHead and re-derive changeImpact against the
|
|
660
|
+
// rebased tree (baseHead..branchHead changed, so the change area may have too).
|
|
661
|
+
const { stdout: rebasedHeadStdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: node.workspace, encoding: 'utf8' });
|
|
662
|
+
branchHead = rebasedHeadStdout.trim();
|
|
663
|
+
ctx.branchHead = branchHead;
|
|
664
|
+
let changeImpact: ChangedPackageClassification | undefined = ctx.changeImpact;
|
|
665
|
+
try {
|
|
666
|
+
changeImpact = await classifyChangedPackages(node.workspace, baseHead, branchHead);
|
|
667
|
+
ctx.changeImpact = changeImpact;
|
|
668
|
+
} catch { /* fail-open: keep the prior changeImpact (or undefined → full validation) */ }
|
|
669
|
+
|
|
670
|
+
recordMeshRefineStage(refineStages, 'sync_base', 'passed', syncStarted, {
|
|
671
|
+
ahead: divergence.ahead,
|
|
672
|
+
behind: divergence.behind,
|
|
673
|
+
diverged: divergence.diverged,
|
|
674
|
+
rebased: true,
|
|
675
|
+
rebasedBranchHead: branchHead,
|
|
676
|
+
...(changeImpact ? { changeImpact } : {}),
|
|
677
|
+
});
|
|
678
|
+
// Mirror the historical stage name so downstream (batch ancestry regression,
|
|
679
|
+
// failing-stage classification) can observe that a rebase-to-base happened.
|
|
680
|
+
recordMeshRefineStage(refineStages, 'patch_equivalence_after_auto_rebase', 'passed', rebaseStarted, {
|
|
681
|
+
rebasedBranchHead: branchHead,
|
|
682
|
+
rebasedOnto: baseHead,
|
|
683
|
+
});
|
|
684
|
+
return { kind: 'continue', ctx };
|
|
685
|
+
}
|
|
686
|
+
|
|
418
687
|
/**
|
|
419
688
|
* validation stage: run the refinery validation gate (typecheck / test /
|
|
420
689
|
* lint / build per node config) and block on failure or when no allowlisted
|
|
@@ -445,8 +714,13 @@ export async function refineValidationStage(self: DaemonCommandRouter, ctx: Refi
|
|
|
445
714
|
{ validationStatus: validationSummary.status, commandsRun: validationSummary.commandsRun.length },
|
|
446
715
|
);
|
|
447
716
|
if (validationSummary.status === 'failed') {
|
|
717
|
+
// QW1: command records carry `passed` (boolean), NOT `success`. The old
|
|
718
|
+
// `c.success === false` predicate never matched any entry, so the first
|
|
719
|
+
// failing command's name/output was always dropped from the error. The
|
|
720
|
+
// failing command is the one with passed===false (skipped-but-passed=true
|
|
721
|
+
// entries never fail the gate, so passed===false uniquely identifies it).
|
|
448
722
|
const firstFailedCmd = Array.isArray(validationSummary.commandsRun)
|
|
449
|
-
? (validationSummary.commandsRun as Array<Record<string, unknown>>).find(c => c.
|
|
723
|
+
? (validationSummary.commandsRun as Array<Record<string, unknown>>).find(c => c.passed === false)
|
|
450
724
|
: undefined;
|
|
451
725
|
const buildValidationFailedError = (): string => {
|
|
452
726
|
const base = validationSummary.failureCode === 'missing_dependencies'
|
|
@@ -518,17 +792,19 @@ export async function refineValidationStage(self: DaemonCommandRouter, ctx: Refi
|
|
|
518
792
|
}
|
|
519
793
|
|
|
520
794
|
/**
|
|
521
|
-
* patch_equivalence stage: preflight that the worktree branch's cumulative
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
* already-merged-via-another-path
|
|
525
|
-
*
|
|
795
|
+
* patch_equivalence stage: preflight that the worktree branch's cumulative patch is
|
|
796
|
+
* equivalent to base+branch. The DS2 sync_base stage already rebased any behind/diverged
|
|
797
|
+
* branch onto the pinned baseHead, so this is now a pure check: equivalent → continue;
|
|
798
|
+
* empty merge-tree with real branch changes → already-merged-via-another-path
|
|
799
|
+
* short-circuit to cleanup; otherwise → patch_equivalence_failed / blocked_review.
|
|
526
800
|
*/
|
|
527
801
|
export async function refinePatchEquivalenceStage(self: DaemonCommandRouter, ctx: RefineContext): Promise<RefineStageOutcome> {
|
|
528
|
-
|
|
529
|
-
|
|
802
|
+
// DS2: node/execFileAsync are no longer needed here — the rebase moved to
|
|
803
|
+
// sync_base — and branchHead/patchEquivalence are no longer mutated in-stage.
|
|
804
|
+
const { meshId, nodeId, args, repoRoot, baseHead, branch, baseBranch, validationSummary, refineStages } = ctx;
|
|
805
|
+
const branchHead = ctx.branchHead;
|
|
530
806
|
const patchEquivalenceStarted = Date.now();
|
|
531
|
-
|
|
807
|
+
const patchEquivalence = await runMeshRefinePatchEquivalenceGate(repoRoot, baseHead, branchHead);
|
|
532
808
|
recordMeshRefineStage(refineStages, 'patch_equivalence', patchEquivalence.status, patchEquivalenceStarted, {
|
|
533
809
|
equivalent: patchEquivalence.equivalent,
|
|
534
810
|
expectedPatchId: patchEquivalence.expectedPatchId,
|
|
@@ -537,99 +813,19 @@ export async function refinePatchEquivalenceStage(self: DaemonCommandRouter, ctx
|
|
|
537
813
|
actionableHint: patchEquivalence.actionableHint,
|
|
538
814
|
});
|
|
539
815
|
if (!patchEquivalence.equivalent) {
|
|
540
|
-
//
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
if (isBehindBase) {
|
|
552
|
-
const autoRebaseStarted = Date.now();
|
|
553
|
-
try {
|
|
554
|
-
execFileSync('git', ['rebase', baseHead], {
|
|
555
|
-
cwd: node.workspace,
|
|
556
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
557
|
-
});
|
|
558
|
-
const { stdout: rebasedHeadStdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: node.workspace, encoding: 'utf8' });
|
|
559
|
-
branchHead = rebasedHeadStdout.trim();
|
|
560
|
-
const rebasedPatchEquivalence = await runMeshRefinePatchEquivalenceGate(repoRoot, baseHead, branchHead);
|
|
561
|
-
recordMeshRefineStage(refineStages, 'patch_equivalence_after_auto_rebase', rebasedPatchEquivalence.status, autoRebaseStarted, {
|
|
562
|
-
equivalent: rebasedPatchEquivalence.equivalent,
|
|
563
|
-
expectedPatchId: rebasedPatchEquivalence.expectedPatchId,
|
|
564
|
-
actualPatchId: rebasedPatchEquivalence.actualPatchId,
|
|
565
|
-
error: rebasedPatchEquivalence.error,
|
|
566
|
-
rebasedBranchHead: branchHead,
|
|
567
|
-
});
|
|
568
|
-
if (rebasedPatchEquivalence.equivalent) {
|
|
569
|
-
patchEquivalence = rebasedPatchEquivalence;
|
|
570
|
-
didAutoRebase = true;
|
|
571
|
-
} else {
|
|
572
|
-
return { kind: 'terminal', result: {
|
|
573
|
-
success: false,
|
|
574
|
-
code: 'needs_rebase',
|
|
575
|
-
convergenceStatus: 'blocked_review',
|
|
576
|
-
error: 'Branch was rebased onto base but patch equivalence still failed; manual intervention required.',
|
|
577
|
-
branch,
|
|
578
|
-
into: baseBranch,
|
|
579
|
-
validationSummary,
|
|
580
|
-
patchEquivalence: rebasedPatchEquivalence,
|
|
581
|
-
refineStages,
|
|
582
|
-
finalBranchConvergenceState: {
|
|
583
|
-
branch,
|
|
584
|
-
baseBranch,
|
|
585
|
-
merged: false,
|
|
586
|
-
removed: false,
|
|
587
|
-
validation: 'passed',
|
|
588
|
-
patchEquivalence: 'failed',
|
|
589
|
-
status: 'blocked_review',
|
|
590
|
-
},
|
|
591
|
-
} };
|
|
592
|
-
}
|
|
593
|
-
} catch (rebaseErr: any) {
|
|
594
|
-
try { execFileSync('git', ['rebase', '--abort'], { cwd: node.workspace, stdio: 'ignore' }); } catch { /* ignore */ }
|
|
595
|
-
recordMeshRefineStage(refineStages, 'patch_equivalence_after_auto_rebase', 'failed', autoRebaseStarted, {
|
|
596
|
-
error: rebaseErr?.message || String(rebaseErr),
|
|
597
|
-
});
|
|
598
|
-
return { kind: 'terminal', result: {
|
|
599
|
-
success: false,
|
|
600
|
-
code: 'needs_rebase_with_conflicts',
|
|
601
|
-
convergenceStatus: 'blocked_review',
|
|
602
|
-
error: 'Branch is behind base and auto-rebase failed due to conflicts; resolve conflicts manually and retry.',
|
|
603
|
-
branch,
|
|
604
|
-
into: baseBranch,
|
|
605
|
-
validationSummary,
|
|
606
|
-
patchEquivalence,
|
|
607
|
-
refineStages,
|
|
608
|
-
finalBranchConvergenceState: {
|
|
609
|
-
branch,
|
|
610
|
-
baseBranch,
|
|
611
|
-
merged: false,
|
|
612
|
-
removed: false,
|
|
613
|
-
validation: 'passed',
|
|
614
|
-
patchEquivalence: 'failed',
|
|
615
|
-
status: 'blocked_review',
|
|
616
|
-
},
|
|
617
|
-
} };
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
// If the actual patch-id is empty, the merge-tree produces no diff vs base —
|
|
622
|
-
// meaning the branch content is already present in base (landed via a different
|
|
623
|
-
// path, e.g. cherry-pick or direct commit). Treat this as "already merged":
|
|
624
|
-
// skip the merge step but still run cleanup so the worktree node is removed.
|
|
625
|
-
// "already merged via another path": the branch has real changes
|
|
626
|
-
// (expectedPatchId non-empty) but the merge-tree produces no diff
|
|
627
|
-
// against base (actualPatchId empty) — meaning every change in the
|
|
628
|
-
// branch is already present in base via a cherry-pick or direct commit.
|
|
629
|
-
// If both patch-ids are empty, the branch itself has no changes; that
|
|
630
|
-
// is a degenerate worktree case, not an "already merged" scenario.
|
|
816
|
+
// DS2: the sync_base stage already rebased a behind/diverged branch onto
|
|
817
|
+
// the pinned baseHead BEFORE validation, so by here the branch is either
|
|
818
|
+
// equivalent (handled above) or genuinely non-equivalent for a reason a
|
|
819
|
+
// rebase cannot fix. The old in-stage auto-rebase (ancestor-only) is gone.
|
|
820
|
+
//
|
|
821
|
+
// The one benign non-equivalent case that remains is "already merged via
|
|
822
|
+
// another path": the branch has real changes (expectedPatchId non-empty)
|
|
823
|
+
// but the merge-tree produces no diff against base (actualPatchId empty) —
|
|
824
|
+
// every change is already present in base via a cherry-pick or direct
|
|
825
|
+
// commit. Short-circuit merge → cleanup. If both patch-ids are empty the
|
|
826
|
+
// branch itself has no changes (degenerate), which is NOT already-merged.
|
|
631
827
|
const alreadyMergedViaOtherPath = !patchEquivalence.actualPatchId && !!patchEquivalence.expectedPatchId;
|
|
632
|
-
if (!
|
|
828
|
+
if (!alreadyMergedViaOtherPath) {
|
|
633
829
|
return { kind: 'terminal', result: {
|
|
634
830
|
success: false,
|
|
635
831
|
code: 'patch_equivalence_failed',
|
|
@@ -652,7 +848,7 @@ export async function refinePatchEquivalenceStage(self: DaemonCommandRouter, ctx
|
|
|
652
848
|
} };
|
|
653
849
|
}
|
|
654
850
|
|
|
655
|
-
|
|
851
|
+
{
|
|
656
852
|
// Content already in base — skip merge, go straight to cleanup.
|
|
657
853
|
recordMeshRefineStage(refineStages, 'merge', 'skipped', Date.now(), {
|
|
658
854
|
reason: 'already_merged_via_other_path',
|
|
@@ -886,6 +1082,105 @@ export async function refineEffectiveDiffStage(self: DaemonCommandRouter, ctx: R
|
|
|
886
1082
|
return { kind: 'continue', ctx };
|
|
887
1083
|
}
|
|
888
1084
|
|
|
1085
|
+
/**
|
|
1086
|
+
* DS3: after a successful Refinery push advanced origin/<baseBranch>, bring the
|
|
1087
|
+
* ORIGINATING COORDINATOR daemon's own local base checkout up to the pushed commit so
|
|
1088
|
+
* the coordinator isn't silently left behind (the "merged to main but my local main is
|
|
1089
|
+
* stale" gap). Guarded and NON-destructive:
|
|
1090
|
+
*
|
|
1091
|
+
* - If the coordinator's base node is hosted by THIS daemon and is reachable locally,
|
|
1092
|
+
* run fastForwardMeshNode(mode:'merge') on it directly. That helper is itself the
|
|
1093
|
+
* guard: it only ff-only-merges when the workspace is clean, ahead=0 and behind>0;
|
|
1094
|
+
* an ahead/diverged/dirty coordinator returns a structured block (never a rebase).
|
|
1095
|
+
* - If the coordinator is a DIFFERENT daemon (remote), we cannot touch its checkout
|
|
1096
|
+
* from here, so we queue a `coordinator_catchup` pending event targeted at that
|
|
1097
|
+
* coordinator daemon; its reconcile loop / next mesh-tool call drains it and runs the
|
|
1098
|
+
* same guarded fast-forward locally (busy → naturally deferred to the next idle edge).
|
|
1099
|
+
*
|
|
1100
|
+
* Best-effort and advisory: the caller never fails the refine on a catch-up problem. The
|
|
1101
|
+
* refine's own repoRoot IS the base it just merged+pushed, so when the coordinator IS this
|
|
1102
|
+
* daemon and IS repoRoot the ff is a no-op `already_up_to_date` — correct and harmless.
|
|
1103
|
+
* Returns a compact summary for the stage record, or undefined when there's nothing to do.
|
|
1104
|
+
*/
|
|
1105
|
+
export async function requestCoordinatorLocalCatchup(
|
|
1106
|
+
self: DaemonCommandRouter,
|
|
1107
|
+
params: { meshId: string; ctx: RefineContext; mesh: any; baseBranch: string; repoRoot: string },
|
|
1108
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
1109
|
+
const { meshId, ctx, mesh, baseBranch, repoRoot } = params;
|
|
1110
|
+
// Originating coordinator daemon id: explicit arg wins, else this daemon's own id.
|
|
1111
|
+
const coordinatorDaemonId = (typeof ctx.args?.coordinatorDaemonId === 'string' && ctx.args.coordinatorDaemonId.trim())
|
|
1112
|
+
? ctx.args.coordinatorDaemonId.trim()
|
|
1113
|
+
: (self.deps.statusInstanceId || undefined);
|
|
1114
|
+
if (!coordinatorDaemonId) return undefined;
|
|
1115
|
+
if (!Array.isArray(mesh?.nodes)) return undefined;
|
|
1116
|
+
|
|
1117
|
+
// The coordinator's base checkout is the non-worktree node owned by the coordinator
|
|
1118
|
+
// daemon. Prefer an exact daemon-id match; the coordinator is a base (non-worktree) node.
|
|
1119
|
+
const coordinatorBaseNode = mesh.nodes.find((n: any) =>
|
|
1120
|
+
!n?.isLocalWorktree && daemonIdListIncludes([coordinatorDaemonId], readStringValue(n?.daemonId)));
|
|
1121
|
+
if (!coordinatorBaseNode) return undefined;
|
|
1122
|
+
const coordinatorWorkspace = readStringValue(coordinatorBaseNode.repoRoot) || readStringValue(coordinatorBaseNode.workspace);
|
|
1123
|
+
if (!coordinatorWorkspace) return undefined;
|
|
1124
|
+
|
|
1125
|
+
// Is the coordinator base node hosted by THIS daemon? Resolve this daemon's self ids
|
|
1126
|
+
// for the mesh (status id + machineId forms + config-form node ids) and check the node.
|
|
1127
|
+
const drainIds = [self.deps.statusInstanceId].filter((v): v is string => typeof v === 'string' && v.length > 0);
|
|
1128
|
+
const selfIds = resolveCoordinatorSelfIds(mesh as any, drainIds);
|
|
1129
|
+
const coordinatorIsSelf = daemonIdListIncludes(selfIds, readStringValue(coordinatorBaseNode.daemonId));
|
|
1130
|
+
|
|
1131
|
+
if (coordinatorIsSelf) {
|
|
1132
|
+
// Run the guarded ff-only catch-up directly on the local coordinator base checkout.
|
|
1133
|
+
// fastForwardMeshNode gates on clean/ahead=0/behind>0 internally and never rebases.
|
|
1134
|
+
try {
|
|
1135
|
+
const ff = await fastForwardMeshNode({
|
|
1136
|
+
meshId,
|
|
1137
|
+
nodeId: readStringValue(coordinatorBaseNode.id),
|
|
1138
|
+
workspace: coordinatorWorkspace,
|
|
1139
|
+
branch: baseBranch,
|
|
1140
|
+
mode: 'merge',
|
|
1141
|
+
execute: true,
|
|
1142
|
+
trigger: 'refine_post_push_catchup',
|
|
1143
|
+
allowAutoPublishSubmoduleMainCommits: mesh?.policy?.allowAutoPublishSubmoduleMainCommits === true,
|
|
1144
|
+
});
|
|
1145
|
+
return {
|
|
1146
|
+
mode: 'local_fast_forward',
|
|
1147
|
+
coordinatorWorkspace,
|
|
1148
|
+
sameAsRepoRoot: coordinatorWorkspace === repoRoot,
|
|
1149
|
+
code: ff.code,
|
|
1150
|
+
executed: ff.executed,
|
|
1151
|
+
success: ff.success,
|
|
1152
|
+
...(ff.blockingReasons?.length ? { blockingReasons: ff.blockingReasons } : {}),
|
|
1153
|
+
};
|
|
1154
|
+
} catch (e: any) {
|
|
1155
|
+
return { mode: 'local_fast_forward', coordinatorWorkspace, error: e?.message || String(e) };
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// Remote coordinator: queue a targeted pending marker for its reconcile loop / next
|
|
1160
|
+
// mesh-tool call to pick up and fast-forward locally (guarded, deferrable when busy).
|
|
1161
|
+
try {
|
|
1162
|
+
queuePendingMeshCoordinatorEvent({
|
|
1163
|
+
event: 'coordinator_catchup',
|
|
1164
|
+
meshId,
|
|
1165
|
+
nodeLabel: readStringValue(coordinatorBaseNode.id) || 'coordinator-base',
|
|
1166
|
+
nodeId: readStringValue(coordinatorBaseNode.id),
|
|
1167
|
+
workspace: coordinatorWorkspace,
|
|
1168
|
+
metadataEvent: {
|
|
1169
|
+
source: 'refine_post_push_coordinator_catchup',
|
|
1170
|
+
operation: 'coordinator_catchup',
|
|
1171
|
+
baseBranch,
|
|
1172
|
+
coordinatorDaemonId,
|
|
1173
|
+
reason: 'post_push_base_advanced',
|
|
1174
|
+
},
|
|
1175
|
+
queuedAt: Date.now(),
|
|
1176
|
+
targetCoordinatorDaemonId: coordinatorDaemonId,
|
|
1177
|
+
});
|
|
1178
|
+
return { mode: 'pending_marker_queued', coordinatorDaemonId, coordinatorWorkspace, baseBranch };
|
|
1179
|
+
} catch (e: any) {
|
|
1180
|
+
return { mode: 'pending_marker_queued', coordinatorDaemonId, error: e?.message || String(e) };
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
889
1184
|
/**
|
|
890
1185
|
* merge + finalize stage: perform the --no-ff merge, align submodule
|
|
891
1186
|
* checkouts after merge, clean up (remove) the worktree node per policy,
|
|
@@ -894,6 +1189,96 @@ export async function refineEffectiveDiffStage(self: DaemonCommandRouter, ctx: R
|
|
|
894
1189
|
*/
|
|
895
1190
|
export async function refineMergeAndFinalizeStage(self: DaemonCommandRouter, ctx: RefineContext): Promise<RefineStageOutcome> {
|
|
896
1191
|
const { meshId, nodeId, args, repoRoot, baseHead, node, branch, baseBranch, sourceNode, validationSummary, patchEquivalence, submoduleReachability, mesh, refineStages, execFileAsync } = ctx;
|
|
1192
|
+
|
|
1193
|
+
// DS2: acquire the repoRoot+baseBranch refinement lease for the base-mutating
|
|
1194
|
+
// window (CAS → merge → push → cleanup). Serializes overlapping single-node
|
|
1195
|
+
// async refines targeting the same base so they cannot both validate against one
|
|
1196
|
+
// baseHead and then race their merges. The batch path is already sequential, so
|
|
1197
|
+
// this only contends across independent async jobs. If another refine holds it,
|
|
1198
|
+
// terminate retryable (base_locked) — the coordinator/batch retries after it
|
|
1199
|
+
// frees. Released in the finally below.
|
|
1200
|
+
const leaseKey = `${repoRoot}::${baseBranch}`;
|
|
1201
|
+
const leaseHolder = buildRefineJobKey(self, meshId, nodeId);
|
|
1202
|
+
if (self.refineBaseLeases.has(leaseKey) && self.refineBaseLeases.get(leaseKey) !== leaseHolder) {
|
|
1203
|
+
recordMeshRefineStage(refineStages, 'base_lease', 'skipped', Date.now(), {
|
|
1204
|
+
leaseKey, heldBy: self.refineBaseLeases.get(leaseKey), retryable: true,
|
|
1205
|
+
});
|
|
1206
|
+
return { kind: 'terminal', result: {
|
|
1207
|
+
success: false,
|
|
1208
|
+
code: 'base_locked',
|
|
1209
|
+
convergenceStatus: 'blocked_review',
|
|
1210
|
+
retryable: true,
|
|
1211
|
+
error: `Another refine holds the base lease for ${baseBranch} in this repo; retry after it completes.`,
|
|
1212
|
+
branch,
|
|
1213
|
+
into: baseBranch,
|
|
1214
|
+
validationSummary,
|
|
1215
|
+
patchEquivalence,
|
|
1216
|
+
submoduleReachability,
|
|
1217
|
+
refineStages,
|
|
1218
|
+
finalBranchConvergenceState: {
|
|
1219
|
+
branch, baseBranch, merged: false, removed: false, status: 'blocked_review',
|
|
1220
|
+
},
|
|
1221
|
+
} };
|
|
1222
|
+
}
|
|
1223
|
+
self.refineBaseLeases.set(leaseKey, leaseHolder);
|
|
1224
|
+
try {
|
|
1225
|
+
return await runRefineMergeAndFinalizeLocked(self, ctx);
|
|
1226
|
+
} finally {
|
|
1227
|
+
if (self.refineBaseLeases.get(leaseKey) === leaseHolder) self.refineBaseLeases.delete(leaseKey);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* DS2 CAS + DS1 order: the base-lease-protected core of merge/finalize. Before the
|
|
1233
|
+
* merge it re-fetches origin/<baseBranch> and compare-and-swaps the live origin SHA
|
|
1234
|
+
* against the pinned baseHead from resolve_refs; if the base moved, it terminates
|
|
1235
|
+
* retryable (base_moved) WITHOUT merging so a re-run rebases onto and validates the
|
|
1236
|
+
* new base. DS1: on the auto-push path the order is merge → push → cleanup, so a push
|
|
1237
|
+
* failure leaves the worktree/branch intact (cleanup withheld) and is reported as a
|
|
1238
|
+
* terminal blocked state — the batch never counts an un-pushed node as merged.
|
|
1239
|
+
*/
|
|
1240
|
+
export async function runRefineMergeAndFinalizeLocked(self: DaemonCommandRouter, ctx: RefineContext): Promise<RefineStageOutcome> {
|
|
1241
|
+
const { meshId, nodeId, args, repoRoot, baseHead, node, branch, baseBranch, sourceNode, validationSummary, patchEquivalence, submoduleReachability, mesh, refineStages, execFileAsync } = ctx;
|
|
1242
|
+
|
|
1243
|
+
// DS2 base-movement CAS: re-fetch origin/<baseBranch> and compare its live SHA
|
|
1244
|
+
// against the baseHead pinned in resolve_refs. If it advanced (a sibling/peer
|
|
1245
|
+
// pushed while this node validated), the merge would be onto a stale base — bail
|
|
1246
|
+
// retryable so the re-run rebases onto and re-validates the NEW base. Fail-open:
|
|
1247
|
+
// a fetch/parse error skips the check (proceed with the merge as before).
|
|
1248
|
+
const casStarted = Date.now();
|
|
1249
|
+
let baseMoved = false;
|
|
1250
|
+
let liveBaseHead: string | undefined;
|
|
1251
|
+
try {
|
|
1252
|
+
await execFileAsync('git', ['fetch', 'origin', baseBranch], { cwd: repoRoot, encoding: 'utf8' });
|
|
1253
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', `origin/${baseBranch}`], { cwd: repoRoot, encoding: 'utf8' });
|
|
1254
|
+
liveBaseHead = stdout.trim();
|
|
1255
|
+
baseMoved = !!liveBaseHead && liveBaseHead !== baseHead;
|
|
1256
|
+
} catch { /* fail-open: cannot verify → proceed (merge itself still guards) */ }
|
|
1257
|
+
if (baseMoved) {
|
|
1258
|
+
recordMeshRefineStage(refineStages, 'base_cas', 'failed', casStarted, {
|
|
1259
|
+
pinnedBaseHead: baseHead, liveBaseHead, retryable: true,
|
|
1260
|
+
});
|
|
1261
|
+
return { kind: 'terminal', result: {
|
|
1262
|
+
success: false,
|
|
1263
|
+
code: 'base_moved',
|
|
1264
|
+
convergenceStatus: 'blocked_review',
|
|
1265
|
+
retryable: true,
|
|
1266
|
+
error: `Base ${baseBranch} advanced from ${baseHead.slice(0, 7)} to ${(liveBaseHead || '').slice(0, 7)} after this node was validated; re-run refine to rebase onto and re-validate the new base.`,
|
|
1267
|
+
branch,
|
|
1268
|
+
into: baseBranch,
|
|
1269
|
+
pinnedBaseHead: baseHead,
|
|
1270
|
+
liveBaseHead,
|
|
1271
|
+
validationSummary,
|
|
1272
|
+
patchEquivalence,
|
|
1273
|
+
submoduleReachability,
|
|
1274
|
+
refineStages,
|
|
1275
|
+
finalBranchConvergenceState: {
|
|
1276
|
+
branch, baseBranch, merged: false, removed: false, status: 'blocked_review',
|
|
1277
|
+
},
|
|
1278
|
+
} };
|
|
1279
|
+
}
|
|
1280
|
+
recordMeshRefineStage(refineStages, 'base_cas', 'passed', casStarted, { pinnedBaseHead: baseHead });
|
|
1281
|
+
|
|
897
1282
|
let mergeResult: Record<string, unknown> | undefined;
|
|
898
1283
|
const mergeStarted = Date.now();
|
|
899
1284
|
try {
|
|
@@ -905,16 +1290,41 @@ export async function refineMergeAndFinalizeStage(self: DaemonCommandRouter, ctx
|
|
|
905
1290
|
};
|
|
906
1291
|
recordMeshRefineStage(refineStages, 'merge', 'passed', mergeStarted, mergeResult);
|
|
907
1292
|
} catch (e: any) {
|
|
1293
|
+
// QW4: a `git merge` conflict is a distinct, structured terminal state —
|
|
1294
|
+
// stamp a stable code='merge_failed' (batch keys not_mergeable off it) and
|
|
1295
|
+
// surface the conflicting paths so a coordinator can classify + report
|
|
1296
|
+
// without abort-and-reparse. git writes "CONFLICT (...): Merge conflict in
|
|
1297
|
+
// <path>" to stdout; abort the half-applied merge so the base workspace is
|
|
1298
|
+
// left clean for the next sibling in a batch.
|
|
1299
|
+
const mergeOutput = `${e?.stdout || ''}\n${e?.stderr || ''}`;
|
|
1300
|
+
const conflictPaths = [...mergeOutput.matchAll(/Merge conflict in (.+)/g)]
|
|
1301
|
+
.map(m => m[1].trim())
|
|
1302
|
+
.filter(Boolean);
|
|
1303
|
+
try {
|
|
1304
|
+
await execFileAsync('git', ['merge', '--abort'], { cwd: repoRoot, encoding: 'utf8' });
|
|
1305
|
+
} catch { /* nothing to abort (e.g. merge never started) — best-effort */ }
|
|
908
1306
|
recordMeshRefineStage(refineStages, 'merge', 'failed', mergeStarted, {
|
|
909
1307
|
error: e?.message || String(e),
|
|
910
1308
|
stdout: truncateValidationOutput(e?.stdout),
|
|
911
1309
|
stderr: truncateValidationOutput(e?.stderr),
|
|
1310
|
+
...(conflictPaths.length ? { conflictPaths } : {}),
|
|
912
1311
|
});
|
|
913
1312
|
return { kind: 'terminal', result: {
|
|
914
1313
|
success: false,
|
|
915
|
-
|
|
1314
|
+
code: 'merge_failed',
|
|
1315
|
+
convergenceStatus: 'not_mergeable',
|
|
1316
|
+
error: conflictPaths.length
|
|
1317
|
+
? `Merge failed — conflicts in ${conflictPaths.length} path(s): ${conflictPaths.join(', ')}. The branch cannot fast-forward-merge onto ${baseBranch}; resolve conflicts (rebase the branch onto the fetched base) and retry.`
|
|
1318
|
+
: `Merge failed (conflicts?): ${e?.message || String(e)}`,
|
|
1319
|
+
branch,
|
|
1320
|
+
into: baseBranch,
|
|
1321
|
+
...(conflictPaths.length ? { conflictPaths } : {}),
|
|
916
1322
|
validationSummary,
|
|
917
1323
|
patchEquivalence,
|
|
1324
|
+
mergeResult: {
|
|
1325
|
+
stdout: truncateValidationOutput(e?.stdout),
|
|
1326
|
+
stderr: truncateValidationOutput(e?.stderr),
|
|
1327
|
+
},
|
|
918
1328
|
refineStages,
|
|
919
1329
|
finalBranchConvergenceState: {
|
|
920
1330
|
branch,
|
|
@@ -974,6 +1384,115 @@ export async function refineMergeAndFinalizeStage(self: DaemonCommandRouter, ctx
|
|
|
974
1384
|
} };
|
|
975
1385
|
}
|
|
976
1386
|
|
|
1387
|
+
// ── DS1: push BEFORE cleanup ──────────────────────────────────────────
|
|
1388
|
+
// The merge has landed on the local base. The contract is "success ⇒ the
|
|
1389
|
+
// change is on origin (or, for the approval path, on local base awaiting an
|
|
1390
|
+
// approved push)". So push (or defer for approval) FIRST, and only run the
|
|
1391
|
+
// destructive worktree/branch cleanup once the push is proven — a push failure
|
|
1392
|
+
// must leave the worktree + branch ref intact so a retry can re-push without
|
|
1393
|
+
// reconstructing anything, and the batch must NOT count the node as merged.
|
|
1394
|
+
const requireApprovalForPush: boolean = (mesh as any)?.policy?.requireApprovalForPush ?? DEFAULT_MESH_POLICY.requireApprovalForPush;
|
|
1395
|
+
|
|
1396
|
+
let pushResult: Record<string, unknown> | undefined;
|
|
1397
|
+
if (!requireApprovalForPush) {
|
|
1398
|
+
const pushStarted = Date.now();
|
|
1399
|
+
try {
|
|
1400
|
+
await execFileAsync('git', ['push', 'origin', baseBranch], { cwd: repoRoot, encoding: 'utf8' });
|
|
1401
|
+
pushResult = { pushed: true, remote: 'origin', branch: baseBranch, durationMs: Date.now() - pushStarted };
|
|
1402
|
+
recordMeshRefineStage(refineStages, 'push', 'passed', pushStarted, pushResult);
|
|
1403
|
+
} catch (e: any) {
|
|
1404
|
+
pushResult = {
|
|
1405
|
+
pushed: false,
|
|
1406
|
+
remote: 'origin',
|
|
1407
|
+
branch: baseBranch,
|
|
1408
|
+
error: e?.message || String(e),
|
|
1409
|
+
stderr: e?.stderr,
|
|
1410
|
+
durationMs: Date.now() - pushStarted,
|
|
1411
|
+
};
|
|
1412
|
+
recordMeshRefineStage(refineStages, 'push', 'failed', pushStarted, pushResult);
|
|
1413
|
+
// DS1: push failed AFTER a good merge. Do NOT clean up — leave the
|
|
1414
|
+
// worktree + branch ref intact so the coordinator can retry the push.
|
|
1415
|
+
// Terminal blocked (retryable); the batch counts this as NOT merged.
|
|
1416
|
+
// The local base HAS the merge commit, so a bare `git push origin
|
|
1417
|
+
// <base>` from repoRoot converges it; the branch ref is preserved as a
|
|
1418
|
+
// safety net.
|
|
1419
|
+
return { kind: 'terminal', result: {
|
|
1420
|
+
success: false,
|
|
1421
|
+
code: 'push_failed',
|
|
1422
|
+
convergenceStatus: 'blocked_review',
|
|
1423
|
+
retryable: true,
|
|
1424
|
+
merged: true,
|
|
1425
|
+
mergedLocal: true,
|
|
1426
|
+
pushed: false,
|
|
1427
|
+
error: `Refinery merged '${branch}' into local ${baseBranch} but the push to origin failed; the worktree and branch ref were preserved (NOT cleaned up) so the push can be retried. Run: git -C ${repoRoot} push origin ${baseBranch}`,
|
|
1428
|
+
branch,
|
|
1429
|
+
into: baseBranch,
|
|
1430
|
+
pushResult,
|
|
1431
|
+
pushCommand: `git push origin ${baseBranch}`,
|
|
1432
|
+
validationSummary,
|
|
1433
|
+
patchEquivalence,
|
|
1434
|
+
submoduleReachability,
|
|
1435
|
+
submoduleAlignment,
|
|
1436
|
+
mergeResult,
|
|
1437
|
+
refineStages,
|
|
1438
|
+
finalBranchConvergenceState: {
|
|
1439
|
+
branch: baseBranch,
|
|
1440
|
+
mergedBranch: branch,
|
|
1441
|
+
baseBranch,
|
|
1442
|
+
merged: true,
|
|
1443
|
+
pushed: false,
|
|
1444
|
+
removed: false,
|
|
1445
|
+
validation: 'passed',
|
|
1446
|
+
patchEquivalence: 'passed',
|
|
1447
|
+
submoduleAlignment: submoduleAlignment.status,
|
|
1448
|
+
status: 'merged_push_failed',
|
|
1449
|
+
nextStep: `Retry the push (git -C ${repoRoot} push origin ${baseBranch}); then the worktree can be cleaned up.`,
|
|
1450
|
+
},
|
|
1451
|
+
} };
|
|
1452
|
+
}
|
|
1453
|
+
} else {
|
|
1454
|
+
// DS1 approval path: the merge is on local base but must NOT be pushed
|
|
1455
|
+
// without approval, and cleanup is WITHHELD until the push is approved and
|
|
1456
|
+
// proven to reach origin (removing the worktree now would drop the branch
|
|
1457
|
+
// ref before the push is authorized). Distinct convergence state so the
|
|
1458
|
+
// batch/coordinator treats it as "landed locally, remote pending" — never
|
|
1459
|
+
// as remote-converged.
|
|
1460
|
+
recordMeshRefineStage(refineStages, 'push', 'skipped', Date.now(), {
|
|
1461
|
+
reason: 'require_approval_for_push',
|
|
1462
|
+
});
|
|
1463
|
+
return { kind: 'terminal', result: {
|
|
1464
|
+
success: true,
|
|
1465
|
+
merged: true,
|
|
1466
|
+
mergedLocal: true,
|
|
1467
|
+
pushed: false,
|
|
1468
|
+
branch,
|
|
1469
|
+
into: baseBranch,
|
|
1470
|
+
validationSummary,
|
|
1471
|
+
patchEquivalence,
|
|
1472
|
+
submoduleReachability,
|
|
1473
|
+
submoduleAlignment,
|
|
1474
|
+
mergeResult,
|
|
1475
|
+
refineStages,
|
|
1476
|
+
pushReady: true,
|
|
1477
|
+
pushCommand: `git push origin ${baseBranch}`,
|
|
1478
|
+
pushNote: 'requireApprovalForPush is enabled — the merge landed on the local base but was NOT pushed and the worktree was NOT cleaned up. Run the push (or approve it), then re-run refine/cleanup to remove the worktree.',
|
|
1479
|
+
finalBranchConvergenceState: {
|
|
1480
|
+
branch: baseBranch,
|
|
1481
|
+
mergedBranch: branch,
|
|
1482
|
+
baseBranch,
|
|
1483
|
+
merged: true,
|
|
1484
|
+
pushed: false,
|
|
1485
|
+
removed: false,
|
|
1486
|
+
validation: 'passed',
|
|
1487
|
+
patchEquivalence: 'passed',
|
|
1488
|
+
submoduleAlignment: submoduleAlignment.status,
|
|
1489
|
+
status: 'merged_local_pending_push',
|
|
1490
|
+
nextStep: `Approve and run the push (git -C ${repoRoot} push origin ${baseBranch}); the worktree is retained until then.`,
|
|
1491
|
+
},
|
|
1492
|
+
} };
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// ── Push succeeded (auto-push path) → now run cleanup ─────────────────
|
|
977
1496
|
const cleanupStarted = Date.now();
|
|
978
1497
|
// Honor the mesh policy for delegated-session cleanup on the auto-removed
|
|
979
1498
|
// worktree node (previously hardcoded to 'preserve', which orphaned the
|
|
@@ -1016,10 +1535,10 @@ export async function refineMergeAndFinalizeStage(self: DaemonCommandRouter, ctx
|
|
|
1016
1535
|
sessionCleanupMode: refineSessionCleanupMode,
|
|
1017
1536
|
...(refineSessionIds && refineSessionIds.length > 0 ? { sessionIds: refineSessionIds } : {}),
|
|
1018
1537
|
inlineMesh: args?.inlineMesh,
|
|
1019
|
-
// REFINE-CLEANUP: refine reaches cleanup only AFTER a verified merge
|
|
1020
|
-
//
|
|
1021
|
-
// (e.g. a bootstrap lockfile rewrite) — never unmerged work.
|
|
1022
|
-
// sets requireClean=false so a plain-dirty worktree no longer aborts
|
|
1538
|
+
// REFINE-CLEANUP: refine reaches cleanup only AFTER a verified merge AND a
|
|
1539
|
+
// successful push (DS1), so any residual worktree dirtiness here is
|
|
1540
|
+
// incidental (e.g. a bootstrap lockfile rewrite) — never unmerged work.
|
|
1541
|
+
// `force` sets requireClean=false so a plain-dirty worktree no longer aborts
|
|
1023
1542
|
// removal with merged_cleanup_failed. Branch-ref deletion still keys off
|
|
1024
1543
|
// mergeConvergence (NOT the force flag), so no merged work can be lost.
|
|
1025
1544
|
force: true,
|
|
@@ -1037,7 +1556,7 @@ export async function refineMergeAndFinalizeStage(self: DaemonCommandRouter, ctx
|
|
|
1037
1556
|
appendLedgerEntry(meshId, {
|
|
1038
1557
|
kind: 'node_removed',
|
|
1039
1558
|
nodeId,
|
|
1040
|
-
payload: { refined: true, mergedBranch: branch, into: baseBranch, validationSummary, patchEquivalence, submoduleReachability, submoduleAlignment },
|
|
1559
|
+
payload: { refined: true, mergedBranch: branch, into: baseBranch, pushed: true, validationSummary, patchEquivalence, submoduleReachability, submoduleAlignment },
|
|
1041
1560
|
});
|
|
1042
1561
|
recordMeshRefineStage(refineStages, 'ledger', 'passed', ledgerStarted);
|
|
1043
1562
|
} catch (e: any) {
|
|
@@ -1050,22 +1569,27 @@ export async function refineMergeAndFinalizeStage(self: DaemonCommandRouter, ctx
|
|
|
1050
1569
|
mergedBranch: branch,
|
|
1051
1570
|
baseBranch,
|
|
1052
1571
|
merged: true,
|
|
1572
|
+
pushed: true,
|
|
1053
1573
|
removed: removeResult?.success !== false,
|
|
1054
1574
|
validation: 'passed',
|
|
1055
1575
|
patchEquivalence: 'passed',
|
|
1056
1576
|
submoduleAlignment: submoduleAlignment.status,
|
|
1057
|
-
status: removeResult?.success === false ? 'merged_cleanup_failed' : '
|
|
1577
|
+
status: removeResult?.success === false ? 'merged_cleanup_failed' : 'merged_pushed',
|
|
1058
1578
|
};
|
|
1059
1579
|
|
|
1060
1580
|
if (removeResult?.success === false) {
|
|
1581
|
+
// Push already succeeded — the change IS on origin; only the local worktree
|
|
1582
|
+
// cleanup failed. Report cleanup_failed but note remote convergence is done.
|
|
1061
1583
|
return { kind: 'terminal', result: {
|
|
1062
1584
|
success: false,
|
|
1063
1585
|
code: 'cleanup_failed',
|
|
1064
|
-
error: 'Refinery merge completed but worktree cleanup failed; manual cleanup/retry is required.',
|
|
1586
|
+
error: 'Refinery merge + push completed but worktree cleanup failed; the change is on origin — manual worktree cleanup/retry is required.',
|
|
1065
1587
|
merged: true,
|
|
1588
|
+
pushed: true,
|
|
1066
1589
|
branch,
|
|
1067
1590
|
into: baseBranch,
|
|
1068
1591
|
removeResult,
|
|
1592
|
+
pushResult,
|
|
1069
1593
|
validationSummary,
|
|
1070
1594
|
patchEquivalence,
|
|
1071
1595
|
submoduleReachability,
|
|
@@ -1077,36 +1601,46 @@ export async function refineMergeAndFinalizeStage(self: DaemonCommandRouter, ctx
|
|
|
1077
1601
|
} };
|
|
1078
1602
|
}
|
|
1079
1603
|
|
|
1080
|
-
//
|
|
1081
|
-
//
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
} catch (e: any) {
|
|
1092
|
-
pushResult = {
|
|
1093
|
-
pushed: false,
|
|
1094
|
-
remote: 'origin',
|
|
1095
|
-
branch: baseBranch,
|
|
1096
|
-
error: e?.message || String(e),
|
|
1097
|
-
stderr: e?.stderr,
|
|
1098
|
-
durationMs: Date.now() - pushStarted,
|
|
1099
|
-
};
|
|
1100
|
-
recordMeshRefineStage(refineStages, 'push', 'failed', pushStarted, pushResult);
|
|
1604
|
+
// DS3: the push advanced origin/<baseBranch>. Request a guarded catch-up so the
|
|
1605
|
+
// originating coordinator daemon's own local base checkout fast-forwards to the
|
|
1606
|
+
// pushed commit (never auto-rebase; a diverged coordinator gets a structured
|
|
1607
|
+
// blocker instead). Best-effort — a catch-up failure never fails the refine.
|
|
1608
|
+
let coordinatorCatchup: Record<string, unknown> | undefined;
|
|
1609
|
+
try {
|
|
1610
|
+
coordinatorCatchup = await requestCoordinatorLocalCatchup(self, {
|
|
1611
|
+
meshId, ctx, mesh, baseBranch, repoRoot,
|
|
1612
|
+
});
|
|
1613
|
+
if (coordinatorCatchup) {
|
|
1614
|
+
recordMeshRefineStage(refineStages, 'coordinator_catchup', 'passed', Date.now(), coordinatorCatchup);
|
|
1101
1615
|
}
|
|
1102
|
-
}
|
|
1616
|
+
} catch { /* catch-up is advisory; never gate refine success on it */ }
|
|
1617
|
+
|
|
1618
|
+
// QW5: promote the worktree-cleanup warnings from inside removeResult to the
|
|
1619
|
+
// top level so a coordinator sees them without descending into removeResult:
|
|
1620
|
+
// branchRefWarning — the feature branch ref was preserved (not merged-proof),
|
|
1621
|
+
// residueWarning — the worktree dir couldn't be fully removed,
|
|
1622
|
+
// branchRefDeleted — whether the branch ref was deleted (from the nested
|
|
1623
|
+
// worktreeCleanup record).
|
|
1624
|
+
// All are best-effort/non-gating; refine still reports success:true.
|
|
1625
|
+
const cleanupBranchRefWarning = typeof (removeResult as any)?.branchRefWarning === 'string'
|
|
1626
|
+
? (removeResult as any).branchRefWarning : undefined;
|
|
1627
|
+
const cleanupResidueWarning = typeof (removeResult as any)?.residueWarning === 'string'
|
|
1628
|
+
? (removeResult as any).residueWarning : undefined;
|
|
1629
|
+
const cleanupBranchRefDeleted = typeof (removeResult as any)?.worktreeCleanup?.branchRefDeleted === 'boolean'
|
|
1630
|
+
? (removeResult as any).worktreeCleanup.branchRefDeleted : undefined;
|
|
1103
1631
|
|
|
1104
1632
|
return { kind: 'terminal', result: {
|
|
1105
1633
|
success: true,
|
|
1106
1634
|
merged: true,
|
|
1635
|
+
pushed: true,
|
|
1107
1636
|
branch,
|
|
1108
1637
|
into: baseBranch,
|
|
1109
1638
|
removeResult,
|
|
1639
|
+
pushResult,
|
|
1640
|
+
...(coordinatorCatchup ? { coordinatorCatchup } : {}),
|
|
1641
|
+
...(cleanupBranchRefWarning ? { branchRefWarning: cleanupBranchRefWarning } : {}),
|
|
1642
|
+
...(cleanupResidueWarning ? { residueWarning: cleanupResidueWarning } : {}),
|
|
1643
|
+
...(cleanupBranchRefDeleted !== undefined ? { branchRefDeleted: cleanupBranchRefDeleted } : {}),
|
|
1110
1644
|
validationSummary,
|
|
1111
1645
|
patchEquivalence,
|
|
1112
1646
|
submoduleReachability,
|
|
@@ -1115,14 +1649,6 @@ export async function refineMergeAndFinalizeStage(self: DaemonCommandRouter, ctx
|
|
|
1115
1649
|
refineStages,
|
|
1116
1650
|
...(ledgerError ? { ledgerError } : {}),
|
|
1117
1651
|
finalBranchConvergenceState,
|
|
1118
|
-
// Push outcome or readiness info for coordinator.
|
|
1119
|
-
...(pushResult
|
|
1120
|
-
? { pushResult }
|
|
1121
|
-
: {
|
|
1122
|
-
pushReady: true,
|
|
1123
|
-
pushCommand: `git push origin ${baseBranch}`,
|
|
1124
|
-
pushNote: 'requireApprovalForPush is enabled — run the push command or obtain user approval before pushing.',
|
|
1125
|
-
}),
|
|
1126
1652
|
} };
|
|
1127
1653
|
}
|
|
1128
1654
|
|
|
@@ -1297,6 +1823,53 @@ export async function batchRefineMeshNodes(self: DaemonCommandRouter, meshId: st
|
|
|
1297
1823
|
return runMeshRefineBatchConvergence(self, meshId, orderedNodes, ordering, args);
|
|
1298
1824
|
}
|
|
1299
1825
|
|
|
1826
|
+
export type BatchNodeConvergence = 'merged_to_main' | 'blocked_review' | 'skipped_patch_equivalent' | 'not_mergeable';
|
|
1827
|
+
|
|
1828
|
+
/**
|
|
1829
|
+
* QW4: classify one node's per-node refine result into a batch convergence bucket.
|
|
1830
|
+
* Pure (a function of the result shape alone) so the not_mergeable-vs-blocked_review
|
|
1831
|
+
* decision is unit-testable without the whole async refine pipeline.
|
|
1832
|
+
*
|
|
1833
|
+
* already_merged (+ alreadyMergedViaOtherPath) → skipped_patch_equivalent (non-error).
|
|
1834
|
+
* success → merged_to_main.
|
|
1835
|
+
* merge_failed code OR the failing stage IS 'merge' → not_mergeable. A real `git merge`
|
|
1836
|
+
* conflict is a distinct, structured state; classifying on the STAGE as well as the
|
|
1837
|
+
* code means a merge conflict is never mislabeled blocked_review even if the code
|
|
1838
|
+
* were ever dropped. (A rebase conflict fails at patch_equivalence_after_auto_rebase,
|
|
1839
|
+
* NOT merge, so it correctly stays blocked_review.)
|
|
1840
|
+
* everything else that failed → blocked_review.
|
|
1841
|
+
*
|
|
1842
|
+
* DS2: `retryable` is set for a base-movement family blocker (base_moved / base_locked)
|
|
1843
|
+
* — the node did NOT converge because the base advanced or was locked WHILE it ran, not
|
|
1844
|
+
* because of its own content. The batch gives ONLY these a second pass (they may succeed
|
|
1845
|
+
* once the base settles / the lease frees); a real conflict is never retried.
|
|
1846
|
+
*/
|
|
1847
|
+
const RETRYABLE_BASE_MOVEMENT_CODES = new Set(['base_moved', 'base_locked']);
|
|
1848
|
+
|
|
1849
|
+
export function classifyBatchNodeConvergence(result: Record<string, unknown>): { convergence: BatchNodeConvergence; code: string; stage?: string; retryable: boolean } {
|
|
1850
|
+
const code = typeof result.code === 'string' ? result.code : '';
|
|
1851
|
+
// The last failed refine stage (undefined on success). Computed BEFORE the
|
|
1852
|
+
// classification so it can back-stop the code-based verdict.
|
|
1853
|
+
const stage = Array.isArray(result.refineStages)
|
|
1854
|
+
? (result.refineStages as Array<Record<string, unknown>>).filter(s => s.status === 'failed').map(s => s.stage).filter(Boolean).pop() as string | undefined
|
|
1855
|
+
: undefined;
|
|
1856
|
+
let convergence: BatchNodeConvergence;
|
|
1857
|
+
if (code === 'already_merged' && result.alreadyMergedViaOtherPath) {
|
|
1858
|
+
convergence = 'skipped_patch_equivalent';
|
|
1859
|
+
} else if (result.success === true) {
|
|
1860
|
+
convergence = 'merged_to_main';
|
|
1861
|
+
} else if (code === 'merge_failed' || stage === 'merge') {
|
|
1862
|
+
convergence = 'not_mergeable';
|
|
1863
|
+
} else {
|
|
1864
|
+
convergence = 'blocked_review';
|
|
1865
|
+
}
|
|
1866
|
+
// Retryable only for a base-movement blocker that left the node blocked_review — a
|
|
1867
|
+
// not_mergeable conflict is never retried.
|
|
1868
|
+
const retryable = convergence === 'blocked_review'
|
|
1869
|
+
&& (result.retryable === true || RETRYABLE_BASE_MOVEMENT_CODES.has(code));
|
|
1870
|
+
return { convergence, code, retryable, ...(stage ? { stage } : {}) };
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1300
1873
|
/**
|
|
1301
1874
|
* Convergence core shared by the synchronous batch entry and the async batch job.
|
|
1302
1875
|
* Refines each node in order: the per-node refine pipeline fetches origin/<base>
|
|
@@ -1305,7 +1878,7 @@ export async function batchRefineMeshNodes(self: DaemonCommandRouter, meshId: st
|
|
|
1305
1878
|
* continues with the remaining nodes. Does NOT touch the per-node merge logic — it
|
|
1306
1879
|
* only sequences calls to executeMeshRefineNodeSynchronously and aggregates outcomes.
|
|
1307
1880
|
*/
|
|
1308
|
-
export async function runMeshRefineBatchConvergence(self: DaemonCommandRouter,
|
|
1881
|
+
export async function runMeshRefineBatchConvergence(self: DaemonCommandRouter,
|
|
1309
1882
|
meshId: string,
|
|
1310
1883
|
orderedNodes: any[],
|
|
1311
1884
|
ordering: { order: string[]; rationale?: unknown },
|
|
@@ -1314,43 +1887,27 @@ export async function runMeshRefineBatchConvergence(self: DaemonCommandRouter,
|
|
|
1314
1887
|
type BatchNodeOutcome = {
|
|
1315
1888
|
nodeId: string;
|
|
1316
1889
|
workspace: string;
|
|
1317
|
-
convergence:
|
|
1890
|
+
convergence: BatchNodeConvergence;
|
|
1318
1891
|
code?: string;
|
|
1319
1892
|
reason?: string;
|
|
1320
1893
|
stage?: string;
|
|
1321
1894
|
error?: string;
|
|
1895
|
+
retryable?: boolean;
|
|
1896
|
+
retried?: boolean;
|
|
1322
1897
|
finalBranchConvergenceState?: Record<string, unknown>;
|
|
1323
1898
|
};
|
|
1324
|
-
const
|
|
1325
|
-
for (const node of orderedNodes) {
|
|
1899
|
+
const refineOne = async (node: any): Promise<BatchNodeOutcome> => {
|
|
1326
1900
|
let result: Record<string, unknown>;
|
|
1327
1901
|
try {
|
|
1328
1902
|
result = await executeMeshRefineNodeSynchronously(self, meshId, node.id, args) as Record<string, unknown>;
|
|
1329
1903
|
} catch (e: any) {
|
|
1330
1904
|
result = { success: false, error: e?.message || String(e) };
|
|
1331
1905
|
}
|
|
1332
|
-
const
|
|
1333
|
-
// already_merged (branch content already on base via another path) is a
|
|
1334
|
-
// non-error skip regardless of success flag — the worktree converges with
|
|
1335
|
-
// no new merge. A real `git merge` conflict surfaces as merge_failed →
|
|
1336
|
-
// not_mergeable. Everything else that failed is isolated as blocked_review.
|
|
1337
|
-
let convergence: BatchNodeOutcome['convergence'];
|
|
1338
|
-
if (code === 'already_merged' && result.alreadyMergedViaOtherPath) {
|
|
1339
|
-
convergence = 'skipped_patch_equivalent';
|
|
1340
|
-
} else if (result.success === true) {
|
|
1341
|
-
convergence = 'merged_to_main';
|
|
1342
|
-
} else if (code === 'merge_failed') {
|
|
1343
|
-
convergence = 'not_mergeable';
|
|
1344
|
-
} else {
|
|
1345
|
-
convergence = 'blocked_review';
|
|
1346
|
-
}
|
|
1906
|
+
const { convergence, code, stage, retryable } = classifyBatchNodeConvergence(result);
|
|
1347
1907
|
const fbcs = (result.finalBranchConvergenceState && typeof result.finalBranchConvergenceState === 'object')
|
|
1348
1908
|
? result.finalBranchConvergenceState as Record<string, unknown>
|
|
1349
1909
|
: undefined;
|
|
1350
|
-
|
|
1351
|
-
? (result.refineStages as Array<Record<string, unknown>>).filter(s => s.status === 'failed').map(s => s.stage).filter(Boolean).pop() as string | undefined
|
|
1352
|
-
: undefined;
|
|
1353
|
-
results.push({
|
|
1910
|
+
return {
|
|
1354
1911
|
nodeId: node.id,
|
|
1355
1912
|
workspace: node.workspace,
|
|
1356
1913
|
convergence,
|
|
@@ -1358,8 +1915,29 @@ export async function runMeshRefineBatchConvergence(self: DaemonCommandRouter,
|
|
|
1358
1915
|
...(typeof result.blockedReason === 'string' ? { reason: result.blockedReason } : {}),
|
|
1359
1916
|
...(stage ? { stage } : {}),
|
|
1360
1917
|
...(typeof result.error === 'string' ? { error: result.error } : {}),
|
|
1918
|
+
...(retryable ? { retryable: true } : {}),
|
|
1361
1919
|
...(fbcs ? { finalBranchConvergenceState: fbcs } : {}),
|
|
1362
|
-
}
|
|
1920
|
+
};
|
|
1921
|
+
};
|
|
1922
|
+
|
|
1923
|
+
const results: BatchNodeOutcome[] = [];
|
|
1924
|
+
const retryQueue: any[] = [];
|
|
1925
|
+
for (const node of orderedNodes) {
|
|
1926
|
+
const outcome = await refineOne(node);
|
|
1927
|
+
results.push(outcome);
|
|
1928
|
+
// DS2: a base-movement blocker (base_moved / base_locked) did not converge for a
|
|
1929
|
+
// reason the earlier merges in THIS batch may have caused (base advanced / lease
|
|
1930
|
+
// held). Defer it to a single second pass AFTER the first pass finishes, when the
|
|
1931
|
+
// base has settled — but never retry a real conflict.
|
|
1932
|
+
if (outcome.retryable) retryQueue.push(node);
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
// ── DS2 second pass: retry ONLY the base-movement retryable nodes, once ─────
|
|
1936
|
+
for (const node of retryQueue) {
|
|
1937
|
+
const idx = results.findIndex(r => r.nodeId === node.id);
|
|
1938
|
+
const retried = await refineOne(node);
|
|
1939
|
+
retried.retried = true;
|
|
1940
|
+
if (idx >= 0) results[idx] = retried; else results.push(retried);
|
|
1363
1941
|
}
|
|
1364
1942
|
|
|
1365
1943
|
const summary = {
|
|
@@ -1367,6 +1945,7 @@ export async function runMeshRefineBatchConvergence(self: DaemonCommandRouter,
|
|
|
1367
1945
|
skipped: results.filter(r => r.convergence === 'skipped_patch_equivalent').length,
|
|
1368
1946
|
blocked: results.filter(r => r.convergence === 'blocked_review').length,
|
|
1369
1947
|
notMergeable: results.filter(r => r.convergence === 'not_mergeable').length,
|
|
1948
|
+
...(retryQueue.length ? { retried: retryQueue.length } : {}),
|
|
1370
1949
|
};
|
|
1371
1950
|
const allConverged = summary.blocked === 0 && summary.notMergeable === 0;
|
|
1372
1951
|
return {
|
|
@@ -1663,7 +2242,14 @@ export async function finishMeshRefineJob(self: DaemonCommandRouter, handle: Mes
|
|
|
1663
2242
|
? 'completed'
|
|
1664
2243
|
: refineCode === 'blocked_review'
|
|
1665
2244
|
? 'blocked_review'
|
|
2245
|
+
// QW3: the validation stage returns `code: validationSummary.failureCode`,
|
|
2246
|
+
// so a dependency/spawn failure surfaces as one of these codes — NOT the
|
|
2247
|
+
// literal 'validation_failed'. They must map to the validation_failed
|
|
2248
|
+
// terminal kind too, otherwise they fell through to the merge_failed
|
|
2249
|
+
// fallback and coordinators saw a merge failure for a missing-deps block.
|
|
1666
2250
|
: refineCode === 'validation_failed' || refineCode === 'validation_dependencies_missing'
|
|
2251
|
+
|| refineCode === 'missing_dependencies' || refineCode === 'dependency_bootstrap_failed'
|
|
2252
|
+
|| refineCode === 'spawn_resolution_failed' || refineCode === 'validation_unavailable'
|
|
1667
2253
|
? 'validation_failed'
|
|
1668
2254
|
: refineCode === 'submodule_reachability_failed'
|
|
1669
2255
|
? 'submodule_reachability_failed'
|
|
@@ -1714,9 +2300,15 @@ export async function finishMeshRefineJob(self: DaemonCommandRouter, handle: Mes
|
|
|
1714
2300
|
// Validation details
|
|
1715
2301
|
if (stage === 'validation' && result.validationSummary) {
|
|
1716
2302
|
const vs = result.validationSummary as Record<string, unknown>;
|
|
2303
|
+
// QW2: same compact failure diagnostics the slim event carries, so the
|
|
2304
|
+
// ledger blockerContext is self-describing (first failing command + exit
|
|
2305
|
+
// code + failureKind + output tail) without a second commandsRun lookup.
|
|
2306
|
+
const diagnostics = extractValidationFailureDiagnostics(vs);
|
|
1717
2307
|
ctx.details = {
|
|
1718
2308
|
failureCode: vs.failureCode,
|
|
2309
|
+
failureKind: vs.failureKind,
|
|
1719
2310
|
commandsRun: Array.isArray(vs.commandsRun) ? vs.commandsRun.length : undefined,
|
|
2311
|
+
...(diagnostics ? { failure: diagnostics } : {}),
|
|
1720
2312
|
};
|
|
1721
2313
|
}
|
|
1722
2314
|
return ctx;
|