@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.
@@ -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.success === false)
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
- * patch is equivalent to base+branch. On a "behind base" branch, auto-rebase
523
- * once and re-check; on an empty merge-tree with real branch changes, treat as
524
- * already-merged-via-another-path and short-circuit to cleanup. Mutates the
525
- * context's branchHead (after rebase) and patchEquivalence (rebased gate).
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
- const { meshId, nodeId, args, repoRoot, baseHead, node, branch, baseBranch, validationSummary, refineStages, execFileAsync } = ctx;
529
- let branchHead = ctx.branchHead;
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
- let patchEquivalence = await runMeshRefinePatchEquivalenceGate(repoRoot, baseHead, branchHead);
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
- // Auto-rebase: if branch is simply behind base, attempt rebase automatically before failing.
541
- let didAutoRebase = false;
542
- let isBehindBase = false;
543
- try {
544
- execFileSync('git', ['merge-base', '--is-ancestor', branchHead, baseHead], {
545
- cwd: node.workspace,
546
- stdio: 'ignore',
547
- });
548
- isBehindBase = true;
549
- } catch { /* non-zero exit means branchHead is not an ancestor of baseHead */ }
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 (!didAutoRebase && !alreadyMergedViaOtherPath) {
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
- if (!didAutoRebase && alreadyMergedViaOtherPath) {
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
- error: `Merge failed (conflicts?): ${e.message}`,
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
- // convergence, so any residual worktree dirtiness here is incidental
1021
- // (e.g. a bootstrap lockfile rewrite) — never unmerged work. `force`
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' : 'merged',
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
- // Push logic: after a successful merge, either auto-push or surface push info
1081
- // so coordinators don't need manual discovery after each refine.
1082
- const requireApprovalForPush: boolean = (mesh as any)?.policy?.requireApprovalForPush ?? DEFAULT_MESH_POLICY.requireApprovalForPush;
1083
- let pushResult: Record<string, unknown> | undefined;
1084
- if (!requireApprovalForPush) {
1085
- const pushStarted = Date.now();
1086
- try {
1087
- await execFileAsync('git', ['push', 'origin', baseBranch], { cwd: repoRoot, encoding: 'utf8' });
1088
- pushResult = { pushed: true, remote: 'origin', branch: baseBranch, durationMs: Date.now() - pushStarted };
1089
- recordMeshRefineStage(refineStages, 'push', 'passed', pushStarted, pushResult);
1090
- finalBranchConvergenceState.status = 'merged_pushed';
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: 'merged_to_main' | 'blocked_review' | 'skipped_patch_equivalent' | 'not_mergeable';
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 results: BatchNodeOutcome[] = [];
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 code = typeof result.code === 'string' ? result.code : '';
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
- const stage = Array.isArray(result.refineStages)
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;