@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.
- package/dist/commands/router-refine.d.ts +137 -0
- package/dist/commands/router-worktree-cleanup.d.ts +133 -0
- package/dist/commands/router.d.ts +103 -151
- package/dist/index.js +2962 -2877
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2962 -2877
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-event-classify.d.ts +2 -0
- package/dist/mesh/worktree-bootstrap-config.d.ts +20 -0
- package/package.json +3 -3
- package/src/commands/med-family/cli-agent.ts +26 -23
- package/src/commands/router-refine.ts +1698 -0
- package/src/commands/router-worktree-cleanup.ts +870 -0
- package/src/commands/router.ts +59 -2452
- package/src/mesh/mesh-active-work.ts +11 -2
- package/src/mesh/mesh-event-classify.ts +22 -0
- package/src/mesh/mesh-event-forwarding.ts +36 -0
- package/src/mesh/mesh-queue-assignment.ts +38 -11
- package/src/mesh/mesh-reconcile-loop.ts +174 -4
- package/src/mesh/mesh-runtime-store.ts +27 -13
- package/src/mesh/worktree-bootstrap-config.ts +19 -0
package/src/commands/router.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
318
|
+
runningRefineBatchJobs = new Map<string, MeshRefineBatchJobHandle>();
|
|
299
319
|
/** Terminal async batch Refinery jobs preserve the last batch outcome for late readers. */
|
|
300
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1420
|
-
return
|
|
948
|
+
isCompletedHostedSession(record: any): boolean {
|
|
949
|
+
return isCompletedHostedSession(this, record);
|
|
1421
950
|
}
|
|
1422
951
|
|
|
1423
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1806
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ───────────────────
|