@adhdev/daemon-core 0.9.82-rc.454 → 0.9.82-rc.455

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.
@@ -87,6 +87,24 @@ import {
87
87
  runMeshRefineValidationGate,
88
88
  truncateValidationOutput,
89
89
  } from '../mesh/mesh-refine-gates.js';
90
+ // ─── Refinery job orchestration (bodies extracted from this file) ───
91
+ import {
92
+ batchRefineMeshNodes,
93
+ resumePendingRefineJobsOnStartup,
94
+ startMeshRefineBatchJob,
95
+ startMeshRefineJob,
96
+ } from './router-refine.js';
97
+ // ─── Worktree / mesh-session cleanup (bodies extracted from this file) ───
98
+ import {
99
+ bestEffortRemoveWorktreeDir,
100
+ cleanupLocalWorktreeNode,
101
+ cleanupMeshSessions,
102
+ getWorktreeForceCleanupConvergence,
103
+ isCompletedHostedSession,
104
+ precheckLocalWorktreeRemovable,
105
+ recordIntentionalMeshSessionStop,
106
+ sessionMatchesMeshNode,
107
+ } from './router-worktree-cleanup.js';
90
108
 
91
109
  // ─── Barrel re-exports: node-identity / git-freshness, refine gates, coordinator config ───
92
110
  // These modules were split out of router.ts. Re-export their public surface so the
@@ -266,7 +284,8 @@ export function buildMemberJoinNode(mesh: any, args: any, fallbackDaemonId?: str
266
284
  }
267
285
 
268
286
  export class DaemonCommandRouter {
269
- private deps: CommandRouterDeps;
287
+ /** Public (not private) so the extracted ./router-refine.ts orchestration can reach it via `self`. */
288
+ deps: CommandRouterDeps;
270
289
  /** In-memory cache for cloud-originating meshes passed via inlineMesh.
271
290
  * Allows the MCP server to query mesh data via get_mesh even when
272
291
  * the mesh doesn't exist in the local meshes.json file. */
@@ -290,14 +309,15 @@ export class DaemonCommandRouter {
290
309
  * flight — so a burst of interactive detail-opens serves the cached snapshot
291
310
  * and coalesces onto ONE background refresh instead of storming the peers. */
292
311
  private swrRefreshInFlight = new Set<string>();
293
- /** In-memory async Refinery jobs keyed by meshId:nodeId to reject/return duplicate in-flight requests. */
294
- private runningRefineJobs = new Map<string, MeshRefineJobHandle>();
312
+ /** In-memory async Refinery jobs keyed by meshId:nodeId to reject/return duplicate in-flight requests.
313
+ * Public (not private) so the extracted ./router-refine.ts orchestration can reach it via `self`. */
314
+ runningRefineJobs = new Map<string, MeshRefineJobHandle>();
295
315
  /** Terminal async Refinery jobs preserve a clear answer after the worktree node has been removed. */
296
- private terminalRefineJobs = new Map<string, MeshRefineTerminalJob>();
316
+ terminalRefineJobs = new Map<string, MeshRefineTerminalJob>();
297
317
  /** In-memory async batch Refinery jobs keyed by meshId (one batch convergence per mesh at a time). */
298
- private runningRefineBatchJobs = new Map<string, MeshRefineBatchJobHandle>();
318
+ runningRefineBatchJobs = new Map<string, MeshRefineBatchJobHandle>();
299
319
  /** Terminal async batch Refinery jobs preserve the last batch outcome for late readers. */
300
- private terminalRefineBatchJobs = new Map<string, MeshRefineBatchTerminalJob>();
320
+ terminalRefineBatchJobs = new Map<string, MeshRefineBatchTerminalJob>();
301
321
 
302
322
  constructor(deps: CommandRouterDeps) {
303
323
  this.deps = deps;
@@ -597,7 +617,7 @@ export class DaemonCommandRouter {
597
617
  return sanitizedInlineMesh as any;
598
618
  }
599
619
 
600
- private async getMeshForCommand(
620
+ async getMeshForCommand(
601
621
  meshId: string,
602
622
  inlineMesh?: unknown,
603
623
  options?: { preferInline?: boolean },
@@ -637,7 +657,7 @@ export class DaemonCommandRouter {
637
657
  return warmedInline ? { mesh: warmedInline, inline: true, source: 'inline_bootstrap' } : null;
638
658
  }
639
659
 
640
- private invalidateAggregateMeshStatus(meshId: string): void {
660
+ invalidateAggregateMeshStatus(meshId: string): void {
641
661
  this.aggregateMeshStatusCache.delete(meshId);
642
662
  this.deps.onMeshStateChange?.(meshId);
643
663
  }
@@ -875,7 +895,7 @@ export class DaemonCommandRouter {
875
895
  return { ...incoming, nodes };
876
896
  }
877
897
 
878
- private normalizeMeshSessionCleanupMode(value: unknown): RepoMeshSessionCleanupMode {
898
+ normalizeMeshSessionCleanupMode(value: unknown): RepoMeshSessionCleanupMode {
879
899
  return value === 'stop'
880
900
  || value === 'delete_stopped'
881
901
  || value === 'stop_and_delete'
@@ -884,543 +904,52 @@ export class DaemonCommandRouter {
884
904
  : 'preserve';
885
905
  }
886
906
 
887
- private sessionMatchesMeshNode(record: any, node: any, nodeId: string, sessionIds?: Set<string>): boolean {
888
- const sessionId = typeof record?.sessionId === 'string' ? record.sessionId : '';
889
- if (!sessionId) return false;
890
- if (sessionIds?.size) return sessionIds.has(sessionId);
891
- const workspace = typeof node?.workspace === 'string' ? node.workspace : '';
892
- if (workspace && record?.workspace === workspace) return true;
893
- if (record?.meta?.meshNodeId === nodeId) return true;
894
- return false;
907
+ // ─── Worktree / mesh-session cleanup ────────────────────────────────
908
+ // Implementation lives in ./router-worktree-cleanup.ts (behavior-preserving
909
+ // code move). Kept here as thin delegators: cleanupMeshSessions /
910
+ // cleanupLocalWorktreeNode / precheckLocalWorktreeRemovable are bound into
911
+ // MedFamilyContext; bestEffortRemoveWorktreeDir is overridden on the instance
912
+ // by a unit test, so callers reach these via `self.` for correct dispatch.
913
+
914
+ sessionMatchesMeshNode(record: any, node: any, nodeId: string, sessionIds?: Set<string>): boolean {
915
+ return sessionMatchesMeshNode(this, record, node, nodeId, sessionIds);
895
916
  }
896
917
 
897
- /**
898
- * Best-effort recursive removal of a managed worktree directory.
899
- *
900
- * The git-registry de-registration is the safety-critical step of worktree
901
- * teardown; a leftover directory must never gate dropping the node from the
902
- * mesh. On Windows, `fs.rmSync` can throw EINVAL/EPERM/EBUSY on submodule
903
- * gitlink (`.git`) files, long paths, junctions, or while a just-stopped
904
- * delegate session is still releasing a handle/cwd on the directory. This
905
- * helper absorbs those errors (never throws), with bounded retries + backoff
906
- * to give handles time to release, and reports whether residue remains.
907
- */
908
- private async bestEffortRemoveWorktreeDir(dir: string): Promise<{ removed: boolean; residue: boolean; error?: string }> {
909
- if (!dir || !fs.existsSync(dir)) return { removed: true, residue: false };
910
- const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
911
- // EINVAL is the Windows symptom for submodule gitlink residue; the rest are
912
- // transient lock/permission classes. None should escape as a throw here.
913
- const ABSORB = new Set(['EINVAL', 'EPERM', 'EBUSY', 'ENOTEMPTY', 'EACCES', 'EMFILE', 'ENFILE']);
914
- let lastErr: any;
915
- for (let attempt = 0; attempt < 4; attempt++) {
916
- try {
917
- // maxRetries/retryDelay give fs.rmSync its own internal backoff for
918
- // EBUSY/EPERM/ENOTEMPTY; the outer loop extends tolerance to EINVAL.
919
- fs.rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
920
- if (!fs.existsSync(dir)) return { removed: true, residue: false };
921
- lastErr = new Error('directory still present after rmSync');
922
- } catch (e: any) {
923
- lastErr = e;
924
- const code = typeof e?.code === 'string' ? e.code : '';
925
- if (code && !ABSORB.has(code)) {
926
- // Unexpected error class — stay best-effort (no throw) but stop retrying.
927
- break;
928
- }
929
- }
930
- await sleep(150 * (attempt + 1));
931
- }
932
- return fs.existsSync(dir)
933
- ? { removed: false, residue: true, error: String(lastErr?.message || lastErr || 'unknown rm error') }
934
- : { removed: true, residue: false };
918
+ async bestEffortRemoveWorktreeDir(dir: string): Promise<{ removed: boolean; residue: boolean; error?: string }> {
919
+ return bestEffortRemoveWorktreeDir(this, dir);
935
920
  }
936
921
 
937
- /**
938
- * Non-destructive precheck mirroring every REFUSAL condition in
939
- * {@link cleanupLocalWorktreeNode} — missing workspace / source-repo / branch
940
- * metadata, unexpected (non-managed) path, branch mismatch — PLUS the
941
- * dirty-worktree guard that `removeWorktree(requireClean)` enforces
942
- * (`git status --porcelain`). It performs ZERO destructive actions: no
943
- * `git worktree remove`, no `git worktree prune`, no directory deletion.
944
- *
945
- * remove_mesh_node calls this BEFORE any session cleanup so that a refusal
946
- * (the common one being a dirty worktree) does not first stop/delete the
947
- * delegated session and orphan it — the original ordering bug. Success/skip
948
- * cases that the real cleanup handles idempotently (worktree path already
949
- * gone, git-de-registered residue) are NOT refusals and return `{ ok: true }`.
950
- *
951
- * `force:true` skips the dirty guard, preserving `removeWorktree`'s
952
- * `requireClean: !force` semantics. This is a read-only superset check; the
953
- * authoritative `requireClean` guard inside `removeWorktree` is intentionally
954
- * kept as a second line of defense against a precheck→execute race.
955
- */
956
- private async precheckLocalWorktreeRemovable(args: {
922
+ async precheckLocalWorktreeRemovable(args: {
957
923
  mesh: any;
958
924
  node: any;
959
925
  nodeId: string;
960
926
  force?: boolean;
961
927
  }): Promise<{ ok: true } | { ok: false; code: string; error: string; recoveryHint: string }> {
962
- const sessionPreservedNote = ' The delegated session was left running (not stopped) — resolve the issue and retry mesh_remove_node.';
963
- const workspace = typeof args.node?.workspace === 'string' ? args.node.workspace.trim() : '';
964
- if (!workspace) {
965
- return {
966
- ok: false,
967
- code: 'mesh_worktree_cleanup_missing_workspace',
968
- error: `Worktree node '${args.nodeId}' is missing workspace metadata`,
969
- recoveryHint: 'Inspect the mesh node record before removing it, or remove stale metadata manually only after confirming no managed worktree remains.' + sessionPreservedNote,
970
- };
971
- }
972
-
973
- // Worktree path already gone → not a refusal; the real cleanup returns a
974
- // skipped:true success and the node is dropped from the registry.
975
- if (!fs.existsSync(workspace)) return { ok: true };
976
-
977
- const sourceNode = args.node?.clonedFromNodeId
978
- ? args.mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, args.node.clonedFromNodeId))
979
- : args.mesh?.nodes?.find((n: any) => !n.isLocalWorktree);
980
- const repoRoot = typeof sourceNode?.repoRoot === 'string' && sourceNode.repoRoot.trim()
981
- ? sourceNode.repoRoot.trim()
982
- : typeof sourceNode?.workspace === 'string' && sourceNode.workspace.trim()
983
- ? sourceNode.workspace.trim()
984
- : '';
985
- if (!repoRoot || !fs.existsSync(repoRoot)) {
986
- return {
987
- ok: false,
988
- code: 'mesh_worktree_cleanup_missing_source_repo',
989
- error: `Refusing to remove worktree '${workspace}' because the source repo root is unavailable`,
990
- recoveryHint: 'Run mesh_remove_node from the machine that owns the source repo, or verify the source node metadata before retrying.' + sessionPreservedNote,
991
- };
992
- }
993
- if (typeof args.node?.worktreeBranch !== 'string' || !args.node.worktreeBranch.trim()) {
994
- return {
995
- ok: false,
996
- code: 'mesh_worktree_cleanup_missing_branch',
997
- error: `Refusing to remove worktree '${workspace}' because worktreeBranch metadata is missing`,
998
- recoveryHint: 'Confirm this is an ADHDev-managed worktree before removing it manually; managed worktree nodes include worktreeBranch metadata.' + sessionPreservedNote,
999
- };
1000
- }
1001
-
1002
- const { resolveWorktreePath, listWorktrees } = await import('../git/git-worktree.js');
1003
- const normalizePath = (value: string) => {
1004
- const resolved = pathResolve(value);
1005
- try { return fs.realpathSync(resolved); } catch { return resolved; }
1006
- };
1007
- const expectedPath = normalizePath(resolveWorktreePath(repoRoot, String(args.mesh?.name || args.mesh?.id || 'mesh'), args.node.worktreeBranch));
1008
- const actualPath = normalizePath(workspace);
1009
- if (actualPath !== expectedPath) {
1010
- return {
1011
- ok: false,
1012
- code: 'mesh_worktree_cleanup_unexpected_path',
1013
- error: `Refusing to remove worktree '${workspace}' because it is not at the expected managed path '${expectedPath}'`,
1014
- recoveryHint: 'Use git worktree list/status to inspect the path. Retry only after confirming the mesh node metadata points to an ADHDev-managed worktree.' + sessionPreservedNote,
1015
- };
1016
- }
1017
-
1018
- const entries = await listWorktrees(repoRoot);
1019
- const managedEntry = entries.find(entry => normalizePath(entry.path) === actualPath);
1020
- // De-registered residue (git no longer lists it as a worktree) is an
1021
- // idempotent recovery path in the real cleanup, NOT a refusal — neither the
1022
- // branch-mismatch nor the dirty check applies, so let the removal proceed.
1023
- if (!managedEntry) return { ok: true };
1024
-
1025
- if (managedEntry.branch && managedEntry.branch !== args.node.worktreeBranch) {
1026
- return {
1027
- ok: false,
1028
- code: 'mesh_worktree_cleanup_branch_mismatch',
1029
- error: `Refusing to remove '${workspace}' because git reports branch '${managedEntry.branch}', expected '${args.node.worktreeBranch}'`,
1030
- recoveryHint: 'Inspect the worktree branch and mesh metadata before retrying cleanup.' + sessionPreservedNote,
1031
- };
1032
- }
1033
-
1034
- // Dirty-worktree guard — a read-only mirror of removeWorktree(requireClean)
1035
- // (`git status --porcelain` run inside the worktree). `force:true` skips it,
1036
- // preserving the requireClean:!force semantics.
1037
- if (args.force !== true) {
1038
- const { execFile } = await import('node:child_process');
1039
- const { promisify } = await import('node:util');
1040
- const execFileAsync = promisify(execFile);
1041
- try {
1042
- const { stdout } = await execFileAsync('git', ['status', '--porcelain'], {
1043
- cwd: workspace, encoding: 'utf8', timeout: 30_000, maxBuffer: 4 * 1024 * 1024, windowsHide: true,
1044
- });
1045
- if (stdout.trim()) {
1046
- return {
1047
- ok: false,
1048
- code: 'mesh_worktree_cleanup_dirty',
1049
- error: `Refusing to remove dirty worktree: ${workspace}`,
1050
- recoveryHint: 'Commit, stash, or intentionally discard the worktree changes before retrying mesh_remove_node. The mesh registry entry is preserved until cleanup is safe.' + sessionPreservedNote,
1051
- };
1052
- }
1053
- } catch {
1054
- // A status probe failure is not itself proof of dirtiness; defer to
1055
- // the authoritative removeWorktree(requireClean) guard rather than
1056
- // refusing here (which would block an otherwise-clean removal).
1057
- return { ok: true };
1058
- }
1059
- }
1060
-
1061
- return { ok: true };
928
+ return precheckLocalWorktreeRemovable(this, args);
1062
929
  }
1063
930
 
1064
- private async cleanupLocalWorktreeNode(args: {
931
+ async cleanupLocalWorktreeNode(args: {
1065
932
  mesh: any;
1066
933
  node: any;
1067
934
  nodeId: string;
1068
935
  force?: boolean;
1069
936
  }): Promise<{ success: true; skipped?: boolean; removedPath?: string; repoRoot?: string; reason?: string; fallback?: string; forced?: boolean; convergence?: Record<string, unknown>; recovered?: boolean; residue?: boolean; residueWarning?: string; residueError?: string; branchRefDeleted?: boolean; branchRefReason?: string; branchRefForced?: boolean; branchRefWarning?: string } | { success: false; code: string; error: string; recoveryHint: string; convergence?: Record<string, unknown> }> {
1070
- const workspace = typeof args.node?.workspace === 'string' ? args.node.workspace.trim() : '';
1071
- if (!workspace) {
1072
- return {
1073
- success: false,
1074
- code: 'mesh_worktree_cleanup_missing_workspace',
1075
- error: `Worktree node '${args.nodeId}' is missing workspace metadata`,
1076
- recoveryHint: 'Inspect the mesh node record before removing it, or remove stale metadata manually only after confirming no managed worktree remains.',
1077
- };
1078
- }
1079
-
1080
- const worktreeExists = fs.existsSync(workspace);
1081
- const sourceNode = args.node?.clonedFromNodeId
1082
- ? args.mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, args.node.clonedFromNodeId))
1083
- : args.mesh?.nodes?.find((n: any) => !n.isLocalWorktree);
1084
- const repoRoot = typeof sourceNode?.repoRoot === 'string' && sourceNode.repoRoot.trim()
1085
- ? sourceNode.repoRoot.trim()
1086
- : typeof sourceNode?.workspace === 'string' && sourceNode.workspace.trim()
1087
- ? sourceNode.workspace.trim()
1088
- : '';
1089
-
1090
- if (!worktreeExists) {
1091
- return { success: true, skipped: true, removedPath: workspace, repoRoot: repoRoot || undefined, reason: 'worktree_path_missing' };
1092
- }
1093
- if (!repoRoot || !fs.existsSync(repoRoot)) {
1094
- return {
1095
- success: false,
1096
- code: 'mesh_worktree_cleanup_missing_source_repo',
1097
- error: `Refusing to remove worktree '${workspace}' because the source repo root is unavailable`,
1098
- recoveryHint: 'Run mesh_remove_node from the machine that owns the source repo, or verify the source node metadata before retrying.',
1099
- };
1100
- }
1101
- if (typeof args.node?.worktreeBranch !== 'string' || !args.node.worktreeBranch.trim()) {
1102
- return {
1103
- success: false,
1104
- code: 'mesh_worktree_cleanup_missing_branch',
1105
- error: `Refusing to remove worktree '${workspace}' because worktreeBranch metadata is missing`,
1106
- recoveryHint: 'Confirm this is an ADHDev-managed worktree before removing it manually; managed worktree nodes include worktreeBranch metadata.',
1107
- };
1108
- }
1109
-
1110
- const { resolveWorktreePath, listWorktrees, removeWorktree } = await import('../git/git-worktree.js');
1111
- const normalizePath = (value: string) => {
1112
- const resolved = pathResolve(value);
1113
- try { return fs.realpathSync(resolved); } catch { return resolved; }
1114
- };
1115
- const expectedPath = normalizePath(resolveWorktreePath(repoRoot, String(args.mesh?.name || args.mesh?.id || 'mesh'), args.node.worktreeBranch));
1116
- const actualPath = normalizePath(workspace);
1117
- if (actualPath !== expectedPath) {
1118
- return {
1119
- success: false,
1120
- code: 'mesh_worktree_cleanup_unexpected_path',
1121
- error: `Refusing to remove worktree '${workspace}' because it is not at the expected managed path '${expectedPath}'`,
1122
- recoveryHint: 'Use git worktree list/status to inspect the path. Retry only after confirming the mesh node metadata points to an ADHDev-managed worktree.',
1123
- };
1124
- }
1125
-
1126
- const entries = await listWorktrees(repoRoot);
1127
- const managedEntry = entries.find(entry => normalizePath(entry.path) === actualPath);
1128
- if (!managedEntry) {
1129
- // Idempotent residue recovery (NOT a refusal). By this point the path is
1130
- // already proven ADHDev-managed: worktreeBranch metadata is present and
1131
- // actualPath === expectedPath. Git nonetheless no longer lists it as a
1132
- // worktree. This is the post-force-fallback re-entry state — an earlier
1133
- // removal de-registered the worktree from git but left the directory
1134
- // behind (commonly Windows EINVAL on submodule gitlink files). Refusing
1135
- // here would strand the node in mesh membership forever, so prune any
1136
- // stale registration, best-effort remove the leftover directory, and
1137
- // report success so the caller drops the node from the mesh registry.
1138
- try {
1139
- const { execFile } = await import('node:child_process');
1140
- const { promisify } = await import('node:util');
1141
- const execFileAsync = promisify(execFile);
1142
- await execFileAsync('git', ['worktree', 'prune'], {
1143
- cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 4 * 1024 * 1024, windowsHide: true,
1144
- });
1145
- } catch { /* prune is best-effort */ }
1146
- const rm = await this.bestEffortRemoveWorktreeDir(workspace);
1147
- return {
1148
- success: true,
1149
- removedPath: workspace,
1150
- repoRoot,
1151
- reason: 'worktree_unregistered_residue_recovered',
1152
- recovered: true,
1153
- ...(rm.residue ? {
1154
- residue: true,
1155
- residueWarning: `Worktree was already de-registered from git but the directory could not be fully removed (leftover residue at '${workspace}'): ${rm.error || 'unknown error'}. The node will be dropped from the mesh; remove the directory manually if needed.`,
1156
- residueError: rm.error,
1157
- } : {}),
1158
- };
1159
- }
1160
- if (managedEntry.branch && managedEntry.branch !== args.node.worktreeBranch) {
1161
- return {
1162
- success: false,
1163
- code: 'mesh_worktree_cleanup_branch_mismatch',
1164
- error: `Refusing to remove '${workspace}' because git reports branch '${managedEntry.branch}', expected '${args.node.worktreeBranch}'`,
1165
- recoveryHint: 'Inspect the worktree branch and mesh metadata before retrying cleanup.',
1166
- };
1167
- }
1168
-
1169
- // Always evaluate real merge convergence so we can decide whether the
1170
- // branch ref is safe to delete after removal — even on the force path,
1171
- // where `force_override` only authorizes the *worktree* removal and must
1172
- // NOT be taken as proof the branch is merged (that would risk work loss).
1173
- const mergeConvergence = await this.getWorktreeForceCleanupConvergence({ repoRoot, workspace, node: args.node });
1174
- const forceFallbackConvergence = args.force
1175
- ? { allow: true, status: 'force_override', source: 'caller_force_flag' }
1176
- : mergeConvergence;
1177
-
1178
- // After the worktree is removed, delete the branch ref iff the branch is
1179
- // fully merged into the default ref (no work loss). Otherwise preserve it
1180
- // and surface a warning. `mergeConvergence` (NOT the force override) is the
1181
- // authority on merged-ness.
1182
- const deleteBranchIfMerged = async () => {
1183
- const branch = String(args.node.worktreeBranch).trim();
1184
- const status = mergeConvergence.allow ? (mergeConvergence.status || '') : '';
1185
- const MERGED_STATUSES = new Set([
1186
- 'merged_to_main', 'merged_pushed', 'merged_to_default_ref', 'cleanup_candidate',
1187
- ]);
1188
- const PATCH_EQUIV_STATUS = 'patch_equivalent_to_default_ref';
1189
- if (!branch) {
1190
- return { branchRefDeleted: false, branchRefReason: 'empty_branch_name' };
1191
- }
1192
- if (!mergeConvergence.allow || (!MERGED_STATUSES.has(status) && status !== PATCH_EQUIV_STATUS)) {
1193
- return {
1194
- branchRefDeleted: false,
1195
- branchRefReason: `branch_not_merged_preserved: ${mergeConvergence.error || mergeConvergence.status || 'convergence_unverified'}`,
1196
- branchRefWarning: `Branch ref '${branch}' was preserved (not deleted) because it is not confirmed merged into the default ref — no work was lost. Merge it (or pass a verified branchConvergence final state) and re-run cleanup, or delete it manually after confirming.`,
1197
- };
1198
- }
1199
- const { deleteBranchRef } = await import('../git/git-worktree.js');
1200
- // `-d` can detect a true fast-forward/merge; patch-equivalent landings
1201
- // (squash/cherry-pick) are invisible to `-d`, so allow the verified `-D`
1202
- // fallback only for the patch-equivalence status.
1203
- const res = await deleteBranchRef(repoRoot, branch, { safeDeleteOnly: status !== PATCH_EQUIV_STATUS });
1204
- return {
1205
- branchRefDeleted: res.deleted,
1206
- branchRefReason: res.reason,
1207
- ...(res.forced ? { branchRefForced: true } : {}),
1208
- ...(res.deleted ? {} : {
1209
- branchRefWarning: `Branch ref '${branch}' could not be deleted (${res.reason}); it was preserved so no work is lost.`,
1210
- }),
1211
- };
1212
- };
1213
-
1214
- try {
1215
- const result = await removeWorktree(repoRoot, workspace, {
1216
- requireClean: !args.force,
1217
- allowSubmoduleForceFallback: forceFallbackConvergence.allow,
1218
- });
1219
- const branchOutcome = await deleteBranchIfMerged();
1220
- return {
1221
- success: true,
1222
- removedPath: result.removedPath,
1223
- repoRoot,
1224
- ...branchOutcome,
1225
- ...(result.fallback ? {
1226
- fallback: result.fallback,
1227
- forced: result.forced,
1228
- reason: result.reason,
1229
- convergence: forceFallbackConvergence,
1230
- } : {}),
1231
- };
1232
- } catch (e: any) {
1233
- const message = String(e?.message || e || 'worktree cleanup failed');
1234
- const dirty = message.includes('dirty worktree') || message.includes('local changes');
1235
- const isSubmoduleGuard = /working trees containing submodules cannot be moved or removed/i.test(message);
1236
- const submoduleForceBlocked = isSubmoduleGuard && !forceFallbackConvergence.allow;
1237
-
1238
- // Fallback 1: submodule guard on --force path — deinit submodules first, then retry remove
1239
- if (isSubmoduleGuard && forceFallbackConvergence.allow) {
1240
- const { execFile } = await import('node:child_process');
1241
- const { promisify } = await import('node:util');
1242
- const execFileAsync = promisify(execFile);
1243
- const GIT_TIMEOUT_CLEANUP = 30_000;
1244
- const GIT_MAX_BUFFER_CLEANUP = 4 * 1024 * 1024;
1245
- try {
1246
- await execFileAsync('git', ['-C', workspace, 'submodule', 'deinit', '--all', '-f'], {
1247
- encoding: 'utf8', timeout: GIT_TIMEOUT_CLEANUP, maxBuffer: GIT_MAX_BUFFER_CLEANUP, windowsHide: true,
1248
- });
1249
- await execFileAsync('git', ['worktree', 'remove', '--force', workspace], {
1250
- cwd: repoRoot, encoding: 'utf8', timeout: GIT_TIMEOUT_CLEANUP, maxBuffer: GIT_MAX_BUFFER_CLEANUP, windowsHide: true,
1251
- });
1252
- const branchOutcome = await deleteBranchIfMerged();
1253
- return {
1254
- success: true,
1255
- removedPath: workspace,
1256
- repoRoot,
1257
- ...branchOutcome,
1258
- fallback: 'git_worktree_remove_submodule_deinit' as const,
1259
- forced: true,
1260
- reason: 'working_trees_containing_submodules' as const,
1261
- convergence: forceFallbackConvergence,
1262
- };
1263
- } catch (deinitError: any) {
1264
- // Fallback 2: deinit+remove still failed — best-effort directory
1265
- // removal + prune. The path is already proven managed/converged
1266
- // here, and a leftover directory must NOT gate dropping the node
1267
- // from the mesh, so absorb Windows EINVAL/EPERM and report success
1268
- // with a residue warning instead of failing the whole removal.
1269
- const rm = await this.bestEffortRemoveWorktreeDir(workspace);
1270
- try {
1271
- await execFileAsync('git', ['worktree', 'prune'], {
1272
- cwd: repoRoot, encoding: 'utf8', timeout: GIT_TIMEOUT_CLEANUP, maxBuffer: GIT_MAX_BUFFER_CLEANUP, windowsHide: true,
1273
- });
1274
- } catch { /* prune is best-effort */ }
1275
- const branchOutcome = await deleteBranchIfMerged();
1276
- return {
1277
- success: true,
1278
- removedPath: workspace,
1279
- repoRoot,
1280
- ...branchOutcome,
1281
- fallback: 'fs_rm_worktree_prune' as const,
1282
- forced: true,
1283
- reason: 'working_trees_containing_submodules' as const,
1284
- convergence: forceFallbackConvergence,
1285
- ...(rm.residue ? {
1286
- residue: true,
1287
- residueWarning: `Worktree was de-registered from git but the directory could not be fully removed (leftover residue at '${workspace}'): ${rm.error || 'unknown error'}; deinit+remove first failed with: ${deinitError?.message || deinitError}. The node will be dropped from the mesh; remove the directory manually if needed.`,
1288
- residueError: rm.error,
1289
- } : {}),
1290
- };
1291
- }
1292
- }
1293
-
1294
- return {
1295
- success: false,
1296
- code: dirty
1297
- ? 'mesh_worktree_cleanup_dirty'
1298
- : submoduleForceBlocked
1299
- ? 'mesh_worktree_cleanup_force_fallback_blocked'
1300
- : 'mesh_worktree_cleanup_failed',
1301
- error: submoduleForceBlocked
1302
- ? `${message}; refusing --force fallback because convergence could not be verified: ${forceFallbackConvergence.error || 'unknown convergence state'}`
1303
- : message,
1304
- recoveryHint: dirty
1305
- ? 'Commit, stash, or intentionally discard the worktree changes before retrying mesh_remove_node. The mesh registry entry is preserved until cleanup is safe.'
1306
- : submoduleForceBlocked
1307
- ? 'Verify the worktree branch is merged/contained in the source default branch (for example origin/main) or mark the node with a safe branchConvergence final state, or pass force:true if content is confirmed already in main.'
1308
- : 'Inspect git worktree status/list from the source repo and retry after resolving the reported cleanup failure.',
1309
- ...(submoduleForceBlocked ? { convergence: forceFallbackConvergence } : {}),
1310
- };
1311
- }
937
+ return cleanupLocalWorktreeNode(this, args);
1312
938
  }
1313
939
 
1314
- private async getWorktreeForceCleanupConvergence(args: {
940
+ async getWorktreeForceCleanupConvergence(args: {
1315
941
  repoRoot: string;
1316
942
  workspace: string;
1317
943
  node: any;
1318
944
  }): Promise<{ allow: boolean; status?: string; source?: string; ref?: string; error?: string }> {
1319
- const metadataStatus = typeof args.node?.branchConvergence?.status === 'string'
1320
- ? args.node.branchConvergence.status
1321
- : '';
1322
- if (metadataStatus === 'merged_to_main' || metadataStatus === 'cleanup_candidate' || metadataStatus === 'merged_pushed') {
1323
- return { allow: true, status: metadataStatus, source: 'node_branch_convergence' };
1324
- }
1325
-
1326
- // Also allow when the node's last recorded refine job reached final convergence merged_pushed
1327
- const refinedConvergence = typeof args.node?.refineState?.finalBranchConvergenceState?.status === 'string'
1328
- ? args.node.refineState.finalBranchConvergenceState.status
1329
- : typeof args.node?.lastRefineResult?.finalBranchConvergenceState?.status === 'string'
1330
- ? args.node.lastRefineResult.finalBranchConvergenceState.status
1331
- : '';
1332
- if (refinedConvergence === 'merged_pushed' || refinedConvergence === 'merged_to_main') {
1333
- return { allow: true, status: refinedConvergence, source: 'node_refine_state' };
1334
- }
1335
-
1336
- const { execFile } = await import('node:child_process');
1337
- const { promisify } = await import('node:util');
1338
- const execFileAsync = promisify(execFile);
1339
- const runGit = async (gitArgs: string[], cwd: string): Promise<string> => {
1340
- const { stdout } = await execFileAsync('git', gitArgs, {
1341
- cwd,
1342
- encoding: 'utf8',
1343
- timeout: 30_000,
1344
- maxBuffer: 4 * 1024 * 1024,
1345
- windowsHide: true,
1346
- });
1347
- return String(stdout || '').trim();
1348
- };
1349
-
1350
- let head = '';
1351
- try {
1352
- head = await runGit(['rev-parse', 'HEAD'], args.workspace);
1353
- } catch (e: any) {
1354
- return { allow: false, error: `could not resolve worktree HEAD: ${e?.message || e}` };
1355
- }
1356
- if (!head) return { allow: false, error: 'worktree HEAD is empty' };
1357
-
1358
- const candidateRefs: string[] = [];
1359
- try {
1360
- const defaultBranch = await runGit(['branch', '--show-current'], args.repoRoot);
1361
- if (defaultBranch) {
1362
- candidateRefs.push(defaultBranch, `origin/${defaultBranch}`);
1363
- }
1364
- } catch { /* fall through to common refs */ }
1365
- candidateRefs.push('origin/main', 'origin/master', 'main', 'master');
1366
-
1367
- const seen = new Set<string>();
1368
- const checkedRefs: string[] = [];
1369
- const resolvedRefCommits: Array<{ ref: string; commit: string }> = [];
1370
- for (const ref of candidateRefs) {
1371
- if (!ref || seen.has(ref)) continue;
1372
- seen.add(ref);
1373
- let commit = '';
1374
- try {
1375
- commit = await runGit(['rev-parse', '--verify', `${ref}^{commit}`], args.repoRoot);
1376
- } catch {
1377
- continue;
1378
- }
1379
- checkedRefs.push(ref);
1380
- resolvedRefCommits.push({ ref, commit });
1381
- try {
1382
- await runGit(['merge-base', '--is-ancestor', head, commit], args.repoRoot);
1383
- return { allow: true, status: 'merged_to_default_ref', source: 'git_merge_base', ref };
1384
- } catch {
1385
- // Not contained in this candidate ref; keep checking other safe refs.
1386
- }
1387
- }
1388
-
1389
- // SHA-reachability fallback: the worktree HEAD is not an ancestor of any
1390
- // candidate ref, but its CONTENT may already be present via cherry-pick /
1391
- // squash / rebase (a different commit SHA carrying the same patch). The
1392
- // Refinery accepts such patch-equivalent landings; mirror that here so the
1393
- // cleanup guard does not falsely block a converged worktree. This is the
1394
- // heavier merge-tree/patch-id path, so it only runs after every ancestor
1395
- // check has already failed. Any failure stays conservative (NOT contained).
1396
- for (const { ref, commit } of resolvedRefCommits) {
1397
- let containment: MeshWorktreePatchContainmentSummary;
1398
- try {
1399
- containment = await checkWorktreeChangesPatchEquivalentInRef(args.repoRoot, commit, head);
1400
- } catch {
1401
- // Defensive: the helper is already exception-safe, but never let a
1402
- // thrown error escape into an allow.
1403
- continue;
1404
- }
1405
- if (containment.contained) {
1406
- return { allow: true, status: 'patch_equivalent_to_default_ref', source: 'git_patch_equivalence', ref };
1407
- }
1408
- }
1409
-
1410
- return {
1411
- allow: false,
1412
- status: metadataStatus || undefined,
1413
- error: checkedRefs.length
1414
- ? `worktree HEAD is not contained in checked refs: ${checkedRefs.join(', ')}`
1415
- : 'no default/main refs were available for convergence verification',
1416
- };
945
+ return getWorktreeForceCleanupConvergence(this, args);
1417
946
  }
1418
947
 
1419
- private isCompletedHostedSession(record: any): boolean {
1420
- return record?.lifecycle === 'stopped' || record?.lifecycle === 'failed' || record?.lifecycle === 'interrupted';
948
+ isCompletedHostedSession(record: any): boolean {
949
+ return isCompletedHostedSession(this, record);
1421
950
  }
1422
951
 
1423
- private async recordIntentionalMeshSessionStop(args: {
952
+ async recordIntentionalMeshSessionStop(args: {
1424
953
  meshId: string;
1425
954
  nodeId: string;
1426
955
  node: any;
@@ -1429,28 +958,10 @@ export class DaemonCommandRouter {
1429
958
  source: 'mesh_cleanup_sessions' | 'mesh_remove_node' | 'magi_session_cleanup';
1430
959
  action: 'stop_session' | 'delete_session_force';
1431
960
  }): Promise<void> {
1432
- try {
1433
- const { appendLedgerEntry } = await import('../mesh/mesh-ledger.js');
1434
- appendLedgerEntry(args.meshId, {
1435
- kind: 'session_stopped',
1436
- nodeId: args.nodeId,
1437
- sessionId: args.sessionId,
1438
- payload: {
1439
- intentional: true,
1440
- reason: 'operator_cleanup',
1441
- intentionalStopReason: 'operator_cleanup',
1442
- source: args.source,
1443
- cleanupMode: args.mode,
1444
- action: args.action,
1445
- workspace: typeof args.node?.workspace === 'string' ? args.node.workspace : undefined,
1446
- },
1447
- });
1448
- } catch (e: any) {
1449
- LOG.warn('MeshCleanup', `Failed to record intentional cleanup stop for ${args.sessionId}: ${e?.message || e}`);
1450
- }
961
+ return recordIntentionalMeshSessionStop(this, args);
1451
962
  }
1452
963
 
1453
- private async cleanupMeshSessions(args: {
964
+ async cleanupMeshSessions(args: {
1454
965
  meshId: string;
1455
966
  nodeId: string;
1456
967
  node: any;
@@ -1458,278 +969,11 @@ export class DaemonCommandRouter {
1458
969
  sessionIds?: string[];
1459
970
  dryRun?: boolean;
1460
971
  source?: 'mesh_cleanup_sessions' | 'mesh_remove_node' | 'magi_session_cleanup';
1461
- /**
1462
- * MAGI auto-cleanup safety gate: a map of sessionId → the queue task id that
1463
- * session must have been AUTO-LAUNCHED for (record meta autoLaunchedForQueueTaskId).
1464
- * When set, a matched explicit session is only acted on if its record carries that
1465
- * exact marker. A reused idle session (no marker), the coordinator session, a
1466
- * re-assigned session, or any session whose marker points at a DIFFERENT task is
1467
- * skipped (reason 'auto_launch_marker_mismatch') — so MAGI never kills a session it
1468
- * didn't itself spawn for this fan-out. Only consulted alongside explicit sessionIds.
1469
- */
1470
972
  requireAutoLaunchedForTaskIds?: Record<string, string>;
1471
- /**
1472
- * Opt-in orphan reclaim (default false). See SESSION-ACCUMULATION-LEAK.
1473
- * When true, a workspace-only live_runtime session (no node binding) OR a
1474
- * live_runtime session bound to a node absent from `liveMeshNodeIds` is
1475
- * stopped instead of skipped by the conservative shared-daemon guard. A
1476
- * session whose meshNodeId is STILL in `liveMeshNodeIds` (active sibling)
1477
- * is never reclaimed — that is the shared-daemon safety this preserves.
1478
- */
1479
973
  reclaimOrphans?: boolean;
1480
974
  liveMeshNodeIds?: string[];
1481
975
  }): Promise<{ success: boolean; [key: string]: unknown }> {
1482
- if (args.mode === 'preserve') {
1483
- return { success: true, mode: 'preserve', matchedCount: 0, stoppedSessionIds: [], deletedSessionIds: [], skippedSessionIds: [] };
1484
- }
1485
- if (!this.deps.sessionHostControl) return { success: false, error: 'Session host control unavailable' };
1486
-
1487
- const requestedSessionIds = Array.isArray(args.sessionIds)
1488
- ? new Set(args.sessionIds.map(id => typeof id === 'string' ? id.trim() : '').filter(Boolean))
1489
- : undefined;
1490
- // Orphan-reclaim support (opt-in). The live-node id list lets us tell an
1491
- // orphan (owning node gone from the mesh) apart from a still-active sibling
1492
- // sharing this daemon. daemonIdsEquivalent (not raw ===) guards against the
1493
- // canon-identity defect class: daemonId forms (daemon_mach_ vs mach_) and
1494
- // normalized vs raw meshNodeId must compare equivalent, not literally equal.
1495
- const reclaimOrphans = args.reclaimOrphans === true;
1496
- const liveMeshNodeIds = Array.isArray(args.liveMeshNodeIds)
1497
- ? args.liveMeshNodeIds.map(id => typeof id === 'string' ? id.trim() : '').filter(Boolean)
1498
- : [];
1499
- const isNodeStillLive = (candidateNodeId: string): boolean => {
1500
- if (!candidateNodeId) return false;
1501
- return liveMeshNodeIds.some(liveId =>
1502
- liveId === candidateNodeId
1503
- || meshNodeIdMatches({ id: liveId }, candidateNodeId)
1504
- || daemonIdsEquivalent(liveId, candidateNodeId));
1505
- };
1506
- const reclaimedOrphanSessionIds: string[] = [];
1507
- const sessions = await this.deps.sessionHostControl.listSessions();
1508
- const matched = sessions.filter(record => this.sessionMatchesMeshNode(record, args.node, args.nodeId, requestedSessionIds));
1509
- const hasExplicitSessionIds = !!requestedSessionIds?.size;
1510
- const stoppedSessionIds: string[] = [];
1511
- const deletedSessionIds: string[] = [];
1512
- const skippedSessionIds: string[] = [];
1513
- const skippedLiveSessionIds: string[] = [];
1514
- const skippedCoordinatorSessionIds: string[] = [];
1515
- const skippedLiveSessionReasons: Array<{ sessionId: string; reason: string }> = [];
1516
- const skippedMarkerMismatchSessionIds: string[] = [];
1517
- const actedLiveDelegateSessionIds: string[] = [];
1518
- const deleteUnsupportedSessionIds: string[] = [];
1519
- const recordsRemainSessionIds: string[] = [];
1520
- const errors: Array<{ sessionId: string; error: string }> = [];
1521
- const cleanupSource = args.source || 'mesh_cleanup_sessions';
1522
- const markedIntentionalStopSessionIds = new Set<string>();
1523
- const markIntentionalStop = async (sessionId: string, action: 'stop_session' | 'delete_session_force') => {
1524
- if (args.dryRun || markedIntentionalStopSessionIds.has(sessionId)) return;
1525
- markedIntentionalStopSessionIds.add(sessionId);
1526
- await this.recordIntentionalMeshSessionStop({
1527
- meshId: args.meshId,
1528
- nodeId: args.nodeId,
1529
- node: args.node,
1530
- sessionId,
1531
- mode: args.mode,
1532
- source: cleanupSource,
1533
- action,
1534
- });
1535
- };
1536
- const matchedBySurfaceKind = {
1537
- live_runtime: 0,
1538
- recovery_snapshot: 0,
1539
- inactive_record: 0,
1540
- };
1541
-
1542
- for (const record of matched) {
1543
- const surfaceKind = getSessionHostSurfaceKind(record);
1544
- matchedBySurfaceKind[surfaceKind] += 1;
1545
- }
1546
-
1547
- for (const record of matched) {
1548
- const sessionId = String(record.sessionId);
1549
- const completed = this.isCompletedHostedSession(record);
1550
- const surfaceKind = getSessionHostSurfaceKind(record);
1551
- const liveRuntime = surfaceKind === 'live_runtime';
1552
- const coordinatorSession = readStringValue(record?.meta?.meshCoordinatorFor) === args.meshId;
1553
- // A delegate session was launched by the coordinator specifically FOR this node
1554
- // (meta.meshNodeId === this node). It is 1:1 bound to the node, even when the node
1555
- // shares its daemon runtime with the main/other nodes. Removing the node should be
1556
- // able to stop its own delegate session — the shared-daemon concern only applies to
1557
- // sessions we matched by workspace alone (which could belong to the coordinator or to
1558
- // a sibling node that is still active).
1559
- const recordNodeId = readStringValue(record?.meta?.meshNodeId);
1560
- const recordMeshNodeFor = readStringValue(record?.meta?.meshNodeFor);
1561
- const delegateBoundToThisNode = !!recordNodeId
1562
- && recordNodeId === args.nodeId
1563
- && (!recordMeshNodeFor || recordMeshNodeFor === args.meshId);
1564
- if (!hasExplicitSessionIds && coordinatorSession) {
1565
- skippedSessionIds.push(sessionId);
1566
- skippedCoordinatorSessionIds.push(sessionId);
1567
- continue;
1568
- }
1569
- // MAGI auto-cleanup marker gate. When the caller supplied a per-session
1570
- // expected autoLaunchedForQueueTaskId, the session must (a) carry the marker
1571
- // on its record meta AND (b) have it equal the expected replica task id.
1572
- // This is the safety core for MAGI: explicit session_ids bypass the
1573
- // self-coordinator / shared-daemon guards (hasExplicitSessionIds=true), so the
1574
- // ONLY thing protecting a reused-idle / coordinator / re-assigned session here
1575
- // is this marker check. Always-skip the coordinator session even when its id is
1576
- // passed explicitly (it never carries an autoLaunchedForQueueTaskId marker, so
1577
- // the mismatch branch already covers it, but be explicit for clarity).
1578
- if (args.requireAutoLaunchedForTaskIds) {
1579
- const decision = magiAutoLaunchedSessionCleanupDecision({
1580
- recordMarker: readStringValue(record?.meta?.autoLaunchedForQueueTaskId),
1581
- expectedTaskId: args.requireAutoLaunchedForTaskIds[sessionId],
1582
- isCoordinatorSession: coordinatorSession,
1583
- });
1584
- if (!decision.allow) {
1585
- skippedSessionIds.push(sessionId);
1586
- skippedMarkerMismatchSessionIds.push(sessionId);
1587
- skippedLiveSessionReasons.push({ sessionId, reason: decision.reason });
1588
- continue;
1589
- }
1590
- }
1591
- // Only the conservative shared-daemon guard for live sessions that are NOT a delegate
1592
- // explicitly bound to this node. Delegate-bound live sessions fall through and are
1593
- // stopped/deleted by the mode handlers below (which already record an intentional stop).
1594
- //
1595
- // Worktree-removal exception: when a WORKTREE node is being removed
1596
- // (source === 'mesh_remove_node' AND node.isLocalWorktree === true), its
1597
- // node-binding is already gone, so a still-live session in that workspace
1598
- // matches by workspace ALONE (recordNodeId is empty). The shared-daemon
1599
- // concern does not apply — a worktree has a private workspace path that is
1600
- // not shared with the base/other nodes — so leaving it skipped orphans the
1601
- // chat after the node + worktree dir + branch are gone. Clean it instead.
1602
- // This narrowly covers ONLY the pure workspace-only-no-binding case; a
1603
- // session bound to ANOTHER node (recordNodeId set and != this node) is still
1604
- // skipped (live_delegate_bound_to_other_node), and the coordinator session is
1605
- // already protected unconditionally above.
1606
- const matchedByWorkspaceOnly = !recordNodeId;
1607
- const isWorktreeNodeRemoval = cleanupSource === 'mesh_remove_node' && args.node?.isLocalWorktree === true;
1608
- const cleanWorkspaceOnlyForWorktree = isWorktreeNodeRemoval && matchedByWorkspaceOnly;
1609
- // Opt-in orphan reclaim (SESSION-ACCUMULATION-LEAK): a live_runtime that
1610
- // is either workspace-only (no node binding — the leaked spec-CLI case)
1611
- // or bound to a node no longer present in the mesh is an orphan and safe
1612
- // to stop. A session bound to a node STILL live in the mesh is an active
1613
- // sibling on this shared daemon and is left to the normal guard. Node
1614
- // equivalence uses isNodeStillLive (daemonIdsEquivalent under the hood),
1615
- // never a raw === on the id form.
1616
- const reclaimableOrphan = reclaimOrphans
1617
- && liveRuntime
1618
- && !delegateBoundToThisNode
1619
- && (matchedByWorkspaceOnly || !isNodeStillLive(recordNodeId));
1620
- if (!hasExplicitSessionIds && liveRuntime && !delegateBoundToThisNode && !cleanWorkspaceOnlyForWorktree && !reclaimableOrphan) {
1621
- skippedSessionIds.push(sessionId);
1622
- skippedLiveSessionIds.push(sessionId);
1623
- const reason = recordNodeId && recordNodeId !== args.nodeId
1624
- ? `live_delegate_bound_to_other_node:${recordNodeId}`
1625
- : matchedByWorkspaceOnly
1626
- ? 'live_session_matched_by_workspace_only_no_node_binding'
1627
- : 'live_session_not_bound_to_this_node';
1628
- skippedLiveSessionReasons.push({ sessionId, reason });
1629
- continue;
1630
- }
1631
- if (cleanWorkspaceOnlyForWorktree && !delegateBoundToThisNode) {
1632
- // A workspace-only live session on a worktree being removed is treated
1633
- // like a bound delegate for accounting (so callers can see it was acted on).
1634
- actedLiveDelegateSessionIds.push(sessionId);
1635
- }
1636
- if (reclaimableOrphan && !cleanWorkspaceOnlyForWorktree && !delegateBoundToThisNode) {
1637
- // Reclaimed orphan — surface it distinctly so callers can audit what
1638
- // the opt-in reclaim path stopped that the normal guard would have kept.
1639
- reclaimedOrphanSessionIds.push(sessionId);
1640
- actedLiveDelegateSessionIds.push(sessionId);
1641
- }
1642
- if (!hasExplicitSessionIds && liveRuntime && delegateBoundToThisNode && args.mode === 'delete_stopped') {
1643
- // delete_stopped never stops live runtimes by contract — even bound delegates.
1644
- // Surface a clear reason instead of an unexplained skip so callers know to use
1645
- // stop / stop_and_delete to release a still-running bound delegate.
1646
- skippedSessionIds.push(sessionId);
1647
- skippedLiveSessionIds.push(sessionId);
1648
- skippedLiveSessionReasons.push({ sessionId, reason: 'live_delegate_preserved_by_delete_stopped_mode_use_stop_or_stop_and_delete' });
1649
- continue;
1650
- }
1651
- if (!hasExplicitSessionIds && liveRuntime && delegateBoundToThisNode) {
1652
- actedLiveDelegateSessionIds.push(sessionId);
1653
- }
1654
- try {
1655
- if (args.mode === 'stop') {
1656
- if (!completed) {
1657
- if (!args.dryRun) {
1658
- await markIntentionalStop(sessionId, 'stop_session');
1659
- await this.deps.sessionHostControl.stopSession(sessionId);
1660
- }
1661
- stoppedSessionIds.push(sessionId);
1662
- } else {
1663
- skippedSessionIds.push(sessionId);
1664
- }
1665
- continue;
1666
- }
1667
-
1668
- if (args.mode === 'delete_stopped') {
1669
- if (completed) {
1670
- if (!args.dryRun) await this.deps.sessionHostControl.deleteSession(sessionId, { force: false });
1671
- deletedSessionIds.push(sessionId);
1672
- } else {
1673
- skippedSessionIds.push(sessionId);
1674
- }
1675
- continue;
1676
- }
1677
-
1678
- if (args.mode === 'stop_and_delete') {
1679
- if (!completed) await markIntentionalStop(sessionId, 'delete_session_force');
1680
- if (!args.dryRun) await this.deps.sessionHostControl.deleteSession(sessionId, { force: true });
1681
- deletedSessionIds.push(sessionId);
1682
- continue;
1683
- }
1684
- } catch (e: any) {
1685
- const message = e?.message || String(e);
1686
- if (message.includes('Unsupported session host request: delete_session')
1687
- && (args.mode === 'delete_stopped' || args.mode === 'stop_and_delete')) {
1688
- deleteUnsupportedSessionIds.push(sessionId);
1689
- recordsRemainSessionIds.push(sessionId);
1690
- if (args.mode === 'stop_and_delete' && !completed) {
1691
- try {
1692
- await markIntentionalStop(sessionId, 'stop_session');
1693
- await this.deps.sessionHostControl.stopSession(sessionId);
1694
- stoppedSessionIds.push(sessionId);
1695
- } catch (stopError: any) {
1696
- errors.push({ sessionId, error: stopError?.message || String(stopError) });
1697
- continue;
1698
- }
1699
- }
1700
- skippedSessionIds.push(sessionId);
1701
- continue;
1702
- }
1703
- errors.push({ sessionId, error: message });
1704
- }
1705
- }
1706
-
1707
- const deleteUnsupported = deleteUnsupportedSessionIds.length > 0;
1708
- return {
1709
- success: errors.length === 0,
1710
- mode: args.mode,
1711
- dryRun: args.dryRun === true,
1712
- matchedCount: matched.length,
1713
- matchedBySurfaceKind,
1714
- stoppedSessionIds,
1715
- deletedSessionIds,
1716
- skippedSessionIds,
1717
- skippedLiveSessionIds,
1718
- skippedCoordinatorSessionIds,
1719
- ...(skippedMarkerMismatchSessionIds.length ? { skippedMarkerMismatchSessionIds } : {}),
1720
- ...(actedLiveDelegateSessionIds.length ? { actedLiveDelegateSessionIds } : {}),
1721
- ...(reclaimedOrphanSessionIds.length ? { reclaimedOrphanSessionIds } : {}),
1722
- ...(skippedLiveSessionReasons.length ? { skippedLiveSessionReasons } : {}),
1723
- ...(deleteUnsupported ? {
1724
- deleteUnsupported: true,
1725
- effectiveCleanup: args.mode === 'stop_and_delete'
1726
- ? 'stopped_only_records_remain'
1727
- : 'delete_unsupported_records_remain',
1728
- deleteUnsupportedSessionIds,
1729
- recordsRemainSessionIds,
1730
- } : {}),
1731
- ...(errors.length ? { errors } : {}),
1732
- };
976
+ return cleanupMeshSessions(this, args);
1733
977
  }
1734
978
  /**
1735
979
  * Unified command routing.
@@ -1802,1661 +1046,24 @@ export class DaemonCommandRouter {
1802
1046
  }
1803
1047
 
1804
1048
 
1805
- private buildRefineJobKey(meshId: string, nodeId: string): string {
1806
- return `${meshId}:${nodeId}`;
1807
- }
1808
-
1809
- private buildRefineJobHandle(args: {
1810
- meshId: string;
1811
- nodeId: string;
1812
- node?: any;
1813
- status?: MeshRefineAsyncJobStatus;
1814
- startedAt?: string;
1815
- completedAt?: string;
1816
- jobId?: string;
1817
- interactionId?: string;
1818
- retryOfJobId?: string;
1819
- coordinatorDaemonId?: string;
1820
- }): MeshRefineJobHandle {
1821
- return {
1822
- success: true,
1823
- async: true,
1824
- status: args.status || 'accepted',
1825
- jobId: args.jobId || `refine_${createInteractionId()}`,
1826
- interactionId: args.interactionId || createInteractionId(),
1827
- meshId: args.meshId,
1828
- nodeId: args.nodeId,
1829
- targetNodeId: args.nodeId,
1830
- targetDaemonId: readStringValue(args.node?.daemonId),
1831
- workspace: readStringValue(args.node?.workspace),
1832
- startedAt: args.startedAt || new Date().toISOString(),
1833
- ...(args.completedAt ? { completedAt: args.completedAt } : {}),
1834
- ...(args.retryOfJobId ? { retryOfJobId: args.retryOfJobId } : {}),
1835
- ...(args.coordinatorDaemonId ? { targetCoordinatorDaemonId: args.coordinatorDaemonId } : {}),
1836
- eventDelivery: { pendingEvents: true, ledger: true },
1837
- evidence: {
1838
- pendingEventsCommand: 'get_pending_mesh_events',
1839
- ledgerCommand: 'get_mesh_ledger_slice',
1840
- taskHistoryKind: args.status === 'completed' ? 'task_completed' : args.status === 'failed' ? 'task_failed' : 'task_dispatched',
1841
- },
1842
- };
1843
- }
1844
-
1845
- private queueRefineJobEvent(event: 'refine:accepted' | 'refine:completed' | 'refine:failed', handle: MeshRefineJobHandle, result?: Record<string, unknown>): void {
1846
- const metadataEvent = {
1847
- source: 'refine_mesh_node_async_job',
1848
- jobId: handle.jobId,
1849
- interactionId: handle.interactionId,
1850
- meshId: handle.meshId,
1851
- nodeId: handle.targetNodeId,
1852
- targetDaemonId: handle.targetDaemonId,
1853
- workspace: handle.workspace,
1854
- status: handle.status,
1855
- startedAt: handle.startedAt,
1856
- completedAt: handle.completedAt,
1857
- retryOfJobId: handle.retryOfJobId,
1858
- ...(result ? { result } : {}),
1859
- };
1860
- const eventPayload = {
1861
- event,
1862
- meshId: handle.meshId,
1863
- nodeLabel: handle.targetNodeId,
1864
- nodeId: handle.targetNodeId,
1865
- workspace: handle.workspace,
1866
- metadataEvent,
1867
- queuedAt: Date.now(),
1868
- ...(handle.targetCoordinatorDaemonId ? { targetCoordinatorDaemonId: handle.targetCoordinatorDaemonId } : {}),
1869
- };
1870
- if (typeof this.deps.instanceManager?.getByCategory === 'function') {
1871
- const forwarded = handleMeshForwardEvent(
1872
- { instanceManager: this.deps.instanceManager } as any,
1873
- {
1874
- event,
1875
- meshId: handle.meshId,
1876
- nodeId: handle.targetNodeId,
1877
- workspace: handle.workspace,
1878
- jobId: handle.jobId,
1879
- interactionId: handle.interactionId,
1880
- status: handle.status,
1881
- targetDaemonId: handle.targetDaemonId,
1882
- startedAt: handle.startedAt,
1883
- completedAt: handle.completedAt,
1884
- retryOfJobId: handle.retryOfJobId,
1885
- ...(result ? { result } : {}),
1886
- },
1887
- );
1888
- if (forwarded?.success === true) return;
1889
- LOG.warn('Mesh', `[Refinery] Failed to forward async refine event ${event}: ${forwarded?.error || 'unknown error'}`);
1890
- }
1891
- queuePendingMeshCoordinatorEvent(eventPayload);
1892
- }
1893
-
1894
- private async appendRefineJobLedger(kind: 'task_dispatched' | 'task_completed' | 'task_failed', handle: MeshRefineJobHandle, result?: Record<string, unknown>): Promise<void> {
1895
- try {
1896
- const { appendLedgerEntry } = await import('../mesh/mesh-ledger.js');
1897
- appendLedgerEntry(handle.meshId, {
1898
- kind,
1899
- nodeId: handle.targetNodeId,
1900
- payload: {
1901
- source: 'refine_mesh_node_async_job',
1902
- refineJob: {
1903
- jobId: handle.jobId,
1904
- interactionId: handle.interactionId,
1905
- status: handle.status,
1906
- meshId: handle.meshId,
1907
- nodeId: handle.targetNodeId,
1908
- targetDaemonId: handle.targetDaemonId,
1909
- targetCoordinatorDaemonId: handle.targetCoordinatorDaemonId,
1910
- workspace: handle.workspace,
1911
- startedAt: handle.startedAt,
1912
- completedAt: handle.completedAt,
1913
- retryOfJobId: handle.retryOfJobId,
1914
- },
1915
- async: true,
1916
- retryOfJobId: handle.retryOfJobId,
1917
- ...(result ? {
1918
- success: result.success === true,
1919
- result,
1920
- finalBranchConvergenceState: result.finalBranchConvergenceState,
1921
- ...(result.blockerContext ? { blockerContext: result.blockerContext } : {}),
1922
- } : {}),
1923
- },
1924
- });
1925
- } catch (e: any) {
1926
- LOG.warn('Mesh', `[Refinery] Failed to append async refine ledger entry: ${e?.message || e}`);
1927
- }
1928
- }
1049
+ // ─── Refinery job orchestration ─────────────────────────────────────
1050
+ // Implementation lives in ./router-refine.ts (behavior-preserving code move).
1051
+ // Only the externally-referenced entry points remain here as thin delegators.
1929
1052
 
1930
- /**
1931
- * On daemon restart, scan all mesh ledgers for refine jobs that were dispatched
1932
- * but never completed/failed (i.e. the daemon died mid-job). Re-queue each one
1933
- * so the job runs to completion automatically without coordinator intervention.
1934
- */
1935
1053
  async resumePendingRefineJobsOnStartup(): Promise<void> {
1936
- try {
1937
- const { listMeshes } = await import('../config/mesh-config.js');
1938
- const { readLedgerEntries } = await import('../mesh/mesh-ledger.js');
1939
- const meshIds: string[] = listMeshes().map(m => m.id).filter(Boolean) as string[];
1940
- for (const meshId of meshIds) {
1941
- const entries = readLedgerEntries(meshId, { kind: ['task_dispatched', 'task_completed', 'task_failed'] });
1942
- // Build set of nodeIds that already have a terminal entry.
1943
- const terminal = new Set<string>();
1944
- for (const e of entries) {
1945
- if ((e.kind === 'task_completed' || e.kind === 'task_failed') && e.nodeId) {
1946
- const jobId = (e.payload as any)?.refineJob?.jobId;
1947
- if (jobId) terminal.add(`${e.nodeId}:${jobId}`);
1948
- }
1949
- }
1950
- // Re-dispatch dispatched jobs with no matching terminal entry.
1951
- for (const e of entries) {
1952
- if (e.kind !== 'task_dispatched' || !e.nodeId) continue;
1953
- const source = (e.payload as any)?.source;
1954
- if (source !== 'refine_mesh_node_async_job') continue;
1955
- const jobId = (e.payload as any)?.refineJob?.jobId;
1956
- if (!jobId || terminal.has(`${e.nodeId}:${jobId}`)) continue;
1957
- const key = this.buildRefineJobKey(meshId, e.nodeId);
1958
- if (this.runningRefineJobs.has(key)) continue;
1959
- const coordinatorDaemonId = (e.payload as any)?.refineJob?.targetCoordinatorDaemonId;
1960
- LOG.info('Mesh', `[Refinery] Auto-resuming interrupted refine job for node ${e.nodeId} (jobId=${jobId})`);
1961
- void this.startMeshRefineJob(meshId, e.nodeId, {
1962
- coordinatorDaemonId,
1963
- });
1964
- }
1965
- }
1966
- } catch (e: any) {
1967
- LOG.warn('Mesh', `[Refinery] resumePendingRefineJobsOnStartup failed: ${e?.message || e}`);
1968
- }
1969
- }
1970
-
1971
- /**
1972
- * Synchronous refinery for a single worktree node — the gate pipeline that
1973
- * validates, preflights (patch-equivalence / submodule-reachability /
1974
- * no-op), merges, aligns submodules, cleans up the worktree node and
1975
- * (optionally) pushes. The body is a flat sequence of stage methods; each
1976
- * stage either returns a terminal CommandRouterResult (gate failure or a
1977
- * successful already-merged short-circuit) or `continue` with the extended
1978
- * context. Behavior — stage order, every early-exit, and every result shape —
1979
- * is identical to the previous single inlined body.
1980
- */
1981
- private async executeMeshRefineNodeSynchronously(meshId: string, nodeId: string, args: any): Promise<CommandRouterResult> {
1982
- const refineStages: Array<Record<string, unknown>> = [];
1983
- try {
1984
- const resolved = await this.refineResolveRefsStage(meshId, nodeId, args, refineStages);
1985
- if (resolved.kind === 'terminal') return resolved.result;
1986
- const ctx = resolved.ctx;
1987
-
1988
- const validation = await this.refineValidationStage(ctx);
1989
- if (validation.kind === 'terminal') return validation.result;
1990
-
1991
- const patchEquivalence = await this.refinePatchEquivalenceStage(ctx);
1992
- if (patchEquivalence.kind === 'terminal') return patchEquivalence.result;
1993
-
1994
- const submoduleReachability = await this.refineSubmoduleReachabilityStage(ctx);
1995
- if (submoduleReachability.kind === 'terminal') return submoduleReachability.result;
1996
-
1997
- const effectiveDiff = await this.refineEffectiveDiffStage(ctx);
1998
- if (effectiveDiff.kind === 'terminal') return effectiveDiff.result;
1999
-
2000
- const merge = await this.refineMergeAndFinalizeStage(ctx);
2001
- return (merge as { kind: 'terminal'; result: CommandRouterResult }).result;
2002
- } catch (e: any) {
2003
- return { success: false, error: e.message, refineStages };
2004
- }
1054
+ return resumePendingRefineJobsOnStartup(this);
2005
1055
  }
2006
1056
 
2007
- /**
2008
- * resolve_refs stage: resolve the mesh / worktree node / source node /
2009
- * repoRoot, then the worktree branch, base branch, fetched base head and
2010
- * branch head. Seeds the RefineContext consumed by every later stage.
2011
- */
2012
- private async refineResolveRefsStage(
2013
- meshId: string,
2014
- nodeId: string,
2015
- args: any,
2016
- refineStages: Array<Record<string, unknown>>,
2017
- ): Promise<RefineStageOutcome> {
2018
- // preferInline: same as startMeshRefineJob — inline-cache-only clone nodes must resolve.
2019
- const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
2020
- const mesh = meshRecord?.mesh;
2021
- const node = mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, nodeId));
2022
- if (!node) return { kind: 'terminal', result: { success: false, error: `Node '${nodeId}' not found in mesh`, refineStages } };
2023
-
2024
- if (!node.isLocalWorktree || !node.workspace) {
2025
- return { kind: 'terminal', result: { success: false, error: `Refinery requires a local worktree node`, refineStages } };
2026
- }
2027
-
2028
- const sourceNode = node.clonedFromNodeId
2029
- ? mesh?.nodes.find((n: any) => meshNodeIdMatches(n, node.clonedFromNodeId))
2030
- : mesh?.nodes.find((n: any) => !n.isLocalWorktree);
2031
- const repoRoot = sourceNode?.repoRoot || sourceNode?.workspace;
2032
- if (!repoRoot) return { kind: 'terminal', result: { success: false, error: 'Source node repoRoot not found', refineStages } };
2033
-
2034
- const { execFile } = await import('node:child_process');
2035
- const { promisify } = await import('node:util');
2036
- const execFileAsync = promisify(execFile) as unknown as RefineExecFileAsync;
2037
-
2038
- const resolveStarted = Date.now();
2039
- const { stdout: branchStdout } = await execFileAsync('git', ['branch', '--show-current'], { cwd: node.workspace, encoding: 'utf8' });
2040
- const branch = branchStdout.trim();
2041
- if (!branch) return { kind: 'terminal', result: { success: false, error: 'Could not determine branch of the worktree node', refineStages } };
2042
-
2043
- const { stdout: baseBranchStdout } = await execFileAsync('git', ['branch', '--show-current'], { cwd: repoRoot, encoding: 'utf8' });
2044
- const baseBranch = baseBranchStdout.trim();
2045
-
2046
- // Fetch origin so baseHead reflects the latest pushed state, not a stale local HEAD.
2047
- // This prevents patch_equivalence failures when sequential Refines push to origin/main
2048
- // but the local main checkout hasn't been fast-forwarded yet.
2049
- let fetchWarning: string | undefined;
2050
- try {
2051
- await execFileAsync('git', ['fetch', 'origin', baseBranch], { cwd: repoRoot, encoding: 'utf8' });
2052
- } catch (e: any) {
2053
- fetchWarning = `git fetch origin ${baseBranch} failed (proceeding with local HEAD): ${e?.message}`;
2054
- }
2055
-
2056
- // Prefer origin/<baseBranch> as the authoritative base; fall back to local HEAD if fetch failed.
2057
- let baseHeadRaw: string;
2058
- try {
2059
- const { stdout } = await execFileAsync('git', ['rev-parse', `origin/${baseBranch}`], { cwd: repoRoot, encoding: 'utf8' });
2060
- baseHeadRaw = stdout.trim();
2061
- } catch {
2062
- const { stdout: localHead } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repoRoot, encoding: 'utf8' });
2063
- baseHeadRaw = localHead.trim();
2064
- }
2065
-
2066
- const { stdout: branchHeadStdout } = await execFileAsync('git', ['rev-parse', branch], { cwd: node.workspace, encoding: 'utf8' });
2067
- const baseHead = baseHeadRaw;
2068
- const branchHead = branchHeadStdout.trim();
2069
- recordMeshRefineStage(refineStages, 'resolve_refs', 'passed', resolveStarted, { branch, baseBranch, baseHead, branchHead, ...(fetchWarning ? { fetchWarning } : {}) });
2070
-
2071
- return {
2072
- kind: 'continue',
2073
- ctx: {
2074
- meshId,
2075
- nodeId,
2076
- args,
2077
- refineStages,
2078
- execFileAsync,
2079
- mesh,
2080
- node,
2081
- sourceNode,
2082
- repoRoot,
2083
- branch,
2084
- baseBranch,
2085
- baseHead,
2086
- branchHead,
2087
- validationSummary: undefined as any,
2088
- patchEquivalence: undefined as any,
2089
- submoduleReachability: undefined as any,
2090
- },
2091
- };
2092
- }
2093
-
2094
- /**
2095
- * validation stage: run the refinery validation gate (typecheck / test /
2096
- * lint / build per node config) and block on failure or when no allowlisted
2097
- * command was available. On pass, stores the summary on the context.
2098
- */
2099
- private async refineValidationStage(ctx: RefineContext): Promise<RefineStageOutcome> {
2100
- const { mesh, node, branch, baseBranch, refineStages } = ctx;
2101
- const validationStarted = Date.now();
2102
- const validationSummary = await runMeshRefineValidationGate(mesh, node.workspace, {
2103
- // M2-2: consume the node's persisted bootstrap state; persist re-runs.
2104
- persistedBootstrapState: (node as any).worktreeBootstrap as WorktreeBootstrapState | undefined,
2105
- onBootstrapStateChange: (state) => {
2106
- (node as any).worktreeBootstrap = state;
2107
- void import('../config/mesh-config.js')
2108
- .then(({ updateNode }) => updateNode(mesh.id, node.id, { worktreeBootstrap: state } as any))
2109
- .catch(() => { /* persistence is best-effort */ });
2110
- },
2111
- });
2112
- ctx.validationSummary = validationSummary;
2113
- recordMeshRefineStage(
2114
- refineStages,
2115
- 'validation',
2116
- validationSummary.status === 'passed' ? 'passed' : validationSummary.status === 'failed' ? 'failed' : 'skipped',
2117
- validationStarted,
2118
- { validationStatus: validationSummary.status, commandsRun: validationSummary.commandsRun.length },
2119
- );
2120
- if (validationSummary.status === 'failed') {
2121
- const firstFailedCmd = Array.isArray(validationSummary.commandsRun)
2122
- ? (validationSummary.commandsRun as Array<Record<string, unknown>>).find(c => c.success === false)
2123
- : undefined;
2124
- const buildValidationFailedError = (): string => {
2125
- const base = validationSummary.failureCode === 'missing_dependencies'
2126
- ? 'Refinery validation dependencies are missing; merge/refine was not attempted. Configure validation.bootstrapCommands if Refinery should bootstrap dependencies before validation.'
2127
- : validationSummary.failureCode === 'dependency_bootstrap_failed'
2128
- ? 'Refinery dependency/bootstrap command failed; merge/refine was not attempted.'
2129
- : validationSummary.failureCode === 'spawn_resolution_failed'
2130
- ? (validationSummary.spawnResolutionError
2131
- || 'Refinery validation command could not be spawned (executable not found); merge/refine was not attempted.')
2132
- : 'Refinery validation gate failed; merge/refine was not attempted.';
2133
- if (!firstFailedCmd) return base;
2134
- const cmdName = typeof firstFailedCmd.displayCommand === 'string' ? firstFailedCmd.displayCommand
2135
- : typeof firstFailedCmd.command === 'string'
2136
- ? [firstFailedCmd.command, ...(Array.isArray(firstFailedCmd.args) ? firstFailedCmd.args : [])].join(' ').trim()
2137
- : typeof firstFailedCmd.cmd === 'string' ? firstFailedCmd.cmd : '';
2138
- const rawOutput = [firstFailedCmd.stdout, firstFailedCmd.stderr, firstFailedCmd.output]
2139
- .filter(s => typeof s === 'string' && s.length > 0)
2140
- .join('\n');
2141
- const tail = rawOutput.length > 800 ? rawOutput.slice(-800) : rawOutput;
2142
- return [
2143
- base,
2144
- cmdName ? `First failing command: ${cmdName}` : '',
2145
- tail ? `Output (tail):\n${tail}` : '',
2146
- ].filter(Boolean).join('\n');
2147
- };
2148
- return { kind: 'terminal', result: {
2149
- success: false,
2150
- code: validationSummary.failureCode || 'validation_failed',
2151
- convergenceStatus: 'blocked_review',
2152
- error: buildValidationFailedError(),
2153
- branch,
2154
- into: baseBranch,
2155
- validationSummary,
2156
- refineStages,
2157
- finalBranchConvergenceState: {
2158
- branch,
2159
- baseBranch,
2160
- merged: false,
2161
- removed: false,
2162
- validation: 'failed',
2163
- status: 'blocked_review',
2164
- },
2165
- } };
2166
- }
2167
- if (validationSummary.status === 'skipped') {
2168
- return { kind: 'terminal', result: {
2169
- success: false,
2170
- code: 'validation_unavailable',
2171
- convergenceStatus: 'blocked_review',
2172
- error: 'Refinery validation gate is required but no allowlisted validation command was available; merge/refine was not attempted.',
2173
- branch,
2174
- into: baseBranch,
2175
- validationSummary,
2176
- refineStages,
2177
- finalBranchConvergenceState: {
2178
- branch,
2179
- baseBranch,
2180
- merged: false,
2181
- removed: false,
2182
- validation: 'unavailable',
2183
- status: 'blocked_review',
2184
- },
2185
- } };
2186
- }
2187
-
2188
- return { kind: 'continue', ctx };
2189
- }
2190
-
2191
- /**
2192
- * patch_equivalence stage: preflight that the worktree branch's cumulative
2193
- * patch is equivalent to base+branch. On a "behind base" branch, auto-rebase
2194
- * once and re-check; on an empty merge-tree with real branch changes, treat as
2195
- * already-merged-via-another-path and short-circuit to cleanup. Mutates the
2196
- * context's branchHead (after rebase) and patchEquivalence (rebased gate).
2197
- */
2198
- private async refinePatchEquivalenceStage(ctx: RefineContext): Promise<RefineStageOutcome> {
2199
- const { meshId, nodeId, args, repoRoot, baseHead, node, branch, baseBranch, validationSummary, refineStages, execFileAsync } = ctx;
2200
- let branchHead = ctx.branchHead;
2201
- const patchEquivalenceStarted = Date.now();
2202
- let patchEquivalence = await runMeshRefinePatchEquivalenceGate(repoRoot, baseHead, branchHead);
2203
- recordMeshRefineStage(refineStages, 'patch_equivalence', patchEquivalence.status, patchEquivalenceStarted, {
2204
- equivalent: patchEquivalence.equivalent,
2205
- expectedPatchId: patchEquivalence.expectedPatchId,
2206
- actualPatchId: patchEquivalence.actualPatchId,
2207
- error: patchEquivalence.error,
2208
- actionableHint: patchEquivalence.actionableHint,
2209
- });
2210
- if (!patchEquivalence.equivalent) {
2211
- // Auto-rebase: if branch is simply behind base, attempt rebase automatically before failing.
2212
- let didAutoRebase = false;
2213
- let isBehindBase = false;
2214
- try {
2215
- execFileSync('git', ['merge-base', '--is-ancestor', branchHead, baseHead], {
2216
- cwd: node.workspace,
2217
- stdio: 'ignore',
2218
- });
2219
- isBehindBase = true;
2220
- } catch { /* non-zero exit means branchHead is not an ancestor of baseHead */ }
2221
-
2222
- if (isBehindBase) {
2223
- const autoRebaseStarted = Date.now();
2224
- try {
2225
- execFileSync('git', ['rebase', baseHead], {
2226
- cwd: node.workspace,
2227
- stdio: ['ignore', 'pipe', 'pipe'],
2228
- });
2229
- const { stdout: rebasedHeadStdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: node.workspace, encoding: 'utf8' });
2230
- branchHead = rebasedHeadStdout.trim();
2231
- const rebasedPatchEquivalence = await runMeshRefinePatchEquivalenceGate(repoRoot, baseHead, branchHead);
2232
- recordMeshRefineStage(refineStages, 'patch_equivalence_after_auto_rebase', rebasedPatchEquivalence.status, autoRebaseStarted, {
2233
- equivalent: rebasedPatchEquivalence.equivalent,
2234
- expectedPatchId: rebasedPatchEquivalence.expectedPatchId,
2235
- actualPatchId: rebasedPatchEquivalence.actualPatchId,
2236
- error: rebasedPatchEquivalence.error,
2237
- rebasedBranchHead: branchHead,
2238
- });
2239
- if (rebasedPatchEquivalence.equivalent) {
2240
- patchEquivalence = rebasedPatchEquivalence;
2241
- didAutoRebase = true;
2242
- } else {
2243
- return { kind: 'terminal', result: {
2244
- success: false,
2245
- code: 'needs_rebase',
2246
- convergenceStatus: 'blocked_review',
2247
- error: 'Branch was rebased onto base but patch equivalence still failed; manual intervention required.',
2248
- branch,
2249
- into: baseBranch,
2250
- validationSummary,
2251
- patchEquivalence: rebasedPatchEquivalence,
2252
- refineStages,
2253
- finalBranchConvergenceState: {
2254
- branch,
2255
- baseBranch,
2256
- merged: false,
2257
- removed: false,
2258
- validation: 'passed',
2259
- patchEquivalence: 'failed',
2260
- status: 'blocked_review',
2261
- },
2262
- } };
2263
- }
2264
- } catch (rebaseErr: any) {
2265
- try { execFileSync('git', ['rebase', '--abort'], { cwd: node.workspace, stdio: 'ignore' }); } catch { /* ignore */ }
2266
- recordMeshRefineStage(refineStages, 'patch_equivalence_after_auto_rebase', 'failed', autoRebaseStarted, {
2267
- error: rebaseErr?.message || String(rebaseErr),
2268
- });
2269
- return { kind: 'terminal', result: {
2270
- success: false,
2271
- code: 'needs_rebase_with_conflicts',
2272
- convergenceStatus: 'blocked_review',
2273
- error: 'Branch is behind base and auto-rebase failed due to conflicts; resolve conflicts manually and retry.',
2274
- branch,
2275
- into: baseBranch,
2276
- validationSummary,
2277
- patchEquivalence,
2278
- refineStages,
2279
- finalBranchConvergenceState: {
2280
- branch,
2281
- baseBranch,
2282
- merged: false,
2283
- removed: false,
2284
- validation: 'passed',
2285
- patchEquivalence: 'failed',
2286
- status: 'blocked_review',
2287
- },
2288
- } };
2289
- }
2290
- }
2291
-
2292
- // If the actual patch-id is empty, the merge-tree produces no diff vs base —
2293
- // meaning the branch content is already present in base (landed via a different
2294
- // path, e.g. cherry-pick or direct commit). Treat this as "already merged":
2295
- // skip the merge step but still run cleanup so the worktree node is removed.
2296
- // "already merged via another path": the branch has real changes
2297
- // (expectedPatchId non-empty) but the merge-tree produces no diff
2298
- // against base (actualPatchId empty) — meaning every change in the
2299
- // branch is already present in base via a cherry-pick or direct commit.
2300
- // If both patch-ids are empty, the branch itself has no changes; that
2301
- // is a degenerate worktree case, not an "already merged" scenario.
2302
- const alreadyMergedViaOtherPath = !patchEquivalence.actualPatchId && !!patchEquivalence.expectedPatchId;
2303
- if (!didAutoRebase && !alreadyMergedViaOtherPath) {
2304
- return { kind: 'terminal', result: {
2305
- success: false,
2306
- code: 'patch_equivalence_failed',
2307
- convergenceStatus: 'blocked_review',
2308
- error: 'Refinery patch-equivalence preflight failed; merge/refine was not attempted.',
2309
- branch,
2310
- into: baseBranch,
2311
- validationSummary,
2312
- patchEquivalence,
2313
- refineStages,
2314
- finalBranchConvergenceState: {
2315
- branch,
2316
- baseBranch,
2317
- merged: false,
2318
- removed: false,
2319
- validation: 'passed',
2320
- patchEquivalence: 'failed',
2321
- status: 'blocked_review',
2322
- },
2323
- } };
2324
- }
2325
-
2326
- if (!didAutoRebase && alreadyMergedViaOtherPath) {
2327
- // Content already in base — skip merge, go straight to cleanup.
2328
- recordMeshRefineStage(refineStages, 'merge', 'skipped', Date.now(), {
2329
- reason: 'already_merged_via_other_path',
2330
- note: 'actualPatchId is empty; branch content is already present in base via a different commit path',
2331
- });
2332
- const cleanupStarted = Date.now();
2333
- const removeResult = await this.execute('remove_mesh_node', {
2334
- meshId,
2335
- nodeId,
2336
- sessionCleanupMode: 'preserve',
2337
- inlineMesh: args?.inlineMesh,
2338
- });
2339
- recordMeshRefineStage(refineStages, 'cleanup', removeResult?.success === false ? 'failed' : 'passed', cleanupStarted, {
2340
- removed: removeResult?.removed,
2341
- code: removeResult?.code,
2342
- error: removeResult?.error,
2343
- });
2344
- try {
2345
- const { appendLedgerEntry } = await import('../mesh/mesh-ledger.js');
2346
- appendLedgerEntry(meshId, {
2347
- kind: 'node_removed',
2348
- nodeId,
2349
- payload: { alreadyMergedViaOtherPath: true, branch, into: baseBranch, validationSummary, patchEquivalence },
2350
- });
2351
- } catch { /* ledger append is best-effort */ }
2352
- return { kind: 'terminal', result: {
2353
- success: removeResult?.success !== false,
2354
- code: 'already_merged',
2355
- merged: false,
2356
- alreadyMergedViaOtherPath: true,
2357
- branch,
2358
- into: baseBranch,
2359
- removeResult,
2360
- validationSummary,
2361
- patchEquivalence,
2362
- refineStages,
2363
- finalBranchConvergenceState: {
2364
- branch: baseBranch,
2365
- mergedBranch: branch,
2366
- baseBranch,
2367
- merged: false,
2368
- alreadyMergedViaOtherPath: true,
2369
- removed: removeResult?.success !== false,
2370
- validation: 'passed',
2371
- patchEquivalence: 'already_merged',
2372
- status: removeResult?.success === false ? 'merged_cleanup_failed' : 'merged_to_main',
2373
- },
2374
- } };
2375
- }
2376
- }
2377
-
2378
- ctx.branchHead = branchHead;
2379
- ctx.patchEquivalence = patchEquivalence;
2380
- return { kind: 'continue', ctx };
2381
- }
2382
-
2383
- /**
2384
- * submodule_reachability stage: verify every submodule gitlink commit that
2385
- * would land via the merge is reachable from its configured remote main
2386
- * branch (optionally auto-publishing when policy allows). Blocks the merge
2387
- * when any commit is unreachable. Stores the result on the context.
2388
- */
2389
- private async refineSubmoduleReachabilityStage(ctx: RefineContext): Promise<RefineStageOutcome> {
2390
- const { mesh, node, repoRoot, branch, baseBranch, branchHead, validationSummary, patchEquivalence, refineStages } = ctx;
2391
- const submoduleReachabilityStarted = Date.now();
2392
- const autoPublishSubmoduleMainCommits = resolveRefineryAutoPublishSubmoduleMainCommits(mesh, node.workspace);
2393
- const submoduleReachability = await runMeshRefineSubmoduleReachabilityGate(repoRoot, patchEquivalence.mergedTree || branchHead, {
2394
- allowAutoPublishSubmoduleMainCommits: autoPublishSubmoduleMainCommits.enabled,
2395
- autoPublishPolicySource: autoPublishSubmoduleMainCommits.source,
2396
- worktreeRoot: node.workspace,
2397
- });
2398
- recordMeshRefineStage(refineStages, 'submodule_reachability', submoduleReachability.status, submoduleReachabilityStarted, {
2399
- checked: submoduleReachability.checked,
2400
- autoPublishAllowed: submoduleReachability.autoPublishAllowed,
2401
- autoPublishPolicySource: submoduleReachability.autoPublishPolicySource,
2402
- autoPublished: submoduleReachability.entries
2403
- .filter(entry => entry.autoPublishAttempted)
2404
- .map(entry => ({
2405
- path: entry.path,
2406
- commit: entry.commit,
2407
- remote: entry.remote,
2408
- remoteUrl: entry.remoteUrl,
2409
- remoteMainBranch: entry.remoteMainBranch,
2410
- refspec: entry.autoPublishRefspec,
2411
- succeeded: entry.autoPublishSucceeded,
2412
- verified: entry.autoPublishVerified,
2413
- remoteMainReachable: entry.remoteMainReachable,
2414
- error: entry.error,
2415
- })),
2416
- autoPublishSkipped: submoduleReachability.entries
2417
- .filter(entry => entry.autoPublishAllowed === true && entry.autoPublishAttempted !== true)
2418
- .map(entry => ({
2419
- path: entry.path,
2420
- commit: entry.commit,
2421
- remote: entry.remote,
2422
- remoteUrl: entry.remoteUrl,
2423
- remoteMainBranch: entry.remoteMainBranch,
2424
- reason: entry.autoPublishSkippedReason || entry.error || 'auto-publish was allowed but no publish attempt was possible',
2425
- })),
2426
- unreachable: submoduleReachability.unreachable.map(entry => ({
2427
- path: entry.path,
2428
- commit: entry.commit,
2429
- publishRequired: entry.publishRequired === true,
2430
- autoPublishAllowed: entry.autoPublishAllowed,
2431
- autoPublishAttempted: entry.autoPublishAttempted,
2432
- autoPublishSucceeded: entry.autoPublishSucceeded,
2433
- autoPublishVerified: entry.autoPublishVerified,
2434
- autoPublishRefspec: entry.autoPublishRefspec,
2435
- autoPublishSkippedReason: entry.autoPublishSkippedReason,
2436
- remote: entry.remote,
2437
- remoteUrl: entry.remoteUrl,
2438
- remoteReachable: entry.remoteReachable,
2439
- remoteMainBranch: entry.remoteMainBranch,
2440
- remoteMainReachable: entry.remoteMainReachable,
2441
- error: entry.error,
2442
- })),
2443
- error: submoduleReachability.error,
2444
- });
2445
- if (submoduleReachability.status === 'failed') {
2446
- const nextStep = buildSubmodulePublishRequiredNextStep(submoduleReachability.unreachable);
2447
- return { kind: 'terminal', result: {
2448
- success: false,
2449
- code: 'submodule_reachability_failed',
2450
- convergenceStatus: 'blocked_review',
2451
- publishRequired: true,
2452
- blockedReason: 'submodule_publish_required',
2453
- error: 'Refinery submodule reachability preflight failed because one or more submodule gitlink commits are not reachable from their configured remote main branch; merge/refine cleanup was not attempted.',
2454
- nextStep,
2455
- nextSteps: [
2456
- 'Ask the user for explicit approval before pushing or publishing any submodule commit.',
2457
- 'Push/publish each unreachable submodule commit to the configured submodule remote main branch shown in the evidence.',
2458
- 'Rerun mesh_refine_node after remote reachability is confirmed.',
2459
- 'Do not merge the root branch until every submodule gitlink commit is reachable from submodule origin/main.',
2460
- ],
2461
- unreachableSubmoduleCommits: submoduleReachability.unreachable.map(entry => ({
2462
- path: entry.path,
2463
- commit: entry.commit,
2464
- remote: entry.remote,
2465
- remoteUrl: entry.remoteUrl,
2466
- remoteReachable: entry.remoteReachable,
2467
- remoteMainBranch: entry.remoteMainBranch,
2468
- remoteMainReachable: entry.remoteMainReachable,
2469
- autoPublishAllowed: entry.autoPublishAllowed,
2470
- autoPublishAttempted: entry.autoPublishAttempted,
2471
- autoPublishSucceeded: entry.autoPublishSucceeded,
2472
- autoPublishVerified: entry.autoPublishVerified,
2473
- autoPublishRefspec: entry.autoPublishRefspec,
2474
- autoPublishSkippedReason: entry.autoPublishSkippedReason,
2475
- error: entry.error,
2476
- })),
2477
- branch,
2478
- into: baseBranch,
2479
- validationSummary,
2480
- patchEquivalence,
2481
- submoduleReachability,
2482
- refineStages,
2483
- finalBranchConvergenceState: {
2484
- branch,
2485
- baseBranch,
2486
- merged: false,
2487
- removed: false,
2488
- validation: 'passed',
2489
- patchEquivalence: 'passed',
2490
- submoduleReachability: 'failed',
2491
- status: 'blocked_review',
2492
- reason: 'submodule_publish_required',
2493
- nextStep,
2494
- },
2495
- } };
2496
- }
2497
-
2498
- ctx.submoduleReachability = submoduleReachability;
2499
- return { kind: 'continue', ctx };
2500
- }
2501
-
2502
- /**
2503
- * effective_diff stage (no-op guard): block a silent no-op merge where the
2504
- * branch produces no effective root-tree diff against base — typically a
2505
- * submodule that has commits but whose root-level gitlink (pointer) bump was
2506
- * never committed, so the merge would land nothing real on main.
2507
- */
2508
- private async refineEffectiveDiffStage(ctx: RefineContext): Promise<RefineStageOutcome> {
2509
- const { repoRoot, baseHead, branchHead, branch, baseBranch, validationSummary, patchEquivalence, refineStages } = ctx;
2510
- // No-op guard: block a silent no-op merge where the root tree is identical to base.
2511
- // This catches the trap where a submodule has commits but the root branch never
2512
- // committed the gitlink (oss-pointer) bump — merging would report success while the
2513
- // real change never lands on main. A committed gitlink bump shows up in the root
2514
- // diff, so legitimate oss-pointer refines pass through untouched.
2515
- const effectiveDiffStarted = Date.now();
2516
- const effectiveDiff = await runMeshRefineEffectiveDiffGate(repoRoot, baseHead, branchHead);
2517
- recordMeshRefineStage(refineStages, 'effective_diff', effectiveDiff.status, effectiveDiffStarted, {
2518
- hasEffectiveDiff: effectiveDiff.hasEffectiveDiff,
2519
- changedPaths: effectiveDiff.changedPaths,
2520
- submoduleHints: effectiveDiff.submoduleHints,
2521
- ...(effectiveDiff.error ? { error: effectiveDiff.error } : {}),
2522
- });
2523
- if (effectiveDiff.status === 'failed' && !effectiveDiff.hasEffectiveDiff) {
2524
- const hintLines = (effectiveDiff.submoduleHints || []).map(h => ` - ${h.path}: ${h.reason}`);
2525
- const message = [
2526
- `Refinery no-op guard: branch '${branch}' has no effective root-tree diff against '${baseBranch}' (${baseHead.slice(0, 12)}); nothing would merge.`,
2527
- 'This usually means a submodule (e.g. oss) has commits but the root branch never committed the gitlink (pointer) bump, so the merge would be a silent no-op while the real change never reaches main.',
2528
- hintLines.length ? `Submodules with uncommitted pointer bumps:\n${hintLines.join('\n')}` : '',
2529
- `Fix: commit the submodule pointer bump on '${branch}' (git add <submodule-path> && git commit), then re-run refine.`,
2530
- ].filter(Boolean).join('\n');
2531
- return { kind: 'terminal', result: {
2532
- success: false,
2533
- code: 'no_effective_diff',
2534
- convergenceStatus: 'blocked_review',
2535
- error: message,
2536
- branch,
2537
- into: baseBranch,
2538
- validationSummary,
2539
- patchEquivalence,
2540
- effectiveDiff,
2541
- refineStages,
2542
- finalBranchConvergenceState: {
2543
- branch,
2544
- baseBranch,
2545
- merged: false,
2546
- removed: false,
2547
- validation: 'passed',
2548
- patchEquivalence: 'passed',
2549
- effectiveDiff: 'no_effective_diff',
2550
- status: 'blocked_review',
2551
- reason: 'no_effective_diff',
2552
- ...(effectiveDiff.submoduleHints?.length ? { submoduleHints: effectiveDiff.submoduleHints } : {}),
2553
- },
2554
- } };
2555
- }
2556
-
2557
- return { kind: 'continue', ctx };
2558
- }
2559
-
2560
- /**
2561
- * merge + finalize stage: perform the --no-ff merge, align submodule
2562
- * checkouts after merge, clean up (remove) the worktree node per policy,
2563
- * append the refinery ledger entry, and (unless approval is required) push the
2564
- * base branch. Always terminal — produces the final CommandRouterResult.
2565
- */
2566
- private async refineMergeAndFinalizeStage(ctx: RefineContext): Promise<RefineStageOutcome> {
2567
- const { meshId, nodeId, args, repoRoot, baseHead, node, branch, baseBranch, sourceNode, validationSummary, patchEquivalence, submoduleReachability, mesh, refineStages, execFileAsync } = ctx;
2568
- let mergeResult: Record<string, unknown> | undefined;
2569
- const mergeStarted = Date.now();
2570
- try {
2571
- const result = await execFileAsync('git', ['merge', '--no-ff', branch, '-m', `Auto-merge branch '${branch}' via Refinery`], { cwd: repoRoot, encoding: 'utf8' });
2572
- mergeResult = {
2573
- stdout: truncateValidationOutput(result.stdout),
2574
- stderr: truncateValidationOutput(result.stderr),
2575
- durationMs: Date.now() - mergeStarted,
2576
- };
2577
- recordMeshRefineStage(refineStages, 'merge', 'passed', mergeStarted, mergeResult);
2578
- } catch (e: any) {
2579
- recordMeshRefineStage(refineStages, 'merge', 'failed', mergeStarted, {
2580
- error: e?.message || String(e),
2581
- stdout: truncateValidationOutput(e?.stdout),
2582
- stderr: truncateValidationOutput(e?.stderr),
2583
- });
2584
- return { kind: 'terminal', result: {
2585
- success: false,
2586
- error: `Merge failed (conflicts?): ${e.message}`,
2587
- validationSummary,
2588
- patchEquivalence,
2589
- refineStages,
2590
- finalBranchConvergenceState: {
2591
- branch,
2592
- baseBranch,
2593
- merged: false,
2594
- removed: false,
2595
- validation: 'passed',
2596
- patchEquivalence: 'passed',
2597
- status: 'not_mergeable',
2598
- },
2599
- } };
2600
- }
2601
-
2602
- const submoduleAlignmentStarted = Date.now();
2603
- const submoduleAlignment = await alignRefinerySubmodulesAfterMerge(repoRoot, baseHead, 'HEAD', {
2604
- submoduleIgnorePaths: Array.isArray(sourceNode?.policy?.submoduleIgnorePaths)
2605
- ? sourceNode.policy.submoduleIgnorePaths.filter((value: unknown): value is string => typeof value === 'string')
2606
- : undefined,
2607
- });
2608
- if (submoduleAlignment.status !== 'skipped') {
2609
- recordMeshRefineStage(refineStages, 'submodule_alignment', submoduleAlignment.status, submoduleAlignmentStarted, {
2610
- changedGitlinkPaths: submoduleAlignment.changedGitlinkPaths,
2611
- outOfSyncPaths: submoduleAlignment.outOfSyncPaths,
2612
- updatedPaths: submoduleAlignment.updatedPaths,
2613
- verifiedPaths: submoduleAlignment.verifiedPaths,
2614
- command: submoduleAlignment.command,
2615
- error: submoduleAlignment.error,
2616
- });
2617
- }
2618
- if (submoduleAlignment.status === 'failed') {
2619
- return { kind: 'terminal', result: {
2620
- success: false,
2621
- code: 'post_merge_submodule_alignment_failed',
2622
- error: 'Refinery merge completed but post-merge submodule checkout alignment failed; run the reported git submodule update command and re-check base workspace status.',
2623
- merged: true,
2624
- branch,
2625
- into: baseBranch,
2626
- validationSummary,
2627
- patchEquivalence,
2628
- submoduleReachability,
2629
- submoduleAlignment,
2630
- mergeResult,
2631
- refineStages,
2632
- finalBranchConvergenceState: {
2633
- branch: baseBranch,
2634
- mergedBranch: branch,
2635
- baseBranch,
2636
- merged: true,
2637
- removed: false,
2638
- validation: 'passed',
2639
- patchEquivalence: 'passed',
2640
- submoduleReachability: 'passed',
2641
- submoduleAlignment: 'failed',
2642
- status: 'post_merge_alignment_failed',
2643
- nextStep: submoduleAlignment.command || 'Run git submodule update --init --recursive for the reported path(s), then re-check base workspace status.',
2644
- },
2645
- } };
2646
- }
2647
-
2648
- const cleanupStarted = Date.now();
2649
- // Honor the mesh policy for delegated-session cleanup on the auto-removed
2650
- // worktree node (previously hardcoded to 'preserve', which orphaned the
2651
- // delegate session as an idle record on the coordinator daemon). Fall back
2652
- // to 'preserve' when no policy is set.
2653
- const refineSessionCleanupMode = this.normalizeMeshSessionCleanupMode(
2654
- mesh?.policy?.sessionCleanupOnNodeRemove,
2655
- );
2656
- // The delegate session launched for a clone worktree is frequently matched
2657
- // by workspace ONLY (no meta.meshNodeId binding), which remove_mesh_node's
2658
- // shared-daemon guard skips. Since refine knows exactly which workspace it
2659
- // just merged, collect that workspace's live session ids explicitly and pass
2660
- // them through — explicit sessionIds bypass the workspace-only-match guard so
2661
- // the policy-driven stop/delete actually runs.
2662
- let refineSessionIds: string[] | undefined;
2663
- if (refineSessionCleanupMode !== 'preserve' && this.deps.sessionHostControl) {
2664
- try {
2665
- const liveSessions = await this.deps.sessionHostControl.listSessions();
2666
- const workspace = typeof node.workspace === 'string' ? node.workspace : '';
2667
- refineSessionIds = liveSessions
2668
- .filter((record: any) => {
2669
- const sid = typeof record?.sessionId === 'string' ? record.sessionId : '';
2670
- if (!sid) return false;
2671
- // Never sweep the coordinator's own session for this mesh.
2672
- if (readStringValue(record?.meta?.meshCoordinatorFor) === meshId) return false;
2673
- const boundToNode = readStringValue(record?.meta?.meshNodeId) === nodeId;
2674
- const matchedByWorkspace = !!workspace && record?.workspace === workspace;
2675
- return boundToNode || matchedByWorkspace;
2676
- })
2677
- .map((record: any) => String(record.sessionId));
2678
- } catch {
2679
- // listSessions failure is non-fatal — fall back to the policy-mode
2680
- // cleanup without explicit ids (still better than hardcoded preserve).
2681
- refineSessionIds = undefined;
2682
- }
2683
- }
2684
- const removeResult = await this.execute('remove_mesh_node', {
2685
- meshId,
2686
- nodeId,
2687
- sessionCleanupMode: refineSessionCleanupMode,
2688
- ...(refineSessionIds && refineSessionIds.length > 0 ? { sessionIds: refineSessionIds } : {}),
2689
- inlineMesh: args?.inlineMesh,
2690
- // REFINE-CLEANUP: refine reaches cleanup only AFTER a verified merge
2691
- // convergence, so any residual worktree dirtiness here is incidental
2692
- // (e.g. a bootstrap lockfile rewrite) — never unmerged work. `force`
2693
- // sets requireClean=false so a plain-dirty worktree no longer aborts
2694
- // removal with merged_cleanup_failed. Branch-ref deletion still keys off
2695
- // mergeConvergence (NOT the force flag), so no merged work can be lost.
2696
- force: true,
2697
- });
2698
- recordMeshRefineStage(refineStages, 'cleanup', removeResult?.success === false ? 'failed' : 'passed', cleanupStarted, {
2699
- removed: removeResult?.removed,
2700
- code: removeResult?.code,
2701
- error: removeResult?.error,
2702
- });
2703
-
2704
- let ledgerError: string | undefined;
2705
- const ledgerStarted = Date.now();
2706
- try {
2707
- const { appendLedgerEntry } = await import('../mesh/mesh-ledger.js');
2708
- appendLedgerEntry(meshId, {
2709
- kind: 'node_removed',
2710
- nodeId,
2711
- payload: { refined: true, mergedBranch: branch, into: baseBranch, validationSummary, patchEquivalence, submoduleReachability, submoduleAlignment },
2712
- });
2713
- recordMeshRefineStage(refineStages, 'ledger', 'passed', ledgerStarted);
2714
- } catch (e: any) {
2715
- ledgerError = e?.message || String(e);
2716
- recordMeshRefineStage(refineStages, 'ledger', 'failed', ledgerStarted, { error: ledgerError });
2717
- }
2718
-
2719
- const finalBranchConvergenceState = {
2720
- branch: baseBranch,
2721
- mergedBranch: branch,
2722
- baseBranch,
2723
- merged: true,
2724
- removed: removeResult?.success !== false,
2725
- validation: 'passed',
2726
- patchEquivalence: 'passed',
2727
- submoduleAlignment: submoduleAlignment.status,
2728
- status: removeResult?.success === false ? 'merged_cleanup_failed' : 'merged',
2729
- };
2730
-
2731
- if (removeResult?.success === false) {
2732
- return { kind: 'terminal', result: {
2733
- success: false,
2734
- code: 'cleanup_failed',
2735
- error: 'Refinery merge completed but worktree cleanup failed; manual cleanup/retry is required.',
2736
- merged: true,
2737
- branch,
2738
- into: baseBranch,
2739
- removeResult,
2740
- validationSummary,
2741
- patchEquivalence,
2742
- submoduleReachability,
2743
- submoduleAlignment,
2744
- mergeResult,
2745
- refineStages,
2746
- ...(ledgerError ? { ledgerError } : {}),
2747
- finalBranchConvergenceState,
2748
- } };
2749
- }
2750
-
2751
- // Push logic: after a successful merge, either auto-push or surface push info
2752
- // so coordinators don't need manual discovery after each refine.
2753
- const requireApprovalForPush: boolean = (mesh as any)?.policy?.requireApprovalForPush ?? DEFAULT_MESH_POLICY.requireApprovalForPush;
2754
- let pushResult: Record<string, unknown> | undefined;
2755
- if (!requireApprovalForPush) {
2756
- const pushStarted = Date.now();
2757
- try {
2758
- await execFileAsync('git', ['push', 'origin', baseBranch], { cwd: repoRoot, encoding: 'utf8' });
2759
- pushResult = { pushed: true, remote: 'origin', branch: baseBranch, durationMs: Date.now() - pushStarted };
2760
- recordMeshRefineStage(refineStages, 'push', 'passed', pushStarted, pushResult);
2761
- finalBranchConvergenceState.status = 'merged_pushed';
2762
- } catch (e: any) {
2763
- pushResult = {
2764
- pushed: false,
2765
- remote: 'origin',
2766
- branch: baseBranch,
2767
- error: e?.message || String(e),
2768
- stderr: e?.stderr,
2769
- durationMs: Date.now() - pushStarted,
2770
- };
2771
- recordMeshRefineStage(refineStages, 'push', 'failed', pushStarted, pushResult);
2772
- }
2773
- }
2774
-
2775
- return { kind: 'terminal', result: {
2776
- success: true,
2777
- merged: true,
2778
- branch,
2779
- into: baseBranch,
2780
- removeResult,
2781
- validationSummary,
2782
- patchEquivalence,
2783
- submoduleReachability,
2784
- submoduleAlignment,
2785
- mergeResult,
2786
- refineStages,
2787
- ...(ledgerError ? { ledgerError } : {}),
2788
- finalBranchConvergenceState,
2789
- // Push outcome or readiness info for coordinator.
2790
- ...(pushResult
2791
- ? { pushResult }
2792
- : {
2793
- pushReady: true,
2794
- pushCommand: `git push origin ${baseBranch}`,
2795
- pushNote: 'requireApprovalForPush is enabled — run the push command or obtain user approval before pushing.',
2796
- }),
2797
- } };
2798
- }
2799
-
2800
- /**
2801
- * Batch refinery: converge multiple sibling worktree nodes onto the base branch
2802
- * in one sequential pipeline, absorbing the rebase + patch-equivalence churn that
2803
- * arises when several siblings touch the same submodule.
2804
- *
2805
- * Reuses executeMeshRefineNodeSynchronously per node — every node goes through the
2806
- * exact same validation / patch-equivalence / submodule-reachability / merge / cleanup
2807
- * gates, including its built-in auto-rebase onto fresh origin/<base>. Because each
2808
- * node fetches origin/<base> at the start of its own refine, a node merged earlier in
2809
- * the batch advances the base, and the next node's refine auto-rebases onto it before
2810
- * re-running patch-equivalence. No force-push, no reset — conflicting nodes are
2811
- * isolated as blocked_review while the rest of the batch proceeds.
2812
- */
2813
1057
  private async batchRefineMeshNodes(meshId: string, requestedNodeIds: string[] | undefined, args: any): Promise<CommandRouterResult> {
2814
- // preferInline: same membership authority as refine_mesh_node — inline-cache-only
2815
- // clone nodes (created in this MCP session) must resolve.
2816
- const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
2817
- const mesh = meshRecord?.mesh;
2818
- if (!mesh) return { success: false, error: `Mesh '${meshId}' not found` };
2819
-
2820
- const allNodes: any[] = Array.isArray(mesh.nodes) ? mesh.nodes : [];
2821
- const isConvergeable = (n: any) => n?.isLocalWorktree && typeof n.workspace === 'string' && n.workspace;
2822
-
2823
- let targetNodes: any[];
2824
- if (Array.isArray(requestedNodeIds) && requestedNodeIds.length > 0) {
2825
- targetNodes = [];
2826
- const missing: string[] = [];
2827
- const nonWorktree: string[] = [];
2828
- for (const nodeId of requestedNodeIds) {
2829
- const node = allNodes.find(n => meshNodeIdMatches(n, nodeId));
2830
- if (!node) { missing.push(nodeId); continue; }
2831
- if (!isConvergeable(node)) { nonWorktree.push(nodeId); continue; }
2832
- targetNodes.push(node);
2833
- }
2834
- if (missing.length || nonWorktree.length) {
2835
- return {
2836
- success: false,
2837
- error: 'One or more requested nodes are not convergeable local worktree nodes.',
2838
- ...(missing.length ? { missingNodeIds: missing } : {}),
2839
- ...(nonWorktree.length ? { nonWorktreeNodeIds: nonWorktree } : {}),
2840
- };
2841
- }
2842
- } else {
2843
- // Auto-collect: every local worktree node is a convergence candidate.
2844
- targetNodes = allNodes.filter(isConvergeable);
2845
- }
2846
-
2847
- if (targetNodes.length === 0) {
2848
- return { success: true, batch: true, dryRun: args?.dryRun !== false, nodeCount: 0, order: [], results: [], note: 'No convergeable local worktree nodes found.' };
2849
- }
2850
-
2851
- const { execFile } = await import('node:child_process');
2852
- const { promisify } = await import('node:util');
2853
- const execFileAsync = promisify(execFile);
2854
-
2855
- // Resolve the base repo root and a base ref to analyze change areas against.
2856
- const resolveRepoRootFor = (node: any): string | undefined => {
2857
- const sourceNode = node.clonedFromNodeId
2858
- ? allNodes.find(n => meshNodeIdMatches(n, node.clonedFromNodeId))
2859
- : allNodes.find(n => !n.isLocalWorktree);
2860
- return sourceNode?.repoRoot || sourceNode?.workspace;
2861
- };
2862
-
2863
- // Analyze change areas for ordering. The repoRoot is shared across siblings of
2864
- // the same source; resolve a base ref (origin/<base> preferred) once per repoRoot.
2865
- const repoRootBaseRef = new Map<string, string>();
2866
- const submodulePathsByRepoRoot = new Map<string, Set<string>>();
2867
- const resolveBaseRef = async (repoRoot: string): Promise<string> => {
2868
- const cached = repoRootBaseRef.get(repoRoot);
2869
- if (cached) return cached;
2870
- let baseBranch = 'main';
2871
- try {
2872
- const { stdout } = await execFileAsync('git', ['branch', '--show-current'], { cwd: repoRoot, encoding: 'utf8' });
2873
- if (stdout.trim()) baseBranch = stdout.trim();
2874
- } catch { /* fall back to main */ }
2875
- let baseRef = 'HEAD';
2876
- try {
2877
- await execFileAsync('git', ['fetch', 'origin', baseBranch], { cwd: repoRoot, encoding: 'utf8' });
2878
- } catch { /* offline / no remote — fall through to local refs */ }
2879
- try {
2880
- const { stdout } = await execFileAsync('git', ['rev-parse', `origin/${baseBranch}`], { cwd: repoRoot, encoding: 'utf8' });
2881
- baseRef = stdout.trim();
2882
- } catch {
2883
- try {
2884
- const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repoRoot, encoding: 'utf8' });
2885
- baseRef = stdout.trim();
2886
- } catch { /* leave HEAD */ }
2887
- }
2888
- repoRootBaseRef.set(repoRoot, baseRef);
2889
- return baseRef;
2890
- };
2891
-
2892
- const changeAreas: Array<Awaited<ReturnType<typeof analyzeMeshRefineNodeChangeArea>>> = [];
2893
- for (const node of targetNodes) {
2894
- const repoRoot = resolveRepoRootFor(node);
2895
- let branch = typeof node.worktreeBranch === 'string' ? node.worktreeBranch : '';
2896
- try {
2897
- const { stdout } = await execFileAsync('git', ['branch', '--show-current'], { cwd: node.workspace, encoding: 'utf8' });
2898
- if (stdout.trim()) branch = stdout.trim();
2899
- } catch { /* use stored worktreeBranch */ }
2900
-
2901
- if (!repoRoot || !branch) {
2902
- changeAreas.push({
2903
- nodeId: node.id, workspace: node.workspace, branch: branch || '(unknown)',
2904
- changedTopLevelPaths: [], changedFiles: [], touchedSubmodulePaths: [],
2905
- touchesSubmodule: false, aheadCount: 0,
2906
- error: !repoRoot ? 'source repoRoot not found' : 'branch not resolved',
2907
- });
2908
- continue;
2909
- }
2910
- if (!submodulePathsByRepoRoot.has(repoRoot)) {
2911
- // Resolve declared submodule paths once per repo root.
2912
- let subPaths = new Set<string>();
2913
- try {
2914
- const { stdout } = await execFileAsync('git', ['config', '--file', '.gitmodules', '--get-regexp', 'path'], { cwd: repoRoot, encoding: 'utf8' });
2915
- for (const line of stdout.split('\n')) {
2916
- const trimmed = line.trim();
2917
- const spaceIdx = trimmed.indexOf(' ');
2918
- if (spaceIdx === -1) continue;
2919
- const value = trimmed.slice(spaceIdx + 1).trim();
2920
- if (value) subPaths.add(value);
2921
- }
2922
- } catch { subPaths = new Set(); }
2923
- submodulePathsByRepoRoot.set(repoRoot, subPaths);
2924
- }
2925
- const baseRef = await resolveBaseRef(repoRoot);
2926
- let branchRef = branch;
2927
- try {
2928
- const { stdout } = await execFileAsync('git', ['rev-parse', branch], { cwd: node.workspace, encoding: 'utf8' });
2929
- branchRef = stdout.trim() || branch;
2930
- } catch { /* use branch name */ }
2931
- changeAreas.push(await analyzeMeshRefineNodeChangeArea({
2932
- nodeId: node.id,
2933
- workspace: node.workspace,
2934
- branch,
2935
- baseRef,
2936
- branchRef,
2937
- diffCwd: node.workspace,
2938
- submodulePaths: submodulePathsByRepoRoot.get(repoRoot)!,
2939
- }));
2940
- }
2941
-
2942
- const ordering = orderMeshRefineBatchNodes(changeAreas);
2943
- const orderedNodes = ordering.order
2944
- .map(nodeId => targetNodes.find(n => meshNodeIdMatches(n, nodeId)))
2945
- .filter((n): n is any => !!n);
2946
-
2947
- const dryRun = args?.dryRun !== false && args?.execute !== true;
2948
- if (dryRun) {
2949
- return {
2950
- success: true,
2951
- batch: true,
2952
- dryRun: true,
2953
- nodeCount: orderedNodes.length,
2954
- order: ordering.order,
2955
- orderingRationale: ordering.rationale,
2956
- changeAreas: ordering.changeAreas,
2957
- plan: orderedNodes.map(node => ({
2958
- nodeId: node.id,
2959
- workspace: node.workspace,
2960
- validationPlan: buildMeshRefineValidationPlan(mesh, node.workspace),
2961
- mergeWillRun: false,
2962
- })),
2963
- note: 'Dry-run: no validation, rebase, or merge was executed. Re-run with execute=true to converge nodes in this order.',
2964
- };
2965
- }
2966
-
2967
- // Execute: refine each node in order via the shared convergence core.
2968
- return this.runMeshRefineBatchConvergence(meshId, orderedNodes, ordering, args);
1058
+ return batchRefineMeshNodes(this, meshId, requestedNodeIds, args);
2969
1059
  }
2970
1060
 
2971
- /**
2972
- * Convergence core shared by the synchronous batch entry and the async batch job.
2973
- * Refines each node in order: the per-node refine pipeline fetches origin/<base>
2974
- * fresh, so each merged sibling advances the base before the next node's auto-rebase
2975
- * + patch-equivalence re-check. A blocked/failed node is isolated; the batch
2976
- * continues with the remaining nodes. Does NOT touch the per-node merge logic — it
2977
- * only sequences calls to executeMeshRefineNodeSynchronously and aggregates outcomes.
2978
- */
2979
- private async runMeshRefineBatchConvergence(
2980
- meshId: string,
2981
- orderedNodes: any[],
2982
- ordering: { order: string[]; rationale?: unknown },
2983
- args: any,
2984
- ): Promise<CommandRouterResult> {
2985
- type BatchNodeOutcome = {
2986
- nodeId: string;
2987
- workspace: string;
2988
- convergence: 'merged_to_main' | 'blocked_review' | 'skipped_patch_equivalent' | 'not_mergeable';
2989
- code?: string;
2990
- reason?: string;
2991
- stage?: string;
2992
- error?: string;
2993
- finalBranchConvergenceState?: Record<string, unknown>;
2994
- };
2995
- const results: BatchNodeOutcome[] = [];
2996
- for (const node of orderedNodes) {
2997
- let result: Record<string, unknown>;
2998
- try {
2999
- result = await this.executeMeshRefineNodeSynchronously(meshId, node.id, args) as Record<string, unknown>;
3000
- } catch (e: any) {
3001
- result = { success: false, error: e?.message || String(e) };
3002
- }
3003
- const code = typeof result.code === 'string' ? result.code : '';
3004
- // already_merged (branch content already on base via another path) is a
3005
- // non-error skip regardless of success flag — the worktree converges with
3006
- // no new merge. A real `git merge` conflict surfaces as merge_failed →
3007
- // not_mergeable. Everything else that failed is isolated as blocked_review.
3008
- let convergence: BatchNodeOutcome['convergence'];
3009
- if (code === 'already_merged' && result.alreadyMergedViaOtherPath) {
3010
- convergence = 'skipped_patch_equivalent';
3011
- } else if (result.success === true) {
3012
- convergence = 'merged_to_main';
3013
- } else if (code === 'merge_failed') {
3014
- convergence = 'not_mergeable';
3015
- } else {
3016
- convergence = 'blocked_review';
3017
- }
3018
- const fbcs = (result.finalBranchConvergenceState && typeof result.finalBranchConvergenceState === 'object')
3019
- ? result.finalBranchConvergenceState as Record<string, unknown>
3020
- : undefined;
3021
- const stage = Array.isArray(result.refineStages)
3022
- ? (result.refineStages as Array<Record<string, unknown>>).filter(s => s.status === 'failed').map(s => s.stage).filter(Boolean).pop() as string | undefined
3023
- : undefined;
3024
- results.push({
3025
- nodeId: node.id,
3026
- workspace: node.workspace,
3027
- convergence,
3028
- ...(code ? { code } : {}),
3029
- ...(typeof result.blockedReason === 'string' ? { reason: result.blockedReason } : {}),
3030
- ...(stage ? { stage } : {}),
3031
- ...(typeof result.error === 'string' ? { error: result.error } : {}),
3032
- ...(fbcs ? { finalBranchConvergenceState: fbcs } : {}),
3033
- });
3034
- }
3035
-
3036
- const summary = {
3037
- merged: results.filter(r => r.convergence === 'merged_to_main').length,
3038
- skipped: results.filter(r => r.convergence === 'skipped_patch_equivalent').length,
3039
- blocked: results.filter(r => r.convergence === 'blocked_review').length,
3040
- notMergeable: results.filter(r => r.convergence === 'not_mergeable').length,
3041
- };
3042
- const allConverged = summary.blocked === 0 && summary.notMergeable === 0;
3043
- return {
3044
- success: true,
3045
- batch: true,
3046
- dryRun: false,
3047
- nodeCount: orderedNodes.length,
3048
- order: ordering.order,
3049
- orderingRationale: ordering.rationale,
3050
- summary,
3051
- allConverged,
3052
- results,
3053
- ...(allConverged ? {} : {
3054
- nextStep: 'Resolve blocked_review / not_mergeable nodes manually (see per-node code/stage/error), then re-run mesh_refine_batch for the remaining nodes.',
3055
- }),
3056
- };
3057
- }
3058
-
3059
- private buildRefineBatchJobKey(meshId: string): string {
3060
- return `${meshId}::batch`;
3061
- }
3062
-
3063
- private buildRefineBatchJobHandle(args: {
3064
- meshId: string;
3065
- nodeIds: string[];
3066
- order: string[];
3067
- status?: MeshRefineBatchJobStatus;
3068
- startedAt?: string;
3069
- completedAt?: string;
3070
- jobId?: string;
3071
- interactionId?: string;
3072
- coordinatorDaemonId?: string;
3073
- }): MeshRefineBatchJobHandle {
3074
- return {
3075
- success: true,
3076
- async: true,
3077
- batch: true,
3078
- status: args.status || 'accepted',
3079
- jobId: args.jobId || `refine_batch_${createInteractionId()}`,
3080
- interactionId: args.interactionId || createInteractionId(),
3081
- meshId: args.meshId,
3082
- batchLabel: `batch:${args.nodeIds.length} node${args.nodeIds.length === 1 ? '' : 's'}`,
3083
- nodeIds: args.nodeIds,
3084
- nodeCount: args.nodeIds.length,
3085
- order: args.order,
3086
- startedAt: args.startedAt || new Date().toISOString(),
3087
- ...(args.completedAt ? { completedAt: args.completedAt } : {}),
3088
- ...(args.coordinatorDaemonId ? { targetCoordinatorDaemonId: args.coordinatorDaemonId } : {}),
3089
- eventDelivery: { pendingEvents: true, ledger: true },
3090
- evidence: {
3091
- pendingEventsCommand: 'get_pending_mesh_events',
3092
- ledgerCommand: 'get_mesh_ledger_slice',
3093
- taskHistoryKind: args.status === 'completed' ? 'task_completed' : args.status === 'failed' ? 'task_failed' : 'task_dispatched',
3094
- },
3095
- };
3096
- }
3097
-
3098
- /**
3099
- * Emit a batch Refinery terminal/accepted event through the SAME pending-event +
3100
- * forward mechanism single-node refine uses (queueRefineJobEvent), so the
3101
- * coordinator's existing refine:accepted/completed/failed handling and message
3102
- * renderer apply unchanged. The aggregate per-node results ride along in `result`.
3103
- */
3104
- private queueRefineBatchJobEvent(
3105
- event: 'refine:accepted' | 'refine:completed' | 'refine:failed',
3106
- handle: MeshRefineBatchJobHandle,
3107
- result?: Record<string, unknown>,
3108
- ): void {
3109
- const metadataEvent = {
3110
- source: 'refine_mesh_node_async_job',
3111
- batch: true,
3112
- jobId: handle.jobId,
3113
- interactionId: handle.interactionId,
3114
- meshId: handle.meshId,
3115
- nodeId: handle.batchLabel,
3116
- nodeIds: handle.nodeIds,
3117
- workspace: undefined,
3118
- status: handle.status,
3119
- startedAt: handle.startedAt,
3120
- completedAt: handle.completedAt,
3121
- order: handle.order,
3122
- ...(result ? { result } : {}),
3123
- };
3124
- const eventPayload = {
3125
- event,
3126
- meshId: handle.meshId,
3127
- nodeLabel: handle.batchLabel,
3128
- nodeId: handle.batchLabel,
3129
- metadataEvent,
3130
- queuedAt: Date.now(),
3131
- ...(handle.targetCoordinatorDaemonId ? { targetCoordinatorDaemonId: handle.targetCoordinatorDaemonId } : {}),
3132
- };
3133
- if (typeof this.deps.instanceManager?.getByCategory === 'function') {
3134
- const forwarded = handleMeshForwardEvent(
3135
- { instanceManager: this.deps.instanceManager } as any,
3136
- {
3137
- event,
3138
- meshId: handle.meshId,
3139
- nodeId: handle.batchLabel,
3140
- jobId: handle.jobId,
3141
- interactionId: handle.interactionId,
3142
- status: handle.status,
3143
- startedAt: handle.startedAt,
3144
- completedAt: handle.completedAt,
3145
- ...(result ? { result } : {}),
3146
- },
3147
- );
3148
- if (forwarded?.success === true) return;
3149
- LOG.warn('Mesh', `[Refinery] Failed to forward async refine batch event ${event}: ${forwarded?.error || 'unknown error'}`);
3150
- }
3151
- queuePendingMeshCoordinatorEvent(eventPayload);
3152
- }
3153
-
3154
- private async appendRefineBatchJobLedger(
3155
- kind: 'task_dispatched' | 'task_completed' | 'task_failed',
3156
- handle: MeshRefineBatchJobHandle,
3157
- result?: Record<string, unknown>,
3158
- ): Promise<void> {
3159
- try {
3160
- const { appendLedgerEntry } = await import('../mesh/mesh-ledger.js');
3161
- appendLedgerEntry(handle.meshId, {
3162
- kind,
3163
- nodeId: handle.batchLabel,
3164
- payload: {
3165
- source: 'refine_mesh_node_async_job',
3166
- refineJob: {
3167
- batch: true,
3168
- jobId: handle.jobId,
3169
- interactionId: handle.interactionId,
3170
- status: handle.status,
3171
- meshId: handle.meshId,
3172
- nodeIds: handle.nodeIds,
3173
- order: handle.order,
3174
- targetCoordinatorDaemonId: handle.targetCoordinatorDaemonId,
3175
- startedAt: handle.startedAt,
3176
- completedAt: handle.completedAt,
3177
- },
3178
- async: true,
3179
- batch: true,
3180
- ...(result ? {
3181
- success: result.success === true,
3182
- result,
3183
- } : {}),
3184
- },
3185
- });
3186
- } catch (e: any) {
3187
- LOG.warn('Mesh', `[Refinery] Failed to append async refine batch ledger entry: ${e?.message || e}`);
3188
- }
3189
- }
3190
-
3191
- private async finishMeshRefineBatchJob(
3192
- handle: MeshRefineBatchJobHandle,
3193
- orderedNodes: any[],
3194
- ordering: { order: string[]; rationale?: unknown },
3195
- args: any,
3196
- ): Promise<void> {
3197
- const key = this.buildRefineBatchJobKey(handle.meshId);
3198
- let result: Record<string, unknown>;
3199
- try {
3200
- result = await this.runMeshRefineBatchConvergence(handle.meshId, orderedNodes, ordering, args) as Record<string, unknown>;
3201
- } catch (e: any) {
3202
- result = { success: false, error: e?.message || String(e), batch: true };
3203
- }
3204
- const completedAt = new Date().toISOString();
3205
-
3206
- // The batch as a whole "completed" only when every node converged (no blocked /
3207
- // not_mergeable). A partial batch is reported as a terminal failure so the
3208
- // coordinator inspects the per-node blockers rather than assuming a clean merge.
3209
- const summary = (result.summary && typeof result.summary === 'object') ? result.summary as Record<string, number> : undefined;
3210
- const allConverged = result.allConverged === true;
3211
- const isTerminalSuccess = result.success === true && allConverged;
3212
-
3213
- const nextStep = typeof result.nextStep === 'string' && result.nextStep
3214
- ? result.nextStep
3215
- : isTerminalSuccess
3216
- ? 'All batched nodes converged onto base. Continue from the updated mesh state.'
3217
- : 'Resolve blocked_review / not_mergeable nodes (see per-node code/stage/error in result.results), then re-run mesh_refine_batch for the remaining nodes.';
3218
- const normalizedResult = {
3219
- ...result,
3220
- batch: true,
3221
- nextStep,
3222
- ...(summary ? {
3223
- convergenceStatus: allConverged ? 'all_converged' : 'partial',
3224
- } : {}),
3225
- };
3226
-
3227
- const terminalHandle = this.buildRefineBatchJobHandle({
3228
- meshId: handle.meshId,
3229
- nodeIds: handle.nodeIds,
3230
- order: handle.order,
3231
- status: isTerminalSuccess ? 'completed' : 'failed',
3232
- startedAt: handle.startedAt,
3233
- completedAt,
3234
- jobId: handle.jobId,
3235
- interactionId: handle.interactionId,
3236
- coordinatorDaemonId: handle.targetCoordinatorDaemonId,
3237
- });
3238
- const terminal: MeshRefineBatchTerminalJob = { ...terminalHandle, result: normalizedResult };
3239
- this.terminalRefineBatchJobs.set(key, terminal);
3240
- this.runningRefineBatchJobs.delete(key);
3241
- this.invalidateAggregateMeshStatus(handle.meshId);
3242
- await this.appendRefineBatchJobLedger(isTerminalSuccess ? 'task_completed' : 'task_failed', terminalHandle, normalizedResult);
3243
- this.queueRefineBatchJobEvent(isTerminalSuccess ? 'refine:completed' : 'refine:failed', terminalHandle, normalizedResult);
3244
- }
3245
-
3246
- /**
3247
- * Async entry for the batch Refinery execute path. Mirrors startMeshRefineJob:
3248
- * resolves the plan synchronously (so target/ordering errors and the dry-run shape
3249
- * stay synchronous), then for execute=true registers an in-flight batch job, returns
3250
- * {async:true, status:'accepted', batch:true, ...plan} immediately, and runs the
3251
- * convergence loop in the background — emitting the same terminal refine event.
3252
- * Idempotent: a batch already in flight for this mesh returns the running handle
3253
- * with duplicate:true rather than spawning a second background job.
3254
- */
3255
1061
  private async startMeshRefineBatchJob(meshId: string, requestedNodeIds: string[] | undefined, args: any): Promise<CommandRouterResult> {
3256
- // Resolve the plan up-front. For dry-run this returns the synchronous plan; for
3257
- // execute it returns the same plan shape but we hand convergence to the bg job.
3258
- const plan = await this.batchRefineMeshNodes(meshId, requestedNodeIds, { ...args, dryRun: true, execute: false });
3259
- const planRecord = plan as Record<string, unknown>;
3260
- if (planRecord.success !== true) return plan;
3261
-
3262
- // If the caller actually asked for a dry-run, return the plan as-is (sync).
3263
- if (args?.dryRun === true && args?.execute !== true) return plan;
3264
-
3265
- const order = Array.isArray(planRecord.order) ? (planRecord.order as unknown[]).filter((v): v is string => typeof v === 'string') : [];
3266
- const nodeIds = order.slice();
3267
- if (nodeIds.length === 0) {
3268
- // No convergeable nodes — nothing to dispatch; return the empty plan synchronously.
3269
- return { ...planRecord, success: true, batch: true, dryRun: false, async: false };
3270
- }
3271
-
3272
- const key = this.buildRefineBatchJobKey(meshId);
3273
- const running = this.runningRefineBatchJobs.get(key);
3274
- if (running) return { ...running, duplicate: true };
3275
-
3276
- // Re-resolve the ordered node objects against current membership so the bg job
3277
- // refines real nodes (the plan only carries ids). preferInline matches refine_mesh_node.
3278
- const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
3279
- const mesh = meshRecord?.mesh;
3280
- const allNodes: any[] = Array.isArray(mesh?.nodes) ? mesh.nodes : [];
3281
- const orderedNodes = nodeIds
3282
- .map(id => allNodes.find(n => meshNodeIdMatches(n, id)))
3283
- .filter((n): n is any => !!n);
3284
- if (orderedNodes.length === 0) {
3285
- return { success: false, error: 'Batch nodes no longer resolvable in mesh', batch: true };
3286
- }
3287
- const ordering = {
3288
- order,
3289
- rationale: planRecord.orderingRationale,
3290
- };
3291
-
3292
- const coordinatorDaemonId = typeof args?.coordinatorDaemonId === 'string' && args.coordinatorDaemonId.trim()
3293
- ? args.coordinatorDaemonId.trim()
3294
- : (this.deps.statusInstanceId || undefined);
3295
- const handle = this.buildRefineBatchJobHandle({ meshId, nodeIds, order, coordinatorDaemonId });
3296
- this.runningRefineBatchJobs.set(key, handle);
3297
- await this.appendRefineBatchJobLedger('task_dispatched', handle);
3298
- this.queueRefineBatchJobEvent('refine:accepted', handle);
3299
-
3300
- setImmediate(() => {
3301
- void this.finishMeshRefineBatchJob(handle, orderedNodes, ordering, args);
3302
- });
3303
-
3304
- // Return the accepted handle plus the plan so the coordinator sees the target set.
3305
- return {
3306
- ...handle,
3307
- order,
3308
- orderingRationale: planRecord.orderingRationale,
3309
- plan: planRecord.plan,
3310
- note: 'Batch convergence accepted and running in the background. Completion/failure (with per-node results) will be delivered as a terminal refine event; do not poll repeatedly.',
3311
- };
3312
- }
3313
-
3314
- private async finishMeshRefineJob(handle: MeshRefineJobHandle, args: any): Promise<void> {
3315
- const key = this.buildRefineJobKey(handle.meshId, handle.targetNodeId);
3316
- let result: Record<string, unknown>;
3317
- try {
3318
- result = await this.executeMeshRefineNodeSynchronously(handle.meshId, handle.targetNodeId, args) as Record<string, unknown>;
3319
- } catch (e: any) {
3320
- result = { success: false, error: e?.message || String(e) };
3321
- }
3322
- const completedAt = new Date().toISOString();
3323
-
3324
- // B1: Discriminated terminal status — do not rely solely on result.success.
3325
- // Map known failure codes to structured terminal kinds.
3326
- type RefineTerminalKind = 'completed' | 'blocked_review' | 'validation_failed' | 'submodule_reachability_failed' | 'merge_failed' | 'cleanup_failed';
3327
- const refineCode = typeof result.code === 'string' ? result.code : '';
3328
- const refineTerminalKind: RefineTerminalKind = result.success === true
3329
- ? 'completed'
3330
- : refineCode === 'blocked_review'
3331
- ? 'blocked_review'
3332
- : refineCode === 'validation_failed' || refineCode === 'validation_dependencies_missing'
3333
- ? 'validation_failed'
3334
- : refineCode === 'submodule_reachability_failed'
3335
- ? 'submodule_reachability_failed'
3336
- : refineCode === 'merge_failed' || refineCode === 'patch_equivalence_failed' || refineCode === 'needs_rebase' || refineCode === 'needs_rebase_with_conflicts'
3337
- ? 'merge_failed'
3338
- : refineCode === 'cleanup_failed'
3339
- ? 'cleanup_failed'
3340
- : 'merge_failed'; // fallback for unclassified failures
3341
- const isTerminalSuccess = refineTerminalKind === 'completed';
3342
-
3343
- // Build structured blocker context for task_failed ledger entries so coordinators
3344
- // can inspect the failure cause without parsing free-form error strings.
3345
- const blockerContext: Record<string, unknown> | undefined = isTerminalSuccess ? undefined : (() => {
3346
- const code = typeof result.code === 'string' ? result.code : refineTerminalKind;
3347
- const stage = refineTerminalKind === 'validation_failed' ? 'validation'
3348
- : refineTerminalKind === 'submodule_reachability_failed' ? 'submodule_reachability'
3349
- : refineCode === 'patch_equivalence_failed' ? 'patch_equivalence'
3350
- : refineCode === 'needs_rebase' || refineCode === 'needs_rebase_with_conflicts' ? 'patch_equivalence'
3351
- : refineTerminalKind === 'merge_failed' ? 'merge'
3352
- : refineTerminalKind === 'cleanup_failed' ? 'cleanup'
3353
- : 'unknown';
3354
- const ctx: Record<string, unknown> = {
3355
- stage,
3356
- reason: code,
3357
- terminalKind: refineTerminalKind,
3358
- };
3359
- if (typeof result.error === 'string') ctx.error = result.error;
3360
- if (typeof result.blockedReason === 'string') ctx.blockedReason = result.blockedReason;
3361
- // Patch equivalence details
3362
- if (stage === 'patch_equivalence' && result.patchEquivalence) {
3363
- const pe = result.patchEquivalence as Record<string, unknown>;
3364
- ctx.details = {
3365
- expectedPatchId: pe.expectedPatchId,
3366
- actualPatchId: pe.actualPatchId,
3367
- status: pe.status,
3368
- actionableHint: pe.actionableHint,
3369
- error: pe.error,
3370
- };
3371
- }
3372
- // Submodule reachability details
3373
- if (stage === 'submodule_reachability' && Array.isArray(result.unreachableSubmoduleCommits)) {
3374
- ctx.details = {
3375
- unreachableCount: (result.unreachableSubmoduleCommits as unknown[]).length,
3376
- paths: (result.unreachableSubmoduleCommits as Array<Record<string, unknown>>).map(e => e.path),
3377
- autoPublishAllowed: (result.unreachableSubmoduleCommits as Array<Record<string, unknown>>)[0]?.autoPublishAllowed,
3378
- };
3379
- }
3380
- // Validation details
3381
- if (stage === 'validation' && result.validationSummary) {
3382
- const vs = result.validationSummary as Record<string, unknown>;
3383
- ctx.details = {
3384
- failureCode: vs.failureCode,
3385
- commandsRun: Array.isArray(vs.commandsRun) ? vs.commandsRun.length : undefined,
3386
- };
3387
- }
3388
- return ctx;
3389
- })();
3390
-
3391
- const normalizedResult = {
3392
- ...result,
3393
- terminalKind: refineTerminalKind,
3394
- ...(blockerContext ? { blockerContext } : {}),
3395
- ...(result.nextStep === undefined && !isTerminalSuccess ? {
3396
- nextStep: refineTerminalKind === 'blocked_review'
3397
- ? 'Request user review/approval before attempting to merge again.'
3398
- : refineTerminalKind === 'validation_failed'
3399
- ? 'Fix failing tests or configure validation.bootstrapCommands and retry mesh_refine_node.'
3400
- : refineTerminalKind === 'submodule_reachability_failed'
3401
- ? 'Push unreachable submodule commits to origin/main, then retry mesh_refine_node.'
3402
- : refineTerminalKind === 'merge_failed'
3403
- ? 'Resolve merge conflicts or patch equivalence issues, then retry mesh_refine_node.'
3404
- : refineTerminalKind === 'cleanup_failed'
3405
- ? 'Manually remove the worktree and retry or use mesh_remove_node.'
3406
- : 'Inspect refineStages for the failing stage and retry.',
3407
- } : {}),
3408
- };
3409
-
3410
- const terminalHandle = this.buildRefineJobHandle({
3411
- meshId: handle.meshId,
3412
- nodeId: handle.targetNodeId,
3413
- status: isTerminalSuccess ? 'completed' : 'failed',
3414
- startedAt: handle.startedAt,
3415
- completedAt,
3416
- jobId: handle.jobId,
3417
- interactionId: handle.interactionId,
3418
- retryOfJobId: handle.retryOfJobId,
3419
- node: { daemonId: handle.targetDaemonId, workspace: handle.workspace },
3420
- coordinatorDaemonId: handle.targetCoordinatorDaemonId,
3421
- });
3422
- const terminal: MeshRefineTerminalJob = { ...terminalHandle, result: normalizedResult };
3423
- this.terminalRefineJobs.set(key, terminal);
3424
- this.runningRefineJobs.delete(key);
3425
- this.invalidateAggregateMeshStatus(handle.meshId);
3426
- await this.appendRefineJobLedger(isTerminalSuccess ? 'task_completed' : 'task_failed', terminalHandle, normalizedResult);
3427
- this.queueRefineJobEvent(isTerminalSuccess ? 'refine:completed' : 'refine:failed', terminalHandle, normalizedResult);
1062
+ return startMeshRefineBatchJob(this, meshId, requestedNodeIds, args);
3428
1063
  }
3429
1064
 
3430
1065
  private async startMeshRefineJob(meshId: string, nodeId: string, args: any): Promise<CommandRouterResult> {
3431
- const key = this.buildRefineJobKey(meshId, nodeId);
3432
- const running = this.runningRefineJobs.get(key);
3433
- if (running) return { ...running, duplicate: true };
3434
- const terminal = this.terminalRefineJobs.get(key);
3435
-
3436
- // preferInline so inline-cache-only clone worktree nodes resolve — same
3437
- // membership authority as clone_mesh_node / get_mesh. Without it refine reads
3438
- // config-first and misses nodes that only live in the inline cache.
3439
- const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
3440
- const mesh = meshRecord?.mesh;
3441
- const node = mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, nodeId));
3442
- if (!node) return { success: false, error: `Node '${nodeId}' not found in mesh` };
3443
- if (!node.isLocalWorktree || !node.workspace) return { success: false, error: `Refinery requires a local worktree node` };
3444
-
3445
- // Capture the caller's coordinator daemon ID so completed/failed events are
3446
- // scoped to that coordinator's pending-events queue and survive daemon restarts.
3447
- const coordinatorDaemonId = typeof args?.coordinatorDaemonId === 'string' && args.coordinatorDaemonId.trim()
3448
- ? args.coordinatorDaemonId.trim()
3449
- : (this.deps.statusInstanceId || undefined);
3450
- const handle = this.buildRefineJobHandle({ meshId, nodeId, node, retryOfJobId: terminal?.jobId, coordinatorDaemonId });
3451
- this.runningRefineJobs.set(key, handle);
3452
- await this.appendRefineJobLedger('task_dispatched', handle);
3453
- this.queueRefineJobEvent('refine:accepted', handle);
3454
-
3455
- setImmediate(() => {
3456
- void this.finishMeshRefineJob(handle, args);
3457
- });
3458
-
3459
- return handle;
1066
+ return startMeshRefineJob(this, meshId, nodeId, args);
3460
1067
  }
3461
1068
 
3462
1069
  // ─── Daemon-level command core ───────────────────