@adhdev/daemon-core 0.9.82-rc.376 → 0.9.82-rc.378

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.
Files changed (48) hide show
  1. package/dist/commands/chat-commands-debug-bundle.d.ts +14 -0
  2. package/dist/commands/chat-commands-read.d.ts +7 -0
  3. package/dist/commands/chat-commands-scope.d.ts +39 -0
  4. package/dist/commands/chat-commands-shared.d.ts +33 -0
  5. package/dist/commands/chat-commands-write.d.ts +14 -0
  6. package/dist/commands/chat-commands.d.ts +9 -49
  7. package/dist/commands/router.d.ts +3 -470
  8. package/dist/index.js +3166 -3115
  9. package/dist/index.js.map +1 -1
  10. package/dist/index.mjs +3561 -3510
  11. package/dist/index.mjs.map +1 -1
  12. package/dist/mesh/mesh-coordinator-config.d.ts +21 -0
  13. package/dist/mesh/mesh-event-classify.d.ts +5 -0
  14. package/dist/mesh/mesh-event-forwarding.d.ts +18 -0
  15. package/dist/mesh/mesh-events-coordinator.d.ts +4 -92
  16. package/dist/mesh/mesh-events-utils.d.ts +3 -0
  17. package/dist/mesh/mesh-ledger-reconciliation.d.ts +0 -1
  18. package/dist/mesh/mesh-node-identity.d.ts +289 -0
  19. package/dist/mesh/mesh-queue-assignment.d.ts +86 -0
  20. package/dist/mesh/mesh-refine-gates.d.ts +428 -0
  21. package/dist/mesh/mesh-runtime-store.d.ts +0 -3
  22. package/dist/providers/native-history/constants.d.ts +12 -0
  23. package/dist/runtime-defaults.d.ts +2 -0
  24. package/package.json +2 -2
  25. package/src/commands/chat-commands-debug-bundle.ts +398 -0
  26. package/src/commands/chat-commands-read.ts +2327 -0
  27. package/src/commands/chat-commands-scope.ts +54 -0
  28. package/src/commands/chat-commands-shared.ts +114 -0
  29. package/src/commands/chat-commands-write.ts +880 -0
  30. package/src/commands/chat-commands.ts +20 -3697
  31. package/src/commands/router.ts +59 -3631
  32. package/src/mesh/mesh-coordinator-config.ts +97 -0
  33. package/src/mesh/mesh-event-classify.ts +51 -0
  34. package/src/mesh/mesh-event-forwarding.ts +1502 -0
  35. package/src/mesh/mesh-events-coordinator.ts +30 -2993
  36. package/src/mesh/mesh-events-pending.ts +1 -10
  37. package/src/mesh/mesh-events-stale.ts +3 -14
  38. package/src/mesh/mesh-events-utils.ts +52 -14
  39. package/src/mesh/mesh-ledger-reconciliation.ts +0 -2
  40. package/src/mesh/mesh-node-identity.ts +1887 -0
  41. package/src/mesh/mesh-queue-assignment.ts +1457 -0
  42. package/src/mesh/mesh-refine-gates.ts +1652 -0
  43. package/src/mesh/mesh-runtime-store.ts +0 -37
  44. package/src/providers/cli-provider-instance.ts +40 -1
  45. package/src/providers/native-history/constants.ts +19 -0
  46. package/src/providers/native-history/dispatcher.ts +2 -3
  47. package/src/providers/spec/native-history-executor.ts +1 -9
  48. package/src/runtime-defaults.ts +39 -0
@@ -9,8 +9,8 @@
9
9
  * 3. Everything else → delegated to commandHandler.handle()
10
10
  */
11
11
 
12
+
12
13
  import { DaemonCdpManager } from '../cdp/manager.js';
13
- import { registerExtensionProviders } from '../cdp/setup.js';
14
14
  import { DaemonCommandHandler } from './handler.js';
15
15
  import { lowFamilyRegistry } from './low-family/index.js';
16
16
  import { medFamilyRegistry } from './med-family/index.js';
@@ -19,3653 +19,81 @@ import type { MedFamilyContext } from './med-family/index.js';
19
19
  import { highFamilyRegistry } from './high-family/index.js';
20
20
  import type { HighFamilyContext } from './high-family/index.js';
21
21
  import { DaemonCliManager } from './cli-manager.js';
22
- import { supportsExplicitSessionResume } from './cli-manager.js';
23
- import type { HostedCliRuntimeDescriptor } from './cli-manager.js';
24
22
  import type { ProviderLoader } from '../providers/provider-loader.js';
25
23
  import type { ProviderInstanceManager } from '../providers/provider-instance-manager.js';
26
- import { launchWithCdp, killIdeProcess, isIdeRunning } from '../launch.js';
27
- import { loadConfig, saveConfig } from '../config/config.js';
28
- import { loadState, saveState } from '../config/state-store.js';
29
- import { resolveIdeLaunchWorkspace } from '../config/workspaces.js';
30
- import { appendRecentActivity, getRecentActivity } from '../config/recent-activity.js';
31
- import { getSavedProviderSessions } from '../config/saved-sessions.js';
32
- import { listProviderHistorySessions } from '../config/chat-history.js';
33
- import { detectIDEs } from '../detection/ide-detector.js';
34
- import { detectCLI, detectCLIs } from '../detection/cli-detector.js';
35
- import { getGitRepoStatus } from '../git/git-status.js';
36
- import {
37
- CHANGE_IMPACT_CONFIG_LOCATIONS,
38
- CHANGE_IMPACT_CONFIG_SCHEMA,
39
- loadChangeImpactConfig,
40
- suggestChangeImpactConfig,
41
- validateChangeImpactConfig,
42
- } from '../git/change-impact-config.js';
43
- import {
44
- normalizeGitStatus as sharedNormalizeGitStatus,
45
- pickBestTransitGitStatus as sharedPickBestTransitGitStatus,
46
- summarizeGitShape as sharedSummarizeGitShape,
47
- normalizeMeshNodeId,
48
- meshNodeIdMatches,
49
- daemonIdsEquivalent,
50
- meshWorkspacesEquivalent,
51
- } from '@adhdev/mesh-shared';
24
+ import { killIdeProcess, isIdeRunning } from '../launch.js';
25
+ import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent } from '@adhdev/mesh-shared';
52
26
  import { SessionRegistry } from '../sessions/registry.js';
53
27
  import { LOG } from '../logging/logger.js';
54
28
  import { logCommand } from '../logging/command-log.js';
55
29
  import type { CommandLogEntry } from '../logging/command-log.js';
56
- import * as yaml from 'js-yaml';
57
30
  import { createInteractionId, recordDebugTrace } from '../logging/debug-trace.js';
58
31
  import { getSessionHostSurfaceKind } from '../session-host/runtime-surface.js';
59
32
  import { handleMeshForwardEvent, queuePendingMeshCoordinatorEvent } from '../mesh/mesh-events.js';
60
- import { buildMeshWorkerRelayStamp } from '../mesh/mesh-events-utils.js';
61
33
  import { buildMeshHostRequiredFailure, resolveMeshHostStatus } from '../mesh/mesh-host-ownership.js';
62
- import { fastForwardMeshNode } from '../mesh/mesh-fast-forward.js';
63
34
  import { analyzeMeshRefineNodeChangeArea, orderMeshRefineBatchNodes } from '../mesh/mesh-refine-batch.js';
64
- import {
65
- MESH_REFINE_CONFIG_LOCATIONS,
66
- MESH_REFINE_CONFIG_SCHEMA,
67
- loadMeshRefineConfig,
68
- resolveMeshRefineValidationPlan,
69
- suggestMeshRefineConfig,
70
- validateMeshRefineConfig,
71
- type MeshRefineValidationCommandPlan,
72
- } from '../mesh/refine-config.js';
73
- import {
74
- MESH_WORKTREE_BOOTSTRAP_CONFIG_LOCATIONS,
75
- MESH_WORKTREE_BOOTSTRAP_CONFIG_SCHEMA,
76
- evaluateWorktreeBootstrapState,
77
- loadMeshWorktreeBootstrapConfig,
78
- runMeshWorktreeBootstrap,
79
- type WorktreeBootstrapState,
80
- } from '../mesh/worktree-bootstrap-config.js';
81
- import { runMeshInit } from '../mesh/mesh-init.js';
35
+ import type { WorktreeBootstrapState } from '../mesh/worktree-bootstrap-config.js';
82
36
  import { getMeshQueueRevision } from '../mesh/mesh-work-queue.js';
83
- import { awaitWithWarmupDeadline, resolveWarmupDeadlineOpts } from '../mesh/mesh-warmup-deadline.js';
84
37
  import type { RepoMeshSessionCleanupMode } from '../repo-mesh-types.js';
85
38
  import { DEFAULT_MESH_POLICY } from '../repo-mesh-types.js';
86
- import { homedir, hostname as osHostname } from 'os';
87
- import { basename as pathBasename, join as pathJoin, resolve as pathResolve } from 'path';
39
+ import { resolve as pathResolve } from 'path';
88
40
  import * as fs from 'fs';
89
41
  import { execFileSync } from 'node:child_process';
90
- import { workingDirBasename } from '../providers/working-dir.js';
91
- import { resolveWin32Executable } from '../cli-adapters/resolve-executable.js';
92
-
93
- export function readProviderPriorityFromPolicy(policy: unknown): string[] {
94
- const record = policy && typeof policy === 'object' && !Array.isArray(policy)
95
- ? policy as Record<string, unknown>
96
- : {};
97
- const raw = record.providerPriority;
98
- if (!Array.isArray(raw)) return [];
99
- const seen = new Set<string>();
100
- return raw
101
- .map(type => typeof type === 'string' ? type.trim() : '')
102
- .filter(Boolean)
103
- .filter(type => {
104
- if (seen.has(type)) return false;
105
- seen.add(type);
106
- return true;
107
- });
108
- }
109
-
110
- /**
111
- * Normalize a providerRoles array (RepoMeshNodePolicy.providerRoles) from raw
112
- * tool args. Each entry binds a providerType to an optional `maxParallel` cap.
113
- * Entries without a usable providerType are dropped; the last entry wins on
114
- * duplicate providerType. Returns [] when no valid entries — callers then omit
115
- * the field entirely (full backward compat). Routing is governed by required_tags;
116
- * any legacy `role` field on the input is ignored.
117
- */
118
- export function normalizeProviderRoles(value: unknown): Array<{ providerType: string; maxParallel?: number }> {
119
- if (!Array.isArray(value)) return [];
120
- const byType = new Map<string, { providerType: string; maxParallel?: number }>();
121
- for (const raw of value) {
122
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue;
123
- const rec = raw as Record<string, unknown>;
124
- const providerType = typeof rec.providerType === 'string' ? rec.providerType.trim() : '';
125
- if (!providerType) continue;
126
- const entry: { providerType: string; maxParallel?: number } = { providerType };
127
- const maxParallel = Number(rec.maxParallel);
128
- if (Number.isFinite(maxParallel) && maxParallel >= 0) entry.maxParallel = Math.floor(maxParallel);
129
- byType.set(providerType.toLowerCase(), entry);
130
- }
131
- return [...byType.values()];
132
- }
133
-
134
- export function readObjectRecord(value: unknown): Record<string, any> {
135
- return value && typeof value === 'object' && !Array.isArray(value)
136
- ? value as Record<string, any>
137
- : {};
138
- }
139
-
140
- export function readStringValue(...values: unknown[]): string | undefined {
141
- for (const value of values) {
142
- if (typeof value === 'string' && value.trim()) return value.trim();
143
- }
144
- return undefined;
145
- }
146
-
147
- function readNumberValue(...values: unknown[]): number | undefined {
148
- for (const value of values) {
149
- if (typeof value === 'number' && Number.isFinite(value)) return value;
150
- }
151
- return undefined;
152
- }
153
-
154
- export function readBooleanValue(...values: unknown[]): boolean | undefined {
155
- for (const value of values) {
156
- if (typeof value === 'boolean') return value;
157
- }
158
- return undefined;
159
- }
160
-
161
- // summarizeRepoMeshDebugGit was a hand-synced copy of the cloud git-shape
162
- // summarizer; both now call shared summarizeGitShape (@adhdev/mesh-shared).
163
-
164
- export function summarizeRepoMeshStatusDebug(status: any): Record<string, unknown> {
165
- const nodes = Array.isArray(status?.nodes) ? status.nodes : [];
166
- return {
167
- success: status?.success,
168
- meshId: readStringValue(status?.meshId, status?.mesh_id) ?? null,
169
- refreshedAt: readStringValue(status?.refreshedAt, status?.refreshed_at) ?? null,
170
- sourceOfTruth: status?.sourceOfTruth ?? null,
171
- branchConvergenceSummary: status?.branchConvergenceSummary ?? status?.branch_convergence_summary ?? null,
172
- nodeCount: nodes.length,
173
- nodes: nodes.map((node: any) => ({
174
- // Status emits the id under `nodeId` (3-way input absorbed). The
175
- // inline cache keeps `id` and `nodeId` equal, so this serialized form
176
- // round-trips back through the cache without flipping shape.
177
- nodeId: normalizeMeshNodeId(node) ?? null,
178
- daemonId: readStringValue(node?.daemonId, node?.daemon_id) ?? null,
179
- workspace: readStringValue(node?.workspace, node?.git?.workspace) ?? null,
180
- health: readStringValue(node?.health) ?? null,
181
- machineStatus: readStringValue(node?.machineStatus, node?.machine_status) ?? null,
182
- connection: node?.connection && typeof node.connection === 'object' ? {
183
- state: readStringValue(node.connection.state) ?? null,
184
- transport: readStringValue(node.connection.transport) ?? null,
185
- source: readStringValue(node.connection.source) ?? null,
186
- reported: readBooleanValue(node.connection.reported) ?? null,
187
- } : null,
188
- gitProbePending: node?.gitProbePending === true,
189
- launchReady: node?.launchReady === true,
190
- git: sharedSummarizeGitShape(node?.git),
191
- branchConvergence: node?.branchConvergence ?? node?.branch_convergence ?? null,
192
- })),
193
- };
194
- }
195
-
196
- export function logRepoMeshStatusDebug(event: string, fields: Record<string, unknown>): void {
197
- try {
198
- LOG.info('MeshStatusDebug', `[RepoMeshStatusDebug] ${JSON.stringify({ event, ...fields })}`);
199
- } catch {
200
- LOG.info('MeshStatusDebug', `[RepoMeshStatusDebug] ${event}`);
201
- }
202
- }
203
-
204
- // joinRepoPath + readGitSubmodules moved to @adhdev/mesh-shared (readGitSubmodules)
205
- // — used via sharedNormalizeGitStatus / sharedPickBestTransitGitStatus below.
206
-
207
- export function buildMeshNodeDisplayLabel(node: Record<string, unknown>, nodeId: string, providerPriority: string[]): string {
208
- const explicit = readStringValue(node.machineLabel, node.machine_label, node.machineNickname, node.machine_nickname, node.alias);
209
- if (explicit) return explicit;
210
- const workspace = readStringValue(node.workspace, node.repoRoot, node.repo_root);
211
- // Use the OS-agnostic basename: a workspace reported by a Windows node
212
- // (`D:\gh\adhdev-cloud`) must still collapse to its trailing segment even when
213
- // this coordinator's own `path.basename` is POSIX-only and would not split `\`.
214
- const workspaceName = workspace ? workingDirBasename(workspace) : undefined;
215
- const host = readStringValue(node.machineName, node.machine_name, node.hostname, node.host, node.daemonId, node.daemon_id, node.machineId, node.machine_id);
216
- const provider = providerPriority[0] || (Array.isArray(node.providers) ? readStringValue(...node.providers) : undefined);
217
- const parts = [workspaceName, host, provider].filter(Boolean);
218
- if (parts.length > 0) return parts.join(' · ');
219
- return nodeId || 'unidentified mesh node';
220
- }
221
-
222
- function normalizeMeshHostname(value: unknown): string | undefined {
223
- const hostname = readStringValue(value);
224
- if (!hostname) return undefined;
225
- return hostname.toLowerCase().replace(/\.$/, '');
226
- }
227
-
228
- export function readMeshNodeMachineId(node: Record<string, unknown>): string | undefined {
229
- return readStringValue(
230
- node.machineId,
231
- node.machine_id,
232
- readObjectRecord(node.machine)?.id,
233
- readObjectRecord(node.machine)?.machineId,
234
- readObjectRecord(node.lastProbe)?.machineId,
235
- readObjectRecord(node.last_probe)?.machine_id,
236
- readObjectRecord(readObjectRecord(node.lastProbe)?.machine)?.id,
237
- readObjectRecord(readObjectRecord(node.lastProbe)?.machine)?.machineId,
238
- readObjectRecord(readObjectRecord(node.last_probe)?.machine)?.id,
239
- readObjectRecord(readObjectRecord(node.last_probe)?.machine)?.machine_id,
240
- );
241
- }
242
-
243
- function readMeshNodeDaemonId(node: Record<string, unknown>): string | undefined {
244
- return readStringValue(
245
- node.daemonId,
246
- node.daemon_id,
247
- readObjectRecord(node.machine)?.daemonId,
248
- readObjectRecord(node.machine)?.daemon_id,
249
- readObjectRecord(node.lastProbe)?.daemonId,
250
- readObjectRecord(node.last_probe)?.daemon_id,
251
- readObjectRecord(readObjectRecord(node.lastProbe)?.machine)?.daemonId,
252
- readObjectRecord(readObjectRecord(node.lastProbe)?.machine)?.daemon_id,
253
- readObjectRecord(readObjectRecord(node.last_probe)?.machine)?.daemonId,
254
- readObjectRecord(readObjectRecord(node.last_probe)?.machine)?.daemon_id,
255
- );
256
- }
257
-
258
- export function readMeshNodeHostname(node: Record<string, unknown>): string | undefined {
259
- return readStringValue(
260
- node.hostname,
261
- node.host,
262
- node.machineHostname,
263
- node.machine_hostname,
264
- readObjectRecord(node.machine)?.hostname,
265
- readObjectRecord(node.machine)?.host,
266
- readObjectRecord(node.lastProbe)?.hostname,
267
- readObjectRecord(node.last_probe)?.hostname,
268
- readObjectRecord(readObjectRecord(node.lastProbe)?.machine)?.hostname,
269
- readObjectRecord(readObjectRecord(node.last_probe)?.machine)?.hostname,
270
- );
271
- }
272
-
273
- function readMeshNodeDisplayMachineName(node: Record<string, unknown>): string | undefined {
274
- return readStringValue(
275
- node.machineName,
276
- node.machine_name,
277
- node.machineLabel,
278
- node.machine_label,
279
- node.machineNickname,
280
- node.machine_nickname,
281
- node.alias,
282
- readObjectRecord(node.machine)?.name,
283
- readObjectRecord(node.machine)?.displayName,
284
- readObjectRecord(node.machine)?.display_name,
285
- readObjectRecord(node.lastProbe)?.machineName,
286
- readObjectRecord(node.last_probe)?.machine_name,
287
- readObjectRecord(readObjectRecord(node.lastProbe)?.machine)?.name,
288
- readObjectRecord(readObjectRecord(node.last_probe)?.machine)?.name,
289
- readMeshNodeHostname(node),
290
- );
291
- }
292
-
293
- function compactMeshIdentityEvidence(value: string | undefined): string | undefined {
294
- if (!value) return undefined;
295
- return value.length > 24 ? `${value.slice(0, 12)}…${value.slice(-8)}` : value;
296
- }
297
-
298
- export function buildMeshNodeMachineIdentity(node: Record<string, unknown>, opts: {
299
- localMachineId?: string;
300
- localDaemonId?: string;
301
- coordinatorHostname?: string;
302
- isSelfNode?: boolean;
303
- }): Record<string, unknown> {
304
- const machineId = readMeshNodeMachineId(node);
305
- const daemonId = readMeshNodeDaemonId(node);
306
- const hostname = readMeshNodeHostname(node);
307
- const machineName = readMeshNodeDisplayMachineName(node);
308
- const coordinatorHostname = readStringValue(opts.coordinatorHostname);
309
- const machineIdMatches = Boolean(opts.localMachineId && machineId && opts.localMachineId === machineId);
310
- const daemonIdMatches = Boolean(opts.localDaemonId && daemonId && daemonIdsEquivalent(opts.localDaemonId, daemonId));
311
- const hostnameMatches = Boolean(
312
- normalizeMeshHostname(hostname)
313
- && normalizeMeshHostname(coordinatorHostname)
314
- && normalizeMeshHostname(hostname) === normalizeMeshHostname(coordinatorHostname),
315
- );
316
- const sameMachine = opts.isSelfNode === true || machineIdMatches || daemonIdMatches || hostnameMatches;
317
- const evidence: string[] = [];
318
- for (const [label, value] of [['machineName', machineName], ['hostname', hostname], ['machineId', machineId], ['daemonId', daemonId]] as const) {
319
- const compact = compactMeshIdentityEvidence(value);
320
- if (compact) evidence.push(`${label}:${compact}`);
321
- }
322
- const locality = sameMachine ? 'same_machine' : (evidence.length > 0 ? 'remote_known' : 'remote_or_unknown');
323
- const localityReason = sameMachine
324
- ? (machineIdMatches ? 'matched coordinator machine id'
325
- : daemonIdMatches ? 'matched coordinator daemon id'
326
- : hostnameMatches ? 'matched coordinator hostname'
327
- : 'selected coordinator node')
328
- : evidence.length > 0
329
- ? `known remote/other machine identity; no local coordinator match (${evidence.join(', ')})`
330
- : 'no useful machine identity evidence available';
331
- return {
332
- daemonId,
333
- machineId,
334
- hostname,
335
- machineName,
336
- displayName: machineName || hostname || daemonId || machineId,
337
- coordinatorHostname,
338
- sameMachine,
339
- locality,
340
- localityReason,
341
- identityEvidence: evidence,
342
- };
343
- }
344
-
345
- // normalizeInlineMeshGitStatus / scoreInlineMeshGitStatus /
346
- // buildInlineMeshTransitGitStatus were the standalone-side copies of the cloud
347
- // transit git normalizers. They now delegate to @adhdev/mesh-shared so the two
348
- // transports can no longer drift (e.g. on the submodule drop / evidence rules).
349
-
350
- function normalizeInlineMeshGitStatus(
351
- status: Record<string, unknown>,
352
- node: any,
353
- options?: { lastCheckedAt?: number },
354
- ): Record<string, unknown> | undefined {
355
- return sharedNormalizeGitStatus(status, readObjectRecord(node), options) as Record<string, unknown> | undefined;
356
- }
357
-
358
- export function buildInlineMeshTransitGitStatus(node: any): Record<string, unknown> | undefined {
359
- return sharedPickBestTransitGitStatus(readObjectRecord(node), { lastCheckedAt: Date.now() }) as Record<string, unknown> | undefined;
360
- }
361
-
362
- function shouldRefreshStalePendingAggregate(snapshot: any, options?: { requireDirectPeerTruth?: boolean }): boolean {
363
- if (options?.requireDirectPeerTruth !== true || !Array.isArray(snapshot?.nodes)) return false;
364
- return snapshot.nodes.some((node: any) => {
365
- if (node?.gitProbePending !== true) return false;
366
- const git = readObjectRecord(node?.git);
367
- return !readBooleanValue(git.isGitRepo) && !readStringValue(git.branch, git.headCommit, git.upstream);
368
- });
369
- }
370
-
371
- export function buildLivePeerGitConnection(connection: Record<string, unknown>, timestamp = new Date().toISOString()): Record<string, unknown> {
372
- const source = readStringValue(connection.source);
373
- const transport = readStringValue(connection.transport);
374
- return {
375
- ...connection,
376
- perspective: readStringValue(connection.perspective) ?? 'selected_coordinator',
377
- source: source && source !== 'not_reported' ? source : 'mesh_peer_status',
378
- state: 'connected',
379
- transport: transport && transport !== 'unknown' ? transport : 'direct',
380
- reported: true,
381
- reason: 'Live peer git snapshot reported by the selected coordinator.',
382
- lastStateChangeAt: readStringValue(connection.lastStateChangeAt) ?? timestamp,
383
- };
384
- }
385
-
386
- export function recordInlineMeshDirectGitTruth(
387
- node: any,
388
- git: Record<string, unknown>,
389
- source: 'selected_coordinator_local_git' | 'selected_coordinator_mesh_p2p_git',
390
- ): { reporterPlatform: string | null; reporterArch: string | null } {
391
- if (!node || typeof node !== 'object' || Array.isArray(node)) {
392
- return { reporterPlatform: null, reporterArch: null };
393
- }
394
- const checkedAt = readNumberValue(git.lastCheckedAt) ?? Date.now();
395
- const updatedAt = new Date(checkedAt).toISOString();
396
- const nextGit: Record<string, unknown> = {
397
- ...git,
398
- lastCheckedAt: checkedAt,
399
- };
400
- node.lastGit = {
401
- source,
402
- checkedAt,
403
- status: nextGit,
404
- };
405
- node.last_git = node.lastGit;
406
- node.machineStatus = 'online';
407
- node.updatedAt = updatedAt;
408
- node.lastSeenAt = updatedAt;
409
- const repoRoot = readStringValue(nextGit.repoRoot);
410
- if (repoRoot && !readStringValue(node.repoRoot)) node.repoRoot = repoRoot;
411
- // Self-heal per-node platform/arch from the live probe. For a remote member
412
- // this is the platform the member daemon reported in its git_status envelope
413
- // (threaded through as reporter*); for the local coordinator's own / worktree
414
- // nodes the git was computed locally (source 'selected_coordinator_local_git'
415
- // ⇒ the workspace lives on THIS machine), so process.platform/process.arch is
416
- // the correct value. Stamp into userOverrides — the exact fields
417
- // buildMeshNodeCapabilityTags reads — only when absent, so an operator's
418
- // explicit override is preserved and the value is corrected once per reconnect
419
- // without any migration.
420
- const isLocalSource = source === 'selected_coordinator_local_git';
421
- const reporterPlatform = readStringValue(git.reporterPlatform) ?? (isLocalSource ? process.platform : null);
422
- const reporterArch = readStringValue(git.reporterArch) ?? (isLocalSource ? process.arch : null);
423
- stampNodeReporterPlatform(node, reporterPlatform, reporterArch);
424
- // Mirror onto the in-memory node's dedicated reporter fields too (distinct
425
- // from userOverrides). For a local_config mesh the caller also persists these
426
- // to meshes.json via updateNode so the value survives a coordinator restart;
427
- // for an inline/cache mesh this keeps the runtime object self-consistent.
428
- if (reporterPlatform) node.reportedPlatform = reporterPlatform;
429
- if (reporterArch) node.reportedArch = reporterArch;
430
- return { reporterPlatform, reporterArch };
431
- }
432
-
433
- /**
434
- * Fill node.userOverrides.platform/arch from a live report, but never overwrite a
435
- * value that is already present (an operator override or an earlier report). Used
436
- * by both the remote-member probe path and the local self-stamp path so the
437
- * coordinator advertises each node's real OS instead of falling back to the
438
- * coordinator's own process.platform.
439
- */
440
- function stampNodeReporterPlatform(node: any, platform: string | null, arch: string | null): void {
441
- if (!node || typeof node !== 'object' || Array.isArray(node)) return;
442
- if (!platform && !arch) return;
443
- const overrides = (node.userOverrides && typeof node.userOverrides === 'object' && !Array.isArray(node.userOverrides))
444
- ? node.userOverrides as Record<string, unknown>
445
- : {};
446
- let changed = false;
447
- if (platform && !readStringValue(overrides.platform)) { overrides.platform = platform; changed = true; }
448
- if (arch && !readStringValue(overrides.arch)) { overrides.arch = arch; changed = true; }
449
- if (changed) node.userOverrides = overrides;
450
- }
451
-
452
- /**
453
- * Persist the live self-reported platform/arch onto the local meshes.json node
454
- * record so capability-tag os=/arch= self-heals across coordinator restarts.
455
- *
456
- * The in-memory stamp done by recordInlineMeshDirectGitTruth lives on the
457
- * mesh_status assembly object and is discarded after the response; only a
458
- * `local_config` mesh has a backing meshes.json node to write through to. Inline
459
- * cache/bootstrap meshes have no local node to update, so we no-op for them.
460
- * Fire-and-forget (same pattern as the worktreeBootstrap writer) — a persistence
461
- * failure must never block the status response.
462
- */
463
- export function persistNodeReporterPlatform(
464
- meshSource: 'inline_cache' | 'inline_bootstrap' | 'local_config',
465
- mesh: any,
466
- nodeId: string | undefined,
467
- reporter: { reporterPlatform: string | null; reporterArch: string | null },
468
- ): void {
469
- if (meshSource !== 'local_config') return;
470
- const meshId = readStringValue(mesh?.id);
471
- if (!meshId || !nodeId) return;
472
- const reportedPlatform = reporter.reporterPlatform ?? undefined;
473
- const reportedArch = reporter.reporterArch ?? undefined;
474
- if (!reportedPlatform && !reportedArch) return;
475
- void import('../config/mesh-config.js')
476
- .then(({ updateNode }) => updateNode(meshId, nodeId, { reportedPlatform, reportedArch }))
477
- .catch(() => { /* best-effort self-heal; never block status assembly */ });
478
- }
479
-
480
- function buildCachedInlineMeshGitStatus(node: any): Record<string, unknown> | undefined {
481
- const liveGit = buildInlineMeshTransitGitStatus(node);
482
- if (liveGit) return liveGit;
483
-
484
- const cachedStatus = readObjectRecord(node?.cachedStatus);
485
- const cachedGit = readObjectRecord(cachedStatus.git);
486
- if (!Object.keys(cachedGit).length) return undefined;
487
- return normalizeInlineMeshGitStatus(cachedGit, node);
488
- }
489
-
490
- function shouldDiscardCachedInlineMeshStatus(node: any): boolean {
491
- const cachedStatus = readObjectRecord(node?.cachedStatus);
492
- if (!Object.keys(cachedStatus).length) return false;
493
- const cachedGit = readObjectRecord(cachedStatus.git);
494
- const workspaceError = readStringValue(cachedStatus.error, node?.error);
495
- if (workspaceError && /workspace must be an existing directory/i.test(workspaceError)) return true;
496
- const isGitRepo = readBooleanValue(cachedGit.isGitRepo);
497
- const branch = readStringValue(cachedGit.branch);
498
- const headCommit = readStringValue(cachedGit.headCommit);
499
- return isGitRepo === false && !branch && !headCommit;
500
- }
501
-
502
- function stripInlineMeshTransientNodeState(node: any): any {
503
- if (!node || typeof node !== 'object' || Array.isArray(node)) return node;
504
- const {
505
- cachedStatus,
506
- lastGit: _lastGit,
507
- last_git: _lastGitLegacy,
508
- lastProbe: _lastProbe,
509
- last_probe: _lastProbeLegacy,
510
- error: _error,
511
- health: _health,
512
- machineStatus: _machineStatus,
513
- lastSeenAt: _lastSeenAt,
514
- last_seen_at: _lastSeenAtLegacy,
515
- updatedAt: _updatedAt,
516
- updated_at: _updatedAtLegacy,
517
- activeSession: _activeSession,
518
- active_session: _activeSessionLegacy,
519
- activeSessionId: _activeSessionId,
520
- active_session_id: _activeSessionIdLegacy,
521
- sessionId: _sessionId,
522
- session_id: _sessionIdLegacy,
523
- providerType: _providerType,
524
- provider_type: _providerTypeLegacy,
525
- ...rest
526
- } = node as Record<string, unknown>;
527
- if (cachedStatus && !shouldDiscardCachedInlineMeshStatus(node)) {
528
- return { ...rest, cachedStatus };
529
- }
530
- return rest;
531
- }
532
-
533
- function hasInlineMeshTransientNodeState(node: any): boolean {
534
- if (!node || typeof node !== 'object' || Array.isArray(node)) return false;
535
- return 'cachedStatus' in node
536
- || 'lastGit' in node
537
- || 'last_git' in node
538
- || 'lastProbe' in node
539
- || 'last_probe' in node
540
- || 'error' in node
541
- || 'health' in node
542
- || 'machineStatus' in node
543
- || 'lastSeenAt' in node
544
- || 'last_seen_at' in node
545
- || 'updatedAt' in node
546
- || 'updated_at' in node
547
- || 'activeSession' in node
548
- || 'active_session' in node
549
- || 'activeSessionId' in node
550
- || 'active_session_id' in node
551
- || 'sessionId' in node
552
- || 'session_id' in node
553
- || 'providerType' in node
554
- || 'provider_type' in node;
555
- }
556
-
557
- function inlineMeshCarriesTransientNodeTruth(inlineMesh: any): boolean {
558
- if (!inlineMesh || typeof inlineMesh !== 'object' || Array.isArray(inlineMesh)) return false;
559
- if (!Array.isArray(inlineMesh.nodes) || inlineMesh.nodes.length === 0) return false;
560
- return inlineMesh.nodes.some((node: any) => hasInlineMeshTransientNodeState(node));
561
- }
562
-
563
- function readInlineMeshNodeId(node: any): string {
564
- // 3-way (id / nodeId / node_id) via the shared normalizer. The old 2-way
565
- // `id ?? nodeId` dropped the SQLite `node_id` form, so an inline-cached node
566
- // that arrived in that form failed to reconcile against its cached twin.
567
- return normalizeMeshNodeId(node) ?? '';
568
- }
569
-
570
- // A local worktree node whose workspace directory has been deleted from disk.
571
- // The worktree was removed (or the machine pruned it) but the node still lingers
572
- // in the inline mesh cache. Such a node has no live truth to confirm and must
573
- // never be probed or counted toward direct-peer-truth — doing so blocks the
574
- // graph with a permanent `direct_peer_truth_unavailable`. Deliberately narrow:
575
- // it only fires for `isLocalWorktree === true` nodes with a recorded workspace
576
- // that does not exist. Remote nodes and nodes whose workspace is present on disk
577
- // are never matched, so a slow remote peer is still classified unavailable.
578
- function isDeadLocalWorktreeNode(node: any): boolean {
579
- if (node?.isLocalWorktree !== true) return false;
580
- const workspace = readStringValue(node?.workspace);
581
- if (!workspace) return false;
582
- return !fs.existsSync(workspace);
583
- }
584
-
585
- // Boundary normalization: reconcile a node's identity so `id` and `nodeId` both
586
- // carry the same canonical value (any incoming form — id / nodeId / node_id — is
587
- // absorbed by normalizeMeshNodeId, and the SQLite `node_id` leak is dropped).
588
- // See foldMeshNodeIdentityToCanonical for why both fields are kept equal rather
589
- // than collapsing to one. The rewrite is shallow (other runtime fields are
590
- // preserved); records that already agree are returned unchanged so
591
- // identity-equality fast paths hold.
592
- function foldMeshNodeIdentityToCanonical(node: any): any {
593
- if (!node || typeof node !== 'object' || Array.isArray(node)) return node;
594
- const canonical = normalizeMeshNodeId(node);
595
- if (canonical === undefined) return node;
596
- // Save-boundary identity folding, applied IN PLACE. We DUAL-WRITE both `id`
597
- // and `nodeId` to the single canonical value (and drop the SQLite `node_id`
598
- // leak), rather than collapsing to one field. Two halves of the system read
599
- // different field names: the mesh_status serializer emits `nodeId`, while the
600
- // worktree clone path and get_mesh membership consumers read `node.id`.
601
- // Folding to ONE form would break whichever side reads the other. Keeping
602
- // both fields equal makes every reader correct AND makes the
603
- // snapshot→cache→reconcile→snapshot round-trip form-stable (no field ever
604
- // flips, because both always agree). Mutating in place (not returning a new
605
- // object) preserves the cached node-object identity that callers warming an
606
- // inline mesh from an already-shared snapshot rely on.
607
- if (node.id === canonical && node.nodeId === canonical && node.node_id === undefined) return node;
608
- node.id = canonical;
609
- node.nodeId = canonical;
610
- if ('node_id' in node) delete node.node_id;
611
- return node;
612
- }
613
-
614
- function normalizeInlineMeshNodeIdentity(inlineMesh: any): any {
615
- if (!inlineMesh || typeof inlineMesh !== 'object' || Array.isArray(inlineMesh)) return inlineMesh;
616
- if (!Array.isArray(inlineMesh.nodes) || inlineMesh.nodes.length === 0) return inlineMesh;
617
- // Fold each node IN PLACE so the mesh object and its nodes array keep their
618
- // identity — sanitizeInlineMesh and the cache-sharing callers depend on
619
- // unchanged inputs returning the same reference.
620
- for (const node of inlineMesh.nodes) foldMeshNodeIdentityToCanonical(node);
621
- return inlineMesh;
622
- }
623
-
624
- function sanitizeInlineMesh(inlineMesh: any): any {
625
- if (!inlineMesh || typeof inlineMesh !== 'object' || Array.isArray(inlineMesh)) return inlineMesh;
626
- if (!Array.isArray(inlineMesh.nodes)) return inlineMesh;
627
- let changed = false;
628
- const nodes = inlineMesh.nodes.map((node: any) => {
629
- if (!hasInlineMeshTransientNodeState(node)) return node;
630
- changed = true;
631
- return stripInlineMeshTransientNodeState(node);
632
- });
633
- if (!changed) return inlineMesh;
634
- return {
635
- ...inlineMesh,
636
- nodes,
637
- };
638
- }
639
-
640
- function reconcileInlineMeshCache(cached: any, incoming: any): any {
641
- if (!cached || typeof cached !== 'object' || Array.isArray(cached)) return incoming;
642
- if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) return cached;
643
- const cachedNodes = Array.isArray(cached.nodes) ? cached.nodes : [];
644
- const incomingNodes = Array.isArray(incoming.nodes) ? incoming.nodes : [];
645
- if (!cachedNodes.length || !incomingNodes.length) return { ...cached, ...incoming };
646
-
647
- const cachedUpdatedAt = Date.parse(readStringValue(cached.updatedAt, cached.updated_at) || '');
648
- const incomingUpdatedAt = Date.parse(readStringValue(incoming.updatedAt, incoming.updated_at) || '');
649
- const preserveCachedMembership = Number.isFinite(cachedUpdatedAt)
650
- && (!Number.isFinite(incomingUpdatedAt) || cachedUpdatedAt > incomingUpdatedAt);
651
-
652
- const cachedById = new Map<string, any>();
653
- for (const node of cachedNodes) {
654
- const nodeId = readInlineMeshNodeId(node);
655
- if (nodeId) cachedById.set(nodeId, node);
656
- }
657
-
658
- const mergedIncomingIds = new Set<string>();
659
- const nodes = incomingNodes.map((incomingNode: any) => {
660
- const nodeId = readInlineMeshNodeId(incomingNode);
661
- const cachedNode = nodeId ? cachedById.get(nodeId) : undefined;
662
- if (!cachedNode && preserveCachedMembership) return null;
663
- if (nodeId) mergedIncomingIds.add(nodeId);
664
- if (!cachedNode) return incomingNode;
665
- if (hasInlineMeshTransientNodeState(incomingNode)) {
666
- return { ...cachedNode, ...incomingNode };
667
- }
668
- return { ...stripInlineMeshTransientNodeState(cachedNode), ...incomingNode };
669
- }).filter(Boolean);
670
-
671
- // When the cached membership is authoritative (newer than the incoming
672
- // snapshot), nodes that exist only in the cache must survive reconciliation.
673
- // A freshly cloned worktree node lives only in the coordinator's cache until
674
- // the next snapshot catches up; iterating incomingNodes alone would silently
675
- // drop it, making the node invisible to get_mesh / membership reads even
676
- // though worktree_bootstrap_complete already fired.
677
- if (preserveCachedMembership) {
678
- for (const cachedNode of cachedNodes) {
679
- const nodeId = readInlineMeshNodeId(cachedNode);
680
- if (nodeId && !mergedIncomingIds.has(nodeId)) {
681
- nodes.push(cachedNode);
682
- }
683
- }
684
- }
685
-
686
- return {
687
- ...cached,
688
- ...incoming,
689
- nodes,
690
- };
691
- }
692
-
693
- function hasGitWorktreeChanges(git: Record<string, unknown> | null | undefined): boolean {
694
- return countGitWorktreeChanges(git) > 0;
695
- }
696
-
697
- function countGitWorktreeChanges(git: Record<string, unknown> | null | undefined): number {
698
- if (!git) return 0;
699
- return Number(git.staged || 0)
700
- + Number(git.modified || 0)
701
- + Number(git.untracked || 0)
702
- + Number(git.deleted || 0)
703
- + Number(git.renamed || 0);
704
- }
705
-
706
- function getGitSubmoduleDriftState(git: Record<string, unknown> | null | undefined): { dirty: boolean; outOfSync: boolean } {
707
- const submodules = Array.isArray(git?.submodules) ? git.submodules : [];
708
- let dirty = false;
709
- let outOfSync = false;
710
- for (const entry of submodules) {
711
- const submodule = readObjectRecord(entry);
712
- if (readBooleanValue(submodule.dirty) === true) dirty = true;
713
- if (readBooleanValue(submodule.outOfSync) === true || !!readStringValue(submodule.error)) outOfSync = true;
714
- }
715
- return { dirty, outOfSync };
716
- }
717
-
718
- function isInlineMeshAutoFastForwardEligible(git: Record<string, unknown> | null | undefined): boolean {
719
- if (!git) return false;
720
- if (readBooleanValue(git.isGitRepo) !== true) return false;
721
- if (!readStringValue(git.branch)) return false;
722
- if (!readStringValue(git.upstream)) return false;
723
- const upstreamStatus = readStringValue(git.upstreamStatus, git.upstream_status);
724
- if (upstreamStatus !== 'fresh') return false;
725
- if ((readNumberValue(git.ahead) ?? 0) !== 0) return false;
726
- if ((readNumberValue(git.behind) ?? 0) <= 0) return false;
727
- const hasConflicts = readBooleanValue(git.hasConflicts)
728
- ?? (Array.isArray(git.conflictFiles) && git.conflictFiles.length > 0);
729
- if (hasConflicts) return false;
730
- if ((readNumberValue(git.stashCount, git.stash_count) ?? 0) > 0) return false;
731
- const submoduleDrift = getGitSubmoduleDriftState(git);
732
- if (submoduleDrift.dirty || submoduleDrift.outOfSync) return false;
733
- const dirty = readBooleanValue(git.dirty) ?? (countGitWorktreeChanges(git) > 0);
734
- return dirty !== true && countGitWorktreeChanges(git) === 0;
735
- }
736
-
737
- export function deriveMeshNodeHealthFromGit(git: Record<string, unknown> | null | undefined): 'online' | 'dirty' | 'degraded' {
738
- if (!git || readBooleanValue(git.isGitRepo) === false) return 'degraded';
739
- const branch = readStringValue(git.branch);
740
- if (!branch) return 'degraded';
741
- const submoduleDrift = getGitSubmoduleDriftState(git);
742
- if (submoduleDrift.outOfSync) return 'degraded';
743
- if (submoduleDrift.dirty || hasGitWorktreeChanges(git)) return 'dirty';
744
- return 'online';
745
- }
746
-
747
- function readMeshNodeLabel(status: Record<string, unknown>, node: any): string {
748
- return readStringValue(status.nodeId, normalizeMeshNodeId(node)) ?? 'unknown';
749
- }
750
-
751
- function buildInlineMeshBranchConvergence(args: {
752
- mesh: any;
753
- node: any;
754
- status: Record<string, unknown>;
755
- }): Record<string, unknown> {
756
- const git = readObjectRecord(args.status.git);
757
- const nodeLabel = readMeshNodeLabel(args.status, args.node);
758
- const defaultBranch = readStringValue(args.mesh?.defaultBranch) ?? 'main';
759
- const branch = readStringValue(git.branch, args.node?.worktreeBranch) ?? null;
760
- const upstream = readStringValue(git.upstream) ?? null;
761
- const upstreamStatus = readStringValue(git.upstreamStatus, git.upstream_status)
762
- ?? (upstream ? 'unchecked' : 'no_upstream');
763
- const ahead = readNumberValue(git.ahead) ?? 0;
764
- const behind = readNumberValue(git.behind) ?? 0;
765
- const uncommittedChanges = countGitWorktreeChanges(git);
766
- const hasConflicts = readBooleanValue(git.hasConflicts)
767
- ?? (Array.isArray(git.conflictFiles) && git.conflictFiles.length > 0);
768
- const base = {
769
- defaultBranch,
770
- branch,
771
- upstream,
772
- upstreamStatus,
773
- ahead,
774
- behind,
775
- isWorktree: args.node?.isLocalWorktree === true || args.status.isLocalWorktree === true,
776
- isDefaultBranch: branch === defaultBranch,
777
- };
778
-
779
- if (readBooleanValue(git.isGitRepo) !== true) {
780
- return {
781
- ...base,
782
- status: 'blocked_review',
783
- needsConvergence: true,
784
- reason: 'git_status_unavailable',
785
- nextStep: `Resolve git status for node '${nodeLabel}' before marking the task complete.`,
786
- };
787
- }
788
-
789
- if (!branch) {
790
- return {
791
- ...base,
792
- status: 'blocked_review',
793
- needsConvergence: true,
794
- reason: 'branch_unknown',
795
- nextStep: `Inspect node '${nodeLabel}' git branch before deciding whether it is merged to ${defaultBranch}.`,
796
- };
797
- }
798
-
799
- if (hasConflicts || uncommittedChanges > 0) {
800
- return {
801
- ...base,
802
- status: 'not_mergeable',
803
- needsConvergence: true,
804
- reason: hasConflicts ? 'conflicts_present' : 'dirty_workspace',
805
- nextStep: `Commit, checkpoint, or resolve node '${nodeLabel}' before any main convergence step.`,
806
- };
807
- }
808
-
809
- if (branch === defaultBranch) {
810
- if (upstream && upstreamStatus !== 'fresh') {
811
- return {
812
- ...base,
813
- status: 'blocked_review',
814
- needsConvergence: true,
815
- reason: 'default_branch_upstream_unverified',
816
- nextStep: `Refresh ${defaultBranch}'s upstream refs or resolve the fetch failure before declaring convergence complete for node '${nodeLabel}'.`,
817
- };
818
- }
819
- if (ahead > 0 || behind > 0) {
820
- return {
821
- ...base,
822
- status: 'blocked_review',
823
- needsConvergence: true,
824
- reason: 'default_branch_not_even_with_upstream',
825
- nextStep: `Bring ${defaultBranch} even with its upstream before declaring convergence complete.`,
826
- };
827
- }
828
- return {
829
- ...base,
830
- status: 'merged_to_main',
831
- needsConvergence: false,
832
- reason: 'clean_default_branch',
833
- nextStep: null,
834
- };
835
- }
836
-
837
- if (args.node?.isLocalWorktree === true || args.status.isLocalWorktree === true) {
838
- return {
839
- ...base,
840
- status: 'cleanup_candidate',
841
- needsConvergence: true,
842
- reason: 'clean_non_default_worktree_branch',
843
- nextStep: `Run mesh_refine_node(node_id: "${nodeLabel}") or explicitly classify this worktree as blocked_review/not_mergeable before ending the task.`,
844
- };
845
- }
846
-
847
- if (upstream && upstreamStatus !== 'fresh') {
848
- return {
849
- ...base,
850
- status: 'blocked_review',
851
- needsConvergence: true,
852
- reason: 'feature_branch_upstream_unverified',
853
- nextStep: `Refresh branch '${branch}' upstream refs or resolve the fetch failure before deciding whether it is ready to merge into ${defaultBranch}.`,
854
- };
855
- }
856
-
857
- if (!upstream || ahead > 0 || behind > 0) {
858
- return {
859
- ...base,
860
- status: 'blocked_review',
861
- needsConvergence: true,
862
- reason: !upstream ? 'feature_branch_missing_upstream' : 'feature_branch_not_even_with_upstream',
863
- nextStep: `Push or reconcile branch '${branch}', then merge it into ${defaultBranch} or mark it not_mergeable with a reason.`,
864
- };
865
- }
866
-
867
- return {
868
- ...base,
869
- status: 'pushed_feature_branch_needs_merge',
870
- needsConvergence: true,
871
- reason: 'clean_non_default_branch',
872
- nextStep: `Review and merge branch '${branch}' into ${defaultBranch}; do not report the task as fully complete while it remains off main.`,
873
- };
874
- }
875
-
876
- export function applyInlineMeshBranchConvergence(mesh: any, node: any, status: Record<string, unknown>): void {
877
- const git = readObjectRecord(status.git);
878
- if (Object.keys(git).length === 0 && !status.gitProbePending) return;
879
- const uncommittedChanges = countGitWorktreeChanges(git);
880
- status.isDirty = uncommittedChanges > 0;
881
- status.uncommittedChanges = uncommittedChanges;
882
- status.branchConvergence = buildInlineMeshBranchConvergence({ mesh, node, status });
883
- status.autoFastForwardEligible = isInlineMeshAutoFastForwardEligible(git);
884
- if (status.autoFastForwardEligible) {
885
- status.suggestedAction = 'auto_fast_forward';
886
- } else {
887
- delete status.suggestedAction;
888
- }
889
- }
890
-
891
- export function summarizeInlineMeshBranchConvergence(nodes: Array<Record<string, unknown>>): Record<string, unknown> {
892
- const followUps = nodes
893
- .filter(node => {
894
- if (readObjectRecord(node.branchConvergence).needsConvergence !== true) return false;
895
- const workspace = typeof node.workspace === 'string' ? node.workspace : '';
896
- if (workspace && !fs.existsSync(workspace)) return false;
897
- return true;
898
- })
899
- .map(node => {
900
- const convergence = readObjectRecord(node.branchConvergence);
901
- return {
902
- nodeId: node.nodeId,
903
- workspace: node.workspace,
904
- branch: convergence.branch,
905
- status: convergence.status,
906
- reason: convergence.reason,
907
- nextStep: convergence.nextStep,
908
- };
909
- });
910
-
911
- return {
912
- needsFollowUp: followUps.length > 0,
913
- unresolvedCount: followUps.length,
914
- requiredFinalStates: ['merged_to_main', 'pushed_feature_branch_needs_merge', 'blocked_review', 'cleanup_candidate', 'not_mergeable'],
915
- followUps,
916
- };
917
- }
918
-
919
- function readCachedInlineMeshActiveSessions(node: any): string[] {
920
- const cachedStatus = readObjectRecord(node?.cachedStatus);
921
- const activeSession = readObjectRecord(cachedStatus.activeSession);
922
- const fallbackSession = Object.keys(activeSession).length
923
- ? activeSession
924
- : readObjectRecord(node?.activeSession ?? node?.active_session);
925
- const sessionId = readStringValue(fallbackSession.id, fallbackSession.sessionId, fallbackSession.session_id, node?.activeSessionId, node?.active_session_id, node?.sessionId, node?.session_id);
926
- return sessionId ? [sessionId] : [];
927
- }
928
-
929
- /**
930
- * Wider session-ownership scan used ONLY by resolveRemoteMeshSessionOwnerDaemonId: collect
931
- * EVERY session id a mesh node currently hosts. readCachedInlineMeshActiveSessions above only
932
- * surfaces the node's single primary session (cachedStatus.activeSession) — other consumers
933
- * depend on that one-session semantics, so it is left untouched. A worker hosting more than one
934
- * session exposes its non-primary sessions only through the plural live-session arrays the
935
- * coordinator carries: status.activeSessions / activeSessionDetails (built from live records on
936
- * the aggregate snapshot, see get_mesh_status), or a worker's merged session report. A
937
- * controlbar/modal command (invoke_provider_script / resolve_action / set_mode / …) targeting a
938
- * non-primary remote session resolves its owner daemon only when those plural shapes are scanned
939
- * too. Mirrors sessionStatusFromNodes' shape tolerance (mesh-active-work.ts): plural arrays of
940
- * string ids OR objects keyed by id/sessionId/session_id/runtimeSessionId/instanceId, on both
941
- * camelCase and snake_case, at the node root and under cachedStatus / lastProbe.
942
- */
943
- function collectMeshNodeHostedSessionIds(node: any): Set<string> {
944
- const ids = new Set<string>();
945
- for (const id of readCachedInlineMeshActiveSessions(node)) ids.add(id);
946
- const cachedStatus = readObjectRecord(node?.cachedStatus);
947
- for (const value of [
948
- node?.activeSessions,
949
- node?.active_sessions,
950
- node?.activeSessionDetails,
951
- node?.active_session_details,
952
- node?.sessions,
953
- node?.sessionDetails,
954
- node?.session_details,
955
- readObjectRecord(node?.lastProbe).sessions,
956
- readObjectRecord(node?.last_probe).sessions,
957
- cachedStatus.activeSessions,
958
- cachedStatus.active_sessions,
959
- cachedStatus.activeSessionDetails,
960
- cachedStatus.active_session_details,
961
- cachedStatus.sessions,
962
- ]) {
963
- if (!Array.isArray(value)) continue;
964
- for (const item of value) {
965
- if (typeof item === 'string') {
966
- const id = readStringValue(item);
967
- if (id) ids.add(id);
968
- continue;
969
- }
970
- const record = readObjectRecord(item);
971
- const id = readStringValue(record.id, record.sessionId, record.session_id, record.runtimeSessionId, record.instanceId);
972
- if (id) ids.add(id);
973
- }
974
- }
975
- return ids;
976
- }
977
-
978
- /**
979
- * Resolve the owning-node attribution for a mesh node record so a coordinator can
980
- * stamp the TRUE owner onto a synthetic session entry instead of letting the
981
- * dashboard fall back to the coordinator's own daemonId. Returns whichever of the
982
- * owning node's `daemonId` / display machine name could be read from the node's
983
- * (possibly multi-serialization-path) shape; both may be undefined for a node that
984
- * never carried machine identity.
985
- */
986
- export function resolveMeshNodeAttribution(node: unknown): { daemonId?: string; machineName?: string } {
987
- const record = readObjectRecord(node);
988
- return {
989
- daemonId: readMeshNodeDaemonId(record),
990
- machineName: readMeshNodeDisplayMachineName(record),
991
- };
992
- }
993
-
994
- export function readCachedInlineMeshActiveSessionDetails(node: any): Array<Record<string, unknown>> {
995
- const cachedStatus = readObjectRecord(node?.cachedStatus);
996
- const activeSession = readObjectRecord(cachedStatus.activeSession);
997
- const fallbackSession = Object.keys(activeSession).length
998
- ? activeSession
999
- : readObjectRecord(node?.activeSession ?? node?.active_session);
1000
- const sessionId = readStringValue(
1001
- fallbackSession.id,
1002
- fallbackSession.sessionId,
1003
- fallbackSession.session_id,
1004
- node?.activeSessionId,
1005
- node?.active_session_id,
1006
- node?.sessionId,
1007
- node?.session_id,
1008
- );
1009
- if (!sessionId) return [];
1010
- return [{
1011
- sessionId,
1012
- providerType: readStringValue(
1013
- fallbackSession.providerType,
1014
- fallbackSession.provider_type,
1015
- fallbackSession.cliType,
1016
- fallbackSession.cli_type,
1017
- fallbackSession.provider,
1018
- node?.providerType,
1019
- node?.provider_type,
1020
- ),
1021
- state: readStringValue(fallbackSession.status, fallbackSession.state, fallbackSession.lifecycle),
1022
- chatStatus: readStringValue(fallbackSession.chatStatus, fallbackSession.chat_status),
1023
- lifecycle: readStringValue(fallbackSession.lifecycle),
1024
- title: readStringValue(fallbackSession.title, fallbackSession.displayName, fallbackSession.display_name) ?? null,
1025
- workspace: readStringValue(fallbackSession.workspace, node?.workspace) ?? null,
1026
- role: readStringValue(fallbackSession.role) ?? null,
1027
- isSelfCoordinator: fallbackSession.isSelfCoordinator === true || fallbackSession.is_self_coordinator === true,
1028
- createdAt: readStringValue(fallbackSession.createdAt, fallbackSession.created_at) ?? null,
1029
- startedAt: readStringValue(fallbackSession.startedAt, fallbackSession.started_at) ?? null,
1030
- lastActivityAt: readStringValue(fallbackSession.lastActivityAt, fallbackSession.last_activity_at) ?? null,
1031
- recoveryState: readStringValue(fallbackSession.recoveryState, fallbackSession.recovery_state) ?? null,
1032
- // [T2] Carry the worker-computed last-message preview through the cached inline-mesh
1033
- // active-session entry. The worker's get_status_metadata slim now ships these
1034
- // (mesh-tools.ts), and this is the surface the coordinator's inbox-preview path reads
1035
- // (buildDaemonMetadataUpdateForSubscription → stampLocalAssistantPreviewOnCachedEntry).
1036
- // Without carrying them here, the coordinator could only derive the preview from a live
1037
- // in-process instance it doesn't host for a remote worker, so the inbox stuck on the
1038
- // dispatched user task. Only present when the worker reported them.
1039
- ...(readStringValue(fallbackSession.lastMessagePreview, fallbackSession.last_message_preview)
1040
- ? { lastMessagePreview: readStringValue(fallbackSession.lastMessagePreview, fallbackSession.last_message_preview) } : {}),
1041
- ...(readStringValue(fallbackSession.lastMessageRole, fallbackSession.last_message_role)
1042
- ? { lastMessageRole: readStringValue(fallbackSession.lastMessageRole, fallbackSession.last_message_role) } : {}),
1043
- ...(readNumberValue(fallbackSession.lastMessageAt, fallbackSession.last_message_at) !== undefined
1044
- ? { lastMessageAt: readNumberValue(fallbackSession.lastMessageAt, fallbackSession.last_message_at) } : {}),
1045
- isCached: true,
1046
- }];
1047
- }
1048
-
1049
- function readLiveMeshSessionState(record: any): string | undefined {
1050
- return readStringValue(
1051
- record?.meta?.sessionStatus,
1052
- record?.meta?.status,
1053
- record?.meta?.providerStatus,
1054
- record?.status,
1055
- record?.state,
1056
- record?.lifecycle,
1057
- );
1058
- }
1059
-
1060
- function toIsoTimestamp(value: unknown): string | null {
1061
- if (typeof value === 'number' && Number.isFinite(value)) return new Date(value).toISOString();
1062
- const stringValue = readStringValue(value);
1063
- return stringValue || null;
1064
- }
1065
-
1066
- function synthesizeMeshNodeFreshnessFromConnection(status: Record<string, unknown>): void {
1067
- const connection = readObjectRecord(status.connection);
1068
- const connectionFreshAt = toIsoTimestamp(connection.lastCommandAt ?? connection.lastConnectedAt ?? connection.lastStateChangeAt);
1069
- const git = readObjectRecord(status.git);
1070
- const gitCheckedAt = toIsoTimestamp(git.lastCheckedAt);
1071
- if (!status.lastSeenAt && connectionFreshAt) status.lastSeenAt = connectionFreshAt;
1072
- if (!status.updatedAt && (gitCheckedAt || connectionFreshAt)) {
1073
- status.updatedAt = gitCheckedAt ?? connectionFreshAt;
1074
- }
1075
- }
1076
-
1077
- /**
1078
- * Transient per-node marker the mesh_status render loop stamps onto a node
1079
- * `status` at the two sites that obtain git truth from a FRESH probe this call
1080
- * (a successful local `getGitRepoStatus`, or a successful P2P `git_status`
1081
- * round-trip). finalizeMeshNodeStatus consumes and deletes it. Held/standing
1082
- * truth (node.lastGit / cachedStatus / inline transit) is deliberately NOT
1083
- * stamped — its absence is exactly how the freshness marker tells "live" apart
1084
- * from "cached". Internal only; never serialized in the response.
1085
- */
1086
- export const MESH_NODE_LIVE_TRUTH_MARKER = '__liveTruthProbed';
1087
-
1088
- type MeshNodeDataSource =
1089
- | 'self' // the selected coordinator's own node — local truth
1090
- | 'live' // git/session truth confirmed by a fresh probe THIS call
1091
- | 'cached' // rendered from held standing truth (possibly old — see staleness)
1092
- | 'pending' // reachable/known but no probe attempted yet (default load)
1093
- | 'unreachable' // peer could not be reached (P2P probe failed / not connected, no held truth)
1094
- | 'empty' // reachable but genuinely no session + git data
1095
- | 'unconfigured'; // node has no daemonId, so transport truth cannot be reported
1096
-
1097
- type MeshNodeStaleness = 'fresh' | 'recent' | 'stale' | 'unknown';
1098
-
1099
- // Staleness buckets (ms). Held/cached truth younger than FRESH reads as fresh,
1100
- // younger than RECENT as recent, older as stale. Kept coarse on purpose — the
1101
- // coordinator only needs "just-now / minutes-old / old", not millisecond precision.
1102
- const MESH_FRESHNESS_FRESH_MS = 30_000;
1103
- const MESH_FRESHNESS_RECENT_MS = 300_000;
1104
-
1105
- function classifyMeshNodeStaleness(dataSource: MeshNodeDataSource, ageMs: number | null): MeshNodeStaleness {
1106
- if (dataSource === 'self' || dataSource === 'live') return 'fresh';
1107
- if (ageMs === null) return 'unknown';
1108
- if (ageMs < MESH_FRESHNESS_FRESH_MS) return 'fresh';
1109
- if (ageMs < MESH_FRESHNESS_RECENT_MS) return 'recent';
1110
- return 'stale';
1111
- }
1112
-
1113
- /**
1114
- * Build the additive per-node `dataFreshness` marker. This NEVER mutates any
1115
- * existing field — it only adds an explicit, machine-readable answer to the
1116
- * question the legacy fields blurred: is this node's data live (just probed),
1117
- * cached (held truth, maybe old), or absent because the peer was unreachable?
1118
- *
1119
- * The crucial separation: an UNREACHABLE peer (P2P probe failed / not connected)
1120
- * is no longer indistinguishable from an idle/EMPTY node. Both used to render as
1121
- * `health:'unknown'` with no sessions; now `dataFreshness.dataSource` and
1122
- * `reachable` tell them apart so a coordinator never reads a dead peer as "online
1123
- * but doing nothing".
1124
- */
1125
- export function buildMeshNodeDataFreshness(args: {
1126
- status: Record<string, unknown>;
1127
- node?: any;
1128
- isSelfNode: boolean;
1129
- daemonId?: string;
1130
- /** True when this node was stamped with a fresh live git probe this call. */
1131
- liveTruthProbed: boolean;
1132
- /** True when direct-peer-truth accounting classified this node unavailable. */
1133
- directTruthUnavailable?: boolean;
1134
- now?: () => number;
1135
- }): Record<string, unknown> {
1136
- const { status, node, isSelfNode, daemonId, liveTruthProbed, directTruthUnavailable } = args;
1137
- const now = args.now ?? Date.now;
1138
- const connection = readObjectRecord(status.connection);
1139
- const connectionState = readStringValue(connection.state);
1140
- const git = readObjectRecord(status.git);
1141
- const hasGit = readBooleanValue(git.isGitRepo) === true
1142
- || !!readStringValue(git.branch, git.headCommit, git.head, git.upstream);
1143
- const connectionFreshAt = toIsoTimestamp(connection.lastCommandAt ?? connection.lastConnectedAt ?? connection.lastStateChangeAt);
1144
- // Provenance-aware probe time. A FRESH probe this call writes a genuine
1145
- // git.lastCheckedAt, so trust it for live nodes. Held/standing truth, however,
1146
- // is re-normalized through pickBestTransitGitStatus which stamps lastCheckedAt
1147
- // with Date.now() on assembly (git-normalize.ts) — so status.git.lastCheckedAt
1148
- // would falsely read fresh. For cached nodes prefer the authentic peer-reported
1149
- // check time persisted on node.lastGit.checkedAt / cachedStatus, so a genuinely
1150
- // old cache is correctly reported stale.
1151
- const liveGitCheckedAt = liveTruthProbed ? toIsoTimestamp(git.lastCheckedAt) : null;
1152
- const heldGit = readObjectRecord(node?.lastGit ?? node?.last_git);
1153
- const heldCheckedAt = toIsoTimestamp(heldGit.checkedAt ?? heldGit.checked_at);
1154
- const cachedStatus = readObjectRecord(node?.cachedStatus);
1155
- const cachedGitCheckedAt = toIsoTimestamp(readObjectRecord(cachedStatus.git).lastCheckedAt);
1156
- const lastProbeAt = liveGitCheckedAt
1157
- ?? heldCheckedAt
1158
- ?? cachedGitCheckedAt
1159
- ?? toIsoTimestamp(git.lastCheckedAt)
1160
- ?? connectionFreshAt
1161
- ?? toIsoTimestamp(status.updatedAt)
1162
- ?? toIsoTimestamp(status.lastSeenAt);
1163
-
1164
- // connectionReachable: true (connected) / false (terminally down) / null (unknown,
1165
- // not yet reported) — used so a cached/pending node carries the coordinator's last
1166
- // known transport state rather than guessing.
1167
- const connectionReachable: boolean | null = connectionState === 'connected'
1168
- ? true
1169
- : (!connectionState || connectionState === 'unknown' || connectionState === 'connecting')
1170
- ? (connectionState === 'connecting' ? true : null)
1171
- : false;
1172
-
1173
- let dataSource: MeshNodeDataSource;
1174
- let reachable: boolean | null;
1175
- if (isSelfNode) {
1176
- dataSource = 'self';
1177
- reachable = true;
1178
- } else if (liveTruthProbed) {
1179
- dataSource = 'live';
1180
- reachable = true;
1181
- } else if (readBooleanValue(status.gitProbePending) === true) {
1182
- dataSource = 'pending';
1183
- reachable = connectionReachable;
1184
- } else if (directTruthUnavailable) {
1185
- dataSource = 'unreachable';
1186
- reachable = false;
1187
- } else if (hasGit) {
1188
- dataSource = 'cached';
1189
- reachable = connectionReachable;
1190
- } else if (!daemonId) {
1191
- dataSource = 'unconfigured';
1192
- reachable = null;
1193
- } else if (connectionState === 'connected') {
1194
- dataSource = 'empty';
1195
- reachable = true;
1196
- } else {
1197
- dataSource = 'unreachable';
1198
- reachable = false;
1199
- }
1200
-
1201
- const probeOk = dataSource === 'live' || dataSource === 'self';
1202
- let ageMs: number | null = null;
1203
- if (lastProbeAt) {
1204
- const parsed = Date.parse(lastProbeAt);
1205
- if (Number.isFinite(parsed)) ageMs = Math.max(0, now() - parsed);
1206
- }
1207
- const staleness = classifyMeshNodeStaleness(dataSource, ageMs);
1208
-
1209
- return {
1210
- dataSource,
1211
- probeOk,
1212
- reachable,
1213
- lastProbeAt: lastProbeAt ?? null,
1214
- ageMs,
1215
- staleness,
1216
- };
1217
- }
1218
-
1219
- /**
1220
- * Canonical live-probe → freshness adapter. The coordinator-facing mesh_status
1221
- * (mcp-server `meshStatus`) builds each node entry from a SINGLE fresh git_status
1222
- * probe that either returns (live truth) or throws (peer unreachable). It used to
1223
- * hand-reconstruct the freshness INPUT inline — a synthetic `{ git, connection }`
1224
- * status plus the directTruthUnavailable/liveTruthProbed wiring — which is exactly
1225
- * how a field added to `buildMeshNodeDataFreshness`'s input contract ends up "wired
1226
- * on the daemon surface, null on the coordinator surface" (the rc.371
1227
- * null-everywhere regression). Routing every live-probe surface through this one
1228
- * adapter keeps the marker derivation canonical: there is a SINGLE place that turns
1229
- * a probe outcome into freshness args, so the two mesh_status surfaces cannot drift.
1230
- *
1231
- * `liveTruthProbed` true → the probe returned (live/self truth); false → it threw,
1232
- * so a configured peer is unreachable while an unconfigured node (no daemonId) falls
1233
- * through to the classifier's `unconfigured` branch.
1234
- */
1235
- export function buildMeshNodeProbeFreshness(args: {
1236
- /** The git snapshot this probe stamped on the node entry (entry.git). */
1237
- git: unknown;
1238
- /** True when the fresh git_status probe RETURNED (live truth); false when it threw. */
1239
- liveTruthProbed: boolean;
1240
- isSelfNode: boolean;
1241
- /** The node's resolved daemonId; absent → unconfigured node. */
1242
- daemonId?: string;
1243
- /** The mesh node record, for held-git fallback when the probe did not return live. */
1244
- node?: any;
1245
- now?: () => number;
1246
- }): Record<string, unknown> {
1247
- const { git, liveTruthProbed, isSelfNode, daemonId, node, now } = args;
1248
- const status: Record<string, unknown> = {
1249
- git,
1250
- connection: { state: liveTruthProbed ? 'connected' : 'disconnected' },
1251
- };
1252
- if (liveTruthProbed) status[MESH_NODE_LIVE_TRUTH_MARKER] = true;
1253
- return buildMeshNodeDataFreshness({
1254
- status,
1255
- node,
1256
- isSelfNode,
1257
- daemonId,
1258
- liveTruthProbed,
1259
- directTruthUnavailable: !liveTruthProbed && !!daemonId,
1260
- now,
1261
- });
1262
- }
1263
-
1264
- export function finalizeMeshNodeStatus(args: {
1265
- status: Record<string, unknown>;
1266
- node: any;
1267
- daemonId?: string;
1268
- isSelfNode: boolean;
1269
- /** True when direct-peer-truth accounting classified this node unavailable. */
1270
- directTruthUnavailable?: boolean;
1271
- }): void {
1272
- const { status, node, daemonId, isSelfNode, directTruthUnavailable } = args;
1273
- if (!readStringValue(status.machineStatus)) {
1274
- const cachedStatus = readObjectRecord(node?.cachedStatus);
1275
- const machineStatus = readStringValue(cachedStatus.machineStatus, cachedStatus.machine_status, node?.machineStatus);
1276
- if (machineStatus) status.machineStatus = machineStatus;
1277
- }
1278
- synthesizeMeshNodeFreshnessFromConnection(status);
1279
- // Stamp the additive freshness/reachability marker before any early return so
1280
- // every node — including bootstrap-blocked ones — carries it. Consume and drop
1281
- // the transient live-probe marker so it never leaks into the response.
1282
- const liveTruthProbed = readBooleanValue(status[MESH_NODE_LIVE_TRUTH_MARKER]) === true;
1283
- delete status[MESH_NODE_LIVE_TRUTH_MARKER];
1284
- status.dataFreshness = buildMeshNodeDataFreshness({
1285
- status,
1286
- node,
1287
- isSelfNode,
1288
- daemonId,
1289
- liveTruthProbed,
1290
- directTruthUnavailable,
1291
- });
1292
- const bootstrap = readObjectRecord(node?.worktreeBootstrap);
1293
- if (node?.isLocalWorktree && readStringValue(bootstrap.status)) {
1294
- status.worktreeBootstrap = bootstrap;
1295
- if (bootstrap.status === 'failed' && bootstrap.required !== false) {
1296
- status.launchReady = false;
1297
- status.launchBlockedReason = 'worktree_bootstrap_failed';
1298
- status.launchBlockedMessage = readStringValue(bootstrap.error)
1299
- || 'Required worktree bootstrap failed; resolve it before launching an agent into this node.';
1300
- status.recoveryHint = 'Run retry_mesh_node_bootstrap to retry';
1301
- return;
1302
- }
1303
- if (bootstrap.status === 'running' && bootstrap.required !== false) {
1304
- status.launchReady = false;
1305
- status.launchBlockedReason = 'worktree_bootstrap_running';
1306
- status.launchBlockedMessage = 'Required worktree bootstrap is still running; wait for it to finish before launching an agent into this node.';
1307
- return;
1308
- }
1309
- }
1310
- const connectionState = readStringValue(readObjectRecord(status.connection).state);
1311
- status.launchReady = !!daemonId && (
1312
- readStringValue(status.machineStatus) === 'online'
1313
- || connectionState === 'connected'
1314
- || isSelfNode
1315
- );
1316
- }
1317
-
1318
- // Reads a positive integer timeout (ms) from an env var, clamped to [1s, 120s];
1319
- // falls back to the default when unset or out of range. Lets slow cross-machine
1320
- // peers (e.g. a TURN-relayed Windows daemon whose git_status RTT is 10-18s) be
1321
- // tuned without a rebuild.
1322
- function readMeshTimeoutEnvMs(name: string, defaultMs: number): number {
1323
- const raw = process.env[name]?.trim();
1324
- if (!raw) return defaultMs;
1325
- const parsed = Number.parseInt(raw, 10);
1326
- if (Number.isFinite(parsed) && parsed >= 1_000 && parsed <= 120_000) return parsed;
1327
- return defaultMs;
1328
- }
1329
-
1330
- // Direct-peer git_status probe timeout for the dashboard's requireDirectPeerTruth
1331
- // bootstrap. The previous hard-coded 8s/12s were shorter than the real P2P
1332
- // round-trip to slow (often TURN-relayed) peers, so such a node was permanently
1333
- // marked unavailable and blocked the whole mesh graph. Default raised to 25s
1334
- // (still under the P2P REQUEST_TIMEOUT of 30s) and made env-overridable.
1335
- export const MESH_DIRECT_PROBE_TIMEOUT_MS = readMeshTimeoutEnvMs('MESH_DIRECT_PROBE_TIMEOUT_MS', 25_000);
1336
- export const MESH_DIRECT_PROBE_RETRY_TIMEOUT_MS = readMeshTimeoutEnvMs('MESH_DIRECT_PROBE_RETRY_TIMEOUT_MS', 25_000);
1337
- // Cold-open warmup budget for the FIRST direct-peer probe to a peer whose mesh
1338
- // DataChannel is not open yet. A fresh cross-machine, TURN-relayed handshake
1339
- // (ICE gather + TURN allocation + DTLS across two residential networks) routinely
1340
- // needs many seconds. Charging that warmup against the response deadline
1341
- // (MESH_DIRECT_PROBE_TIMEOUT_MS) made the very first git_status to a cold peer
1342
- // false-timeout, after which the warm retry — reusing the now-open channel —
1343
- // succeeded: the classic cold-open signature. This budget bounds ONLY the
1344
- // "channel not open yet" phase; once the channel opens the response deadline
1345
- // governs the round trip. A genuine connect failure still rejects immediately —
1346
- // the mesh manager fails the peer the instant its PeerConnection state goes
1347
- // terminal, and isMeshConnectionDefinitivelyDown pre-gates an already-dead peer —
1348
- // so this never masks a real failure for the whole window; it only grants a
1349
- // still-handshaking peer the time it legitimately needs. Matches the daemon-cloud
1350
- // DaemonMeshManager CONNECT_TIMEOUT_MS (45s). Env-overridable for very slow links.
1351
- export const MESH_DIRECT_PROBE_CONNECT_TIMEOUT_MS = readMeshTimeoutEnvMs('MESH_DIRECT_PROBE_CONNECT_TIMEOUT_MS', 45_000);
1352
- // How long a successful per-peer git_status probe stays fresh enough to be
1353
- // reused instead of issuing another blocking `refreshUpstream:true` fan-out.
1354
- // A slow (TURN-relayed) peer's probe can take 9-23s, and the dashboard's
1355
- // auto-retry loop re-fires every few seconds; without this gate every retry
1356
- // would start a brand new probe storm to the same peer. Within this window the
1357
- // last successful result is reused so a refresh quiesces instead of looping.
1358
- // Min-clamped to 1s by readMeshTimeoutEnvMs; raise via env for very slow peers.
1359
- const MESH_DIRECT_PROBE_REUSE_MS = readMeshTimeoutEnvMs('MESH_DIRECT_PROBE_REUSE_MS', 12_000);
1360
-
1361
- /**
1362
- * De-duplicates and rate-limits per-peer git_status probes so a single mesh
1363
- * refresh — or a burst of refreshes from the dashboard auto-retry loop — cannot
1364
- * launch a storm of concurrent/back-to-back `refreshUpstream:true` commands to
1365
- * the same slow peer.
1366
- *
1367
- * Two gates, both keyed by `daemonId::workspace`:
1368
- * - In-flight dedup: a second probe for a key with a probe already running
1369
- * shares (awaits) the in-flight promise instead of issuing a second command.
1370
- * - Recently-probed reuse: a successful probe younger than `reuseMs` is reused
1371
- * verbatim instead of issuing a fresh probe. Failures are NOT cached (so a
1372
- * transient timeout doesn't pin a peer to "no truth" for the whole window).
1373
- *
1374
- * Lives on the router instance so the gate spans separate mesh_status calls,
1375
- * which is exactly where the refresh storm happens.
1376
- */
1377
- export class MeshGitProbeCache {
1378
- private inflight = new Map<string, Promise<Record<string, unknown> | null>>();
1379
- private recent = new Map<string, { at: number; value: Record<string, unknown> }>();
1380
-
1381
- constructor(private readonly reuseMs: number, private readonly now: () => number = Date.now) {}
1382
-
1383
- private key(daemonId: string, workspace: string): string {
1384
- return `${daemonId}::${workspace}`;
1385
- }
1386
-
1387
- /**
1388
- * Run `probe` for this peer, but reuse a fresh recent result or an in-flight
1389
- * probe for the same key when one is available. `probe` is only invoked when
1390
- * neither gate is satisfied.
1391
- */
1392
- async probe(
1393
- daemonId: string,
1394
- workspace: string,
1395
- probe: () => Promise<Record<string, unknown> | null>,
1396
- ): Promise<Record<string, unknown> | null> {
1397
- const key = this.key(daemonId, workspace);
1398
- const cached = this.recent.get(key);
1399
- if (cached && this.now() - cached.at < this.reuseMs) {
1400
- return cached.value;
1401
- }
1402
- const existing = this.inflight.get(key);
1403
- if (existing) return existing;
1404
- const pending = (async () => {
1405
- const result = await probe();
1406
- if (result) this.recent.set(key, { at: this.now(), value: result });
1407
- return result;
1408
- })();
1409
- this.inflight.set(key, pending);
1410
- try {
1411
- return await pending;
1412
- } finally {
1413
- // Only clear the slot if it is still ours — a later overlapping call
1414
- // would have reused this very promise, so it is safe to delete here.
1415
- if (this.inflight.get(key) === pending) this.inflight.delete(key);
1416
- }
1417
- }
1418
- }
1419
-
1420
- // The warmup-aware deadline now lives in the dependency-free mesh leaf so BOTH the
1421
- // dashboard git_status probe (here) and the general task-dispatch path
1422
- // (mesh/mesh-events-coordinator.ts) can share it without an import cycle. Re-exported
1423
- // for the existing `from '../commands/router.js'` callers/tests.
1424
- export { awaitWithWarmupDeadline };
1425
-
1426
- async function probeRemoteMeshGitStatus(args: {
1427
- dispatchMeshCommand?: (daemonId: string, cmd: string, args: Record<string, unknown>) => Promise<unknown>;
1428
- daemonId: string;
1429
- workspace: string;
1430
- // Response deadline — applies only once the peer's DataChannel is open (warm).
1431
- responseTimeoutMs: number;
1432
- // Cold-open warmup budget — applies only while the channel is still opening.
1433
- connectTimeoutMs: number;
1434
- // Live peer connection snapshot getter; lets the deadline tell "still warming
1435
- // up" apart from "warm but slow". Absent → degrade conservatively (fail-loud,
1436
- // combined connect+response window) rather than silently assuming "always warm"
1437
- // — see resolveWarmupDeadlineOpts.
1438
- getConnection?: (daemonId: string) => Record<string, unknown> | null;
1439
- }): Promise<Record<string, unknown> | null> {
1440
- if (!args.dispatchMeshCommand) return null;
1441
- // Fire the dispatch first — this is what drives the mesh manager to ensure /
1442
- // open the peer connection. The warmup-aware deadline then charges the
1443
- // cold-open handshake to the connect budget and only the warm round trip to
1444
- // the response budget, so the first probe to a cold peer is no longer
1445
- // false-timed-out before its channel has even opened.
1446
- const dispatch = args.dispatchMeshCommand(args.daemonId, 'git_status', { workspace: args.workspace, refreshUpstream: true });
1447
- // A missing connection getter no longer silently becomes `() => true`
1448
- // ("always warm") — that charged a still-opening channel against the response
1449
- // budget and re-introduced the cold-open false-timeout. resolveWarmupDeadlineOpts
1450
- // warns once per peer and grants the combined budget instead.
1451
- const remoteResult = await awaitWithWarmupDeadline(dispatch, resolveWarmupDeadlineOpts({
1452
- getConnection: args.getConnection,
1453
- daemonId: args.daemonId,
1454
- connectTimeoutMs: args.connectTimeoutMs,
1455
- responseTimeoutMs: args.responseTimeoutMs,
1456
- onMissingGetter: warnMeshWarmupGetterMissingOnce,
1457
- })) as any;
1458
- const remoteGit = remoteResult?.status ?? remoteResult?.git ?? remoteResult;
1459
- if (!remoteGit || typeof remoteGit !== 'object' || typeof remoteGit.isGitRepo !== 'boolean') return null;
1460
- // The member daemon stamps its own platform/arch onto the git_status result
1461
- // envelope (see git-commands.ts). Reflect them onto the returned git object
1462
- // under non-colliding reporter* keys so recordInlineMeshDirectGitTruth can
1463
- // persist them to node.userOverrides without touching the git status shape.
1464
- const reporterPlatform = readStringValue(remoteResult?.reporterPlatform);
1465
- const reporterArch = readStringValue(remoteResult?.reporterArch);
1466
- const git = remoteGit as Record<string, unknown>;
1467
- if (reporterPlatform) git.reporterPlatform = reporterPlatform;
1468
- if (reporterArch) git.reporterArch = reporterArch;
1469
- return git;
1470
- }
1471
42
 
1472
- /** Number of bounded retries after the initial direct-peer git probe attempt. */
1473
- const MESH_DIRECT_PROBE_MAX_RETRIES = 2;
1474
-
1475
- function readMeshConnectionState(connection: Record<string, unknown> | null | undefined): string | undefined {
1476
- return readStringValue((connection as any)?.state);
1477
- }
1478
-
1479
- // Fail-loud (but throttled) trace for the degraded-warmup case: a direct-peer mesh
1480
- // dispatch ran with NO live connection getter wired. This is a misconfiguration in a
1481
- // P2P-capable daemon (the getter should be present), and the old `() => true`
1482
- // fallback hid it while silently re-introducing the cold-open false-timeout. Warn
1483
- // once per peer so the degrade is visible without flooding the log on every probe.
1484
- const meshWarmupGetterMissingWarned = new Set<string>();
1485
- function warnMeshWarmupGetterMissingOnce(daemonId: string): void {
1486
- if (meshWarmupGetterMissingWarned.has(daemonId)) return;
1487
- meshWarmupGetterMissingWarned.add(daemonId);
1488
- LOG.warn('Mesh', `Mesh peer connection getter unavailable for ${String(daemonId).slice(0, 12)}; warmup deadline degraded to the combined connect+response window (cannot observe DataChannel open). This avoids a cold-open false-timeout but loses warm/cold precision — wire getMeshPeerConnectionStatus on this daemon.`);
1489
- }
1490
-
1491
- /**
1492
- * Connection states that mean the peer is definitively NOT reachable right now —
1493
- * an offline machine (no peer entry at all) or a transport that has dropped
1494
- * (failed/closed/disconnected). Probing such a peer would just burn the full
1495
- * MESH_DIRECT_PROBE_TIMEOUT_MS window before timing out, so the cold-open of the
1496
- * mesh graph stalls 25s behind one powered-off node. `connecting` is deliberately
1497
- * NOT here: a peer mid-handshake may complete during the probe window, so it still
1498
- * gets its attempt. Held standing git truth is consulted by the caller BEFORE this
1499
- * runs, so the invariant "connected+held is never unavailable" is untouched — this
1500
- * only short-circuits a peer that has no usable transport to probe over.
1501
- */
1502
- function isMeshConnectionDefinitivelyDown(
1503
- connection: Record<string, unknown> | null | undefined,
1504
- ): boolean {
1505
- if (!connection) return true;
1506
- const state = readMeshConnectionState(connection);
1507
- return state === 'failed' || state === 'closed' || state === 'disconnected';
1508
- }
1509
-
1510
- /**
1511
- * Probe a remote peer's git_status with a bounded retry budget, but only while
1512
- * the peer is reported `connected`. A single slow (often TURN-relayed) peer can
1513
- * exceed one probe window; retrying — with the connection re-checked before each
1514
- * attempt so we abandon a peer that dropped — recovers it without blocking the
1515
- * mesh forever. Shared by the bootstrap hydrate path and the per-node render
1516
- * path so both treat a connected-but-slow peer identically.
1517
- *
1518
- * Returns the git status on success, or null if every attempt failed/timed out
1519
- * (caller decides how to classify). `getConnection` is consulted before each
1520
- * attempt; a non-`connected` state short-circuits the retry loop (the very first
1521
- * attempt always runs so a missing connection getter still gets one try).
1522
- */
1523
- export async function probeRemoteMeshGitStatusWithRetry(args: {
1524
- dispatchMeshCommand?: (daemonId: string, cmd: string, args: Record<string, unknown>) => Promise<unknown>;
1525
- daemonId: string;
1526
- workspace: string;
1527
- timeoutMs: number;
1528
- /** Per-attempt timeout for retries (attempts > 0); defaults to timeoutMs. */
1529
- retryTimeoutMs?: number;
1530
- /** Cold-open warmup budget per attempt; defaults to MESH_DIRECT_PROBE_CONNECT_TIMEOUT_MS. */
1531
- connectTimeoutMs?: number;
1532
- getConnection?: (daemonId: string) => Record<string, unknown> | null;
1533
- onConnection?: (connection: Record<string, unknown>) => void;
1534
- }): Promise<Record<string, unknown> | null> {
1535
- // Fast-fail an offline / dropped peer BEFORE the first attempt. Previously the
1536
- // liveness re-check only ran *between* attempts, so a powered-off node still ate
1537
- // the full first MESH_DIRECT_PROBE_TIMEOUT_MS (25s) window — stalling the mesh
1538
- // graph cold-open behind one dead machine. If a connection getter is wired and
1539
- // it reports the peer as definitively down (no peer entry / failed / closed /
1540
- // disconnected), skip straight to "no truth" instead of awaiting a 25s timeout.
1541
- // A `connecting` peer still gets its attempt (it may complete mid-probe). No
1542
- // onConnection side effect here: this is a pure liveness gate, and the caller's
1543
- // own connection read already seeds status.connection — only the between-attempt
1544
- // path needs to surface a freshly-observed connection.
1545
- if (args.getConnection && isMeshConnectionDefinitivelyDown(args.getConnection(args.daemonId))) {
1546
- return null;
1547
- }
1548
- for (let attempt = 0; attempt <= MESH_DIRECT_PROBE_MAX_RETRIES; attempt += 1) {
1549
- if (attempt > 0) {
1550
- // Re-check liveness before spending another probe window; a peer that
1551
- // dropped between attempts is not worth retrying.
1552
- const connection = args.getConnection?.(args.daemonId);
1553
- if (args.getConnection && readMeshConnectionState(connection) !== 'connected') break;
1554
- if (connection) args.onConnection?.(connection);
1555
- // Exponential backoff: 250ms, 500ms before attempts 1 and 2.
1556
- await new Promise(resolve => setTimeout(resolve, 250 * 2 ** (attempt - 1)));
1557
- }
1558
- try {
1559
- const remoteGit = await probeRemoteMeshGitStatus({
1560
- dispatchMeshCommand: args.dispatchMeshCommand,
1561
- daemonId: args.daemonId,
1562
- workspace: args.workspace,
1563
- responseTimeoutMs: attempt === 0 ? args.timeoutMs : (args.retryTimeoutMs ?? args.timeoutMs),
1564
- connectTimeoutMs: args.connectTimeoutMs ?? MESH_DIRECT_PROBE_CONNECT_TIMEOUT_MS,
1565
- getConnection: args.getConnection,
1566
- });
1567
- if (remoteGit) return remoteGit;
1568
- } catch {
1569
- // Timed out or P2P error — fall through to the next bounded attempt.
1570
- }
1571
- }
1572
- return null;
1573
- }
1574
-
1575
- export async function hydrateInlineMeshDirectTruth(args: {
1576
- mesh: any;
1577
- meshSource: 'inline_cache' | 'inline_bootstrap' | 'local_config';
1578
- dispatchMeshCommand?: (daemonId: string, cmd: string, args: Record<string, unknown>) => Promise<unknown>;
1579
- getMeshPeerConnectionStatus?: (daemonId: string) => Record<string, unknown> | null;
1580
- statusInstanceId?: string;
1581
- localMachineId?: string;
1582
- // Standing-state model: the default (non-refresh) bootstrap load must NOT
1583
- // fan out a blocking git_status probe to every peer — a single slow
1584
- // (TURN-relayed) peer would time out and mark the whole mesh unavailable,
1585
- // blocking the graph. When false, a non-local node is satisfied from its
1586
- // held standing git truth (lastGit / cachedStatus reflected via mesh
1587
- // events) and is never pushed to unavailableNodeIds merely because no live
1588
- // probe was attempted. Only an explicit refresh (probeRemotePeers=true)
1589
- // performs the fan-out and classifies an unreachable peer as unavailable.
1590
- probeRemotePeers: boolean;
1591
- // Optional shared probe cache: dedups concurrent probes and reuses a
1592
- // recently-probed peer's result instead of re-issuing a blocking
1593
- // refreshUpstream probe within the reuse window.
1594
- probeCache?: MeshGitProbeCache;
1595
- }): Promise<{
1596
- directEvidenceCount: number;
1597
- localConfirmedCount: number;
1598
- peerAttemptedCount: number;
1599
- peerConfirmedCount: number;
1600
- standingEvidenceCount: number;
1601
- unavailableNodeIds: string[];
1602
- deadNodeIds: string[];
1603
- }> {
1604
- const nodes = Array.isArray(args.mesh?.nodes) ? args.mesh.nodes : [];
1605
- if (!nodes.length) {
1606
- return {
1607
- directEvidenceCount: 0,
1608
- localConfirmedCount: 0,
1609
- peerAttemptedCount: 0,
1610
- peerConfirmedCount: 0,
1611
- standingEvidenceCount: 0,
1612
- unavailableNodeIds: [],
1613
- deadNodeIds: [],
1614
- };
1615
- }
1616
-
1617
- const selectedCoordinatorNodeId = readStringValue(
1618
- args.mesh?.coordinator?.preferredNodeId,
1619
- nodes[0]?.id,
1620
- nodes[0]?.nodeId,
1621
- );
1622
-
1623
- let localConfirmedCount = 0;
1624
- let peerAttemptedCount = 0;
1625
- let peerConfirmedCount = 0;
1626
- let standingEvidenceCount = 0;
1627
- const unavailableNodeIds: string[] = [];
1628
- const deadNodeIds: string[] = [];
1629
-
1630
- for (const [nodeIndex, node] of nodes.entries()) {
1631
- const nodeId = normalizeMeshNodeId(node) || `node_${nodeIndex}`;
1632
- const workspace = readStringValue(node?.workspace);
1633
- const daemonId = readStringValue(node?.daemonId);
1634
- const isSelfNode = Boolean(
1635
- nodeId && selectedCoordinatorNodeId && nodeId === selectedCoordinatorNodeId,
1636
- ) || Boolean(
1637
- daemonId && (daemonIdsEquivalent(daemonId, args.localMachineId) || daemonIdsEquivalent(daemonId, args.statusInstanceId)),
1638
- ) || Boolean(args.meshSource !== 'local_config' && nodeIndex === 0);
1639
-
1640
- // A dead local worktree owned by this coordinator (isLocalWorktree, the
1641
- // node's daemon is us, workspace path gone) has no live truth and cannot
1642
- // be probed — the directory it would self-probe no longer exists. Exclude
1643
- // it entirely from direct-peer-truth accounting: do not probe it, do not
1644
- // attempt it, do not push it to unavailableNodeIds (which would otherwise
1645
- // wedge the graph in a permanent direct_peer_truth_unavailable). This is
1646
- // strictly self + isLocalWorktree + absent-path; remote peers and nodes
1647
- // whose workspace still exists are unaffected and stay classifiable.
1648
- const isSelfDaemonNode = Boolean(
1649
- daemonId && (daemonIdsEquivalent(daemonId, args.localMachineId) || daemonIdsEquivalent(daemonId, args.statusInstanceId)),
1650
- );
1651
- if ((isSelfNode || isSelfDaemonNode) && isDeadLocalWorktreeNode(node)) {
1652
- deadNodeIds.push(nodeId);
1653
- continue;
1654
- }
1655
-
1656
- if (!workspace) {
1657
- if (!isSelfNode && daemonId) unavailableNodeIds.push(nodeId);
1658
- continue;
1659
- }
1660
-
1661
- if (fs.existsSync(workspace)) {
1662
- try {
1663
- const localGit = await getGitRepoStatus(workspace, { timeoutMs: 10_000, refreshUpstream: true });
1664
- if (localGit?.isGitRepo) {
1665
- const reporter = recordInlineMeshDirectGitTruth(node, localGit as unknown as Record<string, unknown>, 'selected_coordinator_local_git');
1666
- persistNodeReporterPlatform(args.meshSource, args.mesh, nodeId, reporter);
1667
- localConfirmedCount += 1;
1668
- continue;
1669
- }
1670
- } catch {
1671
- // Fall through to remote classification.
1672
- }
1673
- }
1674
-
1675
- // Standing-state first: a non-local peer's held git truth (reflected
1676
- // from its self-emitted mesh events into node.lastGit / cachedStatus)
1677
- // counts as direct evidence without any probe. On the default load this
1678
- // is the ONLY thing we consult — no fan-out, so one slow peer can't
1679
- // block the bootstrap.
1680
- const standingGit = buildInlineMeshTransitGitStatus(node);
1681
- if (standingGit) {
1682
- standingEvidenceCount += 1;
1683
- continue;
1684
- }
1685
-
1686
- if (!args.probeRemotePeers) {
1687
- // Default (non-refresh) load: a peer with no held truth yet is left
1688
- // pending (the per-node loop marks it gitProbePending and the graph
1689
- // shows setup inventory for it). It is NOT unavailable — the graph
1690
- // must still render. An explicit refresh will fan out and freshen it.
1691
- continue;
1692
- }
1693
-
1694
- if (!daemonId || !args.dispatchMeshCommand) {
1695
- if (!isSelfNode) unavailableNodeIds.push(nodeId);
1696
- continue;
1697
- }
1698
-
1699
- peerAttemptedCount += 1;
1700
- // Bounded retry, gated on the peer staying `connected`: a slow
1701
- // (TURN-relayed) peer that just exceeds one probe window is recovered
1702
- // instead of being hard-failed. The connection is re-checked before each
1703
- // retry so a peer that actually dropped is abandoned promptly. Routed
1704
- // through the shared probe cache so a refresh burst reuses a recent
1705
- // result / shares an in-flight probe instead of storming the peer.
1706
- const runProbe = () => probeRemoteMeshGitStatusWithRetry({
1707
- dispatchMeshCommand: args.dispatchMeshCommand,
1708
- daemonId,
1709
- workspace,
1710
- timeoutMs: MESH_DIRECT_PROBE_TIMEOUT_MS,
1711
- retryTimeoutMs: MESH_DIRECT_PROBE_RETRY_TIMEOUT_MS,
1712
- connectTimeoutMs: MESH_DIRECT_PROBE_CONNECT_TIMEOUT_MS,
1713
- getConnection: args.getMeshPeerConnectionStatus,
1714
- });
1715
- const remoteGit = args.probeCache
1716
- ? await args.probeCache.probe(daemonId, workspace, runProbe)
1717
- : await runProbe();
1718
- if (remoteGit) {
1719
- const reporter = recordInlineMeshDirectGitTruth(node, remoteGit, 'selected_coordinator_mesh_p2p_git');
1720
- persistNodeReporterPlatform(args.meshSource, args.mesh, nodeId, reporter);
1721
- peerConfirmedCount += 1;
1722
- continue;
1723
- }
1724
-
1725
- // Invariant: a connected peer that still holds standing git truth is
1726
- // never classified unavailable (standingGit short-circuited above, so by
1727
- // here there is no held truth). Only push to unavailable when the peer is
1728
- // not currently connected, or it is connected but every bounded probe
1729
- // failed — that is the genuine "connected, no truth, retries exhausted"
1730
- // case that drives the explicit-refresh hard-fail.
1731
- unavailableNodeIds.push(nodeId);
1732
- }
1733
-
1734
- return {
1735
- directEvidenceCount: localConfirmedCount + peerConfirmedCount + standingEvidenceCount,
1736
- localConfirmedCount,
1737
- peerAttemptedCount,
1738
- peerConfirmedCount,
1739
- standingEvidenceCount,
1740
- unavailableNodeIds,
1741
- deadNodeIds,
1742
- };
1743
- }
1744
-
1745
- export function summarizeMeshSessionRecord(record: any): Record<string, unknown> {
1746
- const meta = readObjectRecord(record?.meta);
1747
- const isSelfCoordinator = Boolean(readStringValue(meta.meshCoordinatorFor));
1748
- const chatStatus = readStringValue(record?.chatStatus, record?.activeChat?.status, meta.chatStatus, meta.sessionStatus);
1749
- const state = readLiveMeshSessionState(record);
1750
- const statusNote = isSelfCoordinator && (!chatStatus || chatStatus === 'idle' || state === 'idle')
1751
- ? 'Coordinator self status is sampled from the session host and may read idle while the coordinator is generating this response.'
1752
- : null;
1753
- return {
1754
- sessionId: readStringValue(record?.sessionId) || 'unknown',
1755
- providerType: readStringValue(record?.providerType),
1756
- state,
1757
- chatStatus,
1758
- lifecycle: readStringValue(record?.lifecycle),
1759
- surfaceKind: getSessionHostSurfaceKind(record as any),
1760
- recoveryState: readStringValue(meta.runtimeRecoveryState) ?? null,
1761
- workspace: readStringValue(record?.workspace) ?? null,
1762
- title: readStringValue(record?.displayName, record?.workspaceLabel) ?? null,
1763
- role: isSelfCoordinator ? 'coordinator' : readStringValue(meta.meshRole, meta.role) ?? null,
1764
- isSelfCoordinator,
1765
- statusNote,
1766
- createdAt: toIsoTimestamp(record?.createdAt ?? record?.created_at),
1767
- startedAt: toIsoTimestamp(record?.startedAt ?? record?.started_at ?? record?.spawnedAtMs ?? record?.spawned_at_ms),
1768
- lastActivityAt: toIsoTimestamp(record?.updatedAt ?? record?.lastActivityAt ?? record?.last_activity_at),
1769
- isCached: false,
1770
- };
1771
- }
1772
-
1773
- function liveSessionRecordMatchesMeshNode(record: any, meshId: string, nodeId: string, nodeWorkspace = '', nodeIsMissingLocalWorktree = false): boolean {
1774
- const recordNodeId = readStringValue(record?.meta?.meshNodeId);
1775
- if (!recordNodeId || recordNodeId !== nodeId) return false;
1776
- if (nodeIsMissingLocalWorktree) return false;
1777
- const recordWorkspace = readStringValue(record?.workspace);
1778
- // Normalized compare (shared WTCLAIM rule): a base node and a co-located worktree
1779
- // clone differ ONLY by workspace root, so a separator/case-skewed exact compare
1780
- // could wrongly keep a sibling worktree's session attached to this node.
1781
- if (nodeWorkspace && recordWorkspace && !meshWorkspacesEquivalent(recordWorkspace, nodeWorkspace)) return false;
1782
- const recordMeshId = readStringValue(record?.meta?.meshNodeFor);
1783
- return !recordMeshId || recordMeshId === meshId;
1784
- }
1785
-
1786
- function liveSessionRecordMatchesMeshWorkspace(record: any, meshId: string, workspace: string): boolean {
1787
- const recordWorkspace = readStringValue(record?.workspace);
1788
- if (!recordWorkspace || !workspace || !meshWorkspacesEquivalent(recordWorkspace, workspace)) return false;
1789
-
1790
- const recordMeshId = readStringValue(record?.meta?.meshNodeFor);
1791
- if (recordMeshId) return recordMeshId === meshId;
1792
-
1793
- return record?.meta?.launchedByCoordinator === true || !!readStringValue(record?.meta?.meshNodeId);
1794
- }
1795
-
1796
- export function readLiveMeshNodeWorkspace(args: {
1797
- meshId: string;
1798
- nodeId: string;
1799
- liveSessionRecords: any[];
1800
- allowCoordinatorSession?: boolean;
1801
- }): string {
1802
- const directNodeWorkspace = args.liveSessionRecords.find((record) => (
1803
- liveSessionRecordMatchesMeshNode(record, args.meshId, args.nodeId)
1804
- && readStringValue(record?.workspace)
1805
- ));
1806
- if (directNodeWorkspace) {
1807
- return readStringValue(directNodeWorkspace.workspace) || '';
1808
- }
1809
-
1810
- if (args.allowCoordinatorSession) {
1811
- const coordinatorWorkspace = args.liveSessionRecords.find((record) => (
1812
- readStringValue(record?.meta?.meshCoordinatorFor) === args.meshId
1813
- && readStringValue(record?.workspace)
1814
- ));
1815
- if (coordinatorWorkspace) {
1816
- return readStringValue(coordinatorWorkspace.workspace) || '';
1817
- }
1818
- }
1819
-
1820
- return '';
1821
- }
1822
-
1823
- export function collectLiveMeshSessionRecords(args: {
1824
- meshId: string;
1825
- node: any;
1826
- nodeId: string;
1827
- liveSessionRecords: any[];
1828
- allowCoordinatorSession?: boolean;
1829
- }): any[] {
1830
- const nodeWorkspace = readStringValue(args.node?.workspace);
1831
- const nodeIsMissingLocalWorktree = args.node?.isLocalWorktree === true
1832
- && !!nodeWorkspace
1833
- && !fs.existsSync(nodeWorkspace);
1834
- const matches = args.liveSessionRecords.filter((record) => {
1835
- const recordNodeId = readStringValue(record?.meta?.meshNodeId);
1836
- if (recordNodeId && recordNodeId !== args.nodeId) return false;
1837
- if (liveSessionRecordMatchesMeshNode(record, args.meshId, args.nodeId, nodeWorkspace || '', nodeIsMissingLocalWorktree)) return true;
1838
- if (nodeIsMissingLocalWorktree) return false;
1839
- return !!nodeWorkspace && liveSessionRecordMatchesMeshWorkspace(record, args.meshId, nodeWorkspace);
1840
- });
1841
-
1842
- if (args.allowCoordinatorSession) {
1843
- for (const record of args.liveSessionRecords) {
1844
- if (readStringValue(record?.meta?.meshCoordinatorFor) !== args.meshId) continue;
1845
- const sessionId = readStringValue(record?.sessionId);
1846
- if (sessionId && matches.some((entry) => readStringValue(entry?.sessionId) === sessionId)) continue;
1847
- matches.push(record);
1848
- }
1849
- }
1850
-
1851
- return matches;
1852
- }
1853
-
1854
- export function buildHistoricalMeshSessions(args: {
1855
- meshId: string;
1856
- nodes: any[];
1857
- liveSessionRecords: any[];
1858
- }): { count: number; sessions: Record<string, unknown>[]; instruction: string } | undefined {
1859
- const liveNodeIds = new Set<string>();
1860
- const liveWorkspaces = new Set<string>();
1861
- const missingLocalWorktreeNodeIds = new Set<string>();
1862
- for (const node of args.nodes || []) {
1863
- const nodeId = normalizeMeshNodeId(node);
1864
- const workspace = readStringValue(node?.workspace);
1865
- if (nodeId) liveNodeIds.add(nodeId);
1866
- if (workspace) liveWorkspaces.add(workspace);
1867
- if (nodeId && node?.isLocalWorktree === true && workspace && !fs.existsSync(workspace)) {
1868
- missingLocalWorktreeNodeIds.add(nodeId);
1869
- }
1870
- }
1871
-
1872
- const sessions: Record<string, unknown>[] = [];
1873
- for (const record of args.liveSessionRecords || []) {
1874
- const meta = readObjectRecord(record?.meta);
1875
- const recordMeshId = readStringValue(meta.meshNodeFor, meta.meshCoordinatorFor);
1876
- if (recordMeshId !== args.meshId) continue;
1877
- const recordNodeId = readStringValue(meta.meshNodeId);
1878
- const workspace = readStringValue(record?.workspace);
1879
- const removedNode = !!recordNodeId && (!liveNodeIds.has(recordNodeId) || missingLocalWorktreeNodeIds.has(recordNodeId));
1880
- const orphanedWorkspace = !!workspace && !liveWorkspaces.has(workspace) && meta.meshCoordinatorFor !== args.meshId;
1881
- if (!removedNode && !orphanedWorkspace) continue;
1882
- sessions.push({
1883
- ...summarizeMeshSessionRecord(record),
1884
- classification: removedNode ? 'removedNode' : 'orphanedSession',
1885
- historical: true,
1886
- meshNodeId: recordNodeId || null,
1887
- reason: removedNode
1888
- ? 'Session is tagged to a mesh node that is no longer in live membership.'
1889
- : 'Session workspace is no longer attached to a live mesh node.',
1890
- });
1891
- }
1892
- if (sessions.length === 0) return undefined;
1893
- return {
1894
- count: sessions.length,
1895
- sessions: sessions.slice(0, 5),
1896
- instruction: 'These sessions are separated from normal node activeSessions because their mesh node/workspace is no longer live. Use mesh_cleanup_sessions only if cleanup is intended.',
1897
- };
1898
- }
1899
-
1900
- export function applyCachedInlineMeshNodeStatus(
1901
- status: Record<string, unknown>,
1902
- node: any,
1903
- options?: { skipGit?: boolean; skipError?: boolean; skipHealth?: boolean },
1904
- ): boolean {
1905
- const cachedStatus = readObjectRecord(node?.cachedStatus);
1906
- const liveGit = buildInlineMeshTransitGitStatus(node);
1907
- const git = options?.skipGit ? undefined : (liveGit ?? buildCachedInlineMeshGitStatus(node));
1908
- const error = options?.skipError ? undefined : (liveGit ? undefined : readStringValue(cachedStatus.error, node?.error));
1909
- const health = options?.skipHealth ? undefined : (liveGit ? undefined : readStringValue(cachedStatus.health, node?.health));
1910
- const machineStatus = readStringValue(cachedStatus.machineStatus, node?.machineStatus);
1911
- const lastSeenAt = toIsoTimestamp(cachedStatus.lastSeenAt ?? cachedStatus.last_seen_at ?? node?.lastSeenAt ?? node?.last_seen_at);
1912
- const updatedAt = toIsoTimestamp(cachedStatus.updatedAt ?? cachedStatus.updated_at ?? node?.updatedAt ?? node?.updated_at);
1913
- const activeSessions = readCachedInlineMeshActiveSessions(node);
1914
- const activeSessionDetails = readCachedInlineMeshActiveSessionDetails(node);
1915
- if (!git && !error && !health && !machineStatus && !lastSeenAt && !updatedAt && activeSessions.length === 0) return false;
1916
- if (git) status.git = git;
1917
- if (error) status.error = error;
1918
- if (machineStatus) status.machineStatus = machineStatus;
1919
- if (lastSeenAt) status.lastSeenAt = lastSeenAt;
1920
- if (updatedAt) status.updatedAt = updatedAt;
1921
- if (activeSessions.length > 0) status.activeSessions = activeSessions;
1922
- if (activeSessionDetails.length > 0) status.activeSessionDetails = activeSessionDetails;
1923
- if (health) {
1924
- status.health = health;
1925
- return true;
1926
- }
1927
- if (git) {
1928
- status.health = deriveMeshNodeHealthFromGit(git);
1929
- return true;
1930
- }
1931
- return activeSessions.length > 0 || !!machineStatus || !!lastSeenAt || !!updatedAt;
1932
- }
1933
-
1934
- export async function resolveProviderTypeFromPriority(args: {
1935
- nodeId: string;
1936
- providerPriority: string[];
1937
- providerLoader: ProviderLoader;
1938
- onStatusChange?: () => void;
1939
- }): Promise<{ providerType?: string; error?: string }> {
1940
- if (!args.providerPriority.length) {
1941
- return { error: `Node '${args.nodeId}' has no providerPriority policy; pass cliType explicitly or configure node.policy.providerPriority` };
1942
- }
1943
-
1944
- const failed: string[] = [];
1945
- for (const requestedType of args.providerPriority) {
1946
- const normalizedType = args.providerLoader.resolveAlias(requestedType);
1947
- if (!args.providerLoader.isMachineProviderEnabled(normalizedType)) {
1948
- failed.push(`${requestedType}: disabled`);
1949
- continue;
1950
- }
1951
- const detected = await detectCLI(normalizedType, args.providerLoader, { includeVersion: false });
1952
- args.providerLoader.setCliDetectionResults([{
1953
- id: normalizedType,
1954
- installed: !!detected,
1955
- path: detected?.path,
1956
- }], false);
1957
- args.onStatusChange?.();
1958
- if (detected) return { providerType: normalizedType };
1959
- failed.push(`${requestedType}: not detected`);
1960
- }
1961
-
1962
- return { error: `No usable provider detected for node '${args.nodeId}' from providerPriority: ${failed.join('; ')}` };
1963
- }
1964
- export type MeshCoordinatorConfigFormat = 'claude_mcp_json' | 'hermes_config_yaml';
1965
- type MeshRefineValidationStatus = 'passed' | 'failed' | 'skipped';
1966
- type MeshRefineValidationCommand = MeshRefineValidationCommandPlan;
1967
-
1968
- type MeshRefineValidationSummary = {
1969
- status: MeshRefineValidationStatus;
1970
- required: true;
1971
- commandsRun: Array<Record<string, unknown>>;
1972
- bootstrapCommandsRun: Array<Record<string, unknown>>;
1973
- rejectedCommands: Array<Record<string, unknown>>;
1974
- skippedReason?: string;
1975
- failureKind?: string;
1976
- failureCode?: string;
1977
- /** Human-readable cause when failureKind === 'spawn_resolution_failed' (win32 .cmd shim, etc). */
1978
- spawnResolutionError?: string;
1979
- timeoutMs: number;
1980
- outputLimitBytes: number;
1981
- configSource?: string;
1982
- configSourceType?: string;
1983
- suggestions?: unknown[];
1984
- suggestedConfig?: unknown;
1985
- /**
1986
- * M2-3: the bootstrap stage recorded separately from validation so review
1987
- * surfaces can distinguish environment failures from validation failures.
1988
- * cached — worktree_bootstrap was 'ready' (staleInputs unchanged), skipped
1989
- * ran — worktree_bootstrap was stale/never-ran and re-ran successfully
1990
- * failed — bootstrap run failed (refine stops before validation)
1991
- * skipped — refine config validation.bootstrap === 'skip'
1992
- * legacy — deprecated validation.bootstrapCommands path was used
1993
- * not_configured — no bootstrap definition anywhere
1994
- */
1995
- bootstrap?: {
1996
- stage: 'cached' | 'ran' | 'failed' | 'skipped' | 'legacy' | 'not_configured';
1997
- status?: string;
1998
- skipped?: boolean;
1999
- configSource?: string;
2000
- staleReason?: string;
2001
- error?: string;
2002
- commandsRun?: Array<Record<string, unknown>>;
2003
- };
2004
- /** M2-2: deprecation notices from the refine config (e.g. bootstrapCommands). */
2005
- deprecationWarnings?: string[];
2006
- };
2007
-
2008
- type MeshRefineStageStatus = 'passed' | 'failed' | 'skipped';
2009
-
2010
- type MeshRefinePatchEquivalenceSummary = {
2011
- status: MeshRefineStageStatus;
2012
- equivalent: boolean;
2013
- baseHead: string;
2014
- branchHead: string;
2015
- mergeBase?: string;
2016
- mergedTree?: string;
2017
- expectedPatchId?: string;
2018
- actualPatchId?: string;
2019
- durationMs: number;
2020
- error?: string;
2021
- stdout?: string;
2022
- stderr?: string;
2023
- actionableHint?: MeshRefineSubmoduleConflictHint;
2024
- /**
2025
- * Set when a `merge-tree` submodule conflict was reclassified as a trivial
2026
- * gitlink fast-forward and the gate passed via a synthesized merge tree.
2027
- */
2028
- gitlinkTrivialFastForward?: {
2029
- resolved: boolean;
2030
- gitlinks: Array<{ path: string; baseCommit?: string; branchCommit?: string; fastForward: boolean }>;
2031
- reason?: string;
2032
- };
2033
- };
2034
-
2035
- type MeshRefineEffectiveDiffSummary = {
2036
- status: MeshRefineStageStatus;
2037
- /** True when there is at least one root-tree change between base and branch (incl. gitlink bumps). */
2038
- hasEffectiveDiff: boolean;
2039
- baseHead: string;
2040
- branchHead: string;
2041
- /** Root-level paths that differ between base and branch (capped). */
2042
- changedPaths?: string[];
2043
- /** Submodule paths with uncommitted/divergent commits but NO committed gitlink bump in the root tree. */
2044
- submoduleHints?: Array<{ path: string; reason: string }>;
2045
- durationMs: number;
2046
- error?: string;
2047
- stdout?: string;
2048
- stderr?: string;
2049
- };
2050
-
2051
- type MeshRefineSubmoduleConflictHint = {
2052
- kind: 'submodule_conflict';
2053
- message: string;
2054
- conflicts: Array<{
2055
- path: string;
2056
- baseCommit?: string;
2057
- branchCommit?: string;
2058
- }>;
2059
- nextSteps: string[];
2060
- };
2061
-
2062
- type MeshRefineSubmoduleAlignmentSummary = {
2063
- status: 'passed' | 'failed' | 'skipped';
2064
- changedGitlinkPaths: string[];
2065
- outOfSyncPaths: string[];
2066
- updatedPaths: string[];
2067
- verifiedPaths: string[];
2068
- durationMs: number;
2069
- reason?: string;
2070
- command?: string;
2071
- error?: string;
2072
- stdout?: string;
2073
- stderr?: string;
2074
- };
2075
-
2076
- type MeshRefineSubmoduleReachabilityEntry = {
2077
- path: string;
2078
- commit: string;
2079
- reachable: boolean;
2080
- publishRequired?: boolean;
2081
- autoPublishAllowed?: boolean;
2082
- autoPublishAttempted?: boolean;
2083
- autoPublishSucceeded?: boolean;
2084
- autoPublishVerified?: boolean;
2085
- autoPublishRefspec?: string;
2086
- autoPublishSkippedReason?: string;
2087
- importedFromWorktree?: boolean;
2088
- checkedLocal?: boolean;
2089
- localReachable?: boolean;
2090
- remote?: string;
2091
- remoteUrl?: string;
2092
- remoteReachable?: boolean;
2093
- remoteMainBranch?: string;
2094
- remoteMainReachable?: boolean;
2095
- fetchedFromOrigin?: boolean;
2096
- error?: string;
2097
- publishStdout?: string;
2098
- publishStderr?: string;
2099
- };
2100
-
2101
- type MeshRefineSubmoduleReachabilitySummary = {
2102
- status: MeshRefineStageStatus;
2103
- checked: number;
2104
- unreachable: MeshRefineSubmoduleReachabilityEntry[];
2105
- entries: MeshRefineSubmoduleReachabilityEntry[];
2106
- durationMs: number;
2107
- autoPublishAllowed?: boolean;
2108
- autoPublishPolicySource?: string;
2109
- error?: string;
2110
- };
2111
-
2112
- type MeshRefineAsyncJobStatus = 'accepted' | 'completed' | 'failed';
2113
-
2114
- export type MeshRefineJobHandle = {
2115
- success: true;
2116
- async: true;
2117
- status: MeshRefineAsyncJobStatus;
2118
- jobId: string;
2119
- interactionId: string;
2120
- meshId: string;
2121
- nodeId: string;
2122
- targetNodeId: string;
2123
- targetDaemonId?: string;
2124
- workspace?: string;
2125
- startedAt: string;
2126
- completedAt?: string;
2127
- duplicate?: boolean;
2128
- retryOfJobId?: string;
2129
- /**
2130
- * The coordinator daemon ID that initiated this refine job.
2131
- * When set, events for this job are scoped to that coordinator's
2132
- * pending-events queue instead of the shared broadcast queue.
2133
- */
2134
- targetCoordinatorDaemonId?: string;
2135
- eventDelivery: {
2136
- pendingEvents: true;
2137
- ledger: true;
2138
- };
2139
- evidence: {
2140
- pendingEventsCommand: 'get_pending_mesh_events';
2141
- ledgerCommand: 'get_mesh_ledger_slice';
2142
- taskHistoryKind: 'task_dispatched' | 'task_completed' | 'task_failed';
2143
- };
2144
- };
2145
-
2146
- type MeshRefineTerminalJob = MeshRefineJobHandle & { result?: Record<string, unknown> };
2147
-
2148
- type MeshRefineBatchJobStatus = 'accepted' | 'completed' | 'failed';
2149
-
2150
- /**
2151
- * Async handle returned by the batch Refinery the instant a convergence run is
2152
- * accepted. Mirrors {@link MeshRefineJobHandle} (async:true / status:'accepted' +
2153
- * terminal pending-event + ledger delivery) but scopes a whole batch of sibling
2154
- * nodes rather than a single node. The synthetic `batchLabel` is used as the
2155
- * `nodeLabel` for the shared refine event/message renderer.
2156
- */
2157
- type MeshRefineBatchJobHandle = {
2158
- success: true;
2159
- async: true;
2160
- batch: true;
2161
- status: MeshRefineBatchJobStatus;
2162
- jobId: string;
2163
- interactionId: string;
2164
- meshId: string;
2165
- batchLabel: string;
2166
- nodeIds: string[];
2167
- nodeCount: number;
2168
- order: string[];
2169
- startedAt: string;
2170
- completedAt?: string;
2171
- duplicate?: boolean;
2172
- targetCoordinatorDaemonId?: string;
2173
- eventDelivery: {
2174
- pendingEvents: true;
2175
- ledger: true;
2176
- };
2177
- evidence: {
2178
- pendingEventsCommand: 'get_pending_mesh_events';
2179
- ledgerCommand: 'get_mesh_ledger_slice';
2180
- taskHistoryKind: 'task_dispatched' | 'task_completed' | 'task_failed';
2181
- };
2182
- };
2183
-
2184
- type MeshRefineBatchTerminalJob = MeshRefineBatchJobHandle & { result?: Record<string, unknown> };
2185
-
2186
- const REFINE_VALIDATION_CATEGORIES = ['typecheck', 'test', 'lint', 'build'] as const;
2187
- const REFINE_VALIDATION_TIMEOUT_MS = 120_000;
2188
- const REFINE_VALIDATION_OUTPUT_LIMIT_BYTES = 128 * 1024;
2189
- const REFINE_VALIDATION_SUMMARY_CHARS = 2_000;
2190
- const REFINE_VALIDATION_MAX_COMMANDS = 4;
2191
- const REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES = 4 * 1024 * 1024;
2192
-
2193
- function truncateValidationOutput(value: unknown): string {
2194
- const text = typeof value === 'string' ? value : value == null ? '' : String(value);
2195
- if (text.length <= REFINE_VALIDATION_SUMMARY_CHARS) return text;
2196
- return `${text.slice(0, REFINE_VALIDATION_SUMMARY_CHARS)}\n[truncated ${text.length - REFINE_VALIDATION_SUMMARY_CHARS} chars]`;
2197
- }
2198
-
2199
- /**
2200
- * A spawn-resolution failure is when the executable itself could not be found by
2201
- * the OS spawn boundary — `spawn <cmd> ENOENT` — as opposed to the command
2202
- * running and exiting non-zero. On win32 this is the .cmd-shim case: libuv's
2203
- * spawn search appends only .com/.exe, so a bare `npm`/`npx`/`tsc` (which are
2204
- * .cmd shims) ENOENTs even though it is installed. It carries no stderr, so it
2205
- * must be detected by error.code/syscall, not by string-matching output.
2206
- */
2207
- export function isSpawnResolutionError(error: any): boolean {
2208
- if (!error) return false;
2209
- if (error.code === 'ENOENT' && typeof error.syscall === 'string' && error.syscall.startsWith('spawn')) return true;
2210
- // Fall back to code alone: execFile sets syscall on the spawn boundary error,
2211
- // but guard for environments/mocks that only surface the code.
2212
- return error.code === 'ENOENT' && (error.syscall === undefined || String(error.syscall).startsWith('spawn'));
2213
- }
2214
-
2215
- export function describeSpawnError(error: any, command: string, spawnResolutionFailed: boolean): string {
2216
- if (spawnResolutionFailed) {
2217
- const hint = process.platform === 'win32'
2218
- ? ' On Windows, npm-family commands (npm/npx/tsc/vitest) are .cmd shims that the bare-command spawn search does not resolve; configure an absolute path or ensure the command is on PATH.'
2219
- : '';
2220
- return `Could not resolve executable "${command}" (spawn ENOENT).${hint}`;
2221
- }
2222
- return String(error?.message || error);
2223
- }
2224
-
2225
- function recordMeshRefineStage(
2226
- stages: Array<Record<string, unknown>>,
2227
- stage: string,
2228
- status: MeshRefineStageStatus,
2229
- startedAt: number,
2230
- details?: Record<string, unknown>,
2231
- ): void {
2232
- stages.push({
2233
- stage,
2234
- status,
2235
- durationMs: Date.now() - startedAt,
2236
- ...(details || {}),
2237
- });
2238
- }
2239
-
2240
- function buildSubmodulePublishRequiredNextStep(entries: MeshRefineSubmoduleReachabilityEntry[]): string {
2241
- const refs = entries
2242
- .map(entry => `${entry.path}@${entry.commit}`)
2243
- .join(', ');
2244
- return `Ask the user for explicit approval to push/publish the unreachable submodule commit(s) (${refs}) to the configured submodule remote main branch, then rerun mesh_refine_node. Do not merge the root branch until every submodule gitlink commit is reachable from submodule origin/main.`;
2245
- }
2246
-
2247
- /**
2248
- * Async git exec helper used across the synchronous-refine stage pipeline. Bound
2249
- * once in the orchestrator and threaded through RefineContext so every stage runs
2250
- * git the same way (execFile + promisify, utf8). Returns the child's stdout/stderr.
2251
- */
2252
- type RefineExecFileAsync = (file: string, args: string[], options: { cwd: string; encoding: 'utf8' }) => Promise<{ stdout: string; stderr: string }>;
2253
-
2254
- /**
2255
- * Accumulated state shared by the synchronous-refine stages. The orchestrator
2256
- * (executeMeshRefineNodeSynchronously) seeds this in the resolve_refs stage and
2257
- * each later stage reads / extends it. `branchHead` and `patchEquivalence` are the
2258
- * only fields a stage mutates after creation (auto-rebase updates both), so they
2259
- * are carried on the mutable context rather than re-threaded through return types.
2260
- */
2261
- interface RefineContext {
2262
- meshId: string;
2263
- nodeId: string;
2264
- args: any;
2265
- refineStages: Array<Record<string, unknown>>;
2266
- execFileAsync: RefineExecFileAsync;
2267
- mesh: any;
2268
- node: any;
2269
- sourceNode: any;
2270
- repoRoot: string;
2271
- branch: string;
2272
- baseBranch: string;
2273
- baseHead: string;
2274
- branchHead: string;
2275
- validationSummary: Awaited<ReturnType<typeof runMeshRefineValidationGate>>;
2276
- patchEquivalence: Awaited<ReturnType<typeof runMeshRefinePatchEquivalenceGate>>;
2277
- submoduleReachability: Awaited<ReturnType<typeof runMeshRefineSubmoduleReachabilityGate>>;
2278
- }
2279
-
2280
- /**
2281
- * Stage outcome for the synchronous-refine pipeline. A stage either produces a
2282
- * terminal CommandRouterResult (an early-exit gate failure, or a successful
2283
- * already-merged short-circuit), in which case the orchestrator returns it
2284
- * immediately, or it returns `continue` with the (possibly extended) context for
2285
- * the next stage. This makes the orchestrator a flat sequence of stage calls
2286
- * while preserving the original body's exact early-return control flow.
2287
- */
2288
- type RefineStageOutcome =
2289
- | { kind: 'terminal'; result: CommandRouterResult }
2290
- | { kind: 'continue'; ctx: RefineContext };
2291
-
2292
- function resolveRefineryAutoPublishSubmoduleMainCommits(mesh: any, workspace: string): { enabled: boolean; source?: string } {
2293
- if (mesh?.policy?.allowAutoPublishSubmoduleMainCommits === true) {
2294
- process.stderr.write(
2295
- `[adhdev-mesh] WARNING: allowAutoPublishSubmoduleMainCommits is ENABLED via mesh.policy. `
2296
- + `Refinery may push unreachable submodule commits to submodule origin/main without additional user approval.\n`,
2297
- );
2298
- return { enabled: true, source: 'mesh.policy.allowAutoPublishSubmoduleMainCommits' };
2299
- }
2300
- const loaded = loadMeshRefineConfig(mesh, workspace);
2301
- if (loaded.config?.allowAutoPublishSubmoduleMainCommits === true) {
2302
- process.stderr.write(
2303
- `[adhdev-mesh] WARNING: allowAutoPublishSubmoduleMainCommits is ENABLED via ${loaded.path || loaded.source}. `
2304
- + `Refinery may push unreachable submodule commits to submodule origin/main without additional user approval.\n`,
2305
- );
2306
- return { enabled: true, source: loaded.path || loaded.source };
2307
- }
2308
- return { enabled: false };
2309
- }
2310
-
2311
- async function computeGitPatchId(
2312
- cwd: string,
2313
- fromRef: string,
2314
- toRef: string,
2315
- excludePaths: string[] = [],
2316
- ): Promise<string> {
2317
- const { execFileSync } = await import('node:child_process');
2318
- // When excludePaths is non-empty we drop those paths from the diff via
2319
- // `:(exclude)` pathspecs. This is used to omit gitlink paths that have
2320
- // already been proven a safe fast-forward: their patch hunks legitimately
2321
- // differ between the expected (mergeBase→branch) and actual (base→merged)
2322
- // diffs because base may have advanced the same gitlink, so comparing them
2323
- // would spuriously fail patch-equivalence even though the merge is sound.
2324
- const diffArgs = ['diff', '--patch', '--full-index', fromRef, toRef];
2325
- if (excludePaths.length > 0) {
2326
- diffArgs.push('--', '.', ...excludePaths.map(path => `:(exclude)${path}`));
2327
- }
2328
- const diff = execFileSync('git', diffArgs, {
2329
- cwd,
2330
- encoding: 'utf8',
2331
- maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES,
2332
- });
2333
- if (!diff.trim()) return '';
2334
- const patchId = execFileSync('git', ['patch-id', '--stable'], {
2335
- cwd,
2336
- input: diff,
2337
- encoding: 'utf8',
2338
- maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES,
2339
- }).trim();
2340
- return patchId.split(/\s+/)[0] || '';
2341
- }
2342
-
2343
- export async function runMeshRefinePatchEquivalenceGate(
2344
- repoRoot: string,
2345
- baseHead: string,
2346
- branchHead: string,
2347
- ): Promise<MeshRefinePatchEquivalenceSummary> {
2348
- const startedAt = Date.now();
2349
- try {
2350
- const { execFileSync } = await import('node:child_process');
2351
- const git = (args: string[]) => execFileSync('git', args, {
2352
- cwd: repoRoot,
2353
- encoding: 'utf8',
2354
- maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES,
2355
- });
2356
- const mergeBase = git(['merge-base', baseHead, branchHead]).trim();
2357
-
2358
- // `git merge-tree --write-tree` refuses to merge gitlinks that differ
2359
- // across base/branch even when the advance is a strict fast-forward,
2360
- // failing with "Recursive merging with submodules currently only
2361
- // supports trivial cases". When that happens we check whether the
2362
- // conflict is *entirely* trivial-ff gitlinks and, if so, synthesize the
2363
- // merged tree ourselves (base tree + branch-side gitlinks).
2364
- let mergedTree = '';
2365
- let mergeTreeStdout = '';
2366
- let gitlinkTrivialFastForward: MeshRefinePatchEquivalenceSummary['gitlinkTrivialFastForward'];
2367
- try {
2368
- mergeTreeStdout = git(['merge-tree', '--write-tree', baseHead, branchHead]);
2369
- mergedTree = mergeTreeStdout.trim().split(/\s+/)[0] || '';
2370
- } catch (mergeTreeErr: any) {
2371
- const output = `${mergeTreeErr?.message || ''}\n${mergeTreeErr?.stdout || ''}\n${mergeTreeErr?.stderr || ''}`;
2372
- const isSubmoduleConflict = /(submodule|160000)/i.test(output)
2373
- || /Recursive merging with submodules/i.test(output);
2374
- if (!isSubmoduleConflict) throw mergeTreeErr;
2375
- const evaluation = evaluateGitlinkTrivialFastForward(repoRoot, baseHead, branchHead);
2376
- if (!evaluation.trivial) {
2377
- return {
2378
- status: 'failed',
2379
- equivalent: false,
2380
- baseHead,
2381
- branchHead,
2382
- mergeBase: mergeBase || undefined,
2383
- durationMs: Date.now() - startedAt,
2384
- error: mergeTreeErr?.message || String(mergeTreeErr),
2385
- stdout: truncateValidationOutput(mergeTreeErr?.stdout),
2386
- stderr: truncateValidationOutput(mergeTreeErr?.stderr),
2387
- gitlinkTrivialFastForward: { resolved: false, gitlinks: evaluation.gitlinks, reason: evaluation.reason },
2388
- actionableHint: buildPatchEquivalenceSubmoduleConflictHint(repoRoot, baseHead, branchHead, output),
2389
- };
2390
- }
2391
- // All conflicting gitlinks fast-forward and nothing else conflicts:
2392
- // synthesize the merge result as base's tree with branch-side gitlinks.
2393
- mergedTree = synthesizeTrivialFastForwardMergeTree(repoRoot, baseHead, branchHead, evaluation.gitlinks) || '';
2394
- gitlinkTrivialFastForward = { resolved: true, gitlinks: evaluation.gitlinks };
2395
- }
2396
-
2397
- if (!mergeBase || !mergedTree) {
2398
- return {
2399
- status: 'failed',
2400
- equivalent: false,
2401
- baseHead,
2402
- branchHead,
2403
- mergeBase: mergeBase || undefined,
2404
- mergedTree: mergedTree || undefined,
2405
- durationMs: Date.now() - startedAt,
2406
- error: 'patch equivalence preflight could not resolve merge-base or synthetic merge tree',
2407
- stdout: truncateValidationOutput(mergeTreeStdout),
2408
- gitlinkTrivialFastForward,
2409
- };
2410
- }
2411
- // Exclude *proven fast-forward* gitlink paths from BOTH patch-ids. When
2412
- // base has advanced a submodule pointer (a sibling merged into main ahead
2413
- // of us) the gitlink hunk's old-value differs between the expected diff
2414
- // (mergeBase→branch, showing the full base→branch advance) and the actual
2415
- // diff (base→merged, showing only the shorter advanced-base→branch
2416
- // advance). That mismatch would spuriously fail equivalence even though
2417
- // advancing the pointer to the branch side is a provably safe
2418
- // fast-forward — this is the root cause of the diverged-base
2419
- // patch_equivalence_failed misjudgment.
2420
- //
2421
- // We exclude ONLY gitlinks whose base-side commit is an ancestor of the
2422
- // branch-side commit (a strict ff, both objects available locally). A
2423
- // non-ff or ambiguous gitlink (a genuine submodule divergence, or objects
2424
- // not fetched locally) is deliberately left in the diff so its differing
2425
- // hunk still drives the comparison — this preserves the original behavior
2426
- // and prevents a false pass on a real divergence.
2427
- const ffGitlinkExcludePaths = collectFastForwardGitlinkPaths(repoRoot, baseHead, branchHead);
2428
- const expectedPatchId = await computeGitPatchId(repoRoot, mergeBase, branchHead, ffGitlinkExcludePaths);
2429
- const actualPatchId = await computeGitPatchId(repoRoot, baseHead, mergedTree, ffGitlinkExcludePaths);
2430
- const equivalent = expectedPatchId === actualPatchId;
2431
- return {
2432
- status: equivalent ? 'passed' : 'failed',
2433
- equivalent,
2434
- baseHead,
2435
- branchHead,
2436
- mergeBase,
2437
- mergedTree,
2438
- expectedPatchId,
2439
- actualPatchId,
2440
- durationMs: Date.now() - startedAt,
2441
- gitlinkTrivialFastForward,
2442
- };
2443
- } catch (e: any) {
2444
- return {
2445
- status: 'failed',
2446
- equivalent: false,
2447
- baseHead,
2448
- branchHead,
2449
- durationMs: Date.now() - startedAt,
2450
- error: e?.message || String(e),
2451
- stdout: truncateValidationOutput(e?.stdout),
2452
- stderr: truncateValidationOutput(e?.stderr),
2453
- actionableHint: buildPatchEquivalenceSubmoduleConflictHint(
2454
- repoRoot,
2455
- baseHead,
2456
- branchHead,
2457
- `${e?.message || ''}\n${e?.stdout || ''}\n${e?.stderr || ''}`,
2458
- ),
2459
- };
2460
- }
2461
- }
2462
-
2463
- export type MeshWorktreePatchContainmentSummary = {
2464
- /** True only when merging worktreeHead into ref introduces no new patch. */
2465
- contained: boolean;
2466
- ref: string;
2467
- worktreeHead: string;
2468
- mergeBase?: string;
2469
- mergedTree?: string;
2470
- /** patch-id of (ref -> synthesized merge tree); empty string when nothing new is added. */
2471
- residualPatchId?: string;
2472
- durationMs: number;
2473
- /** Set when the check could not run (treated conservatively as NOT contained). */
2474
- error?: string;
2475
- };
2476
-
2477
- /**
2478
- * Patch-equivalence containment check for the worktree force-cleanup convergence
2479
- * guard. Answers a narrower question than {@link runMeshRefinePatchEquivalenceGate}:
2480
- * "are the worktree branch's changes ALREADY present in `ref` (e.g. origin/main),
2481
- * even though the worktree HEAD's commit SHA is not an ancestor of ref?"
2482
- *
2483
- * This is the cherry-pick / squash / rebase case: the same content landed on the
2484
- * default ref under a different commit SHA, so `merge-base --is-ancestor` (the
2485
- * primary cleanup guard) reports the worktree as un-converged and refuses to
2486
- * remove it. Refinery already accepts patch-equivalent landings via merge-tree +
2487
- * patch-id; this brings the same notion of "convergence" to the cleanup guard.
2488
- *
2489
- * Mechanism: synthesize the merge of `worktreeHead` into `ref` (reusing the same
2490
- * trivial-gitlink-fast-forward handling as the refine gate) and compute the
2491
- * patch-id of (ref -> mergedTree). If that residual diff is EMPTY, merging the
2492
- * worktree adds nothing new on top of ref — its changes are already present there
2493
- * and the worktree is safe to remove. A non-empty residual means the worktree
2494
- * still carries content not in ref, so it is NOT contained and must stay blocked.
2495
- *
2496
- * Conservative by construction: any merge-tree / patch-id failure, a genuine
2497
- * (non-trivial) submodule conflict, or any thrown error yields `contained: false`
2498
- * so an exception can never widen the cleanup allow-list.
2499
- */
2500
- export async function checkWorktreeChangesPatchEquivalentInRef(
2501
- repoRoot: string,
2502
- ref: string,
2503
- worktreeHead: string,
2504
- ): Promise<MeshWorktreePatchContainmentSummary> {
2505
- const startedAt = Date.now();
2506
- try {
2507
- const { execFileSync } = await import('node:child_process');
2508
- const git = (gitArgs: string[]) => execFileSync('git', gitArgs, {
2509
- cwd: repoRoot,
2510
- encoding: 'utf8',
2511
- maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES,
2512
- });
2513
- const mergeBase = git(['merge-base', ref, worktreeHead]).trim();
2514
-
2515
- // Reuse the refine gate's trivial-gitlink-fast-forward handling: a clean
2516
- // submodule pointer fast-forward must not block the cleanup, but a real
2517
- // (non-ff) submodule divergence must keep it blocked.
2518
- let mergedTree = '';
2519
- try {
2520
- mergedTree = git(['merge-tree', '--write-tree', ref, worktreeHead]).trim().split(/\s+/)[0] || '';
2521
- } catch (mergeTreeErr: any) {
2522
- const output = `${mergeTreeErr?.message || ''}\n${mergeTreeErr?.stdout || ''}\n${mergeTreeErr?.stderr || ''}`;
2523
- const isSubmoduleConflict = /(submodule|160000)/i.test(output)
2524
- || /Recursive merging with submodules/i.test(output);
2525
- if (!isSubmoduleConflict) throw mergeTreeErr;
2526
- const evaluation = evaluateGitlinkTrivialFastForward(repoRoot, ref, worktreeHead);
2527
- if (!evaluation.trivial) {
2528
- // A genuine submodule divergence (or unfetched objects): we cannot
2529
- // prove containment, so block conservatively.
2530
- return {
2531
- contained: false,
2532
- ref,
2533
- worktreeHead,
2534
- mergeBase: mergeBase || undefined,
2535
- durationMs: Date.now() - startedAt,
2536
- error: `merge-tree submodule conflict is not a trivial fast-forward: ${evaluation.reason || 'unknown'}`,
2537
- };
2538
- }
2539
- mergedTree = synthesizeTrivialFastForwardMergeTree(repoRoot, ref, worktreeHead, evaluation.gitlinks) || '';
2540
- }
2541
-
2542
- if (!mergedTree) {
2543
- return {
2544
- contained: false,
2545
- ref,
2546
- worktreeHead,
2547
- mergeBase: mergeBase || undefined,
2548
- durationMs: Date.now() - startedAt,
2549
- error: 'could not resolve synthetic merge tree for containment check',
2550
- };
2551
- }
2552
-
2553
- // Exclude proven fast-forward gitlinks from the residual diff for the same
2554
- // reason the refine gate does: advancing a submodule pointer to a strict
2555
- // descendant is a safe fast-forward and must not count as "new content".
2556
- const ffGitlinkExcludePaths = collectFastForwardGitlinkPaths(repoRoot, ref, worktreeHead);
2557
- const residualPatchId = await computeGitPatchId(repoRoot, ref, mergedTree, ffGitlinkExcludePaths);
2558
- const contained = residualPatchId === '';
2559
- return {
2560
- contained,
2561
- ref,
2562
- worktreeHead,
2563
- mergeBase: mergeBase || undefined,
2564
- mergedTree,
2565
- residualPatchId,
2566
- durationMs: Date.now() - startedAt,
2567
- };
2568
- } catch (e: any) {
2569
- return {
2570
- contained: false,
2571
- ref,
2572
- worktreeHead,
2573
- durationMs: Date.now() - startedAt,
2574
- error: e?.message || String(e),
2575
- };
2576
- }
2577
- }
2578
-
2579
- /**
2580
- * No-op guard: detect a "silent no-op" merge before the Refinery merge runs.
2581
- *
2582
- * A silent no-op occurs when the refine target branch's ROOT tree is byte-identical
2583
- * to the merge base (origin/main). This is the trap where a submodule (e.g. oss) has
2584
- * real commits but the root branch never committed the gitlink (oss-pointer) bump, so
2585
- * the root diff Refinery would merge is empty. Merging that produces a merge commit with
2586
- * no content change — reported as "success" while the actual work never reaches main.
2587
- *
2588
- * A committed gitlink bump (the legitimate oss-pointer bump) DOES show up in the root
2589
- * tree diff (as a 160000-mode entry), so this guard does NOT block legitimate refines —
2590
- * it only fires when the root tree diff vs base is COMPLETELY empty.
2591
- *
2592
- * Runs after the patch-equivalence gate; the "already merged via other path" case
2593
- * (branch has real changes already present in base) is handled upstream and never
2594
- * reaches here, so an empty root diff at this point is genuinely a no-op.
2595
- */
2596
- export async function runMeshRefineEffectiveDiffGate(
2597
- repoRoot: string,
2598
- baseHead: string,
2599
- branchHead: string,
2600
- ): Promise<MeshRefineEffectiveDiffSummary> {
2601
- const startedAt = Date.now();
2602
- try {
2603
- const { execFileSync } = await import('node:child_process');
2604
- const git = (args: string[], opts?: { cwd?: string }) => execFileSync('git', args, {
2605
- cwd: opts?.cwd || repoRoot,
2606
- encoding: 'utf8',
2607
- maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES,
2608
- });
2609
- // Root tree diff between base and branch. --raw surfaces gitlink (160000) entries,
2610
- // so a committed submodule-pointer bump counts as an effective change. An empty
2611
- // result means the branch's root tree is identical to base → nothing would merge.
2612
- const rawDiff = git(['diff', '--raw', baseHead, branchHead]).trim();
2613
- if (rawDiff) {
2614
- const changedPaths = rawDiff
2615
- .split('\n')
2616
- .map(line => line.split('\t').slice(1).join('\t').trim())
2617
- .filter(Boolean)
2618
- .slice(0, 50);
2619
- return {
2620
- status: 'passed',
2621
- hasEffectiveDiff: true,
2622
- baseHead,
2623
- branchHead,
2624
- changedPaths,
2625
- durationMs: Date.now() - startedAt,
2626
- };
2627
- }
2628
-
2629
- // No root diff → silent no-op. Try to surface which submodule(s) have commits that
2630
- // were never captured by a committed gitlink bump, to make the message actionable.
2631
- const submoduleHints: Array<{ path: string; reason: string }> = [];
2632
- try {
2633
- // `git submodule status` flags submodules whose checked-out commit differs from
2634
- // the recorded gitlink with a leading '+'. That difference is exactly the
2635
- // uncommitted-pointer-bump situation this guard exists to catch.
2636
- const status = git(['submodule', 'status']);
2637
- for (const line of status.split('\n')) {
2638
- const trimmed = line.trimEnd();
2639
- if (!trimmed) continue;
2640
- if (trimmed.startsWith('+')) {
2641
- const parts = trimmed.slice(1).trim().split(/\s+/);
2642
- const path = parts[1] || parts[0] || '(unknown)';
2643
- submoduleHints.push({
2644
- path,
2645
- reason: 'submodule checked-out commit differs from the committed gitlink (pointer bump not committed on the root branch)',
2646
- });
2647
- }
2648
- }
2649
- } catch { /* submodule status is best-effort */ }
2650
-
2651
- return {
2652
- status: 'failed',
2653
- hasEffectiveDiff: false,
2654
- baseHead,
2655
- branchHead,
2656
- ...(submoduleHints.length ? { submoduleHints } : {}),
2657
- durationMs: Date.now() - startedAt,
2658
- };
2659
- } catch (e: any) {
2660
- // On error, do NOT block the merge — fail open so a probe failure can't wedge refine.
2661
- return {
2662
- status: 'skipped',
2663
- hasEffectiveDiff: true,
2664
- baseHead,
2665
- branchHead,
2666
- durationMs: Date.now() - startedAt,
2667
- error: e?.message || String(e),
2668
- stdout: truncateValidationOutput(e?.stdout),
2669
- stderr: truncateValidationOutput(e?.stderr),
2670
- };
2671
- }
2672
- }
2673
-
2674
- function buildPatchEquivalenceSubmoduleConflictHint(
2675
- repoRoot: string,
2676
- baseHead: string,
2677
- branchHead: string,
2678
- output: string,
2679
- ): MeshRefineSubmoduleConflictHint | undefined {
2680
- if (!/(submodule|160000)/i.test(output) || !/(conflict|failed to merge)/i.test(output)) return undefined;
2681
- const conflicts = readChangedGitlinkPaths(repoRoot, baseHead, branchHead)
2682
- .map(path => ({
2683
- path,
2684
- baseCommit: readTreeObject(repoRoot, baseHead, path),
2685
- branchCommit: readTreeObject(repoRoot, branchHead, path),
2686
- }));
2687
- if (conflicts.length === 0) return undefined;
2688
- return {
2689
- kind: 'submodule_conflict',
2690
- message: 'Refinery could not synthesize a safe merge tree because the branch and base point the same submodule path at different commits.',
2691
- conflicts,
2692
- nextSteps: [
2693
- 'Inspect the listed submodule path in both base and branch: baseCommit is the commit currently recorded by the base workspace, branchCommit is the commit recorded by the worktree branch.',
2694
- 'Resolve the submodule first by checking out or creating the intended submodule commit, then commit the chosen gitlink in the root branch.',
2695
- 'Ensure the chosen submodule commit is reachable from the configured submodule remote main branch, then rerun mesh_refine_node.',
2696
- ],
2697
- };
2698
- }
2699
-
2700
- function readChangedGitlinkPaths(repoRoot: string, fromRef: string, toRef: string): string[] {
2701
- try {
2702
- const output = execFileSync('git', ['diff', '--raw', '--no-abbrev', fromRef, toRef], {
2703
- cwd: repoRoot,
2704
- encoding: 'utf8',
2705
- maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES,
2706
- });
2707
- const paths = new Set<string>();
2708
- for (const line of output.split('\n')) {
2709
- if (!line.trim()) continue;
2710
- const metaAndPath = line.split('\t');
2711
- const meta = metaAndPath[0] || '';
2712
- const path = metaAndPath[metaAndPath.length - 1]?.trim();
2713
- if (!path) continue;
2714
- const parts = meta.split(/\s+/);
2715
- if (parts[0]?.includes('160000') || parts[1]?.includes('160000')) {
2716
- paths.add(path);
2717
- }
2718
- }
2719
- return [...paths].sort();
2720
- } catch {
2721
- return [];
2722
- }
2723
- }
2724
-
2725
- function readTreeObject(repoRoot: string, ref: string, path: string): string | undefined {
2726
- try {
2727
- const output = execFileSync('git', ['ls-tree', ref, '--', path], {
2728
- cwd: repoRoot,
2729
- encoding: 'utf8',
2730
- maxBuffer: 1024 * 1024,
2731
- }).trim();
2732
- const match = output.match(/\bcommit\s+([0-9a-f]{40})\b/i);
2733
- return match?.[1];
2734
- } catch {
2735
- return undefined;
2736
- }
2737
- }
2738
-
2739
- /**
2740
- * Resolve the absolute path to the repo's real git directory. In a linked
2741
- * worktree, `.git` is a file pointing elsewhere, so we cannot assume a `.git`
2742
- * subdirectory exists — a temporary index file must live in the actual git dir.
2743
- */
2744
- function resolveGitDir(repoRoot: string): string {
2745
- const out = execFileSync('git', ['rev-parse', '--absolute-git-dir'], {
2746
- cwd: repoRoot,
2747
- encoding: 'utf8',
2748
- maxBuffer: 1024 * 1024,
2749
- }).trim();
2750
- return out;
2751
- }
2752
-
2753
- /**
2754
- * Result of evaluating whether a `git merge-tree --write-tree` submodule
2755
- * conflict is in fact a trivial gitlink fast-forward that should pass the
2756
- * patch-equivalence gate.
2757
- *
2758
- * `git merge-tree` (and `git merge` with the default recursive strategy)
2759
- * refuses to 3-way merge gitlinks unless the case is "trivial" — and it
2760
- * treats *any* gitlink that differs across merge-base/base/branch as
2761
- * non-trivial, even when the branch-side commit is a strict descendant of the
2762
- * base-side commit (i.e. a real fast-forward). Refinery only ever wants to
2763
- * accept the branch's recorded gitlink, so a fast-forwardable bump is safe to
2764
- * resolve to the branch side without any conflict.
2765
- */
2766
- type GitlinkTrivialFastForwardEvaluation = {
2767
- /** True only when the merge-tree conflict is *fully* explained by trivial-ff gitlinks. */
2768
- trivial: boolean;
2769
- /** Why the evaluation declined to treat the conflict as trivial (set when trivial=false). */
2770
- reason?: string;
2771
- /** Per-path detail for the changed gitlinks that were inspected. */
2772
- gitlinks: Array<{
2773
- path: string;
2774
- baseCommit?: string;
2775
- branchCommit?: string;
2776
- fastForward: boolean;
2777
- }>;
2778
- };
2779
-
2780
- /**
2781
- * Check, inside a submodule repo, whether `baseCommit` is an ancestor of
2782
- * `branchCommit` (i.e. advancing the gitlink from base→branch is a pure
2783
- * fast-forward). Returns false on any error or when either commit is missing
2784
- * locally — safety first, ambiguity stays "not a fast-forward".
2785
- */
2786
- function isSubmoduleFastForward(submoduleRepoPath: string, baseCommit: string, branchCommit: string): boolean {
2787
- if (!baseCommit || !branchCommit) return false;
2788
- if (baseCommit === branchCommit) return true;
2789
- try {
2790
- if (!fs.existsSync(submoduleRepoPath)) return false;
2791
- // Both commits must exist locally for the ancestry check to be meaningful.
2792
- execFileSync('git', ['cat-file', '-e', `${baseCommit}^{commit}`], { cwd: submoduleRepoPath, stdio: 'ignore' });
2793
- execFileSync('git', ['cat-file', '-e', `${branchCommit}^{commit}`], { cwd: submoduleRepoPath, stdio: 'ignore' });
2794
- // exit 0 ⇒ baseCommit is an ancestor of branchCommit ⇒ branch fast-forwards base.
2795
- execFileSync('git', ['merge-base', '--is-ancestor', baseCommit, branchCommit], { cwd: submoduleRepoPath, stdio: 'ignore' });
2796
- return true;
2797
- } catch {
2798
- return false;
2799
- }
2800
- }
2801
-
2802
- /**
2803
- * Read the set of paths that differ between two refs, tagging whether each is a
2804
- * gitlink (submodule, mode 160000) on either side. Returns one entry per
2805
- * changed path. Empty on error.
2806
- */
2807
- function readChangedPathKinds(repoRoot: string, fromRef: string, toRef: string): Array<{ path: string; isGitlink: boolean }> {
2808
- try {
2809
- const output = execFileSync('git', ['diff', '--raw', '--no-abbrev', fromRef, toRef], {
2810
- cwd: repoRoot,
2811
- encoding: 'utf8',
2812
- maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES,
2813
- });
2814
- const result: Array<{ path: string; isGitlink: boolean }> = [];
2815
- const seen = new Set<string>();
2816
- for (const line of output.split('\n')) {
2817
- if (!line.trim()) continue;
2818
- const metaAndPath = line.split('\t');
2819
- const meta = metaAndPath[0] || '';
2820
- const path = metaAndPath[metaAndPath.length - 1]?.trim();
2821
- if (!path || seen.has(path)) continue;
2822
- seen.add(path);
2823
- const parts = meta.split(/\s+/);
2824
- const isGitlink = !!(parts[0]?.includes('160000') || parts[1]?.includes('160000'));
2825
- result.push({ path, isGitlink });
2826
- }
2827
- return result;
2828
- } catch {
2829
- return [];
2830
- }
2831
- }
2832
-
2833
- /**
2834
- * Return the changed gitlink paths between base and branch whose advance is a
2835
- * strict fast-forward (the base-side commit is an ancestor of the branch-side
2836
- * commit inside that submodule's repo). These are the paths whose patch-id hunk
2837
- * may legitimately differ when base has advanced the same submodule, so they
2838
- * are safe to exclude from the patch-equivalence comparison. A non-ff (genuinely
2839
- * diverged) gitlink is deliberately excluded from this set so it still fails the
2840
- * gate.
2841
- */
2842
- export function collectFastForwardGitlinkPaths(repoRoot: string, baseHead: string, branchHead: string): string[] {
2843
- return readChangedGitlinkPaths(repoRoot, baseHead, branchHead).filter(path => {
2844
- const baseCommit = readTreeObject(repoRoot, baseHead, path);
2845
- const branchCommit = readTreeObject(repoRoot, branchHead, path);
2846
- if (!baseCommit || !branchCommit) return false;
2847
- return isSubmoduleFastForward(pathResolve(repoRoot, path), baseCommit, branchCommit);
2848
- });
2849
- }
2850
-
2851
- /**
2852
- * Decide whether a merge-tree submodule conflict between base and branch is a
2853
- * trivial gitlink fast-forward (and nothing else).
2854
- *
2855
- * The conflict is treated as trivial ONLY when:
2856
- * 1. at least one changed gitlink exists,
2857
- * 2. every changed gitlink fast-forwards (base-commit is an ancestor of the
2858
- * branch-commit inside that submodule's repo), and
2859
- * 3. the *only* paths that changed on both sides of the merge (i.e. the paths
2860
- * that could possibly produce a 3-way conflict — the intersection of
2861
- * mergeBase→base and mergeBase→branch changes) are gitlinks. Any
2862
- * overlapping non-gitlink path means a genuine content conflict could be
2863
- * hiding behind the submodule failure, so we keep the block.
2864
- *
2865
- * If any of these fail, the conflict is left as a genuine block. This never
2866
- * passes a regular-file conflict or a diverged (non-ff) gitlink.
2867
- */
2868
- export function evaluateGitlinkTrivialFastForward(
2869
- repoRoot: string,
2870
- baseHead: string,
2871
- branchHead: string,
2872
- ): GitlinkTrivialFastForwardEvaluation {
2873
- const changedGitlinks = readChangedGitlinkPaths(repoRoot, baseHead, branchHead).map(path => {
2874
- const baseCommit = readTreeObject(repoRoot, baseHead, path);
2875
- const branchCommit = readTreeObject(repoRoot, branchHead, path);
2876
- const submoduleRepoPath = pathResolve(repoRoot, path);
2877
- const fastForward = !!baseCommit && !!branchCommit
2878
- && isSubmoduleFastForward(submoduleRepoPath, baseCommit, branchCommit);
2879
- return { path, baseCommit, branchCommit, fastForward };
2880
- });
2881
-
2882
- if (changedGitlinks.length === 0) {
2883
- return { trivial: false, reason: 'no_changed_gitlinks', gitlinks: changedGitlinks };
2884
- }
2885
-
2886
- const nonFastForward = changedGitlinks.filter(entry => !entry.fastForward);
2887
- if (nonFastForward.length > 0) {
2888
- return {
2889
- trivial: false,
2890
- reason: `diverged_gitlinks:${nonFastForward.map(entry => entry.path).join(',')}`,
2891
- gitlinks: changedGitlinks,
2892
- };
2893
- }
2894
-
2895
- // Prove there is no *other* conflict (regular files, or a gitlink that
2896
- // diverged on both sides). A 3-way merge can only conflict on a path that
2897
- // changed on BOTH sides relative to the merge-base. Compute that overlap and
2898
- // require every overlapping path to be a gitlink — non-gitlink overlap means
2899
- // a genuine content conflict that must stay blocked.
2900
- let mergeBase = '';
2901
- try {
2902
- mergeBase = execFileSync('git', ['merge-base', baseHead, branchHead], {
2903
- cwd: repoRoot,
2904
- encoding: 'utf8',
2905
- maxBuffer: 1024 * 1024,
2906
- }).trim();
2907
- } catch {
2908
- return { trivial: false, reason: 'merge_base_unresolved', gitlinks: changedGitlinks };
2909
- }
2910
- if (!mergeBase) {
2911
- return { trivial: false, reason: 'merge_base_unresolved', gitlinks: changedGitlinks };
2912
- }
2913
-
2914
- const baseSideChanges = readChangedPathKinds(repoRoot, mergeBase, baseHead);
2915
- const branchSideChanges = readChangedPathKinds(repoRoot, mergeBase, branchHead);
2916
- const baseChangedPaths = new Map(baseSideChanges.map(entry => [entry.path, entry]));
2917
- // Overlapping paths = candidates for a real 3-way conflict.
2918
- const overlapping = branchSideChanges.filter(entry => baseChangedPaths.has(entry.path));
2919
- const nonGitlinkOverlap = overlapping.filter(entry => {
2920
- const baseEntry = baseChangedPaths.get(entry.path);
2921
- return !(entry.isGitlink && baseEntry?.isGitlink);
2922
- });
2923
- if (nonGitlinkOverlap.length > 0) {
2924
- return {
2925
- trivial: false,
2926
- reason: `non_gitlink_overlap:${nonGitlinkOverlap.map(entry => entry.path).join(',')}`,
2927
- gitlinks: changedGitlinks,
2928
- };
2929
- }
2930
-
2931
- return { trivial: true, gitlinks: changedGitlinks };
2932
- }
2933
-
2934
- /**
2935
- * Build a tree identical to `commitish`'s tree except every gitlink in `paths`
2936
- * is rewritten to `placeholderCommit`. Used to neutralize submodule pointers so
2937
- * `git merge-tree` stops bailing on the "Recursive merging with submodules"
2938
- * limitation and can 3-way merge the surrounding regular-file content. Returns
2939
- * the tree SHA, or undefined on failure.
2940
- */
2941
- function buildTreeWithGitlinksEqualized(
2942
- repoRoot: string,
2943
- commitish: string,
2944
- paths: string[],
2945
- placeholderCommit: string,
2946
- ): string | undefined {
2947
- try {
2948
- const tree = execFileSync('git', ['rev-parse', `${commitish}^{tree}`], {
2949
- cwd: repoRoot, encoding: 'utf8', maxBuffer: 1024 * 1024,
2950
- }).trim();
2951
- if (!tree) return undefined;
2952
- const updates = paths.map(path => `160000 commit ${placeholderCommit}\t${path}`).join('\n');
2953
- if (!updates) return tree;
2954
- const tmpIndex = pathJoin(resolveGitDir(repoRoot), `adhdev-refine-eq-${commitish.slice(0, 12)}.index`);
2955
- const env = { ...process.env, GIT_INDEX_FILE: tmpIndex };
2956
- try {
2957
- execFileSync('git', ['read-tree', tree], { cwd: repoRoot, env, stdio: 'ignore' });
2958
- execFileSync('git', ['update-index', '--index-info'], {
2959
- cwd: repoRoot, env, input: `${updates}\n`, encoding: 'utf8',
2960
- stdio: ['pipe', 'ignore', 'ignore'],
2961
- });
2962
- const newTree = execFileSync('git', ['write-tree'], { cwd: repoRoot, env, encoding: 'utf8' }).trim();
2963
- return newTree || undefined;
2964
- } finally {
2965
- try { fs.rmSync(tmpIndex, { force: true }); } catch { /* ignore */ }
2966
- }
2967
- } catch {
2968
- return undefined;
2969
- }
2970
- }
2971
-
2972
- /**
2973
- * Synthesize the merge result for a trivial gitlink fast-forward.
2974
- *
2975
- * `git merge-tree` bails whenever a gitlink differs across base/branch even
2976
- * when the advance is a strict fast-forward, so we synthesize the result it
2977
- * *would* have produced. Crucially, when the merge-base of base and branch is
2978
- * NOT `baseHead` (i.e. base has diverged — a sibling was merged into main
2979
- * ahead of us), `baseHead`'s tree does not contain our branch's own
2980
- * non-gitlink changes. Simply overlaying gitlinks onto `baseHead`'s tree would
2981
- * therefore drop those changes and break patch-equivalence.
2982
- *
2983
- * To handle the diverged case correctly we run a REAL 3-way merge of the
2984
- * regular-file content (with the conflicting gitlinks temporarily equalized to
2985
- * a common placeholder so merge-tree won't bail), then overlay each changed
2986
- * gitlink's branch-side commit onto the merged result. This preserves both
2987
- * sides' non-gitlink changes.
2988
- *
2989
- * Returns the tree SHA, or undefined on failure / genuine non-gitlink
2990
- * conflict. Caller must have already proven (via
2991
- * evaluateGitlinkTrivialFastForward) that every changed gitlink fast-forwards
2992
- * and no other path conflicts.
2993
- */
2994
- function synthesizeTrivialFastForwardMergeTree(
2995
- repoRoot: string,
2996
- baseHead: string,
2997
- branchHead: string,
2998
- gitlinks: Array<{ path: string; branchCommit?: string }>,
2999
- ): string | undefined {
3000
- try {
3001
- const branchGitlinks = gitlinks.filter(entry => entry.branchCommit);
3002
- const gitlinkPaths = branchGitlinks.map(entry => entry.path);
3003
-
3004
- // Establish the regular-file content of the merge via a real 3-way merge
3005
- // with the conflicting gitlinks neutralized. The placeholder is the
3006
- // merge-base's value for a gitlink (or, failing that, any branch-side
3007
- // commit) — it only needs to be identical across all three trees.
3008
- const mergeBase = execFileSync('git', ['merge-base', baseHead, branchHead], {
3009
- cwd: repoRoot, encoding: 'utf8', maxBuffer: 1024 * 1024,
3010
- }).trim();
3011
-
3012
- let mergedContentTree: string | undefined;
3013
- if (mergeBase && gitlinkPaths.length > 0) {
3014
- const placeholder = readTreeObject(repoRoot, mergeBase, gitlinkPaths[0])
3015
- || branchGitlinks[0].branchCommit!;
3016
- const baseEqTree = buildTreeWithGitlinksEqualized(repoRoot, mergeBase, gitlinkPaths, placeholder);
3017
- const oursEqTree = buildTreeWithGitlinksEqualized(repoRoot, baseHead, gitlinkPaths, placeholder);
3018
- const theirsEqTree = buildTreeWithGitlinksEqualized(repoRoot, branchHead, gitlinkPaths, placeholder);
3019
- if (baseEqTree && oursEqTree && theirsEqTree) {
3020
- try {
3021
- // merge-tree --write-tree needs commits (to derive a merge-base);
3022
- // synthesize ours/theirs as children of a common base commit.
3023
- const baseEqCommit = execFileSync('git', ['commit-tree', baseEqTree, '-m', 'refine-ff-base'], {
3024
- cwd: repoRoot, encoding: 'utf8', maxBuffer: 1024 * 1024,
3025
- }).trim();
3026
- const oursEqCommit = execFileSync('git', ['commit-tree', oursEqTree, '-p', baseEqCommit, '-m', 'refine-ff-ours'], {
3027
- cwd: repoRoot, encoding: 'utf8', maxBuffer: 1024 * 1024,
3028
- }).trim();
3029
- const theirsEqCommit = execFileSync('git', ['commit-tree', theirsEqTree, '-p', baseEqCommit, '-m', 'refine-ff-theirs'], {
3030
- cwd: repoRoot, encoding: 'utf8', maxBuffer: 1024 * 1024,
3031
- }).trim();
3032
- const mergeOut = execFileSync('git', ['merge-tree', '--write-tree', oursEqCommit, theirsEqCommit], {
3033
- cwd: repoRoot, encoding: 'utf8', maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES,
3034
- }).trim();
3035
- mergedContentTree = mergeOut.split(/\s+/)[0] || undefined;
3036
- } catch {
3037
- // A real conflict in the equalized merge means a genuine
3038
- // non-gitlink content conflict the evaluator did not foresee
3039
- // (or unavailable objects). Fall through to the simple synth.
3040
- mergedContentTree = undefined;
3041
- }
3042
- }
3043
- }
3044
-
3045
- // Fallback: when there is no diverged base (merge-base === baseHead) the
3046
- // regular-file content of the merge is exactly baseHead's tree, so just
3047
- // overlay the gitlinks. Also used when the real merge could not run.
3048
- const contentTree = mergedContentTree
3049
- || execFileSync('git', ['rev-parse', `${baseHead}^{tree}`], {
3050
- cwd: repoRoot, encoding: 'utf8', maxBuffer: 1024 * 1024,
3051
- }).trim();
3052
- if (!contentTree) return undefined;
3053
-
3054
- const updates = branchGitlinks
3055
- .map(entry => `160000 commit ${entry.branchCommit}\t${entry.path}`)
3056
- .join('\n');
3057
- if (!updates) return contentTree;
3058
- const tmpIndex = pathJoin(resolveGitDir(repoRoot), `adhdev-refine-ff-${baseHead.slice(0, 12)}-${branchHead.slice(0, 12)}.index`);
3059
- const env = { ...process.env, GIT_INDEX_FILE: tmpIndex };
3060
- try {
3061
- execFileSync('git', ['read-tree', contentTree], { cwd: repoRoot, env, stdio: 'ignore' });
3062
- execFileSync('git', ['update-index', '--index-info'], {
3063
- cwd: repoRoot,
3064
- env,
3065
- input: `${updates}\n`,
3066
- encoding: 'utf8',
3067
- stdio: ['pipe', 'ignore', 'ignore'],
3068
- });
3069
- const newTree = execFileSync('git', ['write-tree'], { cwd: repoRoot, env, encoding: 'utf8' }).trim();
3070
- return newTree || undefined;
3071
- } finally {
3072
- try { fs.rmSync(tmpIndex, { force: true }); } catch { /* ignore */ }
3073
- }
3074
- } catch {
3075
- return undefined;
3076
- }
3077
- }
3078
-
3079
- async function alignRefinerySubmodulesAfterMerge(
3080
- repoRoot: string,
3081
- previousBaseHead: string,
3082
- currentHead: string,
3083
- options: { submoduleIgnorePaths?: string[] } = {},
3084
- ): Promise<MeshRefineSubmoduleAlignmentSummary> {
3085
- const startedAt = Date.now();
3086
- const changedGitlinkPaths = readChangedGitlinkPaths(repoRoot, previousBaseHead, currentHead)
3087
- .filter(path => !(options.submoduleIgnorePaths || []).includes(path));
3088
- const preStatus = await getGitRepoStatus(repoRoot, {
3089
- includeSubmodules: true,
3090
- submoduleIgnorePaths: options.submoduleIgnorePaths,
3091
- timeoutMs: 15_000,
3092
- });
3093
- const outOfSyncPaths = (preStatus.submodules || [])
3094
- .filter(submodule => submodule.dirty || submodule.outOfSync || !!submodule.error)
3095
- .map(submodule => submodule.path);
3096
- const updatePaths = [...new Set([...changedGitlinkPaths, ...outOfSyncPaths])].sort();
3097
-
3098
- if (updatePaths.length === 0) {
3099
- return {
3100
- status: 'skipped',
3101
- changedGitlinkPaths,
3102
- outOfSyncPaths,
3103
- updatedPaths: [],
3104
- verifiedPaths: [],
3105
- durationMs: Date.now() - startedAt,
3106
- reason: 'no_changed_or_out_of_sync_submodules',
3107
- };
3108
- }
3109
-
3110
- const commandArgs = ['submodule', 'update', '--init', '--recursive', '--', ...updatePaths];
3111
- try {
3112
- const { execFile } = await import('node:child_process');
3113
- const { promisify } = await import('node:util');
3114
- const execFileAsync = promisify(execFile);
3115
- const result = await execFileAsync('git', commandArgs, {
3116
- cwd: repoRoot,
3117
- encoding: 'utf8',
3118
- maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES,
3119
- timeout: 60_000,
3120
- });
3121
- const postStatus = await getGitRepoStatus(repoRoot, {
3122
- includeSubmodules: true,
3123
- submoduleIgnorePaths: options.submoduleIgnorePaths,
3124
- timeoutMs: 15_000,
3125
- });
3126
- const remaining = (postStatus.submodules || [])
3127
- .filter(submodule => updatePaths.includes(submodule.path) && (submodule.dirty || submodule.outOfSync || !!submodule.error));
3128
- return {
3129
- status: remaining.length === 0 ? 'passed' : 'failed',
3130
- changedGitlinkPaths,
3131
- outOfSyncPaths,
3132
- updatedPaths: updatePaths,
3133
- verifiedPaths: updatePaths.filter(path => !remaining.some(submodule => submodule.path === path)),
3134
- durationMs: Date.now() - startedAt,
3135
- command: `git ${commandArgs.join(' ')}`,
3136
- stdout: truncateValidationOutput(result.stdout),
3137
- stderr: truncateValidationOutput(result.stderr),
3138
- ...(remaining.length > 0 ? { error: `Submodule checkout remained out of sync after update: ${remaining.map(entry => entry.path).join(', ')}` } : {}),
3139
- };
3140
- } catch (e: any) {
3141
- return {
3142
- status: 'failed',
3143
- changedGitlinkPaths,
3144
- outOfSyncPaths,
3145
- updatedPaths: updatePaths,
3146
- verifiedPaths: [],
3147
- durationMs: Date.now() - startedAt,
3148
- command: `git ${commandArgs.join(' ')}`,
3149
- error: e?.message || String(e),
3150
- stdout: truncateValidationOutput(e?.stdout),
3151
- stderr: truncateValidationOutput(e?.stderr),
3152
- };
3153
- }
3154
- }
3155
-
3156
- async function runMeshRefineSubmoduleReachabilityGate(
3157
- repoRoot: string,
3158
- mergedTree: string,
3159
- options: { allowAutoPublishSubmoduleMainCommits?: boolean; autoPublishPolicySource?: string; worktreeRoot?: string } = {},
3160
- ): Promise<MeshRefineSubmoduleReachabilitySummary> {
3161
- const startedAt = Date.now();
3162
- const entries: MeshRefineSubmoduleReachabilityEntry[] = [];
3163
- try {
3164
- const { execFile } = await import('node:child_process');
3165
- const { promisify } = await import('node:util');
3166
- const execFileAsync = promisify(execFile);
3167
- const runGit = async (cwd: string, args: string[]): Promise<string> => {
3168
- const { stdout } = await execFileAsync('git', args, {
3169
- cwd,
3170
- encoding: 'utf8',
3171
- timeout: 30_000,
3172
- maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES,
3173
- windowsHide: true,
3174
- });
3175
- return String(stdout || '');
3176
- };
3177
- const verifyRemoteMainContainsCommit = async (submodulePath: string, commit: string, branch = 'main'): Promise<void> => {
3178
- await runGit(submodulePath, ['-c', 'protocol.file.allow=always', 'fetch', 'origin', `refs/heads/${branch}:refs/remotes/origin/${branch}`]);
3179
- await runGit(submodulePath, ['merge-base', '--is-ancestor', commit, `refs/remotes/origin/${branch}`]);
3180
- };
3181
- const publishCommitToRemoteMain = async (submodulePath: string, commit: string, branch = 'main'): Promise<{ stdout: string; stderr: string; refspec: string }> => {
3182
- const refspec = `${commit}:refs/heads/${branch}`;
3183
- const { stdout, stderr } = await execFileAsync('git', ['push', 'origin', refspec], {
3184
- cwd: submodulePath,
3185
- encoding: 'utf8',
3186
- timeout: 30_000,
3187
- maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES,
3188
- windowsHide: true,
3189
- });
3190
- return { stdout: String(stdout || ''), stderr: String(stderr || ''), refspec };
3191
- };
3192
- const importCommitFromWorktreeSubmodule = async (submodulePath: string, worktreeSubmodulePath: string, commit: string): Promise<boolean> => {
3193
- if (!fs.existsSync(worktreeSubmodulePath)) return false;
3194
- try {
3195
- await runGit(worktreeSubmodulePath, ['cat-file', '-e', `${commit}^{commit}`]);
3196
- } catch {
3197
- return false;
3198
- }
3199
- await runGit(submodulePath, ['-c', 'protocol.file.allow=always', 'fetch', worktreeSubmodulePath, commit]);
3200
- await runGit(submodulePath, ['cat-file', '-e', `${commit}^{commit}`]);
3201
- return true;
3202
- };
3203
-
3204
- const treeOutput = await runGit(repoRoot, ['ls-tree', '-r', '-z', mergedTree]);
3205
- const gitlinks = treeOutput
3206
- .split('\0')
3207
- .filter(Boolean)
3208
- .map(record => {
3209
- const match = /^160000\s+commit\s+([0-9a-f]{40})\t(.+)$/.exec(record);
3210
- return match ? { commit: match[1], path: match[2] } : null;
3211
- })
3212
- .filter((entry): entry is { commit: string; path: string } => !!entry);
3213
-
3214
- for (const gitlink of gitlinks) {
3215
- const submodulePath = pathResolve(repoRoot, gitlink.path);
3216
- const entry: MeshRefineSubmoduleReachabilityEntry = {
3217
- path: gitlink.path,
3218
- commit: gitlink.commit,
3219
- reachable: false,
3220
- };
3221
- try {
3222
- if (!fs.existsSync(submodulePath)) {
3223
- entry.error = `Submodule checkout missing at ${gitlink.path}`;
3224
- entry.publishRequired = true;
3225
- if (options.allowAutoPublishSubmoduleMainCommits === true) {
3226
- entry.autoPublishAllowed = true;
3227
- entry.autoPublishAttempted = false;
3228
- entry.autoPublishSkippedReason = `submodule checkout missing at ${gitlink.path}; cannot perform non-force push to origin/main`;
3229
- }
3230
- entries.push(entry);
3231
- continue;
3232
- }
3233
-
3234
- entry.checkedLocal = true;
3235
- try {
3236
- await runGit(submodulePath, ['cat-file', '-e', `${gitlink.commit}^{commit}`]);
3237
- entry.localReachable = true;
3238
- } catch {
3239
- entry.localReachable = false;
3240
- if (options.allowAutoPublishSubmoduleMainCommits === true && options.worktreeRoot) {
3241
- try {
3242
- const imported = await importCommitFromWorktreeSubmodule(
3243
- submodulePath,
3244
- pathResolve(options.worktreeRoot, gitlink.path),
3245
- gitlink.commit,
3246
- );
3247
- if (imported) {
3248
- entry.localReachable = true;
3249
- entry.importedFromWorktree = true;
3250
- }
3251
- } catch (importError: any) {
3252
- entry.autoPublishSkippedReason = `candidate commit was not present in the source checkout and could not be imported from worktree submodule: ${truncateValidationOutput(importError?.stderr || importError?.message || String(importError))}`;
3253
- }
3254
- }
3255
- // Probe the submodule remote before allowing cleanup/completion.
3256
- }
3257
-
3258
- try {
3259
- entry.remote = 'origin';
3260
- let remoteUrl = '';
3261
- try {
3262
- remoteUrl = (await runGit(submodulePath, ['remote', 'get-url', 'origin'])).trim();
3263
- if (!remoteUrl) throw new Error('origin remote has no URL');
3264
- entry.remoteUrl = remoteUrl;
3265
- } catch {
3266
- entry.error = 'Submodule remote reachability check failed: no configured origin remote';
3267
- entry.publishRequired = true;
3268
- if (options.allowAutoPublishSubmoduleMainCommits === true) {
3269
- entry.autoPublishAllowed = true;
3270
- entry.autoPublishAttempted = false;
3271
- entry.autoPublishSkippedReason = 'submodule origin remote is not configured; cannot perform non-force push to origin/main';
3272
- }
3273
- entries.push(entry);
3274
- continue;
3275
- }
3276
- entry.remoteMainBranch = 'main';
3277
- try {
3278
- await verifyRemoteMainContainsCommit(submodulePath, gitlink.commit, 'main');
3279
- entry.fetchedFromOrigin = true;
3280
- entry.remoteReachable = true;
3281
- entry.remoteMainReachable = true;
3282
- entry.reachable = true;
3283
- } catch (e: any) {
3284
- entry.remoteReachable = false;
3285
- entry.remoteMainReachable = false;
3286
- entry.publishRequired = true;
3287
- const details = truncateValidationOutput(e?.stderr || e?.message || String(e));
3288
- entry.error = `Submodule remote main reachability check failed for origin/main: ${details}`;
3289
- if (options.allowAutoPublishSubmoduleMainCommits === true && entry.localReachable === true) {
3290
- entry.autoPublishAllowed = true;
3291
- entry.autoPublishAttempted = true;
3292
- try {
3293
- const publish = await publishCommitToRemoteMain(submodulePath, gitlink.commit, 'main');
3294
- entry.autoPublishRefspec = publish.refspec;
3295
- entry.publishStdout = truncateValidationOutput(publish.stdout);
3296
- entry.publishStderr = truncateValidationOutput(publish.stderr);
3297
- entry.autoPublishSucceeded = true;
3298
- await verifyRemoteMainContainsCommit(submodulePath, gitlink.commit, 'main');
3299
- entry.fetchedFromOrigin = true;
3300
- entry.remoteReachable = true;
3301
- entry.remoteMainReachable = true;
3302
- entry.autoPublishVerified = true;
3303
- entry.publishRequired = false;
3304
- entry.reachable = true;
3305
- entry.error = undefined;
3306
- } catch (publishError: any) {
3307
- entry.autoPublishSucceeded = false;
3308
- entry.autoPublishVerified = false;
3309
- const publishDetails = truncateValidationOutput(publishError?.stderr || publishError?.message || String(publishError));
3310
- entry.error = `Submodule auto-publish to origin/main failed or could not be verified: ${publishDetails}`;
3311
- }
3312
- } else if (options.allowAutoPublishSubmoduleMainCommits === true) {
3313
- entry.autoPublishAllowed = true;
3314
- entry.autoPublishAttempted = false;
3315
- entry.autoPublishSkippedReason = entry.autoPublishSkippedReason
3316
- || 'candidate commit is not reachable in the source checkout or worktree submodule, so Refinery cannot push it to origin/main';
3317
- }
3318
- }
3319
- } catch (e: any) {
3320
- entry.remoteReachable = false;
3321
- entry.remoteMainReachable = false;
3322
- entry.publishRequired = true;
3323
- const details = truncateValidationOutput(e?.stderr || e?.message || String(e));
3324
- entry.error = `Submodule remote main reachability check failed for origin/main: ${details}`;
3325
- }
3326
- } catch (e: any) {
3327
- entry.error = truncateValidationOutput(e?.message || String(e));
3328
- entry.publishRequired = true;
3329
- }
3330
- entries.push(entry);
3331
- }
3332
-
3333
- const unreachable = entries.filter(entry => !entry.reachable);
3334
- return {
3335
- status: unreachable.length ? 'failed' : 'passed',
3336
- checked: entries.length,
3337
- unreachable: unreachable.map(entry => ({ ...entry, publishRequired: entry.publishRequired !== false })),
3338
- entries: entries.map(entry => entry.reachable ? entry : { ...entry, publishRequired: entry.publishRequired !== false }),
3339
- durationMs: Date.now() - startedAt,
3340
- autoPublishAllowed: options.allowAutoPublishSubmoduleMainCommits === true,
3341
- autoPublishPolicySource: options.autoPublishPolicySource,
3342
- };
3343
- } catch (e: any) {
3344
- const unreachable = entries.filter(entry => !entry.reachable).map(entry => ({ ...entry, publishRequired: true }));
3345
- return {
3346
- status: 'failed',
3347
- checked: entries.length,
3348
- unreachable,
3349
- entries: entries.map(entry => entry.reachable ? entry : { ...entry, publishRequired: true }),
3350
- durationMs: Date.now() - startedAt,
3351
- autoPublishAllowed: options.allowAutoPublishSubmoduleMainCommits === true,
3352
- autoPublishPolicySource: options.autoPublishPolicySource,
3353
- error: truncateValidationOutput(e?.message || String(e)),
3354
- };
3355
- }
3356
- }
3357
-
3358
- export function buildMeshRefineValidationPlan(mesh: any, workspace: string): Record<string, unknown> {
3359
- const plan = resolveMeshRefineValidationPlan(mesh, workspace);
3360
- const mapCommand = (command: MeshRefineValidationCommandPlan) => ({
3361
- displayCommand: command.displayCommand,
3362
- category: command.category,
3363
- source: command.source,
3364
- cwd: command.cwd,
3365
- timeoutMs: command.timeoutMs,
3366
- });
3367
- return {
3368
- source: plan.source,
3369
- sourceType: plan.sourceType,
3370
- bootstrapCommands: plan.bootstrapCommands.map(mapCommand),
3371
- commands: plan.commands.map(mapCommand),
3372
- unavailableReason: plan.unavailableReason,
3373
- rejectedCommands: plan.rejectedCommands,
3374
- suggestions: plan.suggestions,
3375
- suggestedConfig: plan.suggestedConfig,
3376
- note: plan.sourceType === 'unavailable'
3377
- ? 'No validation command will be executed until a repo mesh/refine config is provided. Heuristics are suggestions only.'
3378
- : 'Validation commands are resolved from repo mesh/refine config; heuristics are suggestions only.',
3379
- };
3380
- }
3381
-
3382
- async function runMeshRefineValidationGate(
3383
- mesh: any,
3384
- workspace: string,
3385
- opts?: {
3386
- /** M2-2: persisted node bootstrap state for staleness evaluation. */
3387
- persistedBootstrapState?: WorktreeBootstrapState | null;
3388
- /** M2-2: called after an inherit-mode bootstrap run so the caller can persist the new state. */
3389
- onBootstrapStateChange?: (state: WorktreeBootstrapState) => void;
3390
- },
3391
- ): Promise<MeshRefineValidationSummary> {
3392
- const { execFile } = await import('node:child_process');
3393
- const { promisify } = await import('node:util');
3394
- const execFileAsync = promisify(execFile);
3395
- const selection = resolveMeshRefineValidationPlan(mesh, workspace);
3396
- const summary: MeshRefineValidationSummary = {
3397
- status: 'skipped',
3398
- required: true,
3399
- commandsRun: [],
3400
- bootstrapCommandsRun: [],
3401
- rejectedCommands: selection.rejectedCommands,
3402
- skippedReason: undefined,
3403
- timeoutMs: REFINE_VALIDATION_TIMEOUT_MS,
3404
- outputLimitBytes: REFINE_VALIDATION_OUTPUT_LIMIT_BYTES,
3405
- configSource: selection.source,
3406
- configSourceType: selection.sourceType,
3407
- suggestions: selection.suggestions,
3408
- suggestedConfig: selection.suggestedConfig,
3409
- ...(selection.deprecationWarnings.length > 0 ? { deprecationWarnings: selection.deprecationWarnings } : {}),
3410
- };
3411
-
3412
- if (!selection.commands.length) {
3413
- summary.skippedReason = selection.unavailableReason || 'validation_unavailable: repo mesh/refine config did not provide executable validation.commands';
3414
- return summary;
3415
- }
3416
-
3417
- // ── M2-2: Bootstrap stage — refine consumes the worktree_bootstrap config
3418
- // instead of defining its own. Legacy validation.bootstrapCommands run
3419
- // only when no worktree_bootstrap config exists (deprecation path).
3420
- let runLegacyBootstrapCommands = selection.bootstrapCommands.length > 0;
3421
- if (selection.bootstrapMode === 'skip') {
3422
- summary.bootstrap = { stage: 'skipped', skipped: true };
3423
- runLegacyBootstrapCommands = false;
3424
- } else {
3425
- const wbLoad = loadMeshWorktreeBootstrapConfig(mesh, workspace);
3426
- const wbUsable = !!wbLoad.config && wbLoad.sourceType !== 'invalid'
3427
- && wbLoad.config.enabled !== false && wbLoad.config.runOnClone !== false;
3428
- if (wbUsable) {
3429
- runLegacyBootstrapCommands = false; // worktree_bootstrap wins over deprecated bootstrapCommands
3430
- const evaluated = evaluateWorktreeBootstrapState(mesh, workspace, opts?.persistedBootstrapState);
3431
- if (evaluated.status === 'ready') {
3432
- summary.bootstrap = { stage: 'cached', status: 'ready', skipped: true, configSource: evaluated.configSource };
3433
- } else {
3434
- const ran = await runMeshWorktreeBootstrap(mesh, workspace);
3435
- try { opts?.onBootstrapStateChange?.(ran); } catch { /* persistence is best-effort */ }
3436
- if (ran.status === 'ready') {
3437
- summary.bootstrap = {
3438
- stage: 'ran',
3439
- status: 'ready',
3440
- configSource: ran.configSource,
3441
- ...(evaluated.staleReason ? { staleReason: evaluated.staleReason } : {}),
3442
- commandsRun: ran.commandsRun,
3443
- };
3444
- } else {
3445
- summary.bootstrap = {
3446
- stage: 'failed',
3447
- status: ran.status,
3448
- configSource: ran.configSource,
3449
- error: ran.error,
3450
- commandsRun: ran.commandsRun,
3451
- };
3452
- summary.status = 'failed';
3453
- summary.failureKind = 'dependency_bootstrap_failed';
3454
- summary.failureCode = 'dependency_bootstrap_failed';
3455
- return summary;
3456
- }
3457
- }
3458
- } else if (!runLegacyBootstrapCommands) {
3459
- summary.bootstrap = { stage: 'not_configured' };
3460
- }
3461
- }
3462
-
3463
- const commandRecord = (candidate: MeshRefineValidationCommand, cwd: string, startedAt: number, result: any, passed: boolean, extras: Record<string, unknown> = {}) => ({
3464
- command: candidate.command,
3465
- args: candidate.args,
3466
- displayCommand: candidate.displayCommand,
3467
- category: candidate.category,
3468
- source: candidate.source,
3469
- cwd,
3470
- passed,
3471
- durationMs: Date.now() - startedAt,
3472
- stdout: truncateValidationOutput(result?.stdout),
3473
- stderr: truncateValidationOutput(result?.stderr || result?.message),
3474
- ...extras,
3475
- });
3476
- const isPackageManagerValidation = (candidate: MeshRefineValidationCommand): boolean => {
3477
- const command = pathBasename(candidate.command).replace(/\.(?:cmd|exe)$/i, '');
3478
- return ['npm', 'pnpm', 'yarn', 'bun'].includes(command)
3479
- && candidate.args.some(arg => arg === 'run' || arg === 'test' || arg === 'exec');
3480
- };
3481
- const dependenciesLikelyMissing = (cwd: string): boolean => {
3482
- if (!fs.existsSync(pathJoin(cwd, 'package.json'))) return false;
3483
- if (fs.existsSync(pathJoin(cwd, 'node_modules'))) return false;
3484
- return ['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lockb', 'bun.lock']
3485
- .some(lock => fs.existsSync(pathJoin(cwd, lock)));
3486
- };
3487
-
3488
- if (runLegacyBootstrapCommands) {
3489
- summary.bootstrap = { stage: 'legacy' };
3490
- for (const candidate of selection.bootstrapCommands) {
3491
- const startedAt = Date.now();
3492
- const cwd = candidate.cwd ? pathResolve(workspace, candidate.cwd) : workspace;
3493
- const timeout = candidate.timeoutMs || REFINE_VALIDATION_TIMEOUT_MS;
3494
- // On win32, libuv's spawn search only appends .com/.exe (not .cmd/.bat),
3495
- // so a bare `npm`/`npx`/`tsc` (which are .cmd shims) throws spawn ENOENT.
3496
- // Resolve to an absolute path via the same helper the PTY path uses
3497
- // (no-op on non-win32 and when the command is already absolute).
3498
- const resolvedCommand = resolveWin32Executable(candidate.command);
3499
- try {
3500
- const result = await execFileAsync(resolvedCommand, candidate.args, {
3501
- cwd,
3502
- encoding: 'utf8',
3503
- timeout,
3504
- maxBuffer: candidate.outputLimitBytes || REFINE_VALIDATION_OUTPUT_LIMIT_BYTES,
3505
- env: { ...process.env, CI: process.env.CI || '1', ...(candidate.env || {}) },
3506
- });
3507
- summary.bootstrapCommandsRun.push(commandRecord(candidate, cwd, startedAt, result, true, { exitCode: 0 }));
3508
- } catch (error: any) {
3509
- const spawnResolutionFailed = isSpawnResolutionError(error);
3510
- summary.bootstrapCommandsRun.push(commandRecord(candidate, cwd, startedAt, error, false, {
3511
- exitCode: typeof error?.code === 'number' ? error.code : null,
3512
- signal: typeof error?.signal === 'string' ? error.signal : null,
3513
- timedOut: error?.killed === true || /timed out/i.test(String(error?.message || '')),
3514
- ...(spawnResolutionFailed
3515
- ? { failureKind: 'spawn_resolution_failed', resolvedCommand }
3516
- : { failureKind: 'dependency_bootstrap_failed' }),
3517
- }));
3518
- summary.bootstrap = { stage: 'failed', error: describeSpawnError(error, candidate.command, spawnResolutionFailed) };
3519
- summary.status = 'failed';
3520
- summary.failureKind = spawnResolutionFailed ? 'spawn_resolution_failed' : 'dependency_bootstrap_failed';
3521
- summary.failureCode = spawnResolutionFailed ? 'spawn_resolution_failed' : 'dependency_bootstrap_failed';
3522
- return summary;
3523
- }
3524
- }
3525
- }
3526
-
3527
- for (const candidate of selection.commands) {
3528
- const startedAt = Date.now();
3529
- const cwd = candidate.cwd ? pathResolve(workspace, candidate.cwd) : workspace;
3530
- const timeout = candidate.timeoutMs || REFINE_VALIDATION_TIMEOUT_MS;
3531
- const bootstrapProvidedDependencies = summary.bootstrap?.stage === 'cached' || summary.bootstrap?.stage === 'ran' || summary.bootstrap?.stage === 'legacy';
3532
- if (!bootstrapProvidedDependencies && isPackageManagerValidation(candidate) && dependenciesLikelyMissing(cwd)) {
3533
- summary.commandsRun.push(commandRecord(candidate, cwd, startedAt, {
3534
- stderr: 'Dependencies appear to be missing: package.json and a lockfile are present, but node_modules is absent. Configure validation.bootstrapCommands in repo mesh/refine config if Refinery should install/bootstrap before validation.',
3535
- }, false, {
3536
- exitCode: null,
3537
- skipped: true,
3538
- failureKind: 'missing_dependencies',
3539
- }));
3540
- summary.status = 'failed';
3541
- summary.failureKind = 'missing_dependencies';
3542
- summary.failureCode = 'missing_dependencies';
3543
- return summary;
3544
- }
3545
- // See the bootstrap loop above: resolve the win32 .cmd shim to an
3546
- // absolute path before handing it to the spawn boundary.
3547
- const resolvedCommand = resolveWin32Executable(candidate.command);
3548
- try {
3549
- const result = await execFileAsync(resolvedCommand, candidate.args, {
3550
- cwd,
3551
- encoding: 'utf8',
3552
- timeout,
3553
- maxBuffer: candidate.outputLimitBytes || REFINE_VALIDATION_OUTPUT_LIMIT_BYTES,
3554
- env: { ...process.env, CI: process.env.CI || '1', ...(candidate.env || {}) },
3555
- });
3556
- summary.commandsRun.push(commandRecord(candidate, cwd, startedAt, result, true, { exitCode: 0 }));
3557
- } catch (error: any) {
3558
- // ENOENT check first: a spawn-resolution failure ("spawn npm ENOENT")
3559
- // carries no stderr and would otherwise fall through to an
3560
- // unclassified generic failure. Classify it distinctly so the
3561
- // coordinator surfaces the real cause (win32 .cmd resolution).
3562
- const spawnResolutionFailed = isSpawnResolutionError(error);
3563
- const stderr = truncateValidationOutput(error?.stderr || error?.message);
3564
- const missingDependencyFailure = !spawnResolutionFailed
3565
- && /Cannot find module|MODULE_NOT_FOUND|node_modules|command not found|not found/i.test(stderr);
3566
- summary.commandsRun.push(commandRecord(candidate, cwd, startedAt, error, false, {
3567
- exitCode: typeof error?.code === 'number' ? error.code : null,
3568
- signal: typeof error?.signal === 'string' ? error.signal : null,
3569
- timedOut: error?.killed === true || /timed out/i.test(String(error?.message || '')),
3570
- ...(spawnResolutionFailed
3571
- ? { failureKind: 'spawn_resolution_failed', resolvedCommand }
3572
- : missingDependencyFailure ? { failureKind: 'missing_dependencies' } : {}),
3573
- }));
3574
- summary.status = 'failed';
3575
- if (spawnResolutionFailed) {
3576
- summary.failureKind = 'spawn_resolution_failed';
3577
- summary.failureCode = 'spawn_resolution_failed';
3578
- summary.spawnResolutionError = describeSpawnError(error, candidate.command, true);
3579
- } else if (missingDependencyFailure) {
3580
- summary.failureKind = 'missing_dependencies';
3581
- summary.failureCode = 'missing_dependencies';
3582
- }
3583
- return summary;
3584
- }
3585
- }
3586
-
3587
- summary.status = 'passed';
3588
- return summary;
3589
- }
3590
-
3591
- function loadYamlModule(): { load: (input: string) => any; dump: (input: any, options?: Record<string, any>) => string } {
3592
- return yaml as { load: (input: string) => any; dump: (input: any, options?: Record<string, any>) => string };
3593
- }
3594
-
3595
- export function getMcpServersKey(format: MeshCoordinatorConfigFormat): 'mcpServers' | 'mcp_servers' {
3596
- return format === 'hermes_config_yaml' ? 'mcp_servers' : 'mcpServers';
3597
- }
3598
-
3599
- export function parseMeshCoordinatorMcpConfig(text: string, format: MeshCoordinatorConfigFormat): Record<string, any> {
3600
- if (!text.trim()) return {};
3601
- if (format === 'claude_mcp_json') return JSON.parse(text);
3602
- const parsed = loadYamlModule().load(text);
3603
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
3604
- }
3605
-
3606
- export function serializeMeshCoordinatorMcpConfig(config: Record<string, any>, format: MeshCoordinatorConfigFormat): string {
3607
- if (format === 'claude_mcp_json') return JSON.stringify(config, null, 2);
3608
- return loadYamlModule().dump(config, { noRefs: true, lineWidth: 120 });
3609
- }
3610
-
3611
- function resolveHermesUserHome(): string {
3612
- const explicitHome = process.env.HERMES_HOME?.trim();
3613
- return explicitHome || pathJoin(homedir(), '.hermes');
3614
- }
3615
-
3616
- export function loadHermesCoordinatorBaseConfig(targetConfigPath: string): { config: Record<string, any>; sourceHome: string; sourceConfigPath: string } {
3617
- const sourceHome = resolveHermesUserHome();
3618
- const sourceConfigPath = pathJoin(sourceHome, 'config.yaml');
3619
- if (!fs.existsSync(sourceConfigPath)) return { config: {}, sourceHome, sourceConfigPath };
3620
- if (pathResolve(sourceConfigPath) === pathResolve(targetConfigPath)) return { config: {}, sourceHome, sourceConfigPath };
3621
-
3622
- const parsed = parseMeshCoordinatorMcpConfig(fs.readFileSync(sourceConfigPath, 'utf-8'), 'hermes_config_yaml');
3623
- const { mcp_servers: _mcpServers, ...baseConfig } = parsed;
3624
- return { config: baseConfig, sourceHome, sourceConfigPath };
3625
- }
3626
-
3627
- export function stripHermesCoordinatorTempModelProviderOverrides(config: Record<string, any>): Record<string, any> {
3628
- const {
3629
- model: _model,
3630
- provider: _provider,
3631
- default_model: _defaultModel,
3632
- defaultProvider: _defaultProvider,
3633
- default_provider: _defaultProviderSnake,
3634
- modelProvider: _modelProvider,
3635
- model_provider: _modelProviderSnake,
3636
- ...sanitized
3637
- } = config;
3638
- const delegation = sanitized.delegation;
3639
- if (delegation && typeof delegation === 'object' && !Array.isArray(delegation)) {
3640
- const {
3641
- model: _delegationModel,
3642
- provider: _delegationProvider,
3643
- modelProvider: _delegationModelProvider,
3644
- model_provider: _delegationModelProviderSnake,
3645
- ...delegationRest
3646
- } = delegation;
3647
- if (Object.keys(delegationRest).length > 0) {
3648
- sanitized.delegation = delegationRest;
3649
- } else {
3650
- delete sanitized.delegation;
3651
- }
3652
- }
3653
- return sanitized;
3654
- }
3655
-
3656
- export function copyHermesCoordinatorCredentialFiles(sourceHome: string, targetHome: string) {
3657
- if (pathResolve(sourceHome) === pathResolve(targetHome)) return;
3658
- for (const fileName of ['.env', 'auth.json']) {
3659
- const sourcePath = pathJoin(sourceHome, fileName);
3660
- const targetPath = pathJoin(targetHome, fileName);
3661
- if (!fs.existsSync(sourcePath)) continue;
3662
- try {
3663
- fs.copyFileSync(sourcePath, targetPath);
3664
- } catch (error: any) {
3665
- LOG.warn('MeshCoordinator', `Could not copy Hermes ${fileName} into isolated coordinator home: ${error?.message || error}`);
3666
- }
3667
- }
3668
- }
43
+ // ─── Extracted-module imports (symbols the dispatch class consumes) ───
44
+ import {
45
+ applyInlineMeshBranchConvergence,
46
+ buildInlineMeshTransitGitStatus,
47
+ buildLivePeerGitConnection,
48
+ collectMeshNodeHostedSessionIds,
49
+ deriveMeshNodeHealthFromGit,
50
+ foldMeshNodeIdentityToCanonical,
51
+ inlineMeshCarriesTransientNodeTruth,
52
+ isDeadLocalWorktreeNode,
53
+ MESH_DIRECT_PROBE_REUSE_MS,
54
+ MeshGitProbeCache,
55
+ normalizeInlineMeshNodeIdentity,
56
+ readBooleanValue,
57
+ readCachedInlineMeshActiveSessions,
58
+ readInlineMeshNodeId,
59
+ readMeshNodeDaemonId,
60
+ readObjectRecord,
61
+ readStringValue,
62
+ reconcileInlineMeshCache,
63
+ sanitizeInlineMesh,
64
+ shouldRefreshStalePendingAggregate,
65
+ summarizeInlineMeshBranchConvergence,
66
+ } from '../mesh/mesh-node-identity.js';
67
+ import {
68
+ alignRefinerySubmodulesAfterMerge,
69
+ buildMeshRefineValidationPlan,
70
+ buildSubmodulePublishRequiredNextStep,
71
+ checkWorktreeChangesPatchEquivalentInRef,
72
+ MeshRefineAsyncJobStatus,
73
+ MeshRefineBatchJobHandle,
74
+ MeshRefineBatchJobStatus,
75
+ MeshRefineBatchTerminalJob,
76
+ MeshRefineJobHandle,
77
+ MeshRefineTerminalJob,
78
+ MeshWorktreePatchContainmentSummary,
79
+ recordMeshRefineStage,
80
+ RefineContext,
81
+ RefineExecFileAsync,
82
+ RefineStageOutcome,
83
+ resolveRefineryAutoPublishSubmoduleMainCommits,
84
+ runMeshRefineEffectiveDiffGate,
85
+ runMeshRefinePatchEquivalenceGate,
86
+ runMeshRefineSubmoduleReachabilityGate,
87
+ runMeshRefineValidationGate,
88
+ truncateValidationOutput,
89
+ } from '../mesh/mesh-refine-gates.js';
90
+
91
+ // ─── Barrel re-exports: node-identity / git-freshness, refine gates, coordinator config ───
92
+ // These modules were split out of router.ts. Re-export their public surface so the
93
+ // many existing `from '.../commands/router.js'` named imports keep resolving here.
94
+ export * from '../mesh/mesh-node-identity.js';
95
+ export * from '../mesh/mesh-refine-gates.js';
96
+ export * from '../mesh/mesh-coordinator-config.js';
3669
97
 
3670
98
  // ─── Types ───
3671
99