@adhdev/daemon-core 0.9.82-rc.260 → 0.9.82-rc.262

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.
@@ -1397,6 +1397,31 @@ type MeshRefinePatchEquivalenceSummary = {
1397
1397
  stdout?: string;
1398
1398
  stderr?: string;
1399
1399
  actionableHint?: MeshRefineSubmoduleConflictHint;
1400
+ /**
1401
+ * Set when a `merge-tree` submodule conflict was reclassified as a trivial
1402
+ * gitlink fast-forward and the gate passed via a synthesized merge tree.
1403
+ */
1404
+ gitlinkTrivialFastForward?: {
1405
+ resolved: boolean;
1406
+ gitlinks: Array<{ path: string; baseCommit?: string; branchCommit?: string; fastForward: boolean }>;
1407
+ reason?: string;
1408
+ };
1409
+ };
1410
+
1411
+ type MeshRefineEffectiveDiffSummary = {
1412
+ status: MeshRefineStageStatus;
1413
+ /** True when there is at least one root-tree change between base and branch (incl. gitlink bumps). */
1414
+ hasEffectiveDiff: boolean;
1415
+ baseHead: string;
1416
+ branchHead: string;
1417
+ /** Root-level paths that differ between base and branch (capped). */
1418
+ changedPaths?: string[];
1419
+ /** Submodule paths with uncommitted/divergent commits but NO committed gitlink bump in the root tree. */
1420
+ submoduleHints?: Array<{ path: string; reason: string }>;
1421
+ durationMs: number;
1422
+ error?: string;
1423
+ stdout?: string;
1424
+ stderr?: string;
1400
1425
  };
1401
1426
 
1402
1427
  type MeshRefineSubmoduleConflictHint = {
@@ -1496,6 +1521,44 @@ type MeshRefineJobHandle = {
1496
1521
 
1497
1522
  type MeshRefineTerminalJob = MeshRefineJobHandle & { result?: Record<string, unknown> };
1498
1523
 
1524
+ type MeshRefineBatchJobStatus = 'accepted' | 'completed' | 'failed';
1525
+
1526
+ /**
1527
+ * Async handle returned by the batch Refinery the instant a convergence run is
1528
+ * accepted. Mirrors {@link MeshRefineJobHandle} (async:true / status:'accepted' +
1529
+ * terminal pending-event + ledger delivery) but scopes a whole batch of sibling
1530
+ * nodes rather than a single node. The synthetic `batchLabel` is used as the
1531
+ * `nodeLabel` for the shared refine event/message renderer.
1532
+ */
1533
+ type MeshRefineBatchJobHandle = {
1534
+ success: true;
1535
+ async: true;
1536
+ batch: true;
1537
+ status: MeshRefineBatchJobStatus;
1538
+ jobId: string;
1539
+ interactionId: string;
1540
+ meshId: string;
1541
+ batchLabel: string;
1542
+ nodeIds: string[];
1543
+ nodeCount: number;
1544
+ order: string[];
1545
+ startedAt: string;
1546
+ completedAt?: string;
1547
+ duplicate?: boolean;
1548
+ targetCoordinatorDaemonId?: string;
1549
+ eventDelivery: {
1550
+ pendingEvents: true;
1551
+ ledger: true;
1552
+ };
1553
+ evidence: {
1554
+ pendingEventsCommand: 'get_pending_mesh_events';
1555
+ ledgerCommand: 'get_mesh_ledger_slice';
1556
+ taskHistoryKind: 'task_dispatched' | 'task_completed' | 'task_failed';
1557
+ };
1558
+ };
1559
+
1560
+ type MeshRefineBatchTerminalJob = MeshRefineBatchJobHandle & { result?: Record<string, unknown> };
1561
+
1499
1562
  const REFINE_VALIDATION_CATEGORIES = ['typecheck', 'test', 'lint', 'build'] as const;
1500
1563
  const REFINE_VALIDATION_TIMEOUT_MS = 120_000;
1501
1564
  const REFINE_VALIDATION_OUTPUT_LIMIT_BYTES = 128 * 1024;
@@ -1581,8 +1644,46 @@ async function runMeshRefinePatchEquivalenceGate(
1581
1644
  maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES,
1582
1645
  });
1583
1646
  const mergeBase = git(['merge-base', baseHead, branchHead]).trim();
1584
- const mergeTreeStdout = git(['merge-tree', '--write-tree', baseHead, branchHead]);
1585
- const mergedTree = mergeTreeStdout.trim().split(/\s+/)[0] || '';
1647
+
1648
+ // `git merge-tree --write-tree` refuses to merge gitlinks that differ
1649
+ // across base/branch even when the advance is a strict fast-forward,
1650
+ // failing with "Recursive merging with submodules currently only
1651
+ // supports trivial cases". When that happens we check whether the
1652
+ // conflict is *entirely* trivial-ff gitlinks and, if so, synthesize the
1653
+ // merged tree ourselves (base tree + branch-side gitlinks).
1654
+ let mergedTree = '';
1655
+ let mergeTreeStdout = '';
1656
+ let gitlinkTrivialFastForward: MeshRefinePatchEquivalenceSummary['gitlinkTrivialFastForward'];
1657
+ try {
1658
+ mergeTreeStdout = git(['merge-tree', '--write-tree', baseHead, branchHead]);
1659
+ mergedTree = mergeTreeStdout.trim().split(/\s+/)[0] || '';
1660
+ } catch (mergeTreeErr: any) {
1661
+ const output = `${mergeTreeErr?.message || ''}\n${mergeTreeErr?.stdout || ''}\n${mergeTreeErr?.stderr || ''}`;
1662
+ const isSubmoduleConflict = /(submodule|160000)/i.test(output)
1663
+ || /Recursive merging with submodules/i.test(output);
1664
+ if (!isSubmoduleConflict) throw mergeTreeErr;
1665
+ const evaluation = evaluateGitlinkTrivialFastForward(repoRoot, baseHead, branchHead);
1666
+ if (!evaluation.trivial) {
1667
+ return {
1668
+ status: 'failed',
1669
+ equivalent: false,
1670
+ baseHead,
1671
+ branchHead,
1672
+ mergeBase: mergeBase || undefined,
1673
+ durationMs: Date.now() - startedAt,
1674
+ error: mergeTreeErr?.message || String(mergeTreeErr),
1675
+ stdout: truncateValidationOutput(mergeTreeErr?.stdout),
1676
+ stderr: truncateValidationOutput(mergeTreeErr?.stderr),
1677
+ gitlinkTrivialFastForward: { resolved: false, gitlinks: evaluation.gitlinks, reason: evaluation.reason },
1678
+ actionableHint: buildPatchEquivalenceSubmoduleConflictHint(repoRoot, baseHead, branchHead, output),
1679
+ };
1680
+ }
1681
+ // All conflicting gitlinks fast-forward and nothing else conflicts:
1682
+ // synthesize the merge result as base's tree with branch-side gitlinks.
1683
+ mergedTree = synthesizeTrivialFastForwardMergeTree(repoRoot, baseHead, branchHead, evaluation.gitlinks) || '';
1684
+ gitlinkTrivialFastForward = { resolved: true, gitlinks: evaluation.gitlinks };
1685
+ }
1686
+
1586
1687
  if (!mergeBase || !mergedTree) {
1587
1688
  return {
1588
1689
  status: 'failed',
@@ -1594,6 +1695,7 @@ async function runMeshRefinePatchEquivalenceGate(
1594
1695
  durationMs: Date.now() - startedAt,
1595
1696
  error: 'patch equivalence preflight could not resolve merge-base or synthetic merge tree',
1596
1697
  stdout: truncateValidationOutput(mergeTreeStdout),
1698
+ gitlinkTrivialFastForward,
1597
1699
  };
1598
1700
  }
1599
1701
  const expectedPatchId = await computeGitPatchId(repoRoot, mergeBase, branchHead);
@@ -1609,6 +1711,7 @@ async function runMeshRefinePatchEquivalenceGate(
1609
1711
  expectedPatchId,
1610
1712
  actualPatchId,
1611
1713
  durationMs: Date.now() - startedAt,
1714
+ gitlinkTrivialFastForward,
1612
1715
  };
1613
1716
  } catch (e: any) {
1614
1717
  return {
@@ -1630,6 +1733,101 @@ async function runMeshRefinePatchEquivalenceGate(
1630
1733
  }
1631
1734
  }
1632
1735
 
1736
+ /**
1737
+ * No-op guard: detect a "silent no-op" merge before the Refinery merge runs.
1738
+ *
1739
+ * A silent no-op occurs when the refine target branch's ROOT tree is byte-identical
1740
+ * to the merge base (origin/main). This is the trap where a submodule (e.g. oss) has
1741
+ * real commits but the root branch never committed the gitlink (oss-pointer) bump, so
1742
+ * the root diff Refinery would merge is empty. Merging that produces a merge commit with
1743
+ * no content change — reported as "success" while the actual work never reaches main.
1744
+ *
1745
+ * A committed gitlink bump (the legitimate oss-pointer bump) DOES show up in the root
1746
+ * tree diff (as a 160000-mode entry), so this guard does NOT block legitimate refines —
1747
+ * it only fires when the root tree diff vs base is COMPLETELY empty.
1748
+ *
1749
+ * Runs after the patch-equivalence gate; the "already merged via other path" case
1750
+ * (branch has real changes already present in base) is handled upstream and never
1751
+ * reaches here, so an empty root diff at this point is genuinely a no-op.
1752
+ */
1753
+ export async function runMeshRefineEffectiveDiffGate(
1754
+ repoRoot: string,
1755
+ baseHead: string,
1756
+ branchHead: string,
1757
+ ): Promise<MeshRefineEffectiveDiffSummary> {
1758
+ const startedAt = Date.now();
1759
+ try {
1760
+ const { execFileSync } = await import('node:child_process');
1761
+ const git = (args: string[], opts?: { cwd?: string }) => execFileSync('git', args, {
1762
+ cwd: opts?.cwd || repoRoot,
1763
+ encoding: 'utf8',
1764
+ maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES,
1765
+ });
1766
+ // Root tree diff between base and branch. --raw surfaces gitlink (160000) entries,
1767
+ // so a committed submodule-pointer bump counts as an effective change. An empty
1768
+ // result means the branch's root tree is identical to base → nothing would merge.
1769
+ const rawDiff = git(['diff', '--raw', baseHead, branchHead]).trim();
1770
+ if (rawDiff) {
1771
+ const changedPaths = rawDiff
1772
+ .split('\n')
1773
+ .map(line => line.split('\t').slice(1).join('\t').trim())
1774
+ .filter(Boolean)
1775
+ .slice(0, 50);
1776
+ return {
1777
+ status: 'passed',
1778
+ hasEffectiveDiff: true,
1779
+ baseHead,
1780
+ branchHead,
1781
+ changedPaths,
1782
+ durationMs: Date.now() - startedAt,
1783
+ };
1784
+ }
1785
+
1786
+ // No root diff → silent no-op. Try to surface which submodule(s) have commits that
1787
+ // were never captured by a committed gitlink bump, to make the message actionable.
1788
+ const submoduleHints: Array<{ path: string; reason: string }> = [];
1789
+ try {
1790
+ // `git submodule status` flags submodules whose checked-out commit differs from
1791
+ // the recorded gitlink with a leading '+'. That difference is exactly the
1792
+ // uncommitted-pointer-bump situation this guard exists to catch.
1793
+ const status = git(['submodule', 'status']);
1794
+ for (const line of status.split('\n')) {
1795
+ const trimmed = line.trimEnd();
1796
+ if (!trimmed) continue;
1797
+ if (trimmed.startsWith('+')) {
1798
+ const parts = trimmed.slice(1).trim().split(/\s+/);
1799
+ const path = parts[1] || parts[0] || '(unknown)';
1800
+ submoduleHints.push({
1801
+ path,
1802
+ reason: 'submodule checked-out commit differs from the committed gitlink (pointer bump not committed on the root branch)',
1803
+ });
1804
+ }
1805
+ }
1806
+ } catch { /* submodule status is best-effort */ }
1807
+
1808
+ return {
1809
+ status: 'failed',
1810
+ hasEffectiveDiff: false,
1811
+ baseHead,
1812
+ branchHead,
1813
+ ...(submoduleHints.length ? { submoduleHints } : {}),
1814
+ durationMs: Date.now() - startedAt,
1815
+ };
1816
+ } catch (e: any) {
1817
+ // On error, do NOT block the merge — fail open so a probe failure can't wedge refine.
1818
+ return {
1819
+ status: 'skipped',
1820
+ hasEffectiveDiff: true,
1821
+ baseHead,
1822
+ branchHead,
1823
+ durationMs: Date.now() - startedAt,
1824
+ error: e?.message || String(e),
1825
+ stdout: truncateValidationOutput(e?.stdout),
1826
+ stderr: truncateValidationOutput(e?.stderr),
1827
+ };
1828
+ }
1829
+ }
1830
+
1633
1831
  function buildPatchEquivalenceSubmoduleConflictHint(
1634
1832
  repoRoot: string,
1635
1833
  baseHead: string,
@@ -1695,6 +1893,230 @@ function readTreeObject(repoRoot: string, ref: string, path: string): string | u
1695
1893
  }
1696
1894
  }
1697
1895
 
1896
+ /**
1897
+ * Resolve the absolute path to the repo's real git directory. In a linked
1898
+ * worktree, `.git` is a file pointing elsewhere, so we cannot assume a `.git`
1899
+ * subdirectory exists — a temporary index file must live in the actual git dir.
1900
+ */
1901
+ function resolveGitDir(repoRoot: string): string {
1902
+ const out = execFileSync('git', ['rev-parse', '--absolute-git-dir'], {
1903
+ cwd: repoRoot,
1904
+ encoding: 'utf8',
1905
+ maxBuffer: 1024 * 1024,
1906
+ }).trim();
1907
+ return out;
1908
+ }
1909
+
1910
+ /**
1911
+ * Result of evaluating whether a `git merge-tree --write-tree` submodule
1912
+ * conflict is in fact a trivial gitlink fast-forward that should pass the
1913
+ * patch-equivalence gate.
1914
+ *
1915
+ * `git merge-tree` (and `git merge` with the default recursive strategy)
1916
+ * refuses to 3-way merge gitlinks unless the case is "trivial" — and it
1917
+ * treats *any* gitlink that differs across merge-base/base/branch as
1918
+ * non-trivial, even when the branch-side commit is a strict descendant of the
1919
+ * base-side commit (i.e. a real fast-forward). Refinery only ever wants to
1920
+ * accept the branch's recorded gitlink, so a fast-forwardable bump is safe to
1921
+ * resolve to the branch side without any conflict.
1922
+ */
1923
+ type GitlinkTrivialFastForwardEvaluation = {
1924
+ /** True only when the merge-tree conflict is *fully* explained by trivial-ff gitlinks. */
1925
+ trivial: boolean;
1926
+ /** Why the evaluation declined to treat the conflict as trivial (set when trivial=false). */
1927
+ reason?: string;
1928
+ /** Per-path detail for the changed gitlinks that were inspected. */
1929
+ gitlinks: Array<{
1930
+ path: string;
1931
+ baseCommit?: string;
1932
+ branchCommit?: string;
1933
+ fastForward: boolean;
1934
+ }>;
1935
+ };
1936
+
1937
+ /**
1938
+ * Check, inside a submodule repo, whether `baseCommit` is an ancestor of
1939
+ * `branchCommit` (i.e. advancing the gitlink from base→branch is a pure
1940
+ * fast-forward). Returns false on any error or when either commit is missing
1941
+ * locally — safety first, ambiguity stays "not a fast-forward".
1942
+ */
1943
+ function isSubmoduleFastForward(submoduleRepoPath: string, baseCommit: string, branchCommit: string): boolean {
1944
+ if (!baseCommit || !branchCommit) return false;
1945
+ if (baseCommit === branchCommit) return true;
1946
+ try {
1947
+ if (!fs.existsSync(submoduleRepoPath)) return false;
1948
+ // Both commits must exist locally for the ancestry check to be meaningful.
1949
+ execFileSync('git', ['cat-file', '-e', `${baseCommit}^{commit}`], { cwd: submoduleRepoPath, stdio: 'ignore' });
1950
+ execFileSync('git', ['cat-file', '-e', `${branchCommit}^{commit}`], { cwd: submoduleRepoPath, stdio: 'ignore' });
1951
+ // exit 0 ⇒ baseCommit is an ancestor of branchCommit ⇒ branch fast-forwards base.
1952
+ execFileSync('git', ['merge-base', '--is-ancestor', baseCommit, branchCommit], { cwd: submoduleRepoPath, stdio: 'ignore' });
1953
+ return true;
1954
+ } catch {
1955
+ return false;
1956
+ }
1957
+ }
1958
+
1959
+ /**
1960
+ * Read the set of paths that differ between two refs, tagging whether each is a
1961
+ * gitlink (submodule, mode 160000) on either side. Returns one entry per
1962
+ * changed path. Empty on error.
1963
+ */
1964
+ function readChangedPathKinds(repoRoot: string, fromRef: string, toRef: string): Array<{ path: string; isGitlink: boolean }> {
1965
+ try {
1966
+ const output = execFileSync('git', ['diff', '--raw', '--no-abbrev', fromRef, toRef], {
1967
+ cwd: repoRoot,
1968
+ encoding: 'utf8',
1969
+ maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES,
1970
+ });
1971
+ const result: Array<{ path: string; isGitlink: boolean }> = [];
1972
+ const seen = new Set<string>();
1973
+ for (const line of output.split('\n')) {
1974
+ if (!line.trim()) continue;
1975
+ const metaAndPath = line.split('\t');
1976
+ const meta = metaAndPath[0] || '';
1977
+ const path = metaAndPath[metaAndPath.length - 1]?.trim();
1978
+ if (!path || seen.has(path)) continue;
1979
+ seen.add(path);
1980
+ const parts = meta.split(/\s+/);
1981
+ const isGitlink = !!(parts[0]?.includes('160000') || parts[1]?.includes('160000'));
1982
+ result.push({ path, isGitlink });
1983
+ }
1984
+ return result;
1985
+ } catch {
1986
+ return [];
1987
+ }
1988
+ }
1989
+
1990
+ /**
1991
+ * Decide whether a merge-tree submodule conflict between base and branch is a
1992
+ * trivial gitlink fast-forward (and nothing else).
1993
+ *
1994
+ * The conflict is treated as trivial ONLY when:
1995
+ * 1. at least one changed gitlink exists,
1996
+ * 2. every changed gitlink fast-forwards (base-commit is an ancestor of the
1997
+ * branch-commit inside that submodule's repo), and
1998
+ * 3. the *only* paths that changed on both sides of the merge (i.e. the paths
1999
+ * that could possibly produce a 3-way conflict — the intersection of
2000
+ * mergeBase→base and mergeBase→branch changes) are gitlinks. Any
2001
+ * overlapping non-gitlink path means a genuine content conflict could be
2002
+ * hiding behind the submodule failure, so we keep the block.
2003
+ *
2004
+ * If any of these fail, the conflict is left as a genuine block. This never
2005
+ * passes a regular-file conflict or a diverged (non-ff) gitlink.
2006
+ */
2007
+ export function evaluateGitlinkTrivialFastForward(
2008
+ repoRoot: string,
2009
+ baseHead: string,
2010
+ branchHead: string,
2011
+ ): GitlinkTrivialFastForwardEvaluation {
2012
+ const changedGitlinks = readChangedGitlinkPaths(repoRoot, baseHead, branchHead).map(path => {
2013
+ const baseCommit = readTreeObject(repoRoot, baseHead, path);
2014
+ const branchCommit = readTreeObject(repoRoot, branchHead, path);
2015
+ const submoduleRepoPath = pathResolve(repoRoot, path);
2016
+ const fastForward = !!baseCommit && !!branchCommit
2017
+ && isSubmoduleFastForward(submoduleRepoPath, baseCommit, branchCommit);
2018
+ return { path, baseCommit, branchCommit, fastForward };
2019
+ });
2020
+
2021
+ if (changedGitlinks.length === 0) {
2022
+ return { trivial: false, reason: 'no_changed_gitlinks', gitlinks: changedGitlinks };
2023
+ }
2024
+
2025
+ const nonFastForward = changedGitlinks.filter(entry => !entry.fastForward);
2026
+ if (nonFastForward.length > 0) {
2027
+ return {
2028
+ trivial: false,
2029
+ reason: `diverged_gitlinks:${nonFastForward.map(entry => entry.path).join(',')}`,
2030
+ gitlinks: changedGitlinks,
2031
+ };
2032
+ }
2033
+
2034
+ // Prove there is no *other* conflict (regular files, or a gitlink that
2035
+ // diverged on both sides). A 3-way merge can only conflict on a path that
2036
+ // changed on BOTH sides relative to the merge-base. Compute that overlap and
2037
+ // require every overlapping path to be a gitlink — non-gitlink overlap means
2038
+ // a genuine content conflict that must stay blocked.
2039
+ let mergeBase = '';
2040
+ try {
2041
+ mergeBase = execFileSync('git', ['merge-base', baseHead, branchHead], {
2042
+ cwd: repoRoot,
2043
+ encoding: 'utf8',
2044
+ maxBuffer: 1024 * 1024,
2045
+ }).trim();
2046
+ } catch {
2047
+ return { trivial: false, reason: 'merge_base_unresolved', gitlinks: changedGitlinks };
2048
+ }
2049
+ if (!mergeBase) {
2050
+ return { trivial: false, reason: 'merge_base_unresolved', gitlinks: changedGitlinks };
2051
+ }
2052
+
2053
+ const baseSideChanges = readChangedPathKinds(repoRoot, mergeBase, baseHead);
2054
+ const branchSideChanges = readChangedPathKinds(repoRoot, mergeBase, branchHead);
2055
+ const baseChangedPaths = new Map(baseSideChanges.map(entry => [entry.path, entry]));
2056
+ // Overlapping paths = candidates for a real 3-way conflict.
2057
+ const overlapping = branchSideChanges.filter(entry => baseChangedPaths.has(entry.path));
2058
+ const nonGitlinkOverlap = overlapping.filter(entry => {
2059
+ const baseEntry = baseChangedPaths.get(entry.path);
2060
+ return !(entry.isGitlink && baseEntry?.isGitlink);
2061
+ });
2062
+ if (nonGitlinkOverlap.length > 0) {
2063
+ return {
2064
+ trivial: false,
2065
+ reason: `non_gitlink_overlap:${nonGitlinkOverlap.map(entry => entry.path).join(',')}`,
2066
+ gitlinks: changedGitlinks,
2067
+ };
2068
+ }
2069
+
2070
+ return { trivial: true, gitlinks: changedGitlinks };
2071
+ }
2072
+
2073
+ /**
2074
+ * Synthesize the merge result for a trivial gitlink fast-forward: take
2075
+ * `baseHead`'s tree and overlay each changed gitlink's branch-side commit.
2076
+ * This mirrors what `git merge-tree` would have produced had it not bailed on
2077
+ * the submodule recursion limitation. Returns the tree SHA, or undefined on
2078
+ * failure. Caller must have already proven (via evaluateGitlinkTrivialFastForward)
2079
+ * that every changed gitlink fast-forwards and no other path conflicts.
2080
+ */
2081
+ function synthesizeTrivialFastForwardMergeTree(
2082
+ repoRoot: string,
2083
+ baseHead: string,
2084
+ branchHead: string,
2085
+ gitlinks: Array<{ path: string; branchCommit?: string }>,
2086
+ ): string | undefined {
2087
+ try {
2088
+ const baseTree = execFileSync('git', ['rev-parse', `${baseHead}^{tree}`], {
2089
+ cwd: repoRoot,
2090
+ encoding: 'utf8',
2091
+ maxBuffer: 1024 * 1024,
2092
+ }).trim();
2093
+ if (!baseTree) return undefined;
2094
+ const updates = gitlinks
2095
+ .filter(entry => entry.branchCommit)
2096
+ .map(entry => `160000 commit ${entry.branchCommit}\t${entry.path}`)
2097
+ .join('\n');
2098
+ if (!updates) return baseTree;
2099
+ const tmpIndex = pathJoin(resolveGitDir(repoRoot), `adhdev-refine-ff-${baseHead.slice(0, 12)}-${branchHead.slice(0, 12)}.index`);
2100
+ const env = { ...process.env, GIT_INDEX_FILE: tmpIndex };
2101
+ try {
2102
+ execFileSync('git', ['read-tree', baseTree], { cwd: repoRoot, env, stdio: 'ignore' });
2103
+ execFileSync('git', ['update-index', '--index-info'], {
2104
+ cwd: repoRoot,
2105
+ env,
2106
+ input: `${updates}\n`,
2107
+ encoding: 'utf8',
2108
+ stdio: ['pipe', 'ignore', 'ignore'],
2109
+ });
2110
+ const newTree = execFileSync('git', ['write-tree'], { cwd: repoRoot, env, encoding: 'utf8' }).trim();
2111
+ return newTree || undefined;
2112
+ } finally {
2113
+ try { fs.rmSync(tmpIndex, { force: true }); } catch { /* ignore */ }
2114
+ }
2115
+ } catch {
2116
+ return undefined;
2117
+ }
2118
+ }
2119
+
1698
2120
  async function alignRefinerySubmodulesAfterMerge(
1699
2121
  repoRoot: string,
1700
2122
  previousBaseHead: string,
@@ -2520,6 +2942,10 @@ export class DaemonCommandRouter {
2520
2942
  private runningRefineJobs = new Map<string, MeshRefineJobHandle>();
2521
2943
  /** Terminal async Refinery jobs preserve a clear answer after the worktree node has been removed. */
2522
2944
  private terminalRefineJobs = new Map<string, MeshRefineTerminalJob>();
2945
+ /** In-memory async batch Refinery jobs keyed by meshId (one batch convergence per mesh at a time). */
2946
+ private runningRefineBatchJobs = new Map<string, MeshRefineBatchJobHandle>();
2947
+ /** Terminal async batch Refinery jobs preserve the last batch outcome for late readers. */
2948
+ private terminalRefineBatchJobs = new Map<string, MeshRefineBatchTerminalJob>();
2523
2949
 
2524
2950
  constructor(deps: CommandRouterDeps) {
2525
2951
  this.deps = deps;
@@ -3935,6 +4361,53 @@ export class DaemonCommandRouter {
3935
4361
  };
3936
4362
  }
3937
4363
 
4364
+ // No-op guard: block a silent no-op merge where the root tree is identical to base.
4365
+ // This catches the trap where a submodule has commits but the root branch never
4366
+ // committed the gitlink (oss-pointer) bump — merging would report success while the
4367
+ // real change never lands on main. A committed gitlink bump shows up in the root
4368
+ // diff, so legitimate oss-pointer refines pass through untouched.
4369
+ const effectiveDiffStarted = Date.now();
4370
+ const effectiveDiff = await runMeshRefineEffectiveDiffGate(repoRoot, baseHead, branchHead);
4371
+ recordMeshRefineStage(refineStages, 'effective_diff', effectiveDiff.status, effectiveDiffStarted, {
4372
+ hasEffectiveDiff: effectiveDiff.hasEffectiveDiff,
4373
+ changedPaths: effectiveDiff.changedPaths,
4374
+ submoduleHints: effectiveDiff.submoduleHints,
4375
+ ...(effectiveDiff.error ? { error: effectiveDiff.error } : {}),
4376
+ });
4377
+ if (effectiveDiff.status === 'failed' && !effectiveDiff.hasEffectiveDiff) {
4378
+ const hintLines = (effectiveDiff.submoduleHints || []).map(h => ` - ${h.path}: ${h.reason}`);
4379
+ const message = [
4380
+ `Refinery no-op guard: branch '${branch}' has no effective root-tree diff against '${baseBranch}' (${baseHead.slice(0, 12)}); nothing would merge.`,
4381
+ 'This usually means a submodule (e.g. oss) has commits but the root branch never committed the gitlink (pointer) bump, so the merge would be a silent no-op while the real change never reaches main.',
4382
+ hintLines.length ? `Submodules with uncommitted pointer bumps:\n${hintLines.join('\n')}` : '',
4383
+ `Fix: commit the submodule pointer bump on '${branch}' (git add <submodule-path> && git commit), then re-run refine.`,
4384
+ ].filter(Boolean).join('\n');
4385
+ return {
4386
+ success: false,
4387
+ code: 'no_effective_diff',
4388
+ convergenceStatus: 'blocked_review',
4389
+ error: message,
4390
+ branch,
4391
+ into: baseBranch,
4392
+ validationSummary,
4393
+ patchEquivalence,
4394
+ effectiveDiff,
4395
+ refineStages,
4396
+ finalBranchConvergenceState: {
4397
+ branch,
4398
+ baseBranch,
4399
+ merged: false,
4400
+ removed: false,
4401
+ validation: 'passed',
4402
+ patchEquivalence: 'passed',
4403
+ effectiveDiff: 'no_effective_diff',
4404
+ status: 'blocked_review',
4405
+ reason: 'no_effective_diff',
4406
+ ...(effectiveDiff.submoduleHints?.length ? { submoduleHints: effectiveDiff.submoduleHints } : {}),
4407
+ },
4408
+ };
4409
+ }
4410
+
3938
4411
  let mergeResult: Record<string, unknown> | undefined;
3939
4412
  const mergeStarted = Date.now();
3940
4413
  try {
@@ -4294,10 +4767,24 @@ export class DaemonCommandRouter {
4294
4767
  };
4295
4768
  }
4296
4769
 
4297
- // Execute: refine each node in order. The per-node refine pipeline fetches
4298
- // origin/<base> fresh, so each merged sibling advances the base before the
4299
- // next node's auto-rebase + patch-equivalence re-check. A blocked/failed node
4300
- // is isolated; the batch continues with the remaining nodes.
4770
+ // Execute: refine each node in order via the shared convergence core.
4771
+ return this.runMeshRefineBatchConvergence(meshId, orderedNodes, ordering, args);
4772
+ }
4773
+
4774
+ /**
4775
+ * Convergence core shared by the synchronous batch entry and the async batch job.
4776
+ * Refines each node in order: the per-node refine pipeline fetches origin/<base>
4777
+ * fresh, so each merged sibling advances the base before the next node's auto-rebase
4778
+ * + patch-equivalence re-check. A blocked/failed node is isolated; the batch
4779
+ * continues with the remaining nodes. Does NOT touch the per-node merge logic — it
4780
+ * only sequences calls to executeMeshRefineNodeSynchronously and aggregates outcomes.
4781
+ */
4782
+ private async runMeshRefineBatchConvergence(
4783
+ meshId: string,
4784
+ orderedNodes: any[],
4785
+ ordering: { order: string[]; rationale?: unknown },
4786
+ args: any,
4787
+ ): Promise<CommandRouterResult> {
4301
4788
  type BatchNodeOutcome = {
4302
4789
  nodeId: string;
4303
4790
  workspace: string;
@@ -4372,6 +4859,261 @@ export class DaemonCommandRouter {
4372
4859
  };
4373
4860
  }
4374
4861
 
4862
+ private buildRefineBatchJobKey(meshId: string): string {
4863
+ return `${meshId}::batch`;
4864
+ }
4865
+
4866
+ private buildRefineBatchJobHandle(args: {
4867
+ meshId: string;
4868
+ nodeIds: string[];
4869
+ order: string[];
4870
+ status?: MeshRefineBatchJobStatus;
4871
+ startedAt?: string;
4872
+ completedAt?: string;
4873
+ jobId?: string;
4874
+ interactionId?: string;
4875
+ coordinatorDaemonId?: string;
4876
+ }): MeshRefineBatchJobHandle {
4877
+ return {
4878
+ success: true,
4879
+ async: true,
4880
+ batch: true,
4881
+ status: args.status || 'accepted',
4882
+ jobId: args.jobId || `refine_batch_${createInteractionId()}`,
4883
+ interactionId: args.interactionId || createInteractionId(),
4884
+ meshId: args.meshId,
4885
+ batchLabel: `batch:${args.nodeIds.length} node${args.nodeIds.length === 1 ? '' : 's'}`,
4886
+ nodeIds: args.nodeIds,
4887
+ nodeCount: args.nodeIds.length,
4888
+ order: args.order,
4889
+ startedAt: args.startedAt || new Date().toISOString(),
4890
+ ...(args.completedAt ? { completedAt: args.completedAt } : {}),
4891
+ ...(args.coordinatorDaemonId ? { targetCoordinatorDaemonId: args.coordinatorDaemonId } : {}),
4892
+ eventDelivery: { pendingEvents: true, ledger: true },
4893
+ evidence: {
4894
+ pendingEventsCommand: 'get_pending_mesh_events',
4895
+ ledgerCommand: 'get_mesh_ledger_slice',
4896
+ taskHistoryKind: args.status === 'completed' ? 'task_completed' : args.status === 'failed' ? 'task_failed' : 'task_dispatched',
4897
+ },
4898
+ };
4899
+ }
4900
+
4901
+ /**
4902
+ * Emit a batch Refinery terminal/accepted event through the SAME pending-event +
4903
+ * forward mechanism single-node refine uses (queueRefineJobEvent), so the
4904
+ * coordinator's existing refine:accepted/completed/failed handling and message
4905
+ * renderer apply unchanged. The aggregate per-node results ride along in `result`.
4906
+ */
4907
+ private queueRefineBatchJobEvent(
4908
+ event: 'refine:accepted' | 'refine:completed' | 'refine:failed',
4909
+ handle: MeshRefineBatchJobHandle,
4910
+ result?: Record<string, unknown>,
4911
+ ): void {
4912
+ const metadataEvent = {
4913
+ source: 'refine_mesh_node_async_job',
4914
+ batch: true,
4915
+ jobId: handle.jobId,
4916
+ interactionId: handle.interactionId,
4917
+ meshId: handle.meshId,
4918
+ nodeId: handle.batchLabel,
4919
+ nodeIds: handle.nodeIds,
4920
+ workspace: undefined,
4921
+ status: handle.status,
4922
+ startedAt: handle.startedAt,
4923
+ completedAt: handle.completedAt,
4924
+ order: handle.order,
4925
+ ...(result ? { result } : {}),
4926
+ };
4927
+ const eventPayload = {
4928
+ event,
4929
+ meshId: handle.meshId,
4930
+ nodeLabel: handle.batchLabel,
4931
+ nodeId: handle.batchLabel,
4932
+ metadataEvent,
4933
+ queuedAt: Date.now(),
4934
+ ...(handle.targetCoordinatorDaemonId ? { targetCoordinatorDaemonId: handle.targetCoordinatorDaemonId } : {}),
4935
+ };
4936
+ if (typeof this.deps.instanceManager?.getByCategory === 'function') {
4937
+ const forwarded = handleMeshForwardEvent(
4938
+ { instanceManager: this.deps.instanceManager } as any,
4939
+ {
4940
+ event,
4941
+ meshId: handle.meshId,
4942
+ nodeId: handle.batchLabel,
4943
+ jobId: handle.jobId,
4944
+ interactionId: handle.interactionId,
4945
+ status: handle.status,
4946
+ startedAt: handle.startedAt,
4947
+ completedAt: handle.completedAt,
4948
+ ...(result ? { result } : {}),
4949
+ },
4950
+ );
4951
+ if (forwarded?.success === true) return;
4952
+ LOG.warn('Mesh', `[Refinery] Failed to forward async refine batch event ${event}: ${forwarded?.error || 'unknown error'}`);
4953
+ }
4954
+ queuePendingMeshCoordinatorEvent(eventPayload);
4955
+ }
4956
+
4957
+ private async appendRefineBatchJobLedger(
4958
+ kind: 'task_dispatched' | 'task_completed' | 'task_failed',
4959
+ handle: MeshRefineBatchJobHandle,
4960
+ result?: Record<string, unknown>,
4961
+ ): Promise<void> {
4962
+ try {
4963
+ const { appendLedgerEntry } = await import('../mesh/mesh-ledger.js');
4964
+ appendLedgerEntry(handle.meshId, {
4965
+ kind,
4966
+ nodeId: handle.batchLabel,
4967
+ payload: {
4968
+ source: 'refine_mesh_node_async_job',
4969
+ refineJob: {
4970
+ batch: true,
4971
+ jobId: handle.jobId,
4972
+ interactionId: handle.interactionId,
4973
+ status: handle.status,
4974
+ meshId: handle.meshId,
4975
+ nodeIds: handle.nodeIds,
4976
+ order: handle.order,
4977
+ targetCoordinatorDaemonId: handle.targetCoordinatorDaemonId,
4978
+ startedAt: handle.startedAt,
4979
+ completedAt: handle.completedAt,
4980
+ },
4981
+ async: true,
4982
+ batch: true,
4983
+ ...(result ? {
4984
+ success: result.success === true,
4985
+ result,
4986
+ } : {}),
4987
+ },
4988
+ });
4989
+ } catch (e: any) {
4990
+ LOG.warn('Mesh', `[Refinery] Failed to append async refine batch ledger entry: ${e?.message || e}`);
4991
+ }
4992
+ }
4993
+
4994
+ private async finishMeshRefineBatchJob(
4995
+ handle: MeshRefineBatchJobHandle,
4996
+ orderedNodes: any[],
4997
+ ordering: { order: string[]; rationale?: unknown },
4998
+ args: any,
4999
+ ): Promise<void> {
5000
+ const key = this.buildRefineBatchJobKey(handle.meshId);
5001
+ let result: Record<string, unknown>;
5002
+ try {
5003
+ result = await this.runMeshRefineBatchConvergence(handle.meshId, orderedNodes, ordering, args) as Record<string, unknown>;
5004
+ } catch (e: any) {
5005
+ result = { success: false, error: e?.message || String(e), batch: true };
5006
+ }
5007
+ const completedAt = new Date().toISOString();
5008
+
5009
+ // The batch as a whole "completed" only when every node converged (no blocked /
5010
+ // not_mergeable). A partial batch is reported as a terminal failure so the
5011
+ // coordinator inspects the per-node blockers rather than assuming a clean merge.
5012
+ const summary = (result.summary && typeof result.summary === 'object') ? result.summary as Record<string, number> : undefined;
5013
+ const allConverged = result.allConverged === true;
5014
+ const isTerminalSuccess = result.success === true && allConverged;
5015
+
5016
+ const nextStep = typeof result.nextStep === 'string' && result.nextStep
5017
+ ? result.nextStep
5018
+ : isTerminalSuccess
5019
+ ? 'All batched nodes converged onto base. Continue from the updated mesh state.'
5020
+ : 'Resolve blocked_review / not_mergeable nodes (see per-node code/stage/error in result.results), then re-run mesh_refine_batch for the remaining nodes.';
5021
+ const normalizedResult = {
5022
+ ...result,
5023
+ batch: true,
5024
+ nextStep,
5025
+ ...(summary ? {
5026
+ convergenceStatus: allConverged ? 'all_converged' : 'partial',
5027
+ } : {}),
5028
+ };
5029
+
5030
+ const terminalHandle = this.buildRefineBatchJobHandle({
5031
+ meshId: handle.meshId,
5032
+ nodeIds: handle.nodeIds,
5033
+ order: handle.order,
5034
+ status: isTerminalSuccess ? 'completed' : 'failed',
5035
+ startedAt: handle.startedAt,
5036
+ completedAt,
5037
+ jobId: handle.jobId,
5038
+ interactionId: handle.interactionId,
5039
+ coordinatorDaemonId: handle.targetCoordinatorDaemonId,
5040
+ });
5041
+ const terminal: MeshRefineBatchTerminalJob = { ...terminalHandle, result: normalizedResult };
5042
+ this.terminalRefineBatchJobs.set(key, terminal);
5043
+ this.runningRefineBatchJobs.delete(key);
5044
+ this.invalidateAggregateMeshStatus(handle.meshId);
5045
+ await this.appendRefineBatchJobLedger(isTerminalSuccess ? 'task_completed' : 'task_failed', terminalHandle, normalizedResult);
5046
+ this.queueRefineBatchJobEvent(isTerminalSuccess ? 'refine:completed' : 'refine:failed', terminalHandle, normalizedResult);
5047
+ }
5048
+
5049
+ /**
5050
+ * Async entry for the batch Refinery execute path. Mirrors startMeshRefineJob:
5051
+ * resolves the plan synchronously (so target/ordering errors and the dry-run shape
5052
+ * stay synchronous), then for execute=true registers an in-flight batch job, returns
5053
+ * {async:true, status:'accepted', batch:true, ...plan} immediately, and runs the
5054
+ * convergence loop in the background — emitting the same terminal refine event.
5055
+ * Idempotent: a batch already in flight for this mesh returns the running handle
5056
+ * with duplicate:true rather than spawning a second background job.
5057
+ */
5058
+ private async startMeshRefineBatchJob(meshId: string, requestedNodeIds: string[] | undefined, args: any): Promise<CommandRouterResult> {
5059
+ // Resolve the plan up-front. For dry-run this returns the synchronous plan; for
5060
+ // execute it returns the same plan shape but we hand convergence to the bg job.
5061
+ const plan = await this.batchRefineMeshNodes(meshId, requestedNodeIds, { ...args, dryRun: true, execute: false });
5062
+ const planRecord = plan as Record<string, unknown>;
5063
+ if (planRecord.success !== true) return plan;
5064
+
5065
+ // If the caller actually asked for a dry-run, return the plan as-is (sync).
5066
+ if (args?.dryRun === true && args?.execute !== true) return plan;
5067
+
5068
+ const order = Array.isArray(planRecord.order) ? (planRecord.order as unknown[]).filter((v): v is string => typeof v === 'string') : [];
5069
+ const nodeIds = order.slice();
5070
+ if (nodeIds.length === 0) {
5071
+ // No convergeable nodes — nothing to dispatch; return the empty plan synchronously.
5072
+ return { ...planRecord, success: true, batch: true, dryRun: false, async: false };
5073
+ }
5074
+
5075
+ const key = this.buildRefineBatchJobKey(meshId);
5076
+ const running = this.runningRefineBatchJobs.get(key);
5077
+ if (running) return { ...running, duplicate: true };
5078
+
5079
+ // Re-resolve the ordered node objects against current membership so the bg job
5080
+ // refines real nodes (the plan only carries ids). preferInline matches refine_mesh_node.
5081
+ const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
5082
+ const mesh = meshRecord?.mesh;
5083
+ const allNodes: any[] = Array.isArray(mesh?.nodes) ? mesh.nodes : [];
5084
+ const orderedNodes = nodeIds
5085
+ .map(id => allNodes.find(n => n.id === id || n.nodeId === id))
5086
+ .filter((n): n is any => !!n);
5087
+ if (orderedNodes.length === 0) {
5088
+ return { success: false, error: 'Batch nodes no longer resolvable in mesh', batch: true };
5089
+ }
5090
+ const ordering = {
5091
+ order,
5092
+ rationale: planRecord.orderingRationale,
5093
+ };
5094
+
5095
+ const coordinatorDaemonId = typeof args?.coordinatorDaemonId === 'string' && args.coordinatorDaemonId.trim()
5096
+ ? args.coordinatorDaemonId.trim()
5097
+ : (this.deps.statusInstanceId || undefined);
5098
+ const handle = this.buildRefineBatchJobHandle({ meshId, nodeIds, order, coordinatorDaemonId });
5099
+ this.runningRefineBatchJobs.set(key, handle);
5100
+ await this.appendRefineBatchJobLedger('task_dispatched', handle);
5101
+ this.queueRefineBatchJobEvent('refine:accepted', handle);
5102
+
5103
+ setImmediate(() => {
5104
+ void this.finishMeshRefineBatchJob(handle, orderedNodes, ordering, args);
5105
+ });
5106
+
5107
+ // Return the accepted handle plus the plan so the coordinator sees the target set.
5108
+ return {
5109
+ ...handle,
5110
+ order,
5111
+ orderingRationale: planRecord.orderingRationale,
5112
+ plan: planRecord.plan,
5113
+ note: 'Batch convergence accepted and running in the background. Completion/failure (with per-node results) will be delivered as a terminal refine event; do not poll repeatedly.',
5114
+ };
5115
+ }
5116
+
4375
5117
  private async finishMeshRefineJob(handle: MeshRefineJobHandle, args: any): Promise<void> {
4376
5118
  const key = this.buildRefineJobKey(handle.meshId, handle.targetNodeId);
4377
5119
  let result: Record<string, unknown>;
@@ -6196,7 +6938,14 @@ export class DaemonCommandRouter {
6196
6938
  const requestedNodeIds = Array.isArray(args?.nodeIds)
6197
6939
  ? (args.nodeIds as unknown[]).filter((v): v is string => typeof v === 'string' && v.trim().length > 0).map(v => v.trim())
6198
6940
  : undefined;
6199
- return this.batchRefineMeshNodes(meshId, requestedNodeIds, args);
6941
+ // Dry-run (plan-only) stays synchronous: it does no validation/merge and
6942
+ // returns instantly. Execute goes through the async batch job — immediate
6943
+ // {async:true, status:'accepted'} + background convergence + terminal event,
6944
+ // matching the single-node refine_mesh_node contract so long validation
6945
+ // suites can't time out the IPC and strand the coordinator.
6946
+ const isDryRun = args?.dryRun !== false && args?.execute !== true;
6947
+ if (isDryRun) return this.batchRefineMeshNodes(meshId, requestedNodeIds, args);
6948
+ return this.startMeshRefineBatchJob(meshId, requestedNodeIds, args);
6200
6949
  }
6201
6950
 
6202
6951
  case 'remove_mesh_node': {