@adhdev/daemon-core 0.9.82-rc.291 → 0.9.82-rc.293

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.
@@ -31,6 +31,8 @@ import {
31
31
  normalizeGitStatus as sharedNormalizeGitStatus,
32
32
  pickBestTransitGitStatus as sharedPickBestTransitGitStatus,
33
33
  summarizeGitShape as sharedSummarizeGitShape,
34
+ normalizeMeshNodeId,
35
+ meshNodeIdMatches,
34
36
  } from '@adhdev/mesh-shared';
35
37
  import { SessionRegistry } from '../sessions/registry.js';
36
38
  import { LOG } from '../logging/logger.js';
@@ -161,7 +163,10 @@ function summarizeRepoMeshStatusDebug(status: any): Record<string, unknown> {
161
163
  branchConvergenceSummary: status?.branchConvergenceSummary ?? status?.branch_convergence_summary ?? null,
162
164
  nodeCount: nodes.length,
163
165
  nodes: nodes.map((node: any) => ({
164
- nodeId: readStringValue(node?.nodeId, node?.id) ?? null,
166
+ // Status emits the id under `nodeId` (3-way input absorbed). The
167
+ // inline cache keeps `id` and `nodeId` equal, so this serialized form
168
+ // round-trips back through the cache without flipping shape.
169
+ nodeId: normalizeMeshNodeId(node) ?? null,
165
170
  daemonId: readStringValue(node?.daemonId, node?.daemon_id) ?? null,
166
171
  workspace: readStringValue(node?.workspace, node?.git?.workspace) ?? null,
167
172
  health: readStringValue(node?.health) ?? null,
@@ -476,7 +481,49 @@ function inlineMeshCarriesTransientNodeTruth(inlineMesh: any): boolean {
476
481
  }
477
482
 
478
483
  function readInlineMeshNodeId(node: any): string {
479
- return readStringValue(node?.id, node?.nodeId) || '';
484
+ // 3-way (id / nodeId / node_id) via the shared normalizer. The old 2-way
485
+ // `id ?? nodeId` dropped the SQLite `node_id` form, so an inline-cached node
486
+ // that arrived in that form failed to reconcile against its cached twin.
487
+ return normalizeMeshNodeId(node) ?? '';
488
+ }
489
+
490
+ // Boundary normalization: reconcile a node's identity so `id` and `nodeId` both
491
+ // carry the same canonical value (any incoming form — id / nodeId / node_id — is
492
+ // absorbed by normalizeMeshNodeId, and the SQLite `node_id` leak is dropped).
493
+ // See foldMeshNodeIdentityToCanonical for why both fields are kept equal rather
494
+ // than collapsing to one. The rewrite is shallow (other runtime fields are
495
+ // preserved); records that already agree are returned unchanged so
496
+ // identity-equality fast paths hold.
497
+ function foldMeshNodeIdentityToCanonical(node: any): any {
498
+ if (!node || typeof node !== 'object' || Array.isArray(node)) return node;
499
+ const canonical = normalizeMeshNodeId(node);
500
+ if (canonical === undefined) return node;
501
+ // Save-boundary identity folding, applied IN PLACE. We DUAL-WRITE both `id`
502
+ // and `nodeId` to the single canonical value (and drop the SQLite `node_id`
503
+ // leak), rather than collapsing to one field. Two halves of the system read
504
+ // different field names: the mesh_status serializer emits `nodeId`, while the
505
+ // worktree clone path and get_mesh membership consumers read `node.id`.
506
+ // Folding to ONE form would break whichever side reads the other. Keeping
507
+ // both fields equal makes every reader correct AND makes the
508
+ // snapshot→cache→reconcile→snapshot round-trip form-stable (no field ever
509
+ // flips, because both always agree). Mutating in place (not returning a new
510
+ // object) preserves the cached node-object identity that callers warming an
511
+ // inline mesh from an already-shared snapshot rely on.
512
+ if (node.id === canonical && node.nodeId === canonical && node.node_id === undefined) return node;
513
+ node.id = canonical;
514
+ node.nodeId = canonical;
515
+ if ('node_id' in node) delete node.node_id;
516
+ return node;
517
+ }
518
+
519
+ function normalizeInlineMeshNodeIdentity(inlineMesh: any): any {
520
+ if (!inlineMesh || typeof inlineMesh !== 'object' || Array.isArray(inlineMesh)) return inlineMesh;
521
+ if (!Array.isArray(inlineMesh.nodes) || inlineMesh.nodes.length === 0) return inlineMesh;
522
+ // Fold each node IN PLACE so the mesh object and its nodes array keep their
523
+ // identity — sanitizeInlineMesh and the cache-sharing callers depend on
524
+ // unchanged inputs returning the same reference.
525
+ for (const node of inlineMesh.nodes) foldMeshNodeIdentityToCanonical(node);
526
+ return inlineMesh;
480
527
  }
481
528
 
482
529
  function sanitizeInlineMesh(inlineMesh: any): any {
@@ -603,7 +650,7 @@ function deriveMeshNodeHealthFromGit(git: Record<string, unknown> | null | undef
603
650
  }
604
651
 
605
652
  function readMeshNodeLabel(status: Record<string, unknown>, node: any): string {
606
- return readStringValue(status.nodeId, node?.id, node?.nodeId) ?? 'unknown';
653
+ return readStringValue(status.nodeId, normalizeMeshNodeId(node)) ?? 'unknown';
607
654
  }
608
655
 
609
656
  function buildInlineMeshBranchConvergence(args: {
@@ -946,7 +993,7 @@ async function hydrateInlineMeshDirectTruth(args: {
946
993
  const unavailableNodeIds: string[] = [];
947
994
 
948
995
  for (const [nodeIndex, node] of nodes.entries()) {
949
- const nodeId = readStringValue(node?.id, node?.nodeId) || `node_${nodeIndex}`;
996
+ const nodeId = normalizeMeshNodeId(node) || `node_${nodeIndex}`;
950
997
  const workspace = readStringValue(node?.workspace);
951
998
  const daemonId = readStringValue(node?.daemonId);
952
999
  const isSelfNode = Boolean(
@@ -1122,7 +1169,7 @@ function buildHistoricalMeshSessions(args: {
1122
1169
  const liveWorkspaces = new Set<string>();
1123
1170
  const missingLocalWorktreeNodeIds = new Set<string>();
1124
1171
  for (const node of args.nodes || []) {
1125
- const nodeId = readStringValue(node?.id, node?.nodeId);
1172
+ const nodeId = normalizeMeshNodeId(node);
1126
1173
  const workspace = readStringValue(node?.workspace);
1127
1174
  if (nodeId) liveNodeIds.add(nodeId);
1128
1175
  if (workspace) liveWorkspaces.add(workspace);
@@ -1497,9 +1544,24 @@ function resolveRefineryAutoPublishSubmoduleMainCommits(mesh: any, workspace: st
1497
1544
  return { enabled: false };
1498
1545
  }
1499
1546
 
1500
- async function computeGitPatchId(cwd: string, fromRef: string, toRef: string): Promise<string> {
1547
+ async function computeGitPatchId(
1548
+ cwd: string,
1549
+ fromRef: string,
1550
+ toRef: string,
1551
+ excludePaths: string[] = [],
1552
+ ): Promise<string> {
1501
1553
  const { execFileSync } = await import('node:child_process');
1502
- const diff = execFileSync('git', ['diff', '--patch', '--full-index', fromRef, toRef], {
1554
+ // When excludePaths is non-empty we drop those paths from the diff via
1555
+ // `:(exclude)` pathspecs. This is used to omit gitlink paths that have
1556
+ // already been proven a safe fast-forward: their patch hunks legitimately
1557
+ // differ between the expected (mergeBase→branch) and actual (base→merged)
1558
+ // diffs because base may have advanced the same gitlink, so comparing them
1559
+ // would spuriously fail patch-equivalence even though the merge is sound.
1560
+ const diffArgs = ['diff', '--patch', '--full-index', fromRef, toRef];
1561
+ if (excludePaths.length > 0) {
1562
+ diffArgs.push('--', '.', ...excludePaths.map(path => `:(exclude)${path}`));
1563
+ }
1564
+ const diff = execFileSync('git', diffArgs, {
1503
1565
  cwd,
1504
1566
  encoding: 'utf8',
1505
1567
  maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES,
@@ -1514,7 +1576,7 @@ async function computeGitPatchId(cwd: string, fromRef: string, toRef: string): P
1514
1576
  return patchId.split(/\s+/)[0] || '';
1515
1577
  }
1516
1578
 
1517
- async function runMeshRefinePatchEquivalenceGate(
1579
+ export async function runMeshRefinePatchEquivalenceGate(
1518
1580
  repoRoot: string,
1519
1581
  baseHead: string,
1520
1582
  branchHead: string,
@@ -1582,8 +1644,25 @@ async function runMeshRefinePatchEquivalenceGate(
1582
1644
  gitlinkTrivialFastForward,
1583
1645
  };
1584
1646
  }
1585
- const expectedPatchId = await computeGitPatchId(repoRoot, mergeBase, branchHead);
1586
- const actualPatchId = await computeGitPatchId(repoRoot, baseHead, mergedTree);
1647
+ // Exclude *proven fast-forward* gitlink paths from BOTH patch-ids. When
1648
+ // base has advanced a submodule pointer (a sibling merged into main ahead
1649
+ // of us) the gitlink hunk's old-value differs between the expected diff
1650
+ // (mergeBase→branch, showing the full base→branch advance) and the actual
1651
+ // diff (base→merged, showing only the shorter advanced-base→branch
1652
+ // advance). That mismatch would spuriously fail equivalence even though
1653
+ // advancing the pointer to the branch side is a provably safe
1654
+ // fast-forward — this is the root cause of the diverged-base
1655
+ // patch_equivalence_failed misjudgment.
1656
+ //
1657
+ // We exclude ONLY gitlinks whose base-side commit is an ancestor of the
1658
+ // branch-side commit (a strict ff, both objects available locally). A
1659
+ // non-ff or ambiguous gitlink (a genuine submodule divergence, or objects
1660
+ // not fetched locally) is deliberately left in the diff so its differing
1661
+ // hunk still drives the comparison — this preserves the original behavior
1662
+ // and prevents a false pass on a real divergence.
1663
+ const ffGitlinkExcludePaths = collectFastForwardGitlinkPaths(repoRoot, baseHead, branchHead);
1664
+ const expectedPatchId = await computeGitPatchId(repoRoot, mergeBase, branchHead, ffGitlinkExcludePaths);
1665
+ const actualPatchId = await computeGitPatchId(repoRoot, baseHead, mergedTree, ffGitlinkExcludePaths);
1587
1666
  const equivalent = expectedPatchId === actualPatchId;
1588
1667
  return {
1589
1668
  status: equivalent ? 'passed' : 'failed',
@@ -1871,6 +1950,24 @@ function readChangedPathKinds(repoRoot: string, fromRef: string, toRef: string):
1871
1950
  }
1872
1951
  }
1873
1952
 
1953
+ /**
1954
+ * Return the changed gitlink paths between base and branch whose advance is a
1955
+ * strict fast-forward (the base-side commit is an ancestor of the branch-side
1956
+ * commit inside that submodule's repo). These are the paths whose patch-id hunk
1957
+ * may legitimately differ when base has advanced the same submodule, so they
1958
+ * are safe to exclude from the patch-equivalence comparison. A non-ff (genuinely
1959
+ * diverged) gitlink is deliberately excluded from this set so it still fails the
1960
+ * gate.
1961
+ */
1962
+ export function collectFastForwardGitlinkPaths(repoRoot: string, baseHead: string, branchHead: string): string[] {
1963
+ return readChangedGitlinkPaths(repoRoot, baseHead, branchHead).filter(path => {
1964
+ const baseCommit = readTreeObject(repoRoot, baseHead, path);
1965
+ const branchCommit = readTreeObject(repoRoot, branchHead, path);
1966
+ if (!baseCommit || !branchCommit) return false;
1967
+ return isSubmoduleFastForward(pathResolve(repoRoot, path), baseCommit, branchCommit);
1968
+ });
1969
+ }
1970
+
1874
1971
  /**
1875
1972
  * Decide whether a merge-tree submodule conflict between base and branch is a
1876
1973
  * trivial gitlink fast-forward (and nothing else).
@@ -1955,12 +2052,64 @@ export function evaluateGitlinkTrivialFastForward(
1955
2052
  }
1956
2053
 
1957
2054
  /**
1958
- * Synthesize the merge result for a trivial gitlink fast-forward: take
1959
- * `baseHead`'s tree and overlay each changed gitlink's branch-side commit.
1960
- * This mirrors what `git merge-tree` would have produced had it not bailed on
1961
- * the submodule recursion limitation. Returns the tree SHA, or undefined on
1962
- * failure. Caller must have already proven (via evaluateGitlinkTrivialFastForward)
1963
- * that every changed gitlink fast-forwards and no other path conflicts.
2055
+ * Build a tree identical to `commitish`'s tree except every gitlink in `paths`
2056
+ * is rewritten to `placeholderCommit`. Used to neutralize submodule pointers so
2057
+ * `git merge-tree` stops bailing on the "Recursive merging with submodules"
2058
+ * limitation and can 3-way merge the surrounding regular-file content. Returns
2059
+ * the tree SHA, or undefined on failure.
2060
+ */
2061
+ function buildTreeWithGitlinksEqualized(
2062
+ repoRoot: string,
2063
+ commitish: string,
2064
+ paths: string[],
2065
+ placeholderCommit: string,
2066
+ ): string | undefined {
2067
+ try {
2068
+ const tree = execFileSync('git', ['rev-parse', `${commitish}^{tree}`], {
2069
+ cwd: repoRoot, encoding: 'utf8', maxBuffer: 1024 * 1024,
2070
+ }).trim();
2071
+ if (!tree) return undefined;
2072
+ const updates = paths.map(path => `160000 commit ${placeholderCommit}\t${path}`).join('\n');
2073
+ if (!updates) return tree;
2074
+ const tmpIndex = pathJoin(resolveGitDir(repoRoot), `adhdev-refine-eq-${commitish.slice(0, 12)}.index`);
2075
+ const env = { ...process.env, GIT_INDEX_FILE: tmpIndex };
2076
+ try {
2077
+ execFileSync('git', ['read-tree', tree], { cwd: repoRoot, env, stdio: 'ignore' });
2078
+ execFileSync('git', ['update-index', '--index-info'], {
2079
+ cwd: repoRoot, env, input: `${updates}\n`, encoding: 'utf8',
2080
+ stdio: ['pipe', 'ignore', 'ignore'],
2081
+ });
2082
+ const newTree = execFileSync('git', ['write-tree'], { cwd: repoRoot, env, encoding: 'utf8' }).trim();
2083
+ return newTree || undefined;
2084
+ } finally {
2085
+ try { fs.rmSync(tmpIndex, { force: true }); } catch { /* ignore */ }
2086
+ }
2087
+ } catch {
2088
+ return undefined;
2089
+ }
2090
+ }
2091
+
2092
+ /**
2093
+ * Synthesize the merge result for a trivial gitlink fast-forward.
2094
+ *
2095
+ * `git merge-tree` bails whenever a gitlink differs across base/branch even
2096
+ * when the advance is a strict fast-forward, so we synthesize the result it
2097
+ * *would* have produced. Crucially, when the merge-base of base and branch is
2098
+ * NOT `baseHead` (i.e. base has diverged — a sibling was merged into main
2099
+ * ahead of us), `baseHead`'s tree does not contain our branch's own
2100
+ * non-gitlink changes. Simply overlaying gitlinks onto `baseHead`'s tree would
2101
+ * therefore drop those changes and break patch-equivalence.
2102
+ *
2103
+ * To handle the diverged case correctly we run a REAL 3-way merge of the
2104
+ * regular-file content (with the conflicting gitlinks temporarily equalized to
2105
+ * a common placeholder so merge-tree won't bail), then overlay each changed
2106
+ * gitlink's branch-side commit onto the merged result. This preserves both
2107
+ * sides' non-gitlink changes.
2108
+ *
2109
+ * Returns the tree SHA, or undefined on failure / genuine non-gitlink
2110
+ * conflict. Caller must have already proven (via
2111
+ * evaluateGitlinkTrivialFastForward) that every changed gitlink fast-forwards
2112
+ * and no other path conflicts.
1964
2113
  */
1965
2114
  function synthesizeTrivialFastForwardMergeTree(
1966
2115
  repoRoot: string,
@@ -1969,21 +2118,67 @@ function synthesizeTrivialFastForwardMergeTree(
1969
2118
  gitlinks: Array<{ path: string; branchCommit?: string }>,
1970
2119
  ): string | undefined {
1971
2120
  try {
1972
- const baseTree = execFileSync('git', ['rev-parse', `${baseHead}^{tree}`], {
1973
- cwd: repoRoot,
1974
- encoding: 'utf8',
1975
- maxBuffer: 1024 * 1024,
2121
+ const branchGitlinks = gitlinks.filter(entry => entry.branchCommit);
2122
+ const gitlinkPaths = branchGitlinks.map(entry => entry.path);
2123
+
2124
+ // Establish the regular-file content of the merge via a real 3-way merge
2125
+ // with the conflicting gitlinks neutralized. The placeholder is the
2126
+ // merge-base's value for a gitlink (or, failing that, any branch-side
2127
+ // commit) — it only needs to be identical across all three trees.
2128
+ const mergeBase = execFileSync('git', ['merge-base', baseHead, branchHead], {
2129
+ cwd: repoRoot, encoding: 'utf8', maxBuffer: 1024 * 1024,
1976
2130
  }).trim();
1977
- if (!baseTree) return undefined;
1978
- const updates = gitlinks
1979
- .filter(entry => entry.branchCommit)
2131
+
2132
+ let mergedContentTree: string | undefined;
2133
+ if (mergeBase && gitlinkPaths.length > 0) {
2134
+ const placeholder = readTreeObject(repoRoot, mergeBase, gitlinkPaths[0])
2135
+ || branchGitlinks[0].branchCommit!;
2136
+ const baseEqTree = buildTreeWithGitlinksEqualized(repoRoot, mergeBase, gitlinkPaths, placeholder);
2137
+ const oursEqTree = buildTreeWithGitlinksEqualized(repoRoot, baseHead, gitlinkPaths, placeholder);
2138
+ const theirsEqTree = buildTreeWithGitlinksEqualized(repoRoot, branchHead, gitlinkPaths, placeholder);
2139
+ if (baseEqTree && oursEqTree && theirsEqTree) {
2140
+ try {
2141
+ // merge-tree --write-tree needs commits (to derive a merge-base);
2142
+ // synthesize ours/theirs as children of a common base commit.
2143
+ const baseEqCommit = execFileSync('git', ['commit-tree', baseEqTree, '-m', 'refine-ff-base'], {
2144
+ cwd: repoRoot, encoding: 'utf8', maxBuffer: 1024 * 1024,
2145
+ }).trim();
2146
+ const oursEqCommit = execFileSync('git', ['commit-tree', oursEqTree, '-p', baseEqCommit, '-m', 'refine-ff-ours'], {
2147
+ cwd: repoRoot, encoding: 'utf8', maxBuffer: 1024 * 1024,
2148
+ }).trim();
2149
+ const theirsEqCommit = execFileSync('git', ['commit-tree', theirsEqTree, '-p', baseEqCommit, '-m', 'refine-ff-theirs'], {
2150
+ cwd: repoRoot, encoding: 'utf8', maxBuffer: 1024 * 1024,
2151
+ }).trim();
2152
+ const mergeOut = execFileSync('git', ['merge-tree', '--write-tree', oursEqCommit, theirsEqCommit], {
2153
+ cwd: repoRoot, encoding: 'utf8', maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES,
2154
+ }).trim();
2155
+ mergedContentTree = mergeOut.split(/\s+/)[0] || undefined;
2156
+ } catch {
2157
+ // A real conflict in the equalized merge means a genuine
2158
+ // non-gitlink content conflict the evaluator did not foresee
2159
+ // (or unavailable objects). Fall through to the simple synth.
2160
+ mergedContentTree = undefined;
2161
+ }
2162
+ }
2163
+ }
2164
+
2165
+ // Fallback: when there is no diverged base (merge-base === baseHead) the
2166
+ // regular-file content of the merge is exactly baseHead's tree, so just
2167
+ // overlay the gitlinks. Also used when the real merge could not run.
2168
+ const contentTree = mergedContentTree
2169
+ || execFileSync('git', ['rev-parse', `${baseHead}^{tree}`], {
2170
+ cwd: repoRoot, encoding: 'utf8', maxBuffer: 1024 * 1024,
2171
+ }).trim();
2172
+ if (!contentTree) return undefined;
2173
+
2174
+ const updates = branchGitlinks
1980
2175
  .map(entry => `160000 commit ${entry.branchCommit}\t${entry.path}`)
1981
2176
  .join('\n');
1982
- if (!updates) return baseTree;
2177
+ if (!updates) return contentTree;
1983
2178
  const tmpIndex = pathJoin(resolveGitDir(repoRoot), `adhdev-refine-ff-${baseHead.slice(0, 12)}-${branchHead.slice(0, 12)}.index`);
1984
2179
  const env = { ...process.env, GIT_INDEX_FILE: tmpIndex };
1985
2180
  try {
1986
- execFileSync('git', ['read-tree', baseTree], { cwd: repoRoot, env, stdio: 'ignore' });
2181
+ execFileSync('git', ['read-tree', contentTree], { cwd: repoRoot, env, stdio: 'ignore' });
1987
2182
  execFileSync('git', ['update-index', '--index-info'], {
1988
2183
  cwd: repoRoot,
1989
2184
  env,
@@ -2787,7 +2982,7 @@ function buildMemberJoinNode(mesh: any, args: any, fallbackDaemonId?: string): R
2787
2982
  : null;
2788
2983
  const configured = Array.isArray(mesh?.nodes)
2789
2984
  ? (requestedNodeId
2790
- ? mesh.nodes.find((node: any) => node?.id === requestedNodeId || node?.nodeId === requestedNodeId)
2985
+ ? mesh.nodes.find((node: any) => meshNodeIdMatches(node, requestedNodeId))
2791
2986
  : mesh.nodes[0])
2792
2987
  : null;
2793
2988
  const source = explicit || configured;
@@ -2859,7 +3054,7 @@ export class DaemonCommandRouter {
2859
3054
  }
2860
3055
 
2861
3056
  const nodes = snapshot.nodes.map((statusNode: any) => {
2862
- const nodeId = readStringValue(statusNode?.nodeId, statusNode?.id);
3057
+ const nodeId = normalizeMeshNodeId(statusNode);
2863
3058
  const inlineNode = nodeId ? inlineNodesById.get(nodeId) : undefined;
2864
3059
  if (!inlineNode) return statusNode;
2865
3060
  const liveGit = buildInlineMeshTransitGitStatus(inlineNode);
@@ -2987,7 +3182,10 @@ export class DaemonCommandRouter {
2987
3182
 
2988
3183
  private warmInlineMeshCache(meshId: string, inlineMesh?: unknown): any | undefined {
2989
3184
  if (!inlineMesh || typeof inlineMesh !== 'object') return undefined;
2990
- const sanitizedInlineMesh = sanitizeInlineMesh(inlineMesh as any);
3185
+ // Save-boundary node-id normalization: reconcile each node's identity so
3186
+ // `id` and `nodeId` agree before it enters the cache, so reconcile keys
3187
+ // and the round-trip through the status serializer stay form-stable.
3188
+ const sanitizedInlineMesh = sanitizeInlineMesh(normalizeInlineMeshNodeIdentity(inlineMesh as any));
2991
3189
  const cached = this.inlineMeshCache.get(meshId);
2992
3190
  if (cached) {
2993
3191
  const merged = reconcileInlineMeshCache(cached, sanitizedInlineMesh);
@@ -3009,7 +3207,7 @@ export class DaemonCommandRouter {
3009
3207
  if (cached) {
3010
3208
  if (inlineMeshCarriesTransientNodeTruth(inlineMesh)) {
3011
3209
  const merged = reconcileInlineMeshCache(cached, inlineMesh as any);
3012
- this.inlineMeshCache.set(meshId, sanitizeInlineMesh(merged));
3210
+ this.inlineMeshCache.set(meshId, sanitizeInlineMesh(normalizeInlineMeshNodeIdentity(merged)));
3013
3211
  return { mesh: merged, inline: true, source: 'inline_cache' };
3014
3212
  }
3015
3213
  return { mesh: cached, inline: true, source: 'inline_cache' };
@@ -3048,18 +3246,25 @@ export class DaemonCommandRouter {
3048
3246
  }
3049
3247
 
3050
3248
  private updateInlineMeshNode(meshId: string, mesh: any, node: any): void {
3051
- if (!mesh || !Array.isArray(mesh.nodes) || !node?.id) return;
3052
- const idx = mesh.nodes.findIndex((entry: any) => entry?.id === node.id || entry?.nodeId === node.id);
3249
+ const incomingId = normalizeMeshNodeId(node);
3250
+ if (!mesh || !Array.isArray(mesh.nodes) || !incomingId) return;
3251
+ const idx = mesh.nodes.findIndex((entry: any) => meshNodeIdMatches(entry, incomingId));
3053
3252
  if (idx >= 0) mesh.nodes[idx] = node;
3054
3253
  else mesh.nodes.push(node);
3055
3254
  mesh.updatedAt = new Date().toISOString();
3255
+ // Canonicalize node identity in place (id and nodeId kept equal): clone
3256
+ // nodes are created with `id` and re-inserted here (bypassing
3257
+ // warmInlineMeshCache), so this is a second save boundary. In-place
3258
+ // folding preserves the caller's `mesh` / nodes-array references, which
3259
+ // persistWorktreeSetupState reuses across subsequent calls.
3260
+ for (const entry of mesh.nodes) foldMeshNodeIdentityToCanonical(entry);
3056
3261
  this.inlineMeshCache.set(meshId, mesh);
3057
3262
  this.invalidateAggregateMeshStatus(meshId);
3058
3263
  }
3059
3264
 
3060
3265
  private removeInlineMeshNode(meshId: string, mesh: any, nodeId: string): boolean {
3061
3266
  if (!mesh || !Array.isArray(mesh.nodes)) return false;
3062
- const idx = mesh.nodes.findIndex((entry: any) => entry?.id === nodeId || entry?.nodeId === nodeId);
3267
+ const idx = mesh.nodes.findIndex((entry: any) => meshNodeIdMatches(entry, nodeId));
3063
3268
  if (idx === -1) return false;
3064
3269
  mesh.nodes.splice(idx, 1);
3065
3270
  mesh.updatedAt = new Date().toISOString();
@@ -3105,7 +3310,7 @@ export class DaemonCommandRouter {
3105
3310
 
3106
3311
  const worktreeExists = fs.existsSync(workspace);
3107
3312
  const sourceNode = args.node?.clonedFromNodeId
3108
- ? args.mesh?.nodes?.find((n: any) => n.id === args.node.clonedFromNodeId || n.nodeId === args.node.clonedFromNodeId)
3313
+ ? args.mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, args.node.clonedFromNodeId))
3109
3314
  : args.mesh?.nodes?.find((n: any) => !n.isLocalWorktree);
3110
3315
  const repoRoot = typeof sourceNode?.repoRoot === 'string' && sourceNode.repoRoot.trim()
3111
3316
  ? sourceNode.repoRoot.trim()
@@ -3865,7 +4070,7 @@ export class DaemonCommandRouter {
3865
4070
  // preferInline: same as startMeshRefineJob — inline-cache-only clone nodes must resolve.
3866
4071
  const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
3867
4072
  const mesh = meshRecord?.mesh;
3868
- const node = mesh?.nodes?.find((n: any) => n.id === nodeId || n.nodeId === nodeId);
4073
+ const node = mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, nodeId));
3869
4074
  if (!node) return { success: false, error: `Node '${nodeId}' not found in mesh`, refineStages };
3870
4075
 
3871
4076
  if (!node.isLocalWorktree || !node.workspace) {
@@ -3873,7 +4078,7 @@ export class DaemonCommandRouter {
3873
4078
  }
3874
4079
 
3875
4080
  const sourceNode = node.clonedFromNodeId
3876
- ? mesh?.nodes.find((n: any) => n.id === node.clonedFromNodeId || n.nodeId === node.clonedFromNodeId)
4081
+ ? mesh?.nodes.find((n: any) => meshNodeIdMatches(n, node.clonedFromNodeId))
3877
4082
  : mesh?.nodes.find((n: any) => !n.isLocalWorktree);
3878
4083
  const repoRoot = sourceNode?.repoRoot || sourceNode?.workspace;
3879
4084
  if (!repoRoot) return { success: false, error: 'Source node repoRoot not found', refineStages };
@@ -4586,7 +4791,7 @@ export class DaemonCommandRouter {
4586
4791
  const missing: string[] = [];
4587
4792
  const nonWorktree: string[] = [];
4588
4793
  for (const nodeId of requestedNodeIds) {
4589
- const node = allNodes.find(n => n.id === nodeId || n.nodeId === nodeId);
4794
+ const node = allNodes.find(n => meshNodeIdMatches(n, nodeId));
4590
4795
  if (!node) { missing.push(nodeId); continue; }
4591
4796
  if (!isConvergeable(node)) { nonWorktree.push(nodeId); continue; }
4592
4797
  targetNodes.push(node);
@@ -4615,7 +4820,7 @@ export class DaemonCommandRouter {
4615
4820
  // Resolve the base repo root and a base ref to analyze change areas against.
4616
4821
  const resolveRepoRootFor = (node: any): string | undefined => {
4617
4822
  const sourceNode = node.clonedFromNodeId
4618
- ? allNodes.find(n => n.id === node.clonedFromNodeId || n.nodeId === node.clonedFromNodeId)
4823
+ ? allNodes.find(n => meshNodeIdMatches(n, node.clonedFromNodeId))
4619
4824
  : allNodes.find(n => !n.isLocalWorktree);
4620
4825
  return sourceNode?.repoRoot || sourceNode?.workspace;
4621
4826
  };
@@ -4701,7 +4906,7 @@ export class DaemonCommandRouter {
4701
4906
 
4702
4907
  const ordering = orderMeshRefineBatchNodes(changeAreas);
4703
4908
  const orderedNodes = ordering.order
4704
- .map(nodeId => targetNodes.find(n => n.id === nodeId || n.nodeId === nodeId))
4909
+ .map(nodeId => targetNodes.find(n => meshNodeIdMatches(n, nodeId)))
4705
4910
  .filter((n): n is any => !!n);
4706
4911
 
4707
4912
  const dryRun = args?.dryRun !== false && args?.execute !== true;
@@ -5039,7 +5244,7 @@ export class DaemonCommandRouter {
5039
5244
  const mesh = meshRecord?.mesh;
5040
5245
  const allNodes: any[] = Array.isArray(mesh?.nodes) ? mesh.nodes : [];
5041
5246
  const orderedNodes = nodeIds
5042
- .map(id => allNodes.find(n => n.id === id || n.nodeId === id))
5247
+ .map(id => allNodes.find(n => meshNodeIdMatches(n, id)))
5043
5248
  .filter((n): n is any => !!n);
5044
5249
  if (orderedNodes.length === 0) {
5045
5250
  return { success: false, error: 'Batch nodes no longer resolvable in mesh', batch: true };
@@ -5198,7 +5403,7 @@ export class DaemonCommandRouter {
5198
5403
  // config-first and misses nodes that only live in the inline cache.
5199
5404
  const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
5200
5405
  const mesh = meshRecord?.mesh;
5201
- const node = mesh?.nodes?.find((n: any) => n.id === nodeId || n.nodeId === nodeId);
5406
+ const node = mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, nodeId));
5202
5407
  if (!node) return { success: false, error: `Node '${nodeId}' not found in mesh` };
5203
5408
  if (!node.isLocalWorktree || !node.workspace) return { success: false, error: `Refinery requires a local worktree node` };
5204
5409
 
@@ -5282,7 +5487,7 @@ export class DaemonCommandRouter {
5282
5487
  const { getMesh } = await import('../config/mesh-config.js');
5283
5488
  const meshObj = getMesh(meshId) ?? this.getCachedInlineMesh(meshId);
5284
5489
  const nodeObj = Array.isArray(meshObj?.nodes)
5285
- ? meshObj.nodes.find((n: any) => n.id === meshNodeId || n.nodeId === meshNodeId)
5490
+ ? meshObj.nodes.find((n: any) => meshNodeIdMatches(n, meshNodeId))
5286
5491
  : undefined;
5287
5492
  const bootstrapStatus = readStringValue(nodeObj?.worktreeBootstrap?.status);
5288
5493
  if (bootstrapStatus === 'running') {
@@ -5339,7 +5544,7 @@ export class DaemonCommandRouter {
5339
5544
  const { getMesh } = await import('../config/mesh-config.js');
5340
5545
  const meshObj = getMesh(dispatchMeshId) ?? this.getCachedInlineMesh(dispatchMeshId);
5341
5546
  const nodeObj = Array.isArray(meshObj?.nodes)
5342
- ? meshObj.nodes.find((n: any) => n.id === dispatchNodeId || n.nodeId === dispatchNodeId)
5547
+ ? meshObj.nodes.find((n: any) => meshNodeIdMatches(n, dispatchNodeId))
5343
5548
  : undefined;
5344
5549
  const bootstrapStatus = readStringValue(nodeObj?.worktreeBootstrap?.status);
5345
5550
  if (bootstrapStatus === 'running') {
@@ -6778,7 +6983,7 @@ export class DaemonCommandRouter {
6778
6983
  const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
6779
6984
  const mesh = meshRecord?.mesh;
6780
6985
  if (!mesh) return { success: false, error: 'Mesh not found' };
6781
- const node = mesh?.nodes?.find((n: any) => n.id === nodeId || n.nodeId === nodeId);
6986
+ const node = mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, nodeId));
6782
6987
  if (!node) return { success: false, error: `Node '${nodeId}' not found in mesh` };
6783
6988
  const mode = this.normalizeMeshSessionCleanupMode(args?.mode ?? mesh?.policy?.sessionCleanupOnNodeRemove);
6784
6989
  const sessionIds = Array.isArray(args?.sessionIds)
@@ -6844,7 +7049,7 @@ export class DaemonCommandRouter {
6844
7049
  // preferInline: plan is the dry-run sibling of refine — clone nodes must resolve.
6845
7050
  const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
6846
7051
  const mesh = meshRecord?.mesh;
6847
- const node = mesh?.nodes?.find((n: any) => n.id === nodeId || n.nodeId === nodeId);
7052
+ const node = mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, nodeId));
6848
7053
  if (!node?.workspace) return { success: false, error: `Node '${nodeId}' workspace not found` };
6849
7054
  return {
6850
7055
  success: true,
@@ -6870,7 +7075,7 @@ export class DaemonCommandRouter {
6870
7075
  // preferInline so fast-forward can resolve inline-cache-only clone worktree nodes.
6871
7076
  const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
6872
7077
  const mesh = meshRecord?.mesh;
6873
- const node = mesh?.nodes?.find((n: any) => n.id === nodeId || n.nodeId === nodeId);
7078
+ const node = mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, nodeId));
6874
7079
  if (!workspace) {
6875
7080
  workspace = typeof node?.workspace === 'string' ? node.workspace.trim() : '';
6876
7081
  }
@@ -6923,7 +7128,7 @@ export class DaemonCommandRouter {
6923
7128
  // preferInline: plan is the dry-run sibling of refine — clone nodes must resolve.
6924
7129
  const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
6925
7130
  const mesh = meshRecord?.mesh;
6926
- const node = mesh?.nodes?.find((n: any) => n.id === nodeId || n.nodeId === nodeId);
7131
+ const node = mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, nodeId));
6927
7132
  if (!node?.workspace) return { success: false, error: `Node '${nodeId}' workspace not found` };
6928
7133
  return {
6929
7134
  success: true,
@@ -6963,7 +7168,7 @@ export class DaemonCommandRouter {
6963
7168
  // preferInline so removal can resolve inline-cache-only clone worktree nodes.
6964
7169
  const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
6965
7170
  const mesh = meshRecord?.mesh;
6966
- const node = mesh?.nodes?.find((n: any) => n.id === nodeId || n.nodeId === nodeId);
7171
+ const node = mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, nodeId));
6967
7172
 
6968
7173
  // Guard: refuse to remove the coordinator's OWN local base node
6969
7174
  // (same machine, NOT a worktree). Removing it breaks live mesh
@@ -7115,7 +7320,7 @@ export class DaemonCommandRouter {
7115
7320
  const mesh = meshRecord?.mesh;
7116
7321
  if (!mesh) return { success: false, error: 'Mesh not found' };
7117
7322
 
7118
- const sourceNode = mesh.nodes?.find((n: any) => n.id === sourceNodeId || n.nodeId === sourceNodeId);
7323
+ const sourceNode = mesh.nodes?.find((n: any) => meshNodeIdMatches(n, sourceNodeId));
7119
7324
  if (!sourceNode) return { success: false, error: `Source node '${sourceNodeId}' not found in mesh` };
7120
7325
 
7121
7326
  // Forward to the source node's daemon if it's on a different machine.
@@ -7390,7 +7595,7 @@ export class DaemonCommandRouter {
7390
7595
  const mesh = meshRecord?.mesh;
7391
7596
  if (!mesh) return { success: false, error: 'Mesh not found' };
7392
7597
 
7393
- const node = mesh.nodes?.find((n: any) => n.id === nodeId || n.nodeId === nodeId);
7598
+ const node = mesh.nodes?.find((n: any) => meshNodeIdMatches(n, nodeId));
7394
7599
  if (!node) return { success: false, error: `Node '${nodeId}' not found in mesh` };
7395
7600
  if (!node.isLocalWorktree) return { success: false, error: 'Node is not a local worktree node' };
7396
7601
 
@@ -7566,14 +7771,14 @@ export class DaemonCommandRouter {
7566
7771
  const liveMeshSessions = partitionSessionHostRecords(Array.isArray(sessionHostRecords) ? sessionHostRecords : []).liveRuntimes;
7567
7772
  const workspace = readLiveMeshNodeWorkspace({
7568
7773
  meshId,
7569
- nodeId: String(coordinatorNode.id || coordinatorNode.nodeId || preferredCoordinatorNodeId || ''),
7774
+ nodeId: String(normalizeMeshNodeId(coordinatorNode) || preferredCoordinatorNodeId || ''),
7570
7775
  liveSessionRecords: liveMeshSessions,
7571
7776
  allowCoordinatorSession: true,
7572
7777
  }) || (typeof coordinatorNode.workspace === 'string' ? coordinatorNode.workspace.trim() : '');
7573
7778
  if (!workspace) return { success: false, error: 'Coordinator node workspace required', meshId, cliType };
7574
7779
  if (!cliType) {
7575
7780
  const resolved = await resolveProviderTypeFromPriority({
7576
- nodeId: String(coordinatorNode.id || coordinatorNode.nodeId || preferredCoordinatorNodeId || 'coordinator'),
7781
+ nodeId: String(normalizeMeshNodeId(coordinatorNode) || preferredCoordinatorNodeId || 'coordinator'),
7577
7782
  providerPriority: readProviderPriorityFromPolicy(coordinatorNode.policy),
7578
7783
  providerLoader: this.deps.providerLoader,
7579
7784
  onStatusChange: this.deps.onStatusChange,
@@ -8062,6 +8267,13 @@ export class DaemonCommandRouter {
8062
8267
  const meshHost = resolveMeshHostStatus(mesh);
8063
8268
 
8064
8269
  const refreshRequested = args?.refresh === true || args?.forceRefresh === true;
8270
+ // Compact (default) elides each mission's full goal text from the
8271
+ // payload — coordinators polling node health don't need every
8272
+ // mission's multi-hundred-char goal repeated. verbose=true (or the
8273
+ // explicit compact=false) restores full goals. Verbose bypasses the
8274
+ // shared (compact) aggregate cache so a verbose call never poisons
8275
+ // the compact cache and vice versa.
8276
+ const verboseMissions = args?.verbose === true || args?.compact === false;
8065
8277
  // See (B3) below: scope the peek to this daemon when the
8066
8278
  // caller doesn't tell us, otherwise scoped events look
8067
8279
  // missing and we falsely return a stale cache.
@@ -8070,7 +8282,7 @@ export class DaemonCommandRouter {
8070
8282
  : (this.deps.statusInstanceId || undefined);
8071
8283
  const pendingCoordinatorEventCount = getPendingMeshCoordinatorEvents(meshId, peekScope).length;
8072
8284
  const hadAggregateCache = this.aggregateMeshStatusCache.has(meshId);
8073
- if (!refreshRequested && pendingCoordinatorEventCount === 0) {
8285
+ if (!refreshRequested && !verboseMissions && pendingCoordinatorEventCount === 0) {
8074
8286
  const cachedStatus = this.getCachedAggregateMeshStatus(meshId, mesh, { requireDirectPeerTruth: args?.requireDirectPeerTruth === true });
8075
8287
  if (cachedStatus) {
8076
8288
  logRepoMeshStatusDebug('return_cached', {
@@ -8133,7 +8345,7 @@ export class DaemonCommandRouter {
8133
8345
  const unavailableNodesAreOnlyRemovedWorktrees = unavailableDirectTruthNodeIds.size > 0
8134
8346
  && Array.isArray(mesh.nodes)
8135
8347
  && mesh.nodes
8136
- .filter((node: any) => unavailableDirectTruthNodeIds.has(String(node.id || node.nodeId || '')))
8348
+ .filter((node: any) => unavailableDirectTruthNodeIds.has(normalizeMeshNodeId(node) ?? ''))
8137
8349
  .every((node: any) => node?.isLocalWorktree === true);
8138
8350
  const directTruthSatisfied = !requireDirectPeerTruth
8139
8351
  || (effectiveDirectTruth.directEvidenceCount > 0 && (effectiveDirectTruth.unavailableNodeIds.length === 0 || unavailableNodesAreOnlyRemovedWorktrees));
@@ -8174,8 +8386,7 @@ export class DaemonCommandRouter {
8174
8386
  const coordinatorHostname = osHostname();
8175
8387
  const selectedCoordinatorNodeId = readStringValue(
8176
8388
  mesh.coordinator?.preferredNodeId,
8177
- (mesh.nodes?.[0] as any)?.id,
8178
- (mesh.nodes?.[0] as any)?.nodeId,
8389
+ normalizeMeshNodeId(mesh.nodes?.[0] as any),
8179
8390
  );
8180
8391
  const inlineCoordinatorNodeId = meshRecord?.inline && Array.isArray(mesh.nodes)
8181
8392
  ? selectedCoordinatorNodeId
@@ -8183,7 +8394,7 @@ export class DaemonCommandRouter {
8183
8394
  const refreshedAt = new Date().toISOString();
8184
8395
  const nodeStatuses = [];
8185
8396
  for (const [nodeIndex, node] of (mesh.nodes || []).entries()) {
8186
- const nodeId = String(node.id || node.nodeId || '');
8397
+ const nodeId = normalizeMeshNodeId(node) ?? '';
8187
8398
  const daemonId = readStringValue(node.daemonId);
8188
8399
  const nodeMachineId = readMeshNodeMachineId(node as Record<string, unknown>);
8189
8400
  const nodeHostname = readMeshNodeHostname(node as Record<string, unknown>);
@@ -8445,7 +8656,7 @@ export class DaemonCommandRouter {
8445
8656
  liveSessionRecords: liveMeshSessions,
8446
8657
  });
8447
8658
  const { getMeshStatusMissionSummaries } = await import('../mesh/mesh-missions.js');
8448
- const missions = getMeshStatusMissionSummaries(meshId);
8659
+ const missions = getMeshStatusMissionSummaries(meshId, { verbose: verboseMissions });
8449
8660
  const statusResult = {
8450
8661
  success: true,
8451
8662
  meshId: mesh.id,
@@ -8505,7 +8716,12 @@ export class DaemonCommandRouter {
8505
8716
  })),
8506
8717
  };
8507
8718
  const { pendingCoordinatorEvents: _pendingCoordinatorEvents, unroutableDeliveries: _unroutableDeliveries, ...cacheableStatusResult } = statusResult as any;
8508
- const rememberedStatus = this.rememberAggregateMeshStatus(meshId, cacheableStatusResult, refreshReason);
8719
+ // Verbose carries full mission goals; never store it in the shared
8720
+ // (compact) aggregate cache or a later compact poll would return the
8721
+ // heavy goals from cache. Return it without caching.
8722
+ const rememberedStatus = verboseMissions
8723
+ ? cacheableStatusResult
8724
+ : this.rememberAggregateMeshStatus(meshId, cacheableStatusResult, refreshReason);
8509
8725
  const returnedStatus = {
8510
8726
  ...rememberedStatus,
8511
8727
  ...(pendingCoordinatorEvents.length > 0 ? { pendingCoordinatorEvents } : {}),