@adhdev/daemon-core 0.9.82-rc.261 → 0.9.82-rc.263

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.
@@ -64,6 +64,7 @@ import {
64
64
  type WorktreeBootstrapState,
65
65
  } from '../mesh/worktree-bootstrap-config.js';
66
66
  import { buildMachineInfo, buildStatusSnapshot } from '../status/snapshot.js';
67
+ import { getDaemonBuildInfo } from '../build-info.js';
67
68
  import { getSessionCompletionMarker } from '../status/snapshot.js';
68
69
  import { execNpmCommandSync, resolveCurrentGlobalInstallSurface, spawnDetachedDaemonUpgradeHelper } from './upgrade-helper.js';
69
70
  import { getMeshQueueRevision } from '../mesh/mesh-work-queue.js';
@@ -1397,6 +1398,31 @@ type MeshRefinePatchEquivalenceSummary = {
1397
1398
  stdout?: string;
1398
1399
  stderr?: string;
1399
1400
  actionableHint?: MeshRefineSubmoduleConflictHint;
1401
+ /**
1402
+ * Set when a `merge-tree` submodule conflict was reclassified as a trivial
1403
+ * gitlink fast-forward and the gate passed via a synthesized merge tree.
1404
+ */
1405
+ gitlinkTrivialFastForward?: {
1406
+ resolved: boolean;
1407
+ gitlinks: Array<{ path: string; baseCommit?: string; branchCommit?: string; fastForward: boolean }>;
1408
+ reason?: string;
1409
+ };
1410
+ };
1411
+
1412
+ type MeshRefineEffectiveDiffSummary = {
1413
+ status: MeshRefineStageStatus;
1414
+ /** True when there is at least one root-tree change between base and branch (incl. gitlink bumps). */
1415
+ hasEffectiveDiff: boolean;
1416
+ baseHead: string;
1417
+ branchHead: string;
1418
+ /** Root-level paths that differ between base and branch (capped). */
1419
+ changedPaths?: string[];
1420
+ /** Submodule paths with uncommitted/divergent commits but NO committed gitlink bump in the root tree. */
1421
+ submoduleHints?: Array<{ path: string; reason: string }>;
1422
+ durationMs: number;
1423
+ error?: string;
1424
+ stdout?: string;
1425
+ stderr?: string;
1400
1426
  };
1401
1427
 
1402
1428
  type MeshRefineSubmoduleConflictHint = {
@@ -1496,6 +1522,44 @@ type MeshRefineJobHandle = {
1496
1522
 
1497
1523
  type MeshRefineTerminalJob = MeshRefineJobHandle & { result?: Record<string, unknown> };
1498
1524
 
1525
+ type MeshRefineBatchJobStatus = 'accepted' | 'completed' | 'failed';
1526
+
1527
+ /**
1528
+ * Async handle returned by the batch Refinery the instant a convergence run is
1529
+ * accepted. Mirrors {@link MeshRefineJobHandle} (async:true / status:'accepted' +
1530
+ * terminal pending-event + ledger delivery) but scopes a whole batch of sibling
1531
+ * nodes rather than a single node. The synthetic `batchLabel` is used as the
1532
+ * `nodeLabel` for the shared refine event/message renderer.
1533
+ */
1534
+ type MeshRefineBatchJobHandle = {
1535
+ success: true;
1536
+ async: true;
1537
+ batch: true;
1538
+ status: MeshRefineBatchJobStatus;
1539
+ jobId: string;
1540
+ interactionId: string;
1541
+ meshId: string;
1542
+ batchLabel: string;
1543
+ nodeIds: string[];
1544
+ nodeCount: number;
1545
+ order: string[];
1546
+ startedAt: string;
1547
+ completedAt?: string;
1548
+ duplicate?: boolean;
1549
+ targetCoordinatorDaemonId?: string;
1550
+ eventDelivery: {
1551
+ pendingEvents: true;
1552
+ ledger: true;
1553
+ };
1554
+ evidence: {
1555
+ pendingEventsCommand: 'get_pending_mesh_events';
1556
+ ledgerCommand: 'get_mesh_ledger_slice';
1557
+ taskHistoryKind: 'task_dispatched' | 'task_completed' | 'task_failed';
1558
+ };
1559
+ };
1560
+
1561
+ type MeshRefineBatchTerminalJob = MeshRefineBatchJobHandle & { result?: Record<string, unknown> };
1562
+
1499
1563
  const REFINE_VALIDATION_CATEGORIES = ['typecheck', 'test', 'lint', 'build'] as const;
1500
1564
  const REFINE_VALIDATION_TIMEOUT_MS = 120_000;
1501
1565
  const REFINE_VALIDATION_OUTPUT_LIMIT_BYTES = 128 * 1024;
@@ -1581,8 +1645,46 @@ async function runMeshRefinePatchEquivalenceGate(
1581
1645
  maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES,
1582
1646
  });
1583
1647
  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] || '';
1648
+
1649
+ // `git merge-tree --write-tree` refuses to merge gitlinks that differ
1650
+ // across base/branch even when the advance is a strict fast-forward,
1651
+ // failing with "Recursive merging with submodules currently only
1652
+ // supports trivial cases". When that happens we check whether the
1653
+ // conflict is *entirely* trivial-ff gitlinks and, if so, synthesize the
1654
+ // merged tree ourselves (base tree + branch-side gitlinks).
1655
+ let mergedTree = '';
1656
+ let mergeTreeStdout = '';
1657
+ let gitlinkTrivialFastForward: MeshRefinePatchEquivalenceSummary['gitlinkTrivialFastForward'];
1658
+ try {
1659
+ mergeTreeStdout = git(['merge-tree', '--write-tree', baseHead, branchHead]);
1660
+ mergedTree = mergeTreeStdout.trim().split(/\s+/)[0] || '';
1661
+ } catch (mergeTreeErr: any) {
1662
+ const output = `${mergeTreeErr?.message || ''}\n${mergeTreeErr?.stdout || ''}\n${mergeTreeErr?.stderr || ''}`;
1663
+ const isSubmoduleConflict = /(submodule|160000)/i.test(output)
1664
+ || /Recursive merging with submodules/i.test(output);
1665
+ if (!isSubmoduleConflict) throw mergeTreeErr;
1666
+ const evaluation = evaluateGitlinkTrivialFastForward(repoRoot, baseHead, branchHead);
1667
+ if (!evaluation.trivial) {
1668
+ return {
1669
+ status: 'failed',
1670
+ equivalent: false,
1671
+ baseHead,
1672
+ branchHead,
1673
+ mergeBase: mergeBase || undefined,
1674
+ durationMs: Date.now() - startedAt,
1675
+ error: mergeTreeErr?.message || String(mergeTreeErr),
1676
+ stdout: truncateValidationOutput(mergeTreeErr?.stdout),
1677
+ stderr: truncateValidationOutput(mergeTreeErr?.stderr),
1678
+ gitlinkTrivialFastForward: { resolved: false, gitlinks: evaluation.gitlinks, reason: evaluation.reason },
1679
+ actionableHint: buildPatchEquivalenceSubmoduleConflictHint(repoRoot, baseHead, branchHead, output),
1680
+ };
1681
+ }
1682
+ // All conflicting gitlinks fast-forward and nothing else conflicts:
1683
+ // synthesize the merge result as base's tree with branch-side gitlinks.
1684
+ mergedTree = synthesizeTrivialFastForwardMergeTree(repoRoot, baseHead, branchHead, evaluation.gitlinks) || '';
1685
+ gitlinkTrivialFastForward = { resolved: true, gitlinks: evaluation.gitlinks };
1686
+ }
1687
+
1586
1688
  if (!mergeBase || !mergedTree) {
1587
1689
  return {
1588
1690
  status: 'failed',
@@ -1594,6 +1696,7 @@ async function runMeshRefinePatchEquivalenceGate(
1594
1696
  durationMs: Date.now() - startedAt,
1595
1697
  error: 'patch equivalence preflight could not resolve merge-base or synthetic merge tree',
1596
1698
  stdout: truncateValidationOutput(mergeTreeStdout),
1699
+ gitlinkTrivialFastForward,
1597
1700
  };
1598
1701
  }
1599
1702
  const expectedPatchId = await computeGitPatchId(repoRoot, mergeBase, branchHead);
@@ -1609,6 +1712,7 @@ async function runMeshRefinePatchEquivalenceGate(
1609
1712
  expectedPatchId,
1610
1713
  actualPatchId,
1611
1714
  durationMs: Date.now() - startedAt,
1715
+ gitlinkTrivialFastForward,
1612
1716
  };
1613
1717
  } catch (e: any) {
1614
1718
  return {
@@ -1630,6 +1734,101 @@ async function runMeshRefinePatchEquivalenceGate(
1630
1734
  }
1631
1735
  }
1632
1736
 
1737
+ /**
1738
+ * No-op guard: detect a "silent no-op" merge before the Refinery merge runs.
1739
+ *
1740
+ * A silent no-op occurs when the refine target branch's ROOT tree is byte-identical
1741
+ * to the merge base (origin/main). This is the trap where a submodule (e.g. oss) has
1742
+ * real commits but the root branch never committed the gitlink (oss-pointer) bump, so
1743
+ * the root diff Refinery would merge is empty. Merging that produces a merge commit with
1744
+ * no content change — reported as "success" while the actual work never reaches main.
1745
+ *
1746
+ * A committed gitlink bump (the legitimate oss-pointer bump) DOES show up in the root
1747
+ * tree diff (as a 160000-mode entry), so this guard does NOT block legitimate refines —
1748
+ * it only fires when the root tree diff vs base is COMPLETELY empty.
1749
+ *
1750
+ * Runs after the patch-equivalence gate; the "already merged via other path" case
1751
+ * (branch has real changes already present in base) is handled upstream and never
1752
+ * reaches here, so an empty root diff at this point is genuinely a no-op.
1753
+ */
1754
+ export async function runMeshRefineEffectiveDiffGate(
1755
+ repoRoot: string,
1756
+ baseHead: string,
1757
+ branchHead: string,
1758
+ ): Promise<MeshRefineEffectiveDiffSummary> {
1759
+ const startedAt = Date.now();
1760
+ try {
1761
+ const { execFileSync } = await import('node:child_process');
1762
+ const git = (args: string[], opts?: { cwd?: string }) => execFileSync('git', args, {
1763
+ cwd: opts?.cwd || repoRoot,
1764
+ encoding: 'utf8',
1765
+ maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES,
1766
+ });
1767
+ // Root tree diff between base and branch. --raw surfaces gitlink (160000) entries,
1768
+ // so a committed submodule-pointer bump counts as an effective change. An empty
1769
+ // result means the branch's root tree is identical to base → nothing would merge.
1770
+ const rawDiff = git(['diff', '--raw', baseHead, branchHead]).trim();
1771
+ if (rawDiff) {
1772
+ const changedPaths = rawDiff
1773
+ .split('\n')
1774
+ .map(line => line.split('\t').slice(1).join('\t').trim())
1775
+ .filter(Boolean)
1776
+ .slice(0, 50);
1777
+ return {
1778
+ status: 'passed',
1779
+ hasEffectiveDiff: true,
1780
+ baseHead,
1781
+ branchHead,
1782
+ changedPaths,
1783
+ durationMs: Date.now() - startedAt,
1784
+ };
1785
+ }
1786
+
1787
+ // No root diff → silent no-op. Try to surface which submodule(s) have commits that
1788
+ // were never captured by a committed gitlink bump, to make the message actionable.
1789
+ const submoduleHints: Array<{ path: string; reason: string }> = [];
1790
+ try {
1791
+ // `git submodule status` flags submodules whose checked-out commit differs from
1792
+ // the recorded gitlink with a leading '+'. That difference is exactly the
1793
+ // uncommitted-pointer-bump situation this guard exists to catch.
1794
+ const status = git(['submodule', 'status']);
1795
+ for (const line of status.split('\n')) {
1796
+ const trimmed = line.trimEnd();
1797
+ if (!trimmed) continue;
1798
+ if (trimmed.startsWith('+')) {
1799
+ const parts = trimmed.slice(1).trim().split(/\s+/);
1800
+ const path = parts[1] || parts[0] || '(unknown)';
1801
+ submoduleHints.push({
1802
+ path,
1803
+ reason: 'submodule checked-out commit differs from the committed gitlink (pointer bump not committed on the root branch)',
1804
+ });
1805
+ }
1806
+ }
1807
+ } catch { /* submodule status is best-effort */ }
1808
+
1809
+ return {
1810
+ status: 'failed',
1811
+ hasEffectiveDiff: false,
1812
+ baseHead,
1813
+ branchHead,
1814
+ ...(submoduleHints.length ? { submoduleHints } : {}),
1815
+ durationMs: Date.now() - startedAt,
1816
+ };
1817
+ } catch (e: any) {
1818
+ // On error, do NOT block the merge — fail open so a probe failure can't wedge refine.
1819
+ return {
1820
+ status: 'skipped',
1821
+ hasEffectiveDiff: true,
1822
+ baseHead,
1823
+ branchHead,
1824
+ durationMs: Date.now() - startedAt,
1825
+ error: e?.message || String(e),
1826
+ stdout: truncateValidationOutput(e?.stdout),
1827
+ stderr: truncateValidationOutput(e?.stderr),
1828
+ };
1829
+ }
1830
+ }
1831
+
1633
1832
  function buildPatchEquivalenceSubmoduleConflictHint(
1634
1833
  repoRoot: string,
1635
1834
  baseHead: string,
@@ -1695,6 +1894,230 @@ function readTreeObject(repoRoot: string, ref: string, path: string): string | u
1695
1894
  }
1696
1895
  }
1697
1896
 
1897
+ /**
1898
+ * Resolve the absolute path to the repo's real git directory. In a linked
1899
+ * worktree, `.git` is a file pointing elsewhere, so we cannot assume a `.git`
1900
+ * subdirectory exists — a temporary index file must live in the actual git dir.
1901
+ */
1902
+ function resolveGitDir(repoRoot: string): string {
1903
+ const out = execFileSync('git', ['rev-parse', '--absolute-git-dir'], {
1904
+ cwd: repoRoot,
1905
+ encoding: 'utf8',
1906
+ maxBuffer: 1024 * 1024,
1907
+ }).trim();
1908
+ return out;
1909
+ }
1910
+
1911
+ /**
1912
+ * Result of evaluating whether a `git merge-tree --write-tree` submodule
1913
+ * conflict is in fact a trivial gitlink fast-forward that should pass the
1914
+ * patch-equivalence gate.
1915
+ *
1916
+ * `git merge-tree` (and `git merge` with the default recursive strategy)
1917
+ * refuses to 3-way merge gitlinks unless the case is "trivial" — and it
1918
+ * treats *any* gitlink that differs across merge-base/base/branch as
1919
+ * non-trivial, even when the branch-side commit is a strict descendant of the
1920
+ * base-side commit (i.e. a real fast-forward). Refinery only ever wants to
1921
+ * accept the branch's recorded gitlink, so a fast-forwardable bump is safe to
1922
+ * resolve to the branch side without any conflict.
1923
+ */
1924
+ type GitlinkTrivialFastForwardEvaluation = {
1925
+ /** True only when the merge-tree conflict is *fully* explained by trivial-ff gitlinks. */
1926
+ trivial: boolean;
1927
+ /** Why the evaluation declined to treat the conflict as trivial (set when trivial=false). */
1928
+ reason?: string;
1929
+ /** Per-path detail for the changed gitlinks that were inspected. */
1930
+ gitlinks: Array<{
1931
+ path: string;
1932
+ baseCommit?: string;
1933
+ branchCommit?: string;
1934
+ fastForward: boolean;
1935
+ }>;
1936
+ };
1937
+
1938
+ /**
1939
+ * Check, inside a submodule repo, whether `baseCommit` is an ancestor of
1940
+ * `branchCommit` (i.e. advancing the gitlink from base→branch is a pure
1941
+ * fast-forward). Returns false on any error or when either commit is missing
1942
+ * locally — safety first, ambiguity stays "not a fast-forward".
1943
+ */
1944
+ function isSubmoduleFastForward(submoduleRepoPath: string, baseCommit: string, branchCommit: string): boolean {
1945
+ if (!baseCommit || !branchCommit) return false;
1946
+ if (baseCommit === branchCommit) return true;
1947
+ try {
1948
+ if (!fs.existsSync(submoduleRepoPath)) return false;
1949
+ // Both commits must exist locally for the ancestry check to be meaningful.
1950
+ execFileSync('git', ['cat-file', '-e', `${baseCommit}^{commit}`], { cwd: submoduleRepoPath, stdio: 'ignore' });
1951
+ execFileSync('git', ['cat-file', '-e', `${branchCommit}^{commit}`], { cwd: submoduleRepoPath, stdio: 'ignore' });
1952
+ // exit 0 ⇒ baseCommit is an ancestor of branchCommit ⇒ branch fast-forwards base.
1953
+ execFileSync('git', ['merge-base', '--is-ancestor', baseCommit, branchCommit], { cwd: submoduleRepoPath, stdio: 'ignore' });
1954
+ return true;
1955
+ } catch {
1956
+ return false;
1957
+ }
1958
+ }
1959
+
1960
+ /**
1961
+ * Read the set of paths that differ between two refs, tagging whether each is a
1962
+ * gitlink (submodule, mode 160000) on either side. Returns one entry per
1963
+ * changed path. Empty on error.
1964
+ */
1965
+ function readChangedPathKinds(repoRoot: string, fromRef: string, toRef: string): Array<{ path: string; isGitlink: boolean }> {
1966
+ try {
1967
+ const output = execFileSync('git', ['diff', '--raw', '--no-abbrev', fromRef, toRef], {
1968
+ cwd: repoRoot,
1969
+ encoding: 'utf8',
1970
+ maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES,
1971
+ });
1972
+ const result: Array<{ path: string; isGitlink: boolean }> = [];
1973
+ const seen = new Set<string>();
1974
+ for (const line of output.split('\n')) {
1975
+ if (!line.trim()) continue;
1976
+ const metaAndPath = line.split('\t');
1977
+ const meta = metaAndPath[0] || '';
1978
+ const path = metaAndPath[metaAndPath.length - 1]?.trim();
1979
+ if (!path || seen.has(path)) continue;
1980
+ seen.add(path);
1981
+ const parts = meta.split(/\s+/);
1982
+ const isGitlink = !!(parts[0]?.includes('160000') || parts[1]?.includes('160000'));
1983
+ result.push({ path, isGitlink });
1984
+ }
1985
+ return result;
1986
+ } catch {
1987
+ return [];
1988
+ }
1989
+ }
1990
+
1991
+ /**
1992
+ * Decide whether a merge-tree submodule conflict between base and branch is a
1993
+ * trivial gitlink fast-forward (and nothing else).
1994
+ *
1995
+ * The conflict is treated as trivial ONLY when:
1996
+ * 1. at least one changed gitlink exists,
1997
+ * 2. every changed gitlink fast-forwards (base-commit is an ancestor of the
1998
+ * branch-commit inside that submodule's repo), and
1999
+ * 3. the *only* paths that changed on both sides of the merge (i.e. the paths
2000
+ * that could possibly produce a 3-way conflict — the intersection of
2001
+ * mergeBase→base and mergeBase→branch changes) are gitlinks. Any
2002
+ * overlapping non-gitlink path means a genuine content conflict could be
2003
+ * hiding behind the submodule failure, so we keep the block.
2004
+ *
2005
+ * If any of these fail, the conflict is left as a genuine block. This never
2006
+ * passes a regular-file conflict or a diverged (non-ff) gitlink.
2007
+ */
2008
+ export function evaluateGitlinkTrivialFastForward(
2009
+ repoRoot: string,
2010
+ baseHead: string,
2011
+ branchHead: string,
2012
+ ): GitlinkTrivialFastForwardEvaluation {
2013
+ const changedGitlinks = readChangedGitlinkPaths(repoRoot, baseHead, branchHead).map(path => {
2014
+ const baseCommit = readTreeObject(repoRoot, baseHead, path);
2015
+ const branchCommit = readTreeObject(repoRoot, branchHead, path);
2016
+ const submoduleRepoPath = pathResolve(repoRoot, path);
2017
+ const fastForward = !!baseCommit && !!branchCommit
2018
+ && isSubmoduleFastForward(submoduleRepoPath, baseCommit, branchCommit);
2019
+ return { path, baseCommit, branchCommit, fastForward };
2020
+ });
2021
+
2022
+ if (changedGitlinks.length === 0) {
2023
+ return { trivial: false, reason: 'no_changed_gitlinks', gitlinks: changedGitlinks };
2024
+ }
2025
+
2026
+ const nonFastForward = changedGitlinks.filter(entry => !entry.fastForward);
2027
+ if (nonFastForward.length > 0) {
2028
+ return {
2029
+ trivial: false,
2030
+ reason: `diverged_gitlinks:${nonFastForward.map(entry => entry.path).join(',')}`,
2031
+ gitlinks: changedGitlinks,
2032
+ };
2033
+ }
2034
+
2035
+ // Prove there is no *other* conflict (regular files, or a gitlink that
2036
+ // diverged on both sides). A 3-way merge can only conflict on a path that
2037
+ // changed on BOTH sides relative to the merge-base. Compute that overlap and
2038
+ // require every overlapping path to be a gitlink — non-gitlink overlap means
2039
+ // a genuine content conflict that must stay blocked.
2040
+ let mergeBase = '';
2041
+ try {
2042
+ mergeBase = execFileSync('git', ['merge-base', baseHead, branchHead], {
2043
+ cwd: repoRoot,
2044
+ encoding: 'utf8',
2045
+ maxBuffer: 1024 * 1024,
2046
+ }).trim();
2047
+ } catch {
2048
+ return { trivial: false, reason: 'merge_base_unresolved', gitlinks: changedGitlinks };
2049
+ }
2050
+ if (!mergeBase) {
2051
+ return { trivial: false, reason: 'merge_base_unresolved', gitlinks: changedGitlinks };
2052
+ }
2053
+
2054
+ const baseSideChanges = readChangedPathKinds(repoRoot, mergeBase, baseHead);
2055
+ const branchSideChanges = readChangedPathKinds(repoRoot, mergeBase, branchHead);
2056
+ const baseChangedPaths = new Map(baseSideChanges.map(entry => [entry.path, entry]));
2057
+ // Overlapping paths = candidates for a real 3-way conflict.
2058
+ const overlapping = branchSideChanges.filter(entry => baseChangedPaths.has(entry.path));
2059
+ const nonGitlinkOverlap = overlapping.filter(entry => {
2060
+ const baseEntry = baseChangedPaths.get(entry.path);
2061
+ return !(entry.isGitlink && baseEntry?.isGitlink);
2062
+ });
2063
+ if (nonGitlinkOverlap.length > 0) {
2064
+ return {
2065
+ trivial: false,
2066
+ reason: `non_gitlink_overlap:${nonGitlinkOverlap.map(entry => entry.path).join(',')}`,
2067
+ gitlinks: changedGitlinks,
2068
+ };
2069
+ }
2070
+
2071
+ return { trivial: true, gitlinks: changedGitlinks };
2072
+ }
2073
+
2074
+ /**
2075
+ * Synthesize the merge result for a trivial gitlink fast-forward: take
2076
+ * `baseHead`'s tree and overlay each changed gitlink's branch-side commit.
2077
+ * This mirrors what `git merge-tree` would have produced had it not bailed on
2078
+ * the submodule recursion limitation. Returns the tree SHA, or undefined on
2079
+ * failure. Caller must have already proven (via evaluateGitlinkTrivialFastForward)
2080
+ * that every changed gitlink fast-forwards and no other path conflicts.
2081
+ */
2082
+ function synthesizeTrivialFastForwardMergeTree(
2083
+ repoRoot: string,
2084
+ baseHead: string,
2085
+ branchHead: string,
2086
+ gitlinks: Array<{ path: string; branchCommit?: string }>,
2087
+ ): string | undefined {
2088
+ try {
2089
+ const baseTree = execFileSync('git', ['rev-parse', `${baseHead}^{tree}`], {
2090
+ cwd: repoRoot,
2091
+ encoding: 'utf8',
2092
+ maxBuffer: 1024 * 1024,
2093
+ }).trim();
2094
+ if (!baseTree) return undefined;
2095
+ const updates = gitlinks
2096
+ .filter(entry => entry.branchCommit)
2097
+ .map(entry => `160000 commit ${entry.branchCommit}\t${entry.path}`)
2098
+ .join('\n');
2099
+ if (!updates) return baseTree;
2100
+ const tmpIndex = pathJoin(resolveGitDir(repoRoot), `adhdev-refine-ff-${baseHead.slice(0, 12)}-${branchHead.slice(0, 12)}.index`);
2101
+ const env = { ...process.env, GIT_INDEX_FILE: tmpIndex };
2102
+ try {
2103
+ execFileSync('git', ['read-tree', baseTree], { cwd: repoRoot, env, stdio: 'ignore' });
2104
+ execFileSync('git', ['update-index', '--index-info'], {
2105
+ cwd: repoRoot,
2106
+ env,
2107
+ input: `${updates}\n`,
2108
+ encoding: 'utf8',
2109
+ stdio: ['pipe', 'ignore', 'ignore'],
2110
+ });
2111
+ const newTree = execFileSync('git', ['write-tree'], { cwd: repoRoot, env, encoding: 'utf8' }).trim();
2112
+ return newTree || undefined;
2113
+ } finally {
2114
+ try { fs.rmSync(tmpIndex, { force: true }); } catch { /* ignore */ }
2115
+ }
2116
+ } catch {
2117
+ return undefined;
2118
+ }
2119
+ }
2120
+
1698
2121
  async function alignRefinerySubmodulesAfterMerge(
1699
2122
  repoRoot: string,
1700
2123
  previousBaseHead: string,
@@ -2520,6 +2943,10 @@ export class DaemonCommandRouter {
2520
2943
  private runningRefineJobs = new Map<string, MeshRefineJobHandle>();
2521
2944
  /** Terminal async Refinery jobs preserve a clear answer after the worktree node has been removed. */
2522
2945
  private terminalRefineJobs = new Map<string, MeshRefineTerminalJob>();
2946
+ /** In-memory async batch Refinery jobs keyed by meshId (one batch convergence per mesh at a time). */
2947
+ private runningRefineBatchJobs = new Map<string, MeshRefineBatchJobHandle>();
2948
+ /** Terminal async batch Refinery jobs preserve the last batch outcome for late readers. */
2949
+ private terminalRefineBatchJobs = new Map<string, MeshRefineBatchTerminalJob>();
2523
2950
 
2524
2951
  constructor(deps: CommandRouterDeps) {
2525
2952
  this.deps = deps;
@@ -3095,6 +3522,8 @@ export class DaemonCommandRouter {
3095
3522
  const skippedSessionIds: string[] = [];
3096
3523
  const skippedLiveSessionIds: string[] = [];
3097
3524
  const skippedCoordinatorSessionIds: string[] = [];
3525
+ const skippedLiveSessionReasons: Array<{ sessionId: string; reason: string }> = [];
3526
+ const actedLiveDelegateSessionIds: string[] = [];
3098
3527
  const deleteUnsupportedSessionIds: string[] = [];
3099
3528
  const recordsRemainSessionIds: string[] = [];
3100
3529
  const errors: Array<{ sessionId: string; error: string }> = [];
@@ -3130,16 +3559,49 @@ export class DaemonCommandRouter {
3130
3559
  const surfaceKind = getSessionHostSurfaceKind(record);
3131
3560
  const liveRuntime = surfaceKind === 'live_runtime';
3132
3561
  const coordinatorSession = readStringValue(record?.meta?.meshCoordinatorFor) === args.meshId;
3562
+ // A delegate session was launched by the coordinator specifically FOR this node
3563
+ // (meta.meshNodeId === this node). It is 1:1 bound to the node, even when the node
3564
+ // shares its daemon runtime with the main/other nodes. Removing the node should be
3565
+ // able to stop its own delegate session — the shared-daemon concern only applies to
3566
+ // sessions we matched by workspace alone (which could belong to the coordinator or to
3567
+ // a sibling node that is still active).
3568
+ const recordNodeId = readStringValue(record?.meta?.meshNodeId);
3569
+ const recordMeshNodeFor = readStringValue(record?.meta?.meshNodeFor);
3570
+ const delegateBoundToThisNode = !!recordNodeId
3571
+ && recordNodeId === args.nodeId
3572
+ && (!recordMeshNodeFor || recordMeshNodeFor === args.meshId);
3133
3573
  if (!hasExplicitSessionIds && coordinatorSession) {
3134
3574
  skippedSessionIds.push(sessionId);
3135
3575
  skippedCoordinatorSessionIds.push(sessionId);
3136
3576
  continue;
3137
3577
  }
3138
- if (!hasExplicitSessionIds && liveRuntime) {
3578
+ // Only the conservative shared-daemon guard for live sessions that are NOT a delegate
3579
+ // explicitly bound to this node. Delegate-bound live sessions fall through and are
3580
+ // stopped/deleted by the mode handlers below (which already record an intentional stop).
3581
+ if (!hasExplicitSessionIds && liveRuntime && !delegateBoundToThisNode) {
3582
+ skippedSessionIds.push(sessionId);
3583
+ skippedLiveSessionIds.push(sessionId);
3584
+ const matchedByWorkspaceOnly = !recordNodeId;
3585
+ const reason = recordNodeId && recordNodeId !== args.nodeId
3586
+ ? `live_delegate_bound_to_other_node:${recordNodeId}`
3587
+ : matchedByWorkspaceOnly
3588
+ ? 'live_session_matched_by_workspace_only_no_node_binding'
3589
+ : 'live_session_not_bound_to_this_node';
3590
+ skippedLiveSessionReasons.push({ sessionId, reason });
3591
+ continue;
3592
+ }
3593
+ if (!hasExplicitSessionIds && liveRuntime && delegateBoundToThisNode && args.mode === 'delete_stopped') {
3594
+ // delete_stopped never stops live runtimes by contract — even bound delegates.
3595
+ // Surface a clear reason instead of an unexplained skip so callers know to use
3596
+ // stop / stop_and_delete to release a still-running bound delegate.
3139
3597
  skippedSessionIds.push(sessionId);
3140
3598
  skippedLiveSessionIds.push(sessionId);
3599
+ skippedLiveSessionReasons.push({ sessionId, reason: 'live_delegate_preserved_by_delete_stopped_mode_use_stop_or_stop_and_delete' });
3141
3600
  continue;
3142
3601
  }
3602
+ if (!hasExplicitSessionIds && liveRuntime && delegateBoundToThisNode) {
3603
+ actedLiveDelegateSessionIds.push(sessionId);
3604
+ }
3143
3605
  try {
3144
3606
  if (args.mode === 'stop') {
3145
3607
  if (!completed) {
@@ -3205,6 +3667,8 @@ export class DaemonCommandRouter {
3205
3667
  skippedSessionIds,
3206
3668
  skippedLiveSessionIds,
3207
3669
  skippedCoordinatorSessionIds,
3670
+ ...(actedLiveDelegateSessionIds.length ? { actedLiveDelegateSessionIds } : {}),
3671
+ ...(skippedLiveSessionReasons.length ? { skippedLiveSessionReasons } : {}),
3208
3672
  ...(deleteUnsupported ? {
3209
3673
  deleteUnsupported: true,
3210
3674
  effectiveCleanup: args.mode === 'stop_and_delete'
@@ -3935,6 +4399,53 @@ export class DaemonCommandRouter {
3935
4399
  };
3936
4400
  }
3937
4401
 
4402
+ // No-op guard: block a silent no-op merge where the root tree is identical to base.
4403
+ // This catches the trap where a submodule has commits but the root branch never
4404
+ // committed the gitlink (oss-pointer) bump — merging would report success while the
4405
+ // real change never lands on main. A committed gitlink bump shows up in the root
4406
+ // diff, so legitimate oss-pointer refines pass through untouched.
4407
+ const effectiveDiffStarted = Date.now();
4408
+ const effectiveDiff = await runMeshRefineEffectiveDiffGate(repoRoot, baseHead, branchHead);
4409
+ recordMeshRefineStage(refineStages, 'effective_diff', effectiveDiff.status, effectiveDiffStarted, {
4410
+ hasEffectiveDiff: effectiveDiff.hasEffectiveDiff,
4411
+ changedPaths: effectiveDiff.changedPaths,
4412
+ submoduleHints: effectiveDiff.submoduleHints,
4413
+ ...(effectiveDiff.error ? { error: effectiveDiff.error } : {}),
4414
+ });
4415
+ if (effectiveDiff.status === 'failed' && !effectiveDiff.hasEffectiveDiff) {
4416
+ const hintLines = (effectiveDiff.submoduleHints || []).map(h => ` - ${h.path}: ${h.reason}`);
4417
+ const message = [
4418
+ `Refinery no-op guard: branch '${branch}' has no effective root-tree diff against '${baseBranch}' (${baseHead.slice(0, 12)}); nothing would merge.`,
4419
+ '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.',
4420
+ hintLines.length ? `Submodules with uncommitted pointer bumps:\n${hintLines.join('\n')}` : '',
4421
+ `Fix: commit the submodule pointer bump on '${branch}' (git add <submodule-path> && git commit), then re-run refine.`,
4422
+ ].filter(Boolean).join('\n');
4423
+ return {
4424
+ success: false,
4425
+ code: 'no_effective_diff',
4426
+ convergenceStatus: 'blocked_review',
4427
+ error: message,
4428
+ branch,
4429
+ into: baseBranch,
4430
+ validationSummary,
4431
+ patchEquivalence,
4432
+ effectiveDiff,
4433
+ refineStages,
4434
+ finalBranchConvergenceState: {
4435
+ branch,
4436
+ baseBranch,
4437
+ merged: false,
4438
+ removed: false,
4439
+ validation: 'passed',
4440
+ patchEquivalence: 'passed',
4441
+ effectiveDiff: 'no_effective_diff',
4442
+ status: 'blocked_review',
4443
+ reason: 'no_effective_diff',
4444
+ ...(effectiveDiff.submoduleHints?.length ? { submoduleHints: effectiveDiff.submoduleHints } : {}),
4445
+ },
4446
+ };
4447
+ }
4448
+
3938
4449
  let mergeResult: Record<string, unknown> | undefined;
3939
4450
  const mergeStarted = Date.now();
3940
4451
  try {
@@ -4294,10 +4805,24 @@ export class DaemonCommandRouter {
4294
4805
  };
4295
4806
  }
4296
4807
 
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.
4808
+ // Execute: refine each node in order via the shared convergence core.
4809
+ return this.runMeshRefineBatchConvergence(meshId, orderedNodes, ordering, args);
4810
+ }
4811
+
4812
+ /**
4813
+ * Convergence core shared by the synchronous batch entry and the async batch job.
4814
+ * Refines each node in order: the per-node refine pipeline fetches origin/<base>
4815
+ * fresh, so each merged sibling advances the base before the next node's auto-rebase
4816
+ * + patch-equivalence re-check. A blocked/failed node is isolated; the batch
4817
+ * continues with the remaining nodes. Does NOT touch the per-node merge logic — it
4818
+ * only sequences calls to executeMeshRefineNodeSynchronously and aggregates outcomes.
4819
+ */
4820
+ private async runMeshRefineBatchConvergence(
4821
+ meshId: string,
4822
+ orderedNodes: any[],
4823
+ ordering: { order: string[]; rationale?: unknown },
4824
+ args: any,
4825
+ ): Promise<CommandRouterResult> {
4301
4826
  type BatchNodeOutcome = {
4302
4827
  nodeId: string;
4303
4828
  workspace: string;
@@ -4372,6 +4897,261 @@ export class DaemonCommandRouter {
4372
4897
  };
4373
4898
  }
4374
4899
 
4900
+ private buildRefineBatchJobKey(meshId: string): string {
4901
+ return `${meshId}::batch`;
4902
+ }
4903
+
4904
+ private buildRefineBatchJobHandle(args: {
4905
+ meshId: string;
4906
+ nodeIds: string[];
4907
+ order: string[];
4908
+ status?: MeshRefineBatchJobStatus;
4909
+ startedAt?: string;
4910
+ completedAt?: string;
4911
+ jobId?: string;
4912
+ interactionId?: string;
4913
+ coordinatorDaemonId?: string;
4914
+ }): MeshRefineBatchJobHandle {
4915
+ return {
4916
+ success: true,
4917
+ async: true,
4918
+ batch: true,
4919
+ status: args.status || 'accepted',
4920
+ jobId: args.jobId || `refine_batch_${createInteractionId()}`,
4921
+ interactionId: args.interactionId || createInteractionId(),
4922
+ meshId: args.meshId,
4923
+ batchLabel: `batch:${args.nodeIds.length} node${args.nodeIds.length === 1 ? '' : 's'}`,
4924
+ nodeIds: args.nodeIds,
4925
+ nodeCount: args.nodeIds.length,
4926
+ order: args.order,
4927
+ startedAt: args.startedAt || new Date().toISOString(),
4928
+ ...(args.completedAt ? { completedAt: args.completedAt } : {}),
4929
+ ...(args.coordinatorDaemonId ? { targetCoordinatorDaemonId: args.coordinatorDaemonId } : {}),
4930
+ eventDelivery: { pendingEvents: true, ledger: true },
4931
+ evidence: {
4932
+ pendingEventsCommand: 'get_pending_mesh_events',
4933
+ ledgerCommand: 'get_mesh_ledger_slice',
4934
+ taskHistoryKind: args.status === 'completed' ? 'task_completed' : args.status === 'failed' ? 'task_failed' : 'task_dispatched',
4935
+ },
4936
+ };
4937
+ }
4938
+
4939
+ /**
4940
+ * Emit a batch Refinery terminal/accepted event through the SAME pending-event +
4941
+ * forward mechanism single-node refine uses (queueRefineJobEvent), so the
4942
+ * coordinator's existing refine:accepted/completed/failed handling and message
4943
+ * renderer apply unchanged. The aggregate per-node results ride along in `result`.
4944
+ */
4945
+ private queueRefineBatchJobEvent(
4946
+ event: 'refine:accepted' | 'refine:completed' | 'refine:failed',
4947
+ handle: MeshRefineBatchJobHandle,
4948
+ result?: Record<string, unknown>,
4949
+ ): void {
4950
+ const metadataEvent = {
4951
+ source: 'refine_mesh_node_async_job',
4952
+ batch: true,
4953
+ jobId: handle.jobId,
4954
+ interactionId: handle.interactionId,
4955
+ meshId: handle.meshId,
4956
+ nodeId: handle.batchLabel,
4957
+ nodeIds: handle.nodeIds,
4958
+ workspace: undefined,
4959
+ status: handle.status,
4960
+ startedAt: handle.startedAt,
4961
+ completedAt: handle.completedAt,
4962
+ order: handle.order,
4963
+ ...(result ? { result } : {}),
4964
+ };
4965
+ const eventPayload = {
4966
+ event,
4967
+ meshId: handle.meshId,
4968
+ nodeLabel: handle.batchLabel,
4969
+ nodeId: handle.batchLabel,
4970
+ metadataEvent,
4971
+ queuedAt: Date.now(),
4972
+ ...(handle.targetCoordinatorDaemonId ? { targetCoordinatorDaemonId: handle.targetCoordinatorDaemonId } : {}),
4973
+ };
4974
+ if (typeof this.deps.instanceManager?.getByCategory === 'function') {
4975
+ const forwarded = handleMeshForwardEvent(
4976
+ { instanceManager: this.deps.instanceManager } as any,
4977
+ {
4978
+ event,
4979
+ meshId: handle.meshId,
4980
+ nodeId: handle.batchLabel,
4981
+ jobId: handle.jobId,
4982
+ interactionId: handle.interactionId,
4983
+ status: handle.status,
4984
+ startedAt: handle.startedAt,
4985
+ completedAt: handle.completedAt,
4986
+ ...(result ? { result } : {}),
4987
+ },
4988
+ );
4989
+ if (forwarded?.success === true) return;
4990
+ LOG.warn('Mesh', `[Refinery] Failed to forward async refine batch event ${event}: ${forwarded?.error || 'unknown error'}`);
4991
+ }
4992
+ queuePendingMeshCoordinatorEvent(eventPayload);
4993
+ }
4994
+
4995
+ private async appendRefineBatchJobLedger(
4996
+ kind: 'task_dispatched' | 'task_completed' | 'task_failed',
4997
+ handle: MeshRefineBatchJobHandle,
4998
+ result?: Record<string, unknown>,
4999
+ ): Promise<void> {
5000
+ try {
5001
+ const { appendLedgerEntry } = await import('../mesh/mesh-ledger.js');
5002
+ appendLedgerEntry(handle.meshId, {
5003
+ kind,
5004
+ nodeId: handle.batchLabel,
5005
+ payload: {
5006
+ source: 'refine_mesh_node_async_job',
5007
+ refineJob: {
5008
+ batch: true,
5009
+ jobId: handle.jobId,
5010
+ interactionId: handle.interactionId,
5011
+ status: handle.status,
5012
+ meshId: handle.meshId,
5013
+ nodeIds: handle.nodeIds,
5014
+ order: handle.order,
5015
+ targetCoordinatorDaemonId: handle.targetCoordinatorDaemonId,
5016
+ startedAt: handle.startedAt,
5017
+ completedAt: handle.completedAt,
5018
+ },
5019
+ async: true,
5020
+ batch: true,
5021
+ ...(result ? {
5022
+ success: result.success === true,
5023
+ result,
5024
+ } : {}),
5025
+ },
5026
+ });
5027
+ } catch (e: any) {
5028
+ LOG.warn('Mesh', `[Refinery] Failed to append async refine batch ledger entry: ${e?.message || e}`);
5029
+ }
5030
+ }
5031
+
5032
+ private async finishMeshRefineBatchJob(
5033
+ handle: MeshRefineBatchJobHandle,
5034
+ orderedNodes: any[],
5035
+ ordering: { order: string[]; rationale?: unknown },
5036
+ args: any,
5037
+ ): Promise<void> {
5038
+ const key = this.buildRefineBatchJobKey(handle.meshId);
5039
+ let result: Record<string, unknown>;
5040
+ try {
5041
+ result = await this.runMeshRefineBatchConvergence(handle.meshId, orderedNodes, ordering, args) as Record<string, unknown>;
5042
+ } catch (e: any) {
5043
+ result = { success: false, error: e?.message || String(e), batch: true };
5044
+ }
5045
+ const completedAt = new Date().toISOString();
5046
+
5047
+ // The batch as a whole "completed" only when every node converged (no blocked /
5048
+ // not_mergeable). A partial batch is reported as a terminal failure so the
5049
+ // coordinator inspects the per-node blockers rather than assuming a clean merge.
5050
+ const summary = (result.summary && typeof result.summary === 'object') ? result.summary as Record<string, number> : undefined;
5051
+ const allConverged = result.allConverged === true;
5052
+ const isTerminalSuccess = result.success === true && allConverged;
5053
+
5054
+ const nextStep = typeof result.nextStep === 'string' && result.nextStep
5055
+ ? result.nextStep
5056
+ : isTerminalSuccess
5057
+ ? 'All batched nodes converged onto base. Continue from the updated mesh state.'
5058
+ : '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.';
5059
+ const normalizedResult = {
5060
+ ...result,
5061
+ batch: true,
5062
+ nextStep,
5063
+ ...(summary ? {
5064
+ convergenceStatus: allConverged ? 'all_converged' : 'partial',
5065
+ } : {}),
5066
+ };
5067
+
5068
+ const terminalHandle = this.buildRefineBatchJobHandle({
5069
+ meshId: handle.meshId,
5070
+ nodeIds: handle.nodeIds,
5071
+ order: handle.order,
5072
+ status: isTerminalSuccess ? 'completed' : 'failed',
5073
+ startedAt: handle.startedAt,
5074
+ completedAt,
5075
+ jobId: handle.jobId,
5076
+ interactionId: handle.interactionId,
5077
+ coordinatorDaemonId: handle.targetCoordinatorDaemonId,
5078
+ });
5079
+ const terminal: MeshRefineBatchTerminalJob = { ...terminalHandle, result: normalizedResult };
5080
+ this.terminalRefineBatchJobs.set(key, terminal);
5081
+ this.runningRefineBatchJobs.delete(key);
5082
+ this.invalidateAggregateMeshStatus(handle.meshId);
5083
+ await this.appendRefineBatchJobLedger(isTerminalSuccess ? 'task_completed' : 'task_failed', terminalHandle, normalizedResult);
5084
+ this.queueRefineBatchJobEvent(isTerminalSuccess ? 'refine:completed' : 'refine:failed', terminalHandle, normalizedResult);
5085
+ }
5086
+
5087
+ /**
5088
+ * Async entry for the batch Refinery execute path. Mirrors startMeshRefineJob:
5089
+ * resolves the plan synchronously (so target/ordering errors and the dry-run shape
5090
+ * stay synchronous), then for execute=true registers an in-flight batch job, returns
5091
+ * {async:true, status:'accepted', batch:true, ...plan} immediately, and runs the
5092
+ * convergence loop in the background — emitting the same terminal refine event.
5093
+ * Idempotent: a batch already in flight for this mesh returns the running handle
5094
+ * with duplicate:true rather than spawning a second background job.
5095
+ */
5096
+ private async startMeshRefineBatchJob(meshId: string, requestedNodeIds: string[] | undefined, args: any): Promise<CommandRouterResult> {
5097
+ // Resolve the plan up-front. For dry-run this returns the synchronous plan; for
5098
+ // execute it returns the same plan shape but we hand convergence to the bg job.
5099
+ const plan = await this.batchRefineMeshNodes(meshId, requestedNodeIds, { ...args, dryRun: true, execute: false });
5100
+ const planRecord = plan as Record<string, unknown>;
5101
+ if (planRecord.success !== true) return plan;
5102
+
5103
+ // If the caller actually asked for a dry-run, return the plan as-is (sync).
5104
+ if (args?.dryRun === true && args?.execute !== true) return plan;
5105
+
5106
+ const order = Array.isArray(planRecord.order) ? (planRecord.order as unknown[]).filter((v): v is string => typeof v === 'string') : [];
5107
+ const nodeIds = order.slice();
5108
+ if (nodeIds.length === 0) {
5109
+ // No convergeable nodes — nothing to dispatch; return the empty plan synchronously.
5110
+ return { ...planRecord, success: true, batch: true, dryRun: false, async: false };
5111
+ }
5112
+
5113
+ const key = this.buildRefineBatchJobKey(meshId);
5114
+ const running = this.runningRefineBatchJobs.get(key);
5115
+ if (running) return { ...running, duplicate: true };
5116
+
5117
+ // Re-resolve the ordered node objects against current membership so the bg job
5118
+ // refines real nodes (the plan only carries ids). preferInline matches refine_mesh_node.
5119
+ const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
5120
+ const mesh = meshRecord?.mesh;
5121
+ const allNodes: any[] = Array.isArray(mesh?.nodes) ? mesh.nodes : [];
5122
+ const orderedNodes = nodeIds
5123
+ .map(id => allNodes.find(n => n.id === id || n.nodeId === id))
5124
+ .filter((n): n is any => !!n);
5125
+ if (orderedNodes.length === 0) {
5126
+ return { success: false, error: 'Batch nodes no longer resolvable in mesh', batch: true };
5127
+ }
5128
+ const ordering = {
5129
+ order,
5130
+ rationale: planRecord.orderingRationale,
5131
+ };
5132
+
5133
+ const coordinatorDaemonId = typeof args?.coordinatorDaemonId === 'string' && args.coordinatorDaemonId.trim()
5134
+ ? args.coordinatorDaemonId.trim()
5135
+ : (this.deps.statusInstanceId || undefined);
5136
+ const handle = this.buildRefineBatchJobHandle({ meshId, nodeIds, order, coordinatorDaemonId });
5137
+ this.runningRefineBatchJobs.set(key, handle);
5138
+ await this.appendRefineBatchJobLedger('task_dispatched', handle);
5139
+ this.queueRefineBatchJobEvent('refine:accepted', handle);
5140
+
5141
+ setImmediate(() => {
5142
+ void this.finishMeshRefineBatchJob(handle, orderedNodes, ordering, args);
5143
+ });
5144
+
5145
+ // Return the accepted handle plus the plan so the coordinator sees the target set.
5146
+ return {
5147
+ ...handle,
5148
+ order,
5149
+ orderingRationale: planRecord.orderingRationale,
5150
+ plan: planRecord.plan,
5151
+ 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.',
5152
+ };
5153
+ }
5154
+
4375
5155
  private async finishMeshRefineJob(handle: MeshRefineJobHandle, args: any): Promise<void> {
4376
5156
  const key = this.buildRefineJobKey(handle.meshId, handle.targetNodeId);
4377
5157
  let result: Record<string, unknown>;
@@ -5041,7 +5821,11 @@ export class DaemonCommandRouter {
5041
5821
  version: this.deps.statusVersion || 'unknown',
5042
5822
  profile: 'metadata',
5043
5823
  });
5044
- return { success: true, status: snapshot };
5824
+ // Surface the daemon's build stamp so coordinators (mesh_status)
5825
+ // can detect a running daemon that predates a just-merged fix and
5826
+ // is awaiting deploy/restart. Sibling of `status` to avoid
5827
+ // perturbing the dashboard status snapshot shape.
5828
+ return { success: true, status: snapshot, daemonBuild: getDaemonBuildInfo() };
5045
5829
  }
5046
5830
 
5047
5831
  case 'get_machine_runtime_stats': {
@@ -6144,6 +6928,7 @@ export class DaemonCommandRouter {
6144
6928
  ? args.submoduleIgnorePaths.filter((value: unknown): value is string => typeof value === 'string')
6145
6929
  : undefined;
6146
6930
  let nodeDaemonId: string | undefined;
6931
+ let allowAutoPublishSubmoduleMainCommits = false;
6147
6932
  if (meshId && nodeId) {
6148
6933
  // preferInline so fast-forward can resolve inline-cache-only clone worktree nodes.
6149
6934
  const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
@@ -6155,6 +6940,7 @@ export class DaemonCommandRouter {
6155
6940
  if (!submoduleIgnorePaths && Array.isArray(node?.policy?.submoduleIgnorePaths)) {
6156
6941
  submoduleIgnorePaths = node.policy.submoduleIgnorePaths.filter((value: unknown): value is string => typeof value === 'string');
6157
6942
  }
6943
+ allowAutoPublishSubmoduleMainCommits = mesh?.policy?.allowAutoPublishSubmoduleMainCommits === true;
6158
6944
  nodeDaemonId = typeof node?.daemonId === 'string' ? node.daemonId.trim() : undefined;
6159
6945
  }
6160
6946
  // If the target node belongs to a remote daemon, forward the command there.
@@ -6179,6 +6965,9 @@ export class DaemonCommandRouter {
6179
6965
  dryRun: args?.dryRun === true,
6180
6966
  updateSubmodules: args?.updateSubmodules === true,
6181
6967
  submoduleIgnorePaths,
6968
+ mode: args?.mode === 'push' ? 'push' : 'merge',
6969
+ pushSubmodules: args?.pushSubmodules === true,
6970
+ allowAutoPublishSubmoduleMainCommits,
6182
6971
  }) as Promise<unknown>);
6183
6972
  return result as CommandRouterResult;
6184
6973
  }
@@ -6196,7 +6985,14 @@ export class DaemonCommandRouter {
6196
6985
  const requestedNodeIds = Array.isArray(args?.nodeIds)
6197
6986
  ? (args.nodeIds as unknown[]).filter((v): v is string => typeof v === 'string' && v.trim().length > 0).map(v => v.trim())
6198
6987
  : undefined;
6199
- return this.batchRefineMeshNodes(meshId, requestedNodeIds, args);
6988
+ // Dry-run (plan-only) stays synchronous: it does no validation/merge and
6989
+ // returns instantly. Execute goes through the async batch job — immediate
6990
+ // {async:true, status:'accepted'} + background convergence + terminal event,
6991
+ // matching the single-node refine_mesh_node contract so long validation
6992
+ // suites can't time out the IPC and strand the coordinator.
6993
+ const isDryRun = args?.dryRun !== false && args?.execute !== true;
6994
+ if (isDryRun) return this.batchRefineMeshNodes(meshId, requestedNodeIds, args);
6995
+ return this.startMeshRefineBatchJob(meshId, requestedNodeIds, args);
6200
6996
  }
6201
6997
 
6202
6998
  case 'remove_mesh_node': {