@triflux/remote 10.1.0 → 10.26.0
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/hub/pipe.mjs +239 -121
- package/hub/public/dashboard.html +61 -0
- package/hub/server.mjs +2179 -709
- package/hub/store-adapter.mjs +59 -26
- package/hub/store.mjs +356 -153
- package/hub/team/agent-map.json +26 -7
- package/hub/team/ansi.mjs +245 -146
- package/hub/team/backend.mjs +78 -17
- package/hub/team/build-worker-prompt.mjs +73 -0
- package/hub/team/check-mcp-hub.mjs +49 -0
- package/hub/team/claude-daemon-control.mjs +1130 -0
- package/hub/team/claude-native-bridge.mjs +1011 -0
- package/hub/team/claude-session-projection.mjs +74 -0
- package/hub/team/cli/commands/attach.mjs +57 -37
- package/hub/team/cli/commands/control.mjs +29 -8
- package/hub/team/cli/commands/debug.mjs +95 -74
- package/hub/team/cli/commands/focus.mjs +80 -53
- package/hub/team/cli/commands/interrupt.mjs +18 -6
- package/hub/team/cli/commands/kill.mjs +16 -5
- package/hub/team/cli/commands/list.mjs +31 -24
- package/hub/team/cli/commands/send.mjs +19 -6
- package/hub/team/cli/commands/start/index.mjs +151 -33
- package/hub/team/cli/commands/start/parse-args.mjs +97 -13
- package/hub/team/cli/commands/start/start-headless.mjs +155 -37
- package/hub/team/cli/commands/start/start-in-process.mjs +50 -40
- package/hub/team/cli/commands/start/start-mux.mjs +122 -73
- package/hub/team/cli/commands/start/start-wt.mjs +86 -69
- package/hub/team/cli/commands/status.mjs +43 -14
- package/hub/team/cli/commands/stop.mjs +11 -4
- package/hub/team/cli/commands/task.mjs +8 -3
- package/hub/team/cli/commands/tasks.mjs +13 -13
- package/hub/team/cli/help.mjs +1 -1
- package/hub/team/cli/index.mjs +2 -2
- package/hub/team/cli/manifest.mjs +38 -8
- package/hub/team/cli/render.mjs +52 -30
- package/hub/team/cli/services/attach-fallback.mjs +74 -54
- package/hub/team/cli/services/hub-client.mjs +44 -16
- package/hub/team/cli/services/member-selector.mjs +37 -30
- package/hub/team/cli/services/native-control.mjs +144 -116
- package/hub/team/cli/services/runtime-mode.mjs +61 -4
- package/hub/team/cli/services/state-store.mjs +66 -9
- package/hub/team/cli/services/task-model.mjs +40 -30
- package/hub/team/codex-review.mjs +521 -0
- package/hub/team/conductor-mesh-bridge.mjs +25 -24
- package/hub/team/conductor-registry.mjs +121 -0
- package/hub/team/conductor.mjs +1379 -671
- package/hub/team/consensus-meta.mjs +176 -0
- package/hub/team/dashboard-anchor.mjs +4 -5
- package/hub/team/dashboard-layout.mjs +3 -1
- package/hub/team/dashboard-open.mjs +20 -118
- package/hub/team/dashboard.mjs +76 -28
- package/hub/team/event-log.mjs +18 -10
- package/hub/team/execution-mode.mjs +256 -0
- package/hub/team/extract-completion-payload.mjs +110 -0
- package/hub/team/git-preflight.mjs +286 -0
- package/hub/team/handoff.mjs +49 -18
- package/hub/team/headless-bridge-session.mjs +17 -0
- package/hub/team/headless.mjs +2213 -1156
- package/hub/team/health-probe.mjs +232 -69
- package/hub/team/interactive-native-launcher.mjs +106 -0
- package/hub/team/interactive-native-worker.mjs +80 -0
- package/hub/team/interactive-tui-transport.mjs +465 -0
- package/hub/team/launcher-template.mjs +49 -17
- package/hub/team/lead-control.mjs +91 -4
- package/hub/team/mcp-selector.mjs +145 -0
- package/hub/team/native-supervisor.mjs +90 -26
- package/hub/team/native.mjs +120 -42
- package/hub/team/nativeProxy.mjs +184 -113
- package/hub/team/notify.mjs +147 -78
- package/hub/team/orchestrator.mjs +173 -161
- package/hub/team/pane.mjs +43 -20
- package/hub/team/process-cleanup.mjs +69 -25
- package/hub/team/psmux.mjs +1022 -281
- package/hub/team/recovery-store.mjs +149 -0
- package/hub/team/remote-probe.mjs +68 -52
- package/hub/team/remote-session.mjs +131 -59
- package/hub/team/remote-watcher.mjs +61 -33
- package/hub/team/retry-state-machine.mjs +336 -0
- package/hub/team/routing.mjs +51 -25
- package/hub/team/runtime-strategy.mjs +200 -0
- package/hub/team/schemas/worker-completion.json +39 -0
- package/hub/team/sentinel-capture.mjs +127 -0
- package/hub/team/session-sync.mjs +153 -6
- package/hub/team/session.mjs +574 -611
- package/hub/team/shared.mjs +13 -13
- package/hub/team/staleState.mjs +72 -30
- package/hub/team/swarm-cli.mjs +390 -0
- package/hub/team/swarm-hypervisor.mjs +1544 -193
- package/hub/team/swarm-intent.mjs +216 -0
- package/hub/team/swarm-locks.mjs +140 -25
- package/hub/team/swarm-planner.mjs +121 -21
- package/hub/team/swarm-preflight.mjs +319 -0
- package/hub/team/swarm-reconciler.mjs +128 -23
- package/hub/team/synapse-cli.mjs +458 -0
- package/hub/team/synapse-http.mjs +60 -0
- package/hub/team/synapse-registry.mjs +329 -0
- package/hub/team/terminal-opener.mjs +216 -0
- package/hub/team/tui-core.mjs +292 -0
- package/hub/team/tui-lite.mjs +237 -148
- package/hub/team/tui-remote-adapter.mjs +18 -34
- package/hub/team/tui-synapse.mjs +223 -0
- package/hub/team/tui-viewer.mjs +108 -48
- package/hub/team/tui-widgets.mjs +262 -0
- package/hub/team/tui.mjs +931 -439
- package/hub/team/worker-completion-validator.mjs +84 -0
- package/hub/team/worker-sandbox.mjs +97 -0
- package/hub/team/worker-signal.mjs +263 -0
- package/hub/team/worker-signal.types.d.ts +52 -0
- package/hub/team/worktree-lifecycle.mjs +517 -63
- package/hub/team/wt-manager.mjs +297 -26
- package/hub/tools.mjs +477 -252
- package/hub/tray.mjs +411 -368
- package/hub/workers/antigravity-route-worker.mjs +169 -0
- package/hub/workers/claude-worker.mjs +162 -118
- package/hub/workers/codex-app-server-worker.mjs +1015 -0
- package/hub/workers/codex-mcp.mjs +724 -507
- package/hub/workers/delegator-mcp.mjs +570 -499
- package/hub/workers/factory.mjs +103 -21
- package/hub/workers/gemini-worker.mjs +126 -284
- package/hub/workers/interface.mjs +24 -2
- package/hub/workers/lib/jsonrpc-stdio.mjs +463 -0
- package/hub/workers/worker-utils.mjs +23 -17
- package/package.json +4 -3
- package/scripts/lib/agent-json.mjs +27 -0
- package/scripts/lib/async.mjs +174 -0
- package/scripts/lib/claudemd-scanner.mjs +223 -0
- package/scripts/lib/cli-agy.mjs +62 -0
- package/scripts/lib/cli-claude.mjs +78 -0
- package/scripts/lib/cli-codex.mjs +199 -0
- package/scripts/lib/cli-gemini.mjs +37 -0
- package/scripts/lib/codex-recovery.sh +50 -0
- package/scripts/lib/context.mjs +67 -0
- package/scripts/lib/cross-review-utils.mjs +59 -0
- package/scripts/lib/doctor-env-checks.mjs +121 -0
- package/scripts/lib/dynamic-route-cli.mjs +107 -0
- package/scripts/lib/env-probe.mjs +269 -0
- package/scripts/lib/env.mjs +14 -0
- package/scripts/lib/gemini-profiles.mjs +119 -0
- package/scripts/lib/handoff.mjs +187 -0
- package/scripts/lib/hook-utils.mjs +16 -0
- package/scripts/lib/hub.mjs +48 -0
- package/scripts/lib/keyword-rules.mjs +195 -0
- package/scripts/lib/logger.mjs +105 -0
- package/scripts/lib/mcp-filter.mjs +883 -0
- package/scripts/lib/mcp-gateway-health-check.mjs +159 -0
- package/scripts/lib/mcp-gateway-servers.mjs +70 -0
- package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -0
- package/scripts/lib/mcp-guard-engine.mjs +2120 -0
- package/scripts/lib/mcp-health.mjs +773 -0
- package/scripts/lib/mcp-manifest.mjs +89 -0
- package/scripts/lib/mcp-server-catalog.mjs +355 -0
- package/scripts/lib/pid.mjs +63 -0
- package/scripts/lib/process-utils.mjs +26 -0
- package/scripts/lib/psmux-info.mjs +224 -0
- package/scripts/lib/quota.mjs +47 -0
- package/scripts/lib/remote-spawn-transfer.mjs +226 -0
- package/scripts/lib/skill-state.mjs +220 -0
- package/scripts/lib/skill-template.mjs +375 -0
- package/scripts/lib/team.mjs +75 -0
- package/scripts/lib/timeout.mjs +66 -0
- package/scripts/lib/tmp.mjs +26 -0
- package/scripts/lib/toml.mjs +113 -0
|
@@ -10,53 +10,177 @@
|
|
|
10
10
|
// F4: File lease violation → revert worker changes, flag shard as failed
|
|
11
11
|
// F5: Merge conflict → retry integration with conflict resolution
|
|
12
12
|
|
|
13
|
-
import {
|
|
14
|
-
import
|
|
15
|
-
import {
|
|
13
|
+
import { execFile } from "node:child_process";
|
|
14
|
+
import crypto from "node:crypto";
|
|
15
|
+
import { EventEmitter } from "node:events";
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import {
|
|
19
|
+
createDynamicRouter,
|
|
20
|
+
isDynamicRoutingEnabled,
|
|
21
|
+
} from "@triflux/core/hub/dynamic-routing-engine.mjs";
|
|
22
|
+
import { cleanupShardProcesses } from "@triflux/core/hub/lib/process-utils.mjs";
|
|
23
|
+
import { getHostConfig } from "@triflux/core/hub/lib/ssh-command.mjs";
|
|
24
|
+
import { buildWorkerPrompt } from "./build-worker-prompt.mjs";
|
|
25
|
+
import { registerSwarmShard } from "./claude-native-bridge.mjs";
|
|
26
|
+
import { createConductor, STATES } from "./conductor.mjs";
|
|
27
|
+
import { ensureConductorRegistry } from "./conductor-registry.mjs";
|
|
28
|
+
import { createEventLog } from "./event-log.mjs";
|
|
29
|
+
import { preserveWorktreePatch } from "./recovery-store.mjs";
|
|
30
|
+
import { probeRemoteEnv, resolveRemoteDir } from "./remote-session.mjs";
|
|
31
|
+
import { createSwarmLocks } from "./swarm-locks.mjs";
|
|
32
|
+
import { validateWorkerCompletion } from "./worker-completion-validator.mjs";
|
|
33
|
+
import {
|
|
34
|
+
cleanupWorktree,
|
|
35
|
+
EXPECTED_WORKTREE_DELETIONS,
|
|
36
|
+
ensureWorktree,
|
|
37
|
+
extractDirtyFiles,
|
|
38
|
+
fetchRemoteShard,
|
|
39
|
+
prepareIntegrationBranch,
|
|
40
|
+
pruneWorktree,
|
|
41
|
+
rebaseShardOntoIntegration,
|
|
42
|
+
} from "./worktree-lifecycle.mjs";
|
|
43
|
+
|
|
44
|
+
let ensureHubAliveFn = null;
|
|
45
|
+
let getHubInfoFn = null;
|
|
46
|
+
try {
|
|
47
|
+
({ ensureHubAlive: ensureHubAliveFn, getHubInfo: getHubInfoFn } =
|
|
48
|
+
await import("./cli/services/hub-client.mjs"));
|
|
49
|
+
} catch {
|
|
50
|
+
// hub-client 미설치 환경 — Hub 수동 관리 필요
|
|
51
|
+
}
|
|
16
52
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
53
|
+
let importedCreateRegistry = null;
|
|
54
|
+
let meshRegistryImportError = null;
|
|
55
|
+
try {
|
|
56
|
+
({ createRegistry: importedCreateRegistry } = await import(
|
|
57
|
+
"@triflux/core/mesh/mesh-registry.mjs"
|
|
58
|
+
));
|
|
59
|
+
} catch (err) {
|
|
60
|
+
meshRegistryImportError = err;
|
|
61
|
+
}
|
|
23
62
|
|
|
24
63
|
// ── Swarm states ──────────────────────────────────────────────
|
|
25
64
|
|
|
26
65
|
export const SWARM_STATES = Object.freeze({
|
|
27
|
-
PLANNING:
|
|
28
|
-
LAUNCHING:
|
|
29
|
-
RUNNING:
|
|
30
|
-
INTEGRATING:
|
|
31
|
-
VALIDATING:
|
|
32
|
-
COMPLETED:
|
|
33
|
-
FAILED:
|
|
66
|
+
PLANNING: "planning",
|
|
67
|
+
LAUNCHING: "launching",
|
|
68
|
+
RUNNING: "running",
|
|
69
|
+
INTEGRATING: "integrating",
|
|
70
|
+
VALIDATING: "validating",
|
|
71
|
+
COMPLETED: "completed",
|
|
72
|
+
FAILED: "failed",
|
|
34
73
|
});
|
|
35
74
|
|
|
36
75
|
// ── Failure mode classification ───────────────────────────────
|
|
37
76
|
|
|
38
77
|
const FAILURE_MODES = Object.freeze({
|
|
39
|
-
F1_CRASH:
|
|
40
|
-
F2_RATE_LIMIT:
|
|
41
|
-
F3_STALL:
|
|
42
|
-
F4_LEASE_VIOLATION:
|
|
43
|
-
F5_MERGE_CONFLICT:
|
|
78
|
+
F1_CRASH: "F1_crash",
|
|
79
|
+
F2_RATE_LIMIT: "F2_rate_limit",
|
|
80
|
+
F3_STALL: "F3_stall",
|
|
81
|
+
F4_LEASE_VIOLATION: "F4_lease_violation",
|
|
82
|
+
F5_MERGE_CONFLICT: "F5_merge_conflict",
|
|
83
|
+
F6_NO_COMMIT: "F6_no_commit",
|
|
84
|
+
F7_WORKER_DID_NOT_COMMIT: "F7_worker_did_not_commit",
|
|
44
85
|
});
|
|
45
86
|
|
|
46
87
|
const FALLBACK_AGENTS = Object.freeze({
|
|
47
|
-
codex:
|
|
48
|
-
gemini:
|
|
49
|
-
claude:
|
|
88
|
+
codex: "gemini",
|
|
89
|
+
gemini: "codex",
|
|
90
|
+
claude: "codex",
|
|
50
91
|
});
|
|
51
92
|
|
|
93
|
+
function createNoopRegistry() {
|
|
94
|
+
return Object.freeze({
|
|
95
|
+
register() {},
|
|
96
|
+
unregister() {},
|
|
97
|
+
discover() {
|
|
98
|
+
return [];
|
|
99
|
+
},
|
|
100
|
+
getAgent() {
|
|
101
|
+
return null;
|
|
102
|
+
},
|
|
103
|
+
listAll() {
|
|
104
|
+
return [];
|
|
105
|
+
},
|
|
106
|
+
clear() {},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function createSharedRegistry(factory) {
|
|
111
|
+
if (typeof factory !== "function") {
|
|
112
|
+
return {
|
|
113
|
+
registry: createNoopRegistry(),
|
|
114
|
+
fallbackReason: meshRegistryImportError
|
|
115
|
+
? `mesh_import_failed:${meshRegistryImportError.message}`
|
|
116
|
+
: "mesh_registry_unavailable",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
return { registry: factory(), fallbackReason: null };
|
|
122
|
+
} catch (err) {
|
|
123
|
+
return {
|
|
124
|
+
registry: createNoopRegistry(),
|
|
125
|
+
fallbackReason: `mesh_registry_init_failed:${err.message}`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function hasOwnDep(deps, key) {
|
|
131
|
+
return Object.hasOwn(deps, key);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function formatHubHostForUrl(host) {
|
|
135
|
+
return host.includes(":") ? `[${host}]` : host;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function resolveHubStatusUrl(hub) {
|
|
139
|
+
if (!hub) return null;
|
|
140
|
+
|
|
141
|
+
if (typeof hub.url === "string" && hub.url.trim()) {
|
|
142
|
+
try {
|
|
143
|
+
const url = new URL(hub.url);
|
|
144
|
+
url.pathname = "/status";
|
|
145
|
+
url.search = "";
|
|
146
|
+
url.hash = "";
|
|
147
|
+
return url.toString();
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const host = typeof hub.host === "string" ? hub.host.trim() : "";
|
|
154
|
+
const port = Number(hub.port);
|
|
155
|
+
if (!host || !Number.isFinite(port) || port <= 0) return null;
|
|
156
|
+
return `http://${formatHubHostForUrl(host)}:${port}/status`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isTruthyConfigValue(value) {
|
|
160
|
+
if (typeof value === "boolean") return value;
|
|
161
|
+
return /^(true|yes|1|interactive)$/i.test(String(value || "").trim());
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isInteractiveNativeBridgeShard(shard) {
|
|
165
|
+
return (
|
|
166
|
+
isTruthyConfigValue(shard?.interactive) ||
|
|
167
|
+
isTruthyConfigValue(shard?.nativeBridgeInteractive) ||
|
|
168
|
+
String(shard?.workerType || "").toLowerCase() === "interactive"
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
52
172
|
/**
|
|
53
173
|
* Create a swarm hypervisor.
|
|
54
174
|
* @param {object} opts
|
|
55
175
|
* @param {string} opts.workdir — repository root / working directory
|
|
56
176
|
* @param {string} opts.logsDir — base directory for all logs
|
|
177
|
+
* @param {string} [opts.baseBranch='main'] — base branch for shard worktrees
|
|
178
|
+
* @param {string} [opts.runId=`swarm-${Date.now()}`] — logical swarm run id
|
|
57
179
|
* @param {number} [opts.maxRestarts=2] — per-shard max restarts
|
|
180
|
+
* @param {boolean} [opts.nativeBridge=false] — expose local shards in Claude agents
|
|
58
181
|
* @param {number} [opts.graceMs=10000] — conductor shutdown grace period
|
|
59
182
|
* @param {number} [opts.integrationTimeoutMs=60000] — max time for integration phase
|
|
183
|
+
* @param {boolean} [opts.keepFailedWorktrees=false] — preserve failed shard worktrees for debugging
|
|
60
184
|
* @param {object} [opts.probeOpts] — health probe overrides
|
|
61
185
|
* @param {object} [opts.deps] — dependency injection for testing
|
|
62
186
|
* @returns {SwarmHypervisor}
|
|
@@ -65,20 +189,47 @@ export function createSwarmHypervisor(opts) {
|
|
|
65
189
|
const {
|
|
66
190
|
workdir,
|
|
67
191
|
logsDir,
|
|
192
|
+
baseBranch = "main",
|
|
193
|
+
runId = `swarm-${Date.now()}`,
|
|
68
194
|
maxRestarts = 2,
|
|
195
|
+
nativeBridge = false,
|
|
69
196
|
graceMs = 10_000,
|
|
197
|
+
keepFailedWorktrees = false,
|
|
70
198
|
_integrationTimeoutMs = 60_000,
|
|
71
199
|
probeOpts = {},
|
|
72
200
|
_deps = {},
|
|
73
201
|
} = opts;
|
|
74
202
|
|
|
75
|
-
if (!workdir) throw new Error(
|
|
76
|
-
if (!logsDir) throw new Error(
|
|
203
|
+
if (!workdir) throw new Error("workdir is required");
|
|
204
|
+
if (!logsDir) throw new Error("logsDir is required");
|
|
77
205
|
|
|
78
206
|
mkdirSync(logsDir, { recursive: true });
|
|
79
|
-
|
|
207
|
+
ensureConductorRegistry();
|
|
208
|
+
|
|
209
|
+
const createConductorImpl = _deps.createConductor || createConductor;
|
|
210
|
+
const createRegistryImpl = _deps.createRegistry || importedCreateRegistry;
|
|
211
|
+
const ensureWorktreeImpl = _deps.ensureWorktree || ensureWorktree;
|
|
212
|
+
const prepareIntegrationBranchImpl =
|
|
213
|
+
_deps.prepareIntegrationBranch || prepareIntegrationBranch;
|
|
214
|
+
const rebaseShardOntoIntegrationImpl =
|
|
215
|
+
_deps.rebaseShardOntoIntegration || rebaseShardOntoIntegration;
|
|
216
|
+
const cleanupWorktreeImpl =
|
|
217
|
+
_deps.cleanupWorktree || cleanupWorktree || pruneWorktree;
|
|
218
|
+
const cleanupShardProcessesImpl =
|
|
219
|
+
_deps.cleanupShardProcesses || cleanupShardProcesses;
|
|
220
|
+
const registerSwarmShardImpl = _deps.registerSwarmShard || registerSwarmShard;
|
|
221
|
+
const preserveWorktreePatchImpl =
|
|
222
|
+
_deps.preserveWorktreePatch || preserveWorktreePatch;
|
|
223
|
+
const ensureHubAliveImpl = hasOwnDep(_deps, "ensureHubAlive")
|
|
224
|
+
? _deps.ensureHubAlive
|
|
225
|
+
: ensureHubAliveFn;
|
|
226
|
+
const getHubInfoImpl = hasOwnDep(_deps, "getHubInfo")
|
|
227
|
+
? _deps.getHubInfo
|
|
228
|
+
: getHubInfoFn;
|
|
80
229
|
const emitter = new EventEmitter();
|
|
81
|
-
const eventLog = createEventLog(join(logsDir,
|
|
230
|
+
const eventLog = createEventLog(join(logsDir, "swarm-events.jsonl"));
|
|
231
|
+
const { registry: sharedRegistry, fallbackReason: meshRegistryFallback } =
|
|
232
|
+
createSharedRegistry(createRegistryImpl);
|
|
82
233
|
|
|
83
234
|
let state = SWARM_STATES.PLANNING;
|
|
84
235
|
let plan = null;
|
|
@@ -90,38 +241,456 @@ export function createSwarmHypervisor(opts) {
|
|
|
90
241
|
/** @type {Map<string, { conductor, shardConfig }>} redundant workers for critical shards */
|
|
91
242
|
const redundantWorkers = new Map();
|
|
92
243
|
|
|
93
|
-
|
|
94
|
-
const
|
|
244
|
+
/** @type {Set<string>} shards that have fully completed (not just launched) */
|
|
245
|
+
const completedShards = new Set();
|
|
246
|
+
const cleanedWorktreePaths = new Set();
|
|
247
|
+
const processCleanupTotals = {
|
|
248
|
+
totalKilled: 0,
|
|
249
|
+
byCategory: {},
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const results = new Map(); // shardName → validated result
|
|
253
|
+
const failures = new Map(); // shardName → failure info
|
|
254
|
+
let integrationResult = null;
|
|
255
|
+
let resolveIntegrationPromise = null;
|
|
256
|
+
let integrationPromiseState = {
|
|
257
|
+
state: "idle",
|
|
258
|
+
startedAt: null,
|
|
259
|
+
settledAt: null,
|
|
260
|
+
partial: false,
|
|
261
|
+
integrated: [],
|
|
262
|
+
failed: [],
|
|
263
|
+
integrationFailures: [],
|
|
264
|
+
skipped: [],
|
|
265
|
+
integrationBranch: null,
|
|
266
|
+
report: [],
|
|
267
|
+
processCleanup: {
|
|
268
|
+
totalKilled: 0,
|
|
269
|
+
byCategory: {},
|
|
270
|
+
},
|
|
271
|
+
error: null,
|
|
272
|
+
};
|
|
273
|
+
const integrationPromise = new Promise((resolve) => {
|
|
274
|
+
resolveIntegrationPromise = resolve;
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
if (meshRegistryFallback) {
|
|
278
|
+
eventLog.append("mesh_registry_fallback", { reason: meshRegistryFallback });
|
|
279
|
+
}
|
|
95
280
|
|
|
96
281
|
// ── State machine ───────────────────────────────────────────
|
|
97
282
|
|
|
98
|
-
function setState(next, reason =
|
|
283
|
+
function setState(next, reason = "") {
|
|
99
284
|
const prev = state;
|
|
100
285
|
state = next;
|
|
101
|
-
eventLog.append(
|
|
102
|
-
emitter.emit(
|
|
286
|
+
eventLog.append("swarm_state", { from: prev, to: next, reason });
|
|
287
|
+
emitter.emit("stateChange", { from: prev, to: next, reason });
|
|
288
|
+
// Best-effort phase tracking via phase-manager
|
|
289
|
+
void writeSwarmPhaseSafely(
|
|
290
|
+
plan?.runId || plan?.id || `swarm-${Date.now()}`,
|
|
291
|
+
next,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function writeSwarmPhaseSafely(runId, swarmState) {
|
|
296
|
+
if (!runId) return;
|
|
297
|
+
const mapping = {
|
|
298
|
+
[SWARM_STATES.LAUNCHING]: ["Execution", "active"],
|
|
299
|
+
[SWARM_STATES.RUNNING]: ["Execution", "active"],
|
|
300
|
+
[SWARM_STATES.INTEGRATING]: ["Validation", "active"],
|
|
301
|
+
[SWARM_STATES.VALIDATING]: ["Validation", "active"],
|
|
302
|
+
[SWARM_STATES.COMPLETED]: ["Validation", "complete"],
|
|
303
|
+
[SWARM_STATES.FAILED]: ["Validation", "failed"],
|
|
304
|
+
};
|
|
305
|
+
const target = mapping[swarmState];
|
|
306
|
+
if (!target) return;
|
|
307
|
+
try {
|
|
308
|
+
const mod = await import("@triflux/core/hub/lib/phase-manager.mjs");
|
|
309
|
+
if (typeof mod?.writePhase === "function") {
|
|
310
|
+
await mod.writePhase(runId, target[0], target[1]);
|
|
311
|
+
}
|
|
312
|
+
if (typeof mod?.syncToGstack === "function") {
|
|
313
|
+
const slug = process.cwd().split(/[\\/]/).pop();
|
|
314
|
+
await mod.syncToGstack(runId, slug);
|
|
315
|
+
}
|
|
316
|
+
} catch {
|
|
317
|
+
/* silent — phase tracking is best-effort */
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function markIntegrationPromisePending() {
|
|
322
|
+
if (integrationPromiseState.state !== "idle") return;
|
|
323
|
+
integrationPromiseState = {
|
|
324
|
+
...integrationPromiseState,
|
|
325
|
+
state: "pending",
|
|
326
|
+
startedAt: Date.now(),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function settleIntegrationPromise(payload) {
|
|
331
|
+
if (integrationPromiseState.state === "fulfilled" && integrationResult) {
|
|
332
|
+
return integrationResult;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const processCleanup = payload.processCleanup || getProcessCleanupSummary();
|
|
336
|
+
|
|
337
|
+
integrationResult = Object.freeze({
|
|
338
|
+
integrated: Object.freeze([...(payload.integrated || [])]),
|
|
339
|
+
failed: Object.freeze([...(payload.failed || [])]),
|
|
340
|
+
integrationFailures: Object.freeze([
|
|
341
|
+
...(payload.integrationFailures || []),
|
|
342
|
+
]),
|
|
343
|
+
skipped: Object.freeze([...(payload.skipped || [])]),
|
|
344
|
+
integrationBranch: payload.integrationBranch || null,
|
|
345
|
+
report: Object.freeze(
|
|
346
|
+
(payload.report || []).map((row) => Object.freeze({ ...row })),
|
|
347
|
+
),
|
|
348
|
+
results: Object.freeze([...(payload.results || [])]),
|
|
349
|
+
processCleanup: Object.freeze({
|
|
350
|
+
totalKilled: processCleanup.totalKilled || 0,
|
|
351
|
+
byCategory: Object.freeze({ ...(processCleanup.byCategory || {}) }),
|
|
352
|
+
}),
|
|
353
|
+
partial:
|
|
354
|
+
payload.partial ??
|
|
355
|
+
(Array.isArray(payload.failed) && payload.failed.length > 0),
|
|
356
|
+
error: payload.error || null,
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
integrationPromiseState = {
|
|
360
|
+
state: "fulfilled",
|
|
361
|
+
startedAt: integrationPromiseState.startedAt ?? Date.now(),
|
|
362
|
+
settledAt: Date.now(),
|
|
363
|
+
partial: integrationResult.partial,
|
|
364
|
+
integrated: [...integrationResult.integrated],
|
|
365
|
+
failed: [...integrationResult.failed],
|
|
366
|
+
integrationFailures: [...integrationResult.integrationFailures],
|
|
367
|
+
skipped: [...integrationResult.skipped],
|
|
368
|
+
integrationBranch: integrationResult.integrationBranch,
|
|
369
|
+
report: integrationResult.report.map((row) => ({ ...row })),
|
|
370
|
+
processCleanup: integrationResult.processCleanup,
|
|
371
|
+
error: integrationResult.error,
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
resolveIntegrationPromise?.(integrationResult);
|
|
375
|
+
return integrationResult;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function getProcessCleanupSummary() {
|
|
379
|
+
return {
|
|
380
|
+
totalKilled: processCleanupTotals.totalKilled,
|
|
381
|
+
byCategory: { ...processCleanupTotals.byCategory },
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function recordProcessCleanup(result) {
|
|
386
|
+
if (!result) return getProcessCleanupSummary();
|
|
387
|
+
|
|
388
|
+
const killed = Number(result.killed ?? result.totalKilled ?? 0);
|
|
389
|
+
if (Number.isFinite(killed) && killed > 0) {
|
|
390
|
+
processCleanupTotals.totalKilled += killed;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
for (const [category, count] of Object.entries(result.byCategory || {})) {
|
|
394
|
+
const numeric = Number(count);
|
|
395
|
+
if (!Number.isFinite(numeric) || numeric <= 0) continue;
|
|
396
|
+
processCleanupTotals.byCategory[category] =
|
|
397
|
+
(processCleanupTotals.byCategory[category] || 0) + numeric;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return getProcessCleanupSummary();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function normalizeIntegrationReportRow(row) {
|
|
404
|
+
return {
|
|
405
|
+
shard: row.shard,
|
|
406
|
+
strategy: row.strategy || "failed",
|
|
407
|
+
commits:
|
|
408
|
+
Number.isFinite(Number(row.commits)) && row.commits !== null
|
|
409
|
+
? Number(row.commits)
|
|
410
|
+
: null,
|
|
411
|
+
finalSha: row.finalSha || null,
|
|
412
|
+
notes: row.notes || "",
|
|
413
|
+
recoveryPatch: row.recoveryPatch || null,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function git(args, cwd = workdir) {
|
|
418
|
+
return new Promise((resolve, reject) => {
|
|
419
|
+
execFile(
|
|
420
|
+
"git",
|
|
421
|
+
args,
|
|
422
|
+
{ cwd, windowsHide: true, timeout: 30_000 },
|
|
423
|
+
(err, stdout, stderr) => {
|
|
424
|
+
if (err) {
|
|
425
|
+
reject(
|
|
426
|
+
new Error(
|
|
427
|
+
`git ${args[0]} failed: ${stderr?.trim() || err.message}`,
|
|
428
|
+
),
|
|
429
|
+
);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
resolve(stdout.trim());
|
|
433
|
+
},
|
|
434
|
+
);
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function computeAuthoritativeStatus(shardName, workerEntry, sessions) {
|
|
439
|
+
const failureInfo = failures.get(shardName) || null;
|
|
440
|
+
if (failureInfo) {
|
|
441
|
+
return {
|
|
442
|
+
status: "failed",
|
|
443
|
+
reason: failureInfo.mode || failureInfo.reason || "failed",
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (results.has(shardName)) {
|
|
448
|
+
return { status: "done", reason: "integrated" };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (!sessions.length) {
|
|
452
|
+
if (completedShards.has(shardName)) {
|
|
453
|
+
return { status: "done", reason: "awaiting_integration" };
|
|
454
|
+
}
|
|
455
|
+
return { status: "running", reason: "no_sessions" };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const states = sessions.map((session) => session.state);
|
|
459
|
+
if (states.every((stateValue) => stateValue === STATES.COMPLETED)) {
|
|
460
|
+
return { status: "done", reason: "awaiting_integration" };
|
|
461
|
+
}
|
|
462
|
+
if (states.some((stateValue) => stateValue === STATES.INPUT_WAIT)) {
|
|
463
|
+
return { status: "blocked", reason: "user_input" };
|
|
464
|
+
}
|
|
465
|
+
if (states.some((stateValue) => stateValue === STATES.STALLED)) {
|
|
466
|
+
return { status: "stalled", reason: "health_probe_stall" };
|
|
467
|
+
}
|
|
468
|
+
if (
|
|
469
|
+
states.some(
|
|
470
|
+
(stateValue) =>
|
|
471
|
+
stateValue === STATES.FAILED || stateValue === STATES.DEAD,
|
|
472
|
+
)
|
|
473
|
+
) {
|
|
474
|
+
return { status: "failed", reason: "session_terminal" };
|
|
475
|
+
}
|
|
476
|
+
if (
|
|
477
|
+
states.some(
|
|
478
|
+
(stateValue) =>
|
|
479
|
+
stateValue === STATES.STARTING ||
|
|
480
|
+
stateValue === STATES.RESTARTING ||
|
|
481
|
+
stateValue === STATES.INIT,
|
|
482
|
+
)
|
|
483
|
+
) {
|
|
484
|
+
return { status: "running", reason: "starting" };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return { status: "running", reason: "healthy" };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async function collectCommitEvidence(worker, integrationBranch) {
|
|
491
|
+
const branchName = worker?.branchName || null;
|
|
492
|
+
const evidence = {
|
|
493
|
+
branchName,
|
|
494
|
+
integrationBranch,
|
|
495
|
+
commitsAhead: 0,
|
|
496
|
+
dirty: false,
|
|
497
|
+
dirtyFiles: [],
|
|
498
|
+
headCommit: null,
|
|
499
|
+
ok: false,
|
|
500
|
+
error: null,
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
if (!branchName) {
|
|
504
|
+
evidence.error = "missing_branch_name";
|
|
505
|
+
return evidence;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
try {
|
|
509
|
+
evidence.commitsAhead =
|
|
510
|
+
Number.parseInt(
|
|
511
|
+
await git([
|
|
512
|
+
"rev-list",
|
|
513
|
+
"--count",
|
|
514
|
+
`${integrationBranch}..${branchName}`,
|
|
515
|
+
]),
|
|
516
|
+
10,
|
|
517
|
+
) || 0;
|
|
518
|
+
} catch (err) {
|
|
519
|
+
evidence.error = err.message;
|
|
520
|
+
return evidence;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
try {
|
|
524
|
+
evidence.headCommit = await git(["rev-parse", branchName]);
|
|
525
|
+
} catch {
|
|
526
|
+
/* best-effort */
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (worker?.worktreePath && !worker?.shardConfig?.host) {
|
|
530
|
+
try {
|
|
531
|
+
const rawStatus = await git(["status", "--short"], worker.worktreePath);
|
|
532
|
+
// Only explicitly expected lifecycle deletions are filtered. Plugin
|
|
533
|
+
// metadata deletions remain dirty because Codex workers need them.
|
|
534
|
+
evidence.dirtyFiles = extractDirtyFiles(
|
|
535
|
+
rawStatus,
|
|
536
|
+
EXPECTED_WORKTREE_DELETIONS,
|
|
537
|
+
);
|
|
538
|
+
evidence.dirty = evidence.dirtyFiles.length > 0;
|
|
539
|
+
} catch (err) {
|
|
540
|
+
evidence.error = evidence.error || err.message;
|
|
541
|
+
return evidence;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
evidence.ok = evidence.commitsAhead > 0 && evidence.dirty === false;
|
|
546
|
+
return evidence;
|
|
103
547
|
}
|
|
104
548
|
|
|
105
549
|
// ── Worker lifecycle ────────────────────────────────────────
|
|
106
550
|
|
|
551
|
+
function safeSessionPart(value) {
|
|
552
|
+
return String(value || "unknown")
|
|
553
|
+
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
|
554
|
+
.replace(/^-+|-+$/g, "")
|
|
555
|
+
.slice(0, 80);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function buildSwarmSessionId(shard) {
|
|
559
|
+
const short8 = crypto.randomUUID().replace(/-/g, "").slice(0, 8);
|
|
560
|
+
return `swarm-${safeSessionPart(runId)}-${safeSessionPart(shard.name)}-${short8}`;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function getShardDisplayPosition(shard) {
|
|
564
|
+
const shardIndex = plan?.shards?.findIndex(
|
|
565
|
+
(item) => item.name === shard.name,
|
|
566
|
+
);
|
|
567
|
+
if (!plan?.shards?.length || shardIndex < 0) return {};
|
|
568
|
+
return {
|
|
569
|
+
shardIndex: shardIndex + 1,
|
|
570
|
+
shardCount: plan.shards.length,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async function closeNativeBridgeRegistration(worker, shardName, reason) {
|
|
575
|
+
if (worker?.nativeBridgeClosePromise) {
|
|
576
|
+
await worker.nativeBridgeClosePromise;
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
const registration = worker?.nativeBridgeRegistration;
|
|
580
|
+
if (!registration || typeof registration.close !== "function") return;
|
|
581
|
+
worker.nativeBridgeClosePromise = (async () => {
|
|
582
|
+
let closed = false;
|
|
583
|
+
try {
|
|
584
|
+
await registration.close();
|
|
585
|
+
closed = true;
|
|
586
|
+
eventLog.append("native_bridge_registration_closed", {
|
|
587
|
+
shard: shardName,
|
|
588
|
+
reason,
|
|
589
|
+
});
|
|
590
|
+
} catch (err) {
|
|
591
|
+
eventLog.append("native_bridge_registration_close_failed", {
|
|
592
|
+
shard: shardName,
|
|
593
|
+
reason,
|
|
594
|
+
error: err.message,
|
|
595
|
+
});
|
|
596
|
+
} finally {
|
|
597
|
+
worker.nativeBridgeClosePromise = null;
|
|
598
|
+
if (closed) worker.nativeBridgeRegistration = null;
|
|
599
|
+
}
|
|
600
|
+
})();
|
|
601
|
+
await worker.nativeBridgeClosePromise;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async function maybeRegisterNativeBridgeShard(shard, sessionConfig) {
|
|
605
|
+
if (!nativeBridge) return null;
|
|
606
|
+
try {
|
|
607
|
+
const interactive = !shard.host && isInteractiveNativeBridgeShard(shard);
|
|
608
|
+
const registration = await registerSwarmShardImpl({
|
|
609
|
+
sessionId: sessionConfig.id,
|
|
610
|
+
cli: shard.agent,
|
|
611
|
+
role: shard.role || "worker",
|
|
612
|
+
swarmName: runId,
|
|
613
|
+
...getShardDisplayPosition(shard),
|
|
614
|
+
shardName: shard.name,
|
|
615
|
+
cwd: interactive
|
|
616
|
+
? shard.worktreePath || sessionConfig.workdir || workdir
|
|
617
|
+
: workdir,
|
|
618
|
+
host: shard.host || "local",
|
|
619
|
+
interactive,
|
|
620
|
+
_deps: {
|
|
621
|
+
warn(message) {
|
|
622
|
+
emitter.emit("warning", {
|
|
623
|
+
type: "native_bridge_registration_skipped",
|
|
624
|
+
shard: shard.name,
|
|
625
|
+
host: shard.host || "local",
|
|
626
|
+
message,
|
|
627
|
+
});
|
|
628
|
+
},
|
|
629
|
+
},
|
|
630
|
+
});
|
|
631
|
+
eventLog.append(
|
|
632
|
+
registration?.skipped
|
|
633
|
+
? "native_bridge_registration_skipped"
|
|
634
|
+
: "native_bridge_registration",
|
|
635
|
+
{
|
|
636
|
+
shard: shard.name,
|
|
637
|
+
sessionId: sessionConfig.id,
|
|
638
|
+
host: shard.host || "local",
|
|
639
|
+
displayName: registration?.displayName || null,
|
|
640
|
+
},
|
|
641
|
+
);
|
|
642
|
+
return registration;
|
|
643
|
+
} catch (err) {
|
|
644
|
+
eventLog.append("native_bridge_registration_failed", {
|
|
645
|
+
shard: shard.name,
|
|
646
|
+
sessionId: sessionConfig.id,
|
|
647
|
+
host: shard.host || "local",
|
|
648
|
+
error: err.message,
|
|
649
|
+
});
|
|
650
|
+
emitter.emit("warning", {
|
|
651
|
+
type: "native_bridge_registration_failed",
|
|
652
|
+
shard: shard.name,
|
|
653
|
+
host: shard.host || "local",
|
|
654
|
+
error: err.message,
|
|
655
|
+
});
|
|
656
|
+
return null;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
107
660
|
function buildSessionConfig(shard) {
|
|
108
661
|
const config = {
|
|
109
|
-
id:
|
|
662
|
+
id: buildSwarmSessionId(shard),
|
|
110
663
|
agent: shard.agent,
|
|
111
|
-
|
|
112
|
-
|
|
664
|
+
// #125: append Completion Protocol appendix so workers emit a
|
|
665
|
+
// sentinel-framed JSON payload that conductor can reliably capture.
|
|
666
|
+
prompt: buildWorkerPrompt(shard.prompt, { leaseFiles: shard.files }),
|
|
667
|
+
workdir: shard.worktreePath || workdir,
|
|
113
668
|
mcpServers: shard.mcp,
|
|
669
|
+
worktreePath: shard.worktreePath || null,
|
|
670
|
+
branchName: shard.branchName || null,
|
|
671
|
+
// #90: shard는 worktree 격리가 본질. main 직접 커밋 방지 가드 env 주입.
|
|
672
|
+
branchGuard: true,
|
|
114
673
|
};
|
|
115
674
|
|
|
116
|
-
|
|
675
|
+
if (shard.worktreePath) {
|
|
676
|
+
return shard.host
|
|
677
|
+
? {
|
|
678
|
+
...config,
|
|
679
|
+
remote: true,
|
|
680
|
+
host: shard.host,
|
|
681
|
+
}
|
|
682
|
+
: config;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Remote shard: use host's default_dir from hosts.json
|
|
117
686
|
if (shard.host && shard._remoteEnv) {
|
|
118
|
-
const
|
|
687
|
+
const hostConfig = getHostConfig(shard.host);
|
|
688
|
+
const remoteSrc = hostConfig?.default_dir || workdir;
|
|
689
|
+
const remoteDir = resolveRemoteDir(remoteSrc, shard._remoteEnv);
|
|
119
690
|
return {
|
|
120
691
|
...config,
|
|
121
692
|
remote: true,
|
|
122
693
|
host: shard.host,
|
|
123
|
-
sessionName: `swarm-${shard.name}-${Date.now()}`,
|
|
124
|
-
paneTarget: `swarm-${shard.name}-${Date.now()}:0.0`,
|
|
125
694
|
workdir: remoteDir,
|
|
126
695
|
};
|
|
127
696
|
}
|
|
@@ -129,109 +698,308 @@ export function createSwarmHypervisor(opts) {
|
|
|
129
698
|
return config;
|
|
130
699
|
}
|
|
131
700
|
|
|
132
|
-
function launchShard(shard, isRedundant = false) {
|
|
133
|
-
|
|
701
|
+
async function launchShard(shard, isRedundant = false) {
|
|
702
|
+
// Clone frozen shard so we can attach mutable runtime state (_remoteEnv)
|
|
703
|
+
shard = { ...shard };
|
|
704
|
+
let nativeBridgeRegistration = null;
|
|
705
|
+
|
|
706
|
+
const shardLogsDir = join(
|
|
707
|
+
logsDir,
|
|
708
|
+
isRedundant ? `${shard.name}-redundant` : shard.name,
|
|
709
|
+
);
|
|
134
710
|
mkdirSync(shardLogsDir, { recursive: true });
|
|
135
711
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
712
|
+
try {
|
|
713
|
+
// Remote shard: probe environment before conductor creation
|
|
714
|
+
if (shard.host && !shard._remoteEnv) {
|
|
715
|
+
try {
|
|
716
|
+
shard._remoteEnv = probeRemoteEnv(shard.host);
|
|
717
|
+
if (!shard._remoteEnv.claudePath) {
|
|
718
|
+
eventLog.append("remote_probe_no_claude", {
|
|
719
|
+
shard: shard.name,
|
|
720
|
+
host: shard.host,
|
|
721
|
+
});
|
|
722
|
+
if (!isRedundant) {
|
|
723
|
+
failures.set(shard.name, {
|
|
724
|
+
mode: FAILURE_MODES.F1_CRASH,
|
|
725
|
+
reason: `claude not found on ${shard.host}`,
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
return null;
|
|
729
|
+
}
|
|
730
|
+
eventLog.append("remote_probe_ok", {
|
|
731
|
+
shard: shard.name,
|
|
732
|
+
host: shard.host,
|
|
733
|
+
env: shard._remoteEnv,
|
|
734
|
+
});
|
|
735
|
+
} catch (err) {
|
|
736
|
+
eventLog.append("remote_probe_failed", {
|
|
737
|
+
shard: shard.name,
|
|
738
|
+
host: shard.host,
|
|
739
|
+
error: err.message,
|
|
740
|
+
});
|
|
741
|
+
if (!isRedundant) {
|
|
742
|
+
failures.set(shard.name, {
|
|
743
|
+
mode: FAILURE_MODES.F1_CRASH,
|
|
744
|
+
reason: `remote probe failed: ${err.message}`,
|
|
745
|
+
});
|
|
746
|
+
}
|
|
143
747
|
return null;
|
|
144
748
|
}
|
|
145
|
-
eventLog.append('remote_probe_ok', { shard: shard.name, host: shard.host, env: shard._remoteEnv });
|
|
146
|
-
} catch (err) {
|
|
147
|
-
eventLog.append('remote_probe_failed', { shard: shard.name, host: shard.host, error: err.message });
|
|
148
|
-
failures.set(shard.name, { mode: FAILURE_MODES.F1_CRASH, reason: `remote probe failed: ${err.message}` });
|
|
149
|
-
return null;
|
|
150
749
|
}
|
|
151
|
-
}
|
|
152
750
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
751
|
+
// Acquire file leases
|
|
752
|
+
if (!isRedundant) {
|
|
753
|
+
const leaseResult = lockManager.acquire(shard.name, shard.files);
|
|
754
|
+
if (!leaseResult.ok) {
|
|
755
|
+
eventLog.append("lease_denied", {
|
|
756
|
+
shard: shard.name,
|
|
757
|
+
conflicts: leaseResult.conflicts,
|
|
758
|
+
});
|
|
759
|
+
failures.set(shard.name, {
|
|
760
|
+
mode: FAILURE_MODES.F4_LEASE_VIOLATION,
|
|
761
|
+
conflicts: leaseResult.conflicts,
|
|
762
|
+
});
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
160
766
|
|
|
161
|
-
|
|
767
|
+
const worktreeMeta = await ensureWorktreeImpl({
|
|
768
|
+
slug: isRedundant ? `${shard.name}-redundant` : shard.name,
|
|
769
|
+
runId,
|
|
770
|
+
rootDir: workdir,
|
|
771
|
+
baseBranch,
|
|
772
|
+
host: shard.host,
|
|
773
|
+
remoteEnv: shard._remoteEnv,
|
|
774
|
+
});
|
|
775
|
+
shard.worktreePath = worktreeMeta.worktreePath;
|
|
776
|
+
shard.branchName = worktreeMeta.branchName;
|
|
777
|
+
|
|
778
|
+
const conductor = createConductorImpl({
|
|
779
|
+
logsDir: shardLogsDir,
|
|
780
|
+
maxRestarts,
|
|
781
|
+
graceMs,
|
|
782
|
+
probeOpts,
|
|
783
|
+
meshRegistry: sharedRegistry,
|
|
784
|
+
enableMesh: true,
|
|
785
|
+
});
|
|
162
786
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
787
|
+
// NOTE: conductor 는 `session.config.onCompleted({ sessionId })` 형태로
|
|
788
|
+
// 세션별 콜백만 호출한다 (factory 옵션은 무시). 따라서 sessionConfig 에
|
|
789
|
+
// 직접 주입해야 shardCompleted 이벤트가 발화된다. 2026-04-17 세션에서
|
|
790
|
+
// follow-up swarm 이 여기서 hang 했던 원인.
|
|
791
|
+
const sessionConfig = buildSessionConfig(shard);
|
|
792
|
+
nativeBridgeRegistration = await maybeRegisterNativeBridgeShard(
|
|
793
|
+
shard,
|
|
794
|
+
sessionConfig,
|
|
795
|
+
);
|
|
796
|
+
sessionConfig.onCompleted = ({ sessionId, completionPayload } = {}) =>
|
|
797
|
+
handleShardCompleted(
|
|
798
|
+
shard.name,
|
|
799
|
+
sessionId,
|
|
800
|
+
isRedundant,
|
|
801
|
+
completionPayload,
|
|
802
|
+
);
|
|
803
|
+
conductor.spawnSession(sessionConfig);
|
|
804
|
+
|
|
805
|
+
eventLog.append("shard_launched", {
|
|
806
|
+
shard: shard.name,
|
|
807
|
+
agent: shard.agent,
|
|
808
|
+
sessionId: sessionConfig.id,
|
|
809
|
+
isRedundant,
|
|
810
|
+
files: shard.files,
|
|
811
|
+
remote: Boolean(shard.host),
|
|
812
|
+
host: shard.host || null,
|
|
813
|
+
worktreePath: sessionConfig.worktreePath,
|
|
814
|
+
branchName: sessionConfig.branchName,
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
emitter.emit("shardLaunched", {
|
|
818
|
+
shardName: shard.name,
|
|
819
|
+
sessionId: sessionConfig.id,
|
|
820
|
+
agent: shard.agent,
|
|
821
|
+
isRedundant,
|
|
822
|
+
remote: Boolean(shard.host),
|
|
823
|
+
host: shard.host || null,
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
const entry = {
|
|
827
|
+
conductor,
|
|
828
|
+
shardConfig: shard,
|
|
829
|
+
sessionConfig,
|
|
830
|
+
startedAt: Date.now(),
|
|
831
|
+
worktreePath: sessionConfig.worktreePath,
|
|
832
|
+
branchName: sessionConfig.branchName,
|
|
833
|
+
nativeBridgeRegistration,
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
if (isRedundant) {
|
|
837
|
+
redundantWorkers.set(shard.name, entry);
|
|
838
|
+
} else {
|
|
839
|
+
workers.set(shard.name, entry);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// Listen for dead events (F1/F2/F3)
|
|
843
|
+
conductor.on("dead", ({ sessionId, reason }) => {
|
|
844
|
+
handleShardFailed(shard.name, sessionId, reason, isRedundant);
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
return entry;
|
|
848
|
+
} catch (err) {
|
|
849
|
+
if (nativeBridgeRegistration) {
|
|
850
|
+
await closeNativeBridgeRegistration(
|
|
851
|
+
{ nativeBridgeRegistration },
|
|
852
|
+
shard.name,
|
|
853
|
+
"launch_failed",
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
eventLog.append("shard_launch_failed", {
|
|
857
|
+
shard: shard.name,
|
|
858
|
+
isRedundant,
|
|
859
|
+
error: err.message,
|
|
860
|
+
});
|
|
861
|
+
if (!isRedundant) {
|
|
171
862
|
failures.set(shard.name, {
|
|
172
|
-
mode: FAILURE_MODES.
|
|
173
|
-
|
|
863
|
+
mode: FAILURE_MODES.F1_CRASH,
|
|
864
|
+
reason: err.message,
|
|
174
865
|
});
|
|
175
|
-
return null;
|
|
176
866
|
}
|
|
867
|
+
if (!isRedundant) {
|
|
868
|
+
lockManager.release(shard.name);
|
|
869
|
+
}
|
|
870
|
+
if (shard.worktreePath) {
|
|
871
|
+
await maybeCleanupWorktree(
|
|
872
|
+
shard.name,
|
|
873
|
+
{
|
|
874
|
+
worktreePath: shard.worktreePath,
|
|
875
|
+
branchName: shard.branchName,
|
|
876
|
+
},
|
|
877
|
+
shard,
|
|
878
|
+
{
|
|
879
|
+
branchName: shard.branchName,
|
|
880
|
+
force: true,
|
|
881
|
+
failureReason: `launch_failed:${err.message}`,
|
|
882
|
+
},
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
return null;
|
|
177
886
|
}
|
|
887
|
+
}
|
|
178
888
|
|
|
179
|
-
|
|
889
|
+
// ── Completion handling ─────────────────────────────────────
|
|
180
890
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
891
|
+
function handleShardCompleted(
|
|
892
|
+
shardName,
|
|
893
|
+
sessionId,
|
|
894
|
+
isRedundant,
|
|
895
|
+
completionPayload,
|
|
896
|
+
) {
|
|
897
|
+
eventLog.append("shard_completed", {
|
|
898
|
+
shard: shardName,
|
|
899
|
+
sessionId,
|
|
185
900
|
isRedundant,
|
|
186
|
-
|
|
187
|
-
remote: Boolean(shard.host),
|
|
188
|
-
host: shard.host || null,
|
|
901
|
+
hasPayload: completionPayload !== undefined,
|
|
189
902
|
});
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
if (
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
903
|
+
const completedWorker = isRedundant
|
|
904
|
+
? redundantWorkers.get(shardName)
|
|
905
|
+
: workers.get(shardName);
|
|
906
|
+
if (completedWorker) {
|
|
907
|
+
void closeNativeBridgeRegistration(
|
|
908
|
+
completedWorker,
|
|
909
|
+
shardName,
|
|
910
|
+
"completed",
|
|
911
|
+
);
|
|
197
912
|
}
|
|
198
913
|
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
914
|
+
// F7 — worker self-reported completion must include commits_made.
|
|
915
|
+
// Only enforce when payload is actually provided; legacy workers that
|
|
916
|
+
// don't emit a structured payload keep the F6 integration-time guard.
|
|
917
|
+
if (!isRedundant && completionPayload !== undefined) {
|
|
918
|
+
const verdict = validateWorkerCompletion(completionPayload);
|
|
919
|
+
if (!verdict.ok) {
|
|
920
|
+
const reason = verdict.reason || "invalid_completion_payload";
|
|
921
|
+
eventLog.append("no_worker_commit_report", {
|
|
922
|
+
shard: shardName,
|
|
923
|
+
sessionId,
|
|
924
|
+
reason,
|
|
925
|
+
});
|
|
926
|
+
failures.set(shardName, {
|
|
927
|
+
mode: FAILURE_MODES.F7_WORKER_DID_NOT_COMMIT,
|
|
928
|
+
reason,
|
|
929
|
+
sessionId,
|
|
930
|
+
completionPayload,
|
|
931
|
+
});
|
|
932
|
+
lockManager.release(shardName);
|
|
933
|
+
|
|
934
|
+
const worker = workers.get(shardName);
|
|
935
|
+
const shard = plan?.shards.find((item) => item.name === shardName);
|
|
936
|
+
if (worker && shard) {
|
|
937
|
+
const cleanupPromise = maybeCleanupWorktree(
|
|
938
|
+
shardName,
|
|
939
|
+
worker,
|
|
940
|
+
shard,
|
|
941
|
+
{
|
|
942
|
+
force: true,
|
|
943
|
+
failureReason: FAILURE_MODES.F7_WORKER_DID_NOT_COMMIT,
|
|
944
|
+
},
|
|
945
|
+
);
|
|
946
|
+
worker.cleanupPromise = cleanupPromise;
|
|
947
|
+
void cleanupPromise
|
|
948
|
+
.finally(() => {
|
|
949
|
+
if (worker.cleanupPromise === cleanupPromise) {
|
|
950
|
+
worker.cleanupPromise = null;
|
|
951
|
+
}
|
|
952
|
+
})
|
|
953
|
+
.catch(() => {});
|
|
954
|
+
}
|
|
203
955
|
|
|
204
|
-
|
|
205
|
-
|
|
956
|
+
const redundant = redundantWorkers.get(shardName);
|
|
957
|
+
if (redundant) {
|
|
958
|
+
void redundant.conductor.shutdown("primary_failed_f7");
|
|
959
|
+
}
|
|
206
960
|
|
|
207
|
-
|
|
961
|
+
emitter.emit("shardFailed", {
|
|
962
|
+
shardName,
|
|
963
|
+
failureMode: FAILURE_MODES.F7_WORKER_DID_NOT_COMMIT,
|
|
964
|
+
reason,
|
|
965
|
+
});
|
|
208
966
|
|
|
209
|
-
|
|
210
|
-
|
|
967
|
+
cascadeDependencyFailure(shardName);
|
|
968
|
+
checkAllShardsCompleted();
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
completedShards.add(shardName);
|
|
211
974
|
|
|
212
975
|
if (isRedundant) {
|
|
213
976
|
// Redundant worker completed first — kill primary if still running
|
|
214
977
|
const primary = workers.get(shardName);
|
|
978
|
+
const redundant = redundantWorkers.get(shardName);
|
|
979
|
+
if (redundant) {
|
|
980
|
+
workers.set(shardName, redundant);
|
|
981
|
+
redundantWorkers.delete(shardName);
|
|
982
|
+
}
|
|
215
983
|
if (primary && !isTerminal(primary)) {
|
|
216
|
-
eventLog.append(
|
|
217
|
-
void primary.conductor.shutdown(
|
|
984
|
+
eventLog.append("redundant_wins", { shard: shardName });
|
|
985
|
+
void primary.conductor.shutdown("redundant_completed_first");
|
|
218
986
|
}
|
|
219
987
|
} else {
|
|
220
988
|
// Primary completed — kill redundant if exists
|
|
221
989
|
const redundant = redundantWorkers.get(shardName);
|
|
222
990
|
if (redundant) {
|
|
223
|
-
void redundant.conductor.shutdown(
|
|
991
|
+
void redundant.conductor.shutdown("primary_completed_first");
|
|
224
992
|
}
|
|
225
993
|
}
|
|
226
994
|
|
|
227
|
-
emitter.emit(
|
|
995
|
+
emitter.emit("shardCompleted", { shardName, sessionId, isRedundant });
|
|
228
996
|
checkAllShardsCompleted();
|
|
229
997
|
}
|
|
230
998
|
|
|
231
999
|
function handleShardFailed(shardName, sessionId, reason, isRedundant) {
|
|
232
1000
|
const failureMode = classifyFailure(reason);
|
|
233
1001
|
|
|
234
|
-
eventLog.append(
|
|
1002
|
+
eventLog.append("shard_failed", {
|
|
235
1003
|
shard: shardName,
|
|
236
1004
|
sessionId,
|
|
237
1005
|
reason,
|
|
@@ -240,6 +1008,10 @@ export function createSwarmHypervisor(opts) {
|
|
|
240
1008
|
});
|
|
241
1009
|
|
|
242
1010
|
if (isRedundant) return; // redundant failure is non-critical
|
|
1011
|
+
const failedWorker = workers.get(shardName);
|
|
1012
|
+
if (failedWorker) {
|
|
1013
|
+
void closeNativeBridgeRegistration(failedWorker, shardName, "failed");
|
|
1014
|
+
}
|
|
243
1015
|
|
|
244
1016
|
// F2: Rate limit — try fallback agent
|
|
245
1017
|
if (failureMode === FAILURE_MODES.F2_RATE_LIMIT) {
|
|
@@ -247,14 +1019,14 @@ export function createSwarmHypervisor(opts) {
|
|
|
247
1019
|
if (shard) {
|
|
248
1020
|
const fallbackAgent = FALLBACK_AGENTS[shard.agent];
|
|
249
1021
|
if (fallbackAgent) {
|
|
250
|
-
eventLog.append(
|
|
1022
|
+
eventLog.append("fallback_agent", {
|
|
251
1023
|
shard: shardName,
|
|
252
1024
|
from: shard.agent,
|
|
253
1025
|
to: fallbackAgent,
|
|
254
1026
|
});
|
|
255
1027
|
const fallbackShard = { ...shard, agent: fallbackAgent };
|
|
256
1028
|
lockManager.release(shardName);
|
|
257
|
-
launchShard(fallbackShard);
|
|
1029
|
+
void launchShard(fallbackShard);
|
|
258
1030
|
return;
|
|
259
1031
|
}
|
|
260
1032
|
}
|
|
@@ -263,10 +1035,48 @@ export function createSwarmHypervisor(opts) {
|
|
|
263
1035
|
failures.set(shardName, { mode: failureMode, reason, sessionId });
|
|
264
1036
|
lockManager.release(shardName);
|
|
265
1037
|
|
|
266
|
-
emitter.emit(
|
|
1038
|
+
emitter.emit("shardFailed", { shardName, failureMode, reason });
|
|
1039
|
+
|
|
1040
|
+
// Cascade failure to dependents so they do not hold integration hostage
|
|
1041
|
+
// (BFS over plan.shards). Without this, a dead shard keeps dependents in
|
|
1042
|
+
// `pending` forever and integrateResults is never reachable.
|
|
1043
|
+
cascadeDependencyFailure(shardName);
|
|
1044
|
+
|
|
267
1045
|
checkAllShardsCompleted();
|
|
268
1046
|
}
|
|
269
1047
|
|
|
1048
|
+
function cascadeDependencyFailure(rootFailedShard) {
|
|
1049
|
+
const queue = [rootFailedShard];
|
|
1050
|
+
const visited = new Set([rootFailedShard]);
|
|
1051
|
+
while (queue.length > 0) {
|
|
1052
|
+
const failedName = queue.shift();
|
|
1053
|
+
for (const candidate of plan.shards) {
|
|
1054
|
+
if (visited.has(candidate.name)) continue;
|
|
1055
|
+
if (failures.has(candidate.name)) continue;
|
|
1056
|
+
if (completedShards.has(candidate.name)) continue;
|
|
1057
|
+
if (!candidate.depends?.includes(failedName)) continue;
|
|
1058
|
+
|
|
1059
|
+
visited.add(candidate.name);
|
|
1060
|
+
failures.set(candidate.name, {
|
|
1061
|
+
mode: FAILURE_MODES.F1_CRASH,
|
|
1062
|
+
reason: `dep_failed:${failedName}`,
|
|
1063
|
+
sessionId: null,
|
|
1064
|
+
});
|
|
1065
|
+
lockManager.release(candidate.name);
|
|
1066
|
+
eventLog.append("shard_blocked", {
|
|
1067
|
+
shard: candidate.name,
|
|
1068
|
+
failedDep: failedName,
|
|
1069
|
+
});
|
|
1070
|
+
emitter.emit("shardFailed", {
|
|
1071
|
+
shardName: candidate.name,
|
|
1072
|
+
failureMode: FAILURE_MODES.F1_CRASH,
|
|
1073
|
+
reason: `dep_failed:${failedName}`,
|
|
1074
|
+
});
|
|
1075
|
+
queue.push(candidate.name);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
270
1080
|
function classifyFailure(reason) {
|
|
271
1081
|
if (!reason) return FAILURE_MODES.F1_CRASH;
|
|
272
1082
|
const r = String(reason).toLowerCase();
|
|
@@ -274,12 +1084,15 @@ export function createSwarmHypervisor(opts) {
|
|
|
274
1084
|
if (/stall|l1_stall|timeout/u.test(r)) return FAILURE_MODES.F3_STALL;
|
|
275
1085
|
if (/lease|violation/u.test(r)) return FAILURE_MODES.F4_LEASE_VIOLATION;
|
|
276
1086
|
if (/merge|conflict/u.test(r)) return FAILURE_MODES.F5_MERGE_CONFLICT;
|
|
1087
|
+
if (/no.?commit|dirty_worktree/u.test(r)) return FAILURE_MODES.F6_NO_COMMIT;
|
|
277
1088
|
return FAILURE_MODES.F1_CRASH;
|
|
278
1089
|
}
|
|
279
1090
|
|
|
280
1091
|
function isTerminal(entry) {
|
|
281
1092
|
const snap = entry.conductor.getSnapshot();
|
|
282
|
-
return snap.every(
|
|
1093
|
+
return snap.every(
|
|
1094
|
+
(s) => s.state === STATES.COMPLETED || s.state === STATES.DEAD,
|
|
1095
|
+
);
|
|
283
1096
|
}
|
|
284
1097
|
|
|
285
1098
|
// ── Integration ─────────────────────────────────────────────
|
|
@@ -287,10 +1100,16 @@ export function createSwarmHypervisor(opts) {
|
|
|
287
1100
|
function checkAllShardsCompleted() {
|
|
288
1101
|
if (state !== SWARM_STATES.RUNNING) return;
|
|
289
1102
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
1103
|
+
// `completedShards` + `failures` are set synchronously inside
|
|
1104
|
+
// handleShardCompleted / handleShardFailed, so they are the authoritative
|
|
1105
|
+
// terminal signal. `isTerminal(w)` queries conductor.getSnapshot() which
|
|
1106
|
+
// lags because the snapshot state transition is applied asynchronously
|
|
1107
|
+
// after the onCompleted callback chain that invoked this check. Using the
|
|
1108
|
+
// snapshot here races with its own producer and leaves integrateResults
|
|
1109
|
+
// unreachable (2026-04-17 swarm hang — required manual cherry-pick).
|
|
1110
|
+
const allDone = plan.mergeOrder.every(
|
|
1111
|
+
(name) => completedShards.has(name) || failures.has(name),
|
|
1112
|
+
);
|
|
294
1113
|
|
|
295
1114
|
if (allDone) {
|
|
296
1115
|
void integrateResults();
|
|
@@ -304,9 +1123,16 @@ export function createSwarmHypervisor(opts) {
|
|
|
304
1123
|
* @returns {{ ok: boolean, violations: Array }}
|
|
305
1124
|
*/
|
|
306
1125
|
function validateResult(shardName, changedFiles) {
|
|
307
|
-
|
|
1126
|
+
// Pass the shard's own lease so validateChanges can flag out-of-lease
|
|
1127
|
+
// writes to distribution-critical paths (regression of #115/#34 —
|
|
1128
|
+
// recovery patches were carrying `+++ /dev/null` deletions of
|
|
1129
|
+
// .claude-plugin/marketplace.json, undetected by lease-only checks).
|
|
1130
|
+
const ownLease = plan.leaseMap.get(shardName) || [];
|
|
1131
|
+
const violations = lockManager.validateChanges(shardName, changedFiles, {
|
|
1132
|
+
ownLease,
|
|
1133
|
+
});
|
|
308
1134
|
|
|
309
|
-
eventLog.append(
|
|
1135
|
+
eventLog.append("validate_result", {
|
|
310
1136
|
shard: shardName,
|
|
311
1137
|
changedFiles,
|
|
312
1138
|
violations,
|
|
@@ -324,85 +1150,411 @@ export function createSwarmHypervisor(opts) {
|
|
|
324
1150
|
* Uses git operations for conflict detection.
|
|
325
1151
|
*/
|
|
326
1152
|
async function integrateResults() {
|
|
327
|
-
setState(SWARM_STATES.INTEGRATING,
|
|
1153
|
+
setState(SWARM_STATES.INTEGRATING, "all_shards_done");
|
|
328
1154
|
|
|
329
1155
|
const integrated = [];
|
|
330
1156
|
const integrationFailures = [];
|
|
1157
|
+
const integrationReport = [];
|
|
1158
|
+
const preIntegrationFailures = [...failures.keys()];
|
|
1159
|
+
let integrationBranch = null;
|
|
1160
|
+
|
|
1161
|
+
const appendIntegrationReport = (row) => {
|
|
1162
|
+
const normalized = normalizeIntegrationReportRow(row);
|
|
1163
|
+
integrationReport.push(normalized);
|
|
1164
|
+
eventLog.append("integration_shard_report", normalized);
|
|
1165
|
+
return normalized;
|
|
1166
|
+
};
|
|
331
1167
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
1168
|
+
const failureNotes = (shardName, fallback = "failed") => {
|
|
1169
|
+
const info = failures.get(shardName);
|
|
1170
|
+
if (!info) return fallback;
|
|
1171
|
+
return info.reason || info.mode || fallback;
|
|
1172
|
+
};
|
|
1173
|
+
|
|
1174
|
+
try {
|
|
1175
|
+
({ integrationBranch } = await prepareIntegrationBranchImpl({
|
|
1176
|
+
runId,
|
|
1177
|
+
baseBranch,
|
|
1178
|
+
rootDir: workdir,
|
|
1179
|
+
}));
|
|
1180
|
+
|
|
1181
|
+
for (const shardName of plan.mergeOrder) {
|
|
1182
|
+
if (failures.has(shardName)) {
|
|
1183
|
+
const worker = workers.get(shardName);
|
|
1184
|
+
if (worker?.cleanupPromise) await worker.cleanupPromise;
|
|
1185
|
+
eventLog.append("skip_failed_shard", { shard: shardName });
|
|
1186
|
+
appendIntegrationReport({
|
|
1187
|
+
shard: shardName,
|
|
1188
|
+
strategy: "failed",
|
|
1189
|
+
notes: `skipped: ${failureNotes(shardName)}`,
|
|
1190
|
+
});
|
|
1191
|
+
continue;
|
|
1192
|
+
}
|
|
337
1193
|
|
|
338
|
-
|
|
339
|
-
|
|
1194
|
+
const worker = workers.get(shardName);
|
|
1195
|
+
if (!worker) continue;
|
|
1196
|
+
|
|
1197
|
+
// Fetch remote shard branch to local (push-blocked hosts like Ultra4)
|
|
1198
|
+
const shard = plan.shards.find((s) => s.name === shardName);
|
|
1199
|
+
if (shard?.host && shard._remoteEnv) {
|
|
1200
|
+
const hostConfig = getHostConfig(shard.host, workdir);
|
|
1201
|
+
const sshUser = hostConfig?.ssh_user || shard.host;
|
|
1202
|
+
const remoteRepoPath = resolveRemoteDir(workdir, shard._remoteEnv);
|
|
1203
|
+
const fetchResult = await fetchRemoteShard({
|
|
1204
|
+
host: shard.host,
|
|
1205
|
+
sshUser,
|
|
1206
|
+
remoteRepoPath,
|
|
1207
|
+
branchName: worker.branchName || `swarm/${runId}/${shardName}`,
|
|
1208
|
+
rootDir: workdir,
|
|
1209
|
+
});
|
|
340
1210
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
1211
|
+
if (!fetchResult.ok) {
|
|
1212
|
+
eventLog.append("remote_fetch_failed", {
|
|
1213
|
+
shard: shardName,
|
|
1214
|
+
error: fetchResult.error,
|
|
1215
|
+
});
|
|
1216
|
+
const cleanup = await maybeCleanupWorktree(
|
|
1217
|
+
shardName,
|
|
1218
|
+
worker,
|
|
1219
|
+
shard,
|
|
1220
|
+
{
|
|
1221
|
+
force: true,
|
|
1222
|
+
failureReason: "remote_fetch_failed",
|
|
1223
|
+
},
|
|
1224
|
+
);
|
|
1225
|
+
integrationFailures.push(shardName);
|
|
1226
|
+
appendIntegrationReport({
|
|
1227
|
+
shard: shardName,
|
|
1228
|
+
strategy: "failed",
|
|
1229
|
+
notes: `remote_fetch_failed: ${fetchResult.error || "unknown"}`,
|
|
1230
|
+
recoveryPatch: cleanup?.recoveryPatch?.patchPath || null,
|
|
1231
|
+
});
|
|
1232
|
+
continue;
|
|
1233
|
+
}
|
|
1234
|
+
eventLog.append("remote_fetch_ok", {
|
|
1235
|
+
shard: shardName,
|
|
1236
|
+
headCommit: fetchResult.headCommit,
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// Read shard output log for changed files
|
|
1241
|
+
const commitEvidence = await collectCommitEvidence(
|
|
1242
|
+
worker,
|
|
1243
|
+
integrationBranch,
|
|
1244
|
+
);
|
|
1245
|
+
worker.commitEvidence = commitEvidence;
|
|
1246
|
+
eventLog.append("commit_evidence", {
|
|
1247
|
+
shard: shardName,
|
|
1248
|
+
...commitEvidence,
|
|
353
1249
|
});
|
|
354
1250
|
|
|
355
|
-
|
|
356
|
-
|
|
1251
|
+
const expectsCommitEvidence =
|
|
1252
|
+
Array.isArray(shard?.files) && shard.files.length > 0;
|
|
1253
|
+
if (expectsCommitEvidence && !commitEvidence.ok) {
|
|
1254
|
+
failures.set(shardName, {
|
|
1255
|
+
mode: FAILURE_MODES.F6_NO_COMMIT,
|
|
1256
|
+
reason: commitEvidence.error
|
|
1257
|
+
? `no_commit_evidence:${commitEvidence.error}`
|
|
1258
|
+
: commitEvidence.dirty
|
|
1259
|
+
? "dirty_worktree_without_commit"
|
|
1260
|
+
: "no_commit_evidence",
|
|
1261
|
+
commitEvidence,
|
|
1262
|
+
});
|
|
1263
|
+
eventLog.append("no_commit_guard_failed", {
|
|
1264
|
+
shard: shardName,
|
|
1265
|
+
...commitEvidence,
|
|
1266
|
+
});
|
|
1267
|
+
const cleanup = await maybeCleanupWorktree(shardName, worker, shard, {
|
|
1268
|
+
force: true,
|
|
1269
|
+
failureReason:
|
|
1270
|
+
failures.get(shardName)?.mode || "no_commit_evidence",
|
|
1271
|
+
});
|
|
357
1272
|
integrationFailures.push(shardName);
|
|
1273
|
+
appendIntegrationReport({
|
|
1274
|
+
shard: shardName,
|
|
1275
|
+
strategy: "failed",
|
|
1276
|
+
commits: commitEvidence.commitsAhead,
|
|
1277
|
+
finalSha: commitEvidence.headCommit,
|
|
1278
|
+
notes: failureNotes(shardName, "no_commit_evidence"),
|
|
1279
|
+
recoveryPatch: cleanup?.recoveryPatch?.patchPath || null,
|
|
1280
|
+
});
|
|
358
1281
|
continue;
|
|
359
1282
|
}
|
|
360
|
-
eventLog.append('remote_fetch_ok', { shard: shardName, headCommit: fetchResult.headCommit });
|
|
361
|
-
}
|
|
362
1283
|
|
|
363
|
-
|
|
364
|
-
|
|
1284
|
+
// Read shard output log for changed files
|
|
1285
|
+
const changedFiles = detectChangedFiles(shardName, worker);
|
|
365
1286
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
1287
|
+
// Validate against lease map
|
|
1288
|
+
const validation = validateResult(shardName, changedFiles);
|
|
1289
|
+
if (!validation.ok) {
|
|
1290
|
+
failures.set(shardName, {
|
|
1291
|
+
mode: FAILURE_MODES.F4_LEASE_VIOLATION,
|
|
1292
|
+
violations: validation.violations,
|
|
1293
|
+
});
|
|
1294
|
+
eventLog.append("lease_violation_revert", {
|
|
1295
|
+
shard: shardName,
|
|
1296
|
+
violations: validation.violations,
|
|
1297
|
+
});
|
|
1298
|
+
const cleanup = await maybeCleanupWorktree(shardName, worker, shard, {
|
|
1299
|
+
force: true,
|
|
1300
|
+
failureReason:
|
|
1301
|
+
failures.get(shardName)?.mode || "lease_violation_revert",
|
|
1302
|
+
});
|
|
1303
|
+
integrationFailures.push(shardName);
|
|
1304
|
+
appendIntegrationReport({
|
|
1305
|
+
shard: shardName,
|
|
1306
|
+
strategy: "failed",
|
|
1307
|
+
notes: failureNotes(shardName, "lease_violation_revert"),
|
|
1308
|
+
recoveryPatch: cleanup?.recoveryPatch?.patchPath || null,
|
|
1309
|
+
});
|
|
1310
|
+
continue;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
const shardBranch = worker.branchName || `swarm/${runId}/${shardName}`;
|
|
1314
|
+
const rebaseResult = await rebaseShardOntoIntegrationImpl({
|
|
1315
|
+
shardBranch,
|
|
1316
|
+
integrationBranch,
|
|
1317
|
+
rootDir: workdir,
|
|
372
1318
|
});
|
|
373
|
-
|
|
1319
|
+
if (!rebaseResult.ok) {
|
|
1320
|
+
eventLog.append("integration_rebase_failed", {
|
|
1321
|
+
shard: shardName,
|
|
1322
|
+
shardBranch,
|
|
1323
|
+
integrationBranch,
|
|
1324
|
+
error: rebaseResult.error,
|
|
1325
|
+
});
|
|
1326
|
+
const cleanup = await maybeCleanupWorktree(shardName, worker, shard, {
|
|
1327
|
+
force: true,
|
|
1328
|
+
failureReason:
|
|
1329
|
+
rebaseResult.error || FAILURE_MODES.F5_MERGE_CONFLICT,
|
|
1330
|
+
});
|
|
1331
|
+
integrationFailures.push(shardName);
|
|
1332
|
+
appendIntegrationReport({
|
|
1333
|
+
shard: shardName,
|
|
1334
|
+
strategy: "failed",
|
|
1335
|
+
commits: rebaseResult.commits ?? commitEvidence.commitsAhead,
|
|
1336
|
+
notes: rebaseResult.error || FAILURE_MODES.F5_MERGE_CONFLICT,
|
|
1337
|
+
recoveryPatch: cleanup?.recoveryPatch?.patchPath || null,
|
|
1338
|
+
});
|
|
1339
|
+
continue;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
results.set(shardName, {
|
|
1343
|
+
shard: shardName,
|
|
1344
|
+
changedFiles,
|
|
1345
|
+
branchName: shardBranch,
|
|
1346
|
+
worktreePath: worker.worktreePath || null,
|
|
1347
|
+
integrationBranch,
|
|
1348
|
+
headCommit: rebaseResult.headCommit,
|
|
1349
|
+
integrationStrategy: rebaseResult.strategy || "cherry-pick",
|
|
1350
|
+
commits: rebaseResult.commits ?? commitEvidence.commitsAhead,
|
|
1351
|
+
notes: rebaseResult.notes || "",
|
|
1352
|
+
completedAt: Date.now(),
|
|
1353
|
+
});
|
|
1354
|
+
integrated.push(shardName);
|
|
1355
|
+
appendIntegrationReport({
|
|
1356
|
+
shard: shardName,
|
|
1357
|
+
strategy: rebaseResult.strategy || "cherry-pick",
|
|
1358
|
+
commits: rebaseResult.commits ?? commitEvidence.commitsAhead,
|
|
1359
|
+
finalSha: rebaseResult.headCommit,
|
|
1360
|
+
notes: rebaseResult.notes || "",
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
await maybeCleanupWorktree(shardName, worker, shard, {
|
|
1364
|
+
branchName: shardBranch,
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
} catch (err) {
|
|
1368
|
+
const unresolved = plan.mergeOrder.filter(
|
|
1369
|
+
(name) =>
|
|
1370
|
+
!integrated.includes(name) &&
|
|
1371
|
+
!preIntegrationFailures.includes(name) &&
|
|
1372
|
+
!integrationFailures.includes(name),
|
|
1373
|
+
);
|
|
1374
|
+
const failed = [
|
|
1375
|
+
...new Set([
|
|
1376
|
+
...preIntegrationFailures,
|
|
1377
|
+
...integrationFailures,
|
|
1378
|
+
...unresolved,
|
|
1379
|
+
]),
|
|
1380
|
+
];
|
|
1381
|
+
const skipped = preIntegrationFailures.filter(
|
|
1382
|
+
(name) => !integrationFailures.includes(name),
|
|
1383
|
+
);
|
|
1384
|
+
const reported = new Set(integrationReport.map((row) => row.shard));
|
|
1385
|
+
for (const shardName of failed) {
|
|
1386
|
+
if (reported.has(shardName)) continue;
|
|
1387
|
+
appendIntegrationReport({
|
|
374
1388
|
shard: shardName,
|
|
375
|
-
|
|
1389
|
+
strategy: "failed",
|
|
1390
|
+
notes: `integration_error: ${err.message}`,
|
|
376
1391
|
});
|
|
377
|
-
integrationFailures.push(shardName);
|
|
378
|
-
continue;
|
|
379
1392
|
}
|
|
380
1393
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
1394
|
+
eventLog.append("integration_complete", {
|
|
1395
|
+
integrated,
|
|
1396
|
+
failed,
|
|
1397
|
+
integrationFailures,
|
|
1398
|
+
integrationBranch,
|
|
1399
|
+
skipped,
|
|
1400
|
+
report: integrationReport,
|
|
1401
|
+
error: err.message,
|
|
1402
|
+
});
|
|
1403
|
+
|
|
1404
|
+
setState(SWARM_STATES.FAILED, "integration_error");
|
|
1405
|
+
const payload = settleIntegrationPromise({
|
|
1406
|
+
integrated,
|
|
1407
|
+
failed,
|
|
1408
|
+
integrationFailures,
|
|
1409
|
+
integrationBranch,
|
|
1410
|
+
skipped,
|
|
1411
|
+
report: integrationReport,
|
|
1412
|
+
results: [...results.values()],
|
|
1413
|
+
partial: true,
|
|
1414
|
+
error: err.message,
|
|
385
1415
|
});
|
|
386
|
-
|
|
1416
|
+
emitter.emit("integrationComplete", payload);
|
|
1417
|
+
return payload;
|
|
387
1418
|
}
|
|
388
1419
|
|
|
389
|
-
|
|
1420
|
+
const skipped = preIntegrationFailures.filter(
|
|
1421
|
+
(name) => !integrationFailures.includes(name),
|
|
1422
|
+
);
|
|
1423
|
+
const failed = [...new Set([...skipped, ...integrationFailures])];
|
|
1424
|
+
|
|
1425
|
+
eventLog.append("integration_complete", {
|
|
390
1426
|
integrated,
|
|
391
|
-
failed
|
|
392
|
-
|
|
1427
|
+
failed,
|
|
1428
|
+
integrationFailures,
|
|
1429
|
+
integrationBranch,
|
|
1430
|
+
skipped,
|
|
1431
|
+
report: integrationReport,
|
|
393
1432
|
});
|
|
394
1433
|
|
|
395
|
-
if (
|
|
396
|
-
setState(SWARM_STATES.FAILED,
|
|
1434
|
+
if (failed.length > 0 && integrated.length === 0) {
|
|
1435
|
+
setState(SWARM_STATES.FAILED, "all_shards_failed_integration");
|
|
397
1436
|
} else {
|
|
398
|
-
setState(
|
|
1437
|
+
setState(
|
|
1438
|
+
SWARM_STATES.COMPLETED,
|
|
1439
|
+
`${integrated.length}/${plan.shards.length} integrated`,
|
|
1440
|
+
);
|
|
399
1441
|
}
|
|
400
1442
|
|
|
401
|
-
|
|
1443
|
+
const payload = settleIntegrationPromise({
|
|
402
1444
|
integrated,
|
|
403
|
-
failed
|
|
1445
|
+
failed,
|
|
1446
|
+
integrationFailures,
|
|
1447
|
+
integrationBranch,
|
|
1448
|
+
skipped,
|
|
1449
|
+
report: integrationReport,
|
|
404
1450
|
results: [...results.values()],
|
|
1451
|
+
partial: failed.length > 0,
|
|
405
1452
|
});
|
|
1453
|
+
emitter.emit("integrationComplete", payload);
|
|
1454
|
+
return payload;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
async function maybeCleanupWorktree(
|
|
1458
|
+
shardName,
|
|
1459
|
+
worker,
|
|
1460
|
+
shard,
|
|
1461
|
+
{
|
|
1462
|
+
branchName = worker?.branchName,
|
|
1463
|
+
force = false,
|
|
1464
|
+
failureReason = null,
|
|
1465
|
+
} = {},
|
|
1466
|
+
) {
|
|
1467
|
+
const outcome = { recoveryPatch: null, processCleanup: null };
|
|
1468
|
+
if (!worker?.worktreePath || shard?.host) return outcome;
|
|
1469
|
+
if (failureReason && keepFailedWorktrees) return outcome;
|
|
1470
|
+
if (cleanedWorktreePaths.has(worker.worktreePath)) return outcome;
|
|
1471
|
+
|
|
1472
|
+
await closeNativeBridgeRegistration(
|
|
1473
|
+
worker,
|
|
1474
|
+
shardName,
|
|
1475
|
+
"before_worktree_cleanup",
|
|
1476
|
+
);
|
|
1477
|
+
|
|
1478
|
+
// Best-effort: preserve any uncommitted worker changes before removing
|
|
1479
|
+
// the worktree. Silent on failure — must not block cleanup or mask the
|
|
1480
|
+
// original failure reason.
|
|
1481
|
+
if (failureReason) {
|
|
1482
|
+
try {
|
|
1483
|
+
const preservation = await preserveWorktreePatchImpl({
|
|
1484
|
+
worktreePath: worker.worktreePath,
|
|
1485
|
+
shardId: shardName,
|
|
1486
|
+
recoveryDir: join(workdir, ".codex-swarm", "recovery"),
|
|
1487
|
+
});
|
|
1488
|
+
if (preservation?.ok && !preservation.skipped) {
|
|
1489
|
+
outcome.recoveryPatch = {
|
|
1490
|
+
patchPath: preservation.patchPath,
|
|
1491
|
+
manifestPath: preservation.manifestPath || null,
|
|
1492
|
+
};
|
|
1493
|
+
eventLog.append("recovery_patch_saved", {
|
|
1494
|
+
shard: shardName,
|
|
1495
|
+
worktreePath: worker.worktreePath,
|
|
1496
|
+
patchPath: preservation.patchPath,
|
|
1497
|
+
reason: failureReason,
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
} catch {
|
|
1501
|
+
/* silent: preservation failures must not block cleanup */
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
try {
|
|
1506
|
+
const snapshot = worker.conductor?.getSnapshot?.();
|
|
1507
|
+
const processCleanup = await cleanupShardProcessesImpl({
|
|
1508
|
+
worktreePath: worker.worktreePath,
|
|
1509
|
+
sessionIds: [worker.sessionId || worker.sessionConfig?.id].filter(
|
|
1510
|
+
Boolean,
|
|
1511
|
+
),
|
|
1512
|
+
topPids: snapshot?.topPids ?? [],
|
|
1513
|
+
runId: plan?.runId || runId,
|
|
1514
|
+
shardName,
|
|
1515
|
+
});
|
|
1516
|
+
outcome.processCleanup = processCleanup;
|
|
1517
|
+
worker.processCleanup = processCleanup;
|
|
1518
|
+
recordProcessCleanup(processCleanup);
|
|
1519
|
+
eventLog.append("process_cleanup", {
|
|
1520
|
+
shard: shardName,
|
|
1521
|
+
worktreePath: worker.worktreePath,
|
|
1522
|
+
killed: processCleanup?.killed ?? 0,
|
|
1523
|
+
byCategory: processCleanup?.byCategory || {},
|
|
1524
|
+
});
|
|
1525
|
+
} catch (err) {
|
|
1526
|
+
eventLog.append("process_cleanup_failed", {
|
|
1527
|
+
shard: shardName,
|
|
1528
|
+
worktreePath: worker.worktreePath,
|
|
1529
|
+
error: err.message,
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
try {
|
|
1534
|
+
await cleanupWorktreeImpl({
|
|
1535
|
+
worktreePath: worker.worktreePath,
|
|
1536
|
+
branchName,
|
|
1537
|
+
rootDir: workdir,
|
|
1538
|
+
force,
|
|
1539
|
+
});
|
|
1540
|
+
cleanedWorktreePaths.add(worker.worktreePath);
|
|
1541
|
+
if (failureReason) {
|
|
1542
|
+
eventLog.append("worktree_auto_cleanup", {
|
|
1543
|
+
shard: shardName,
|
|
1544
|
+
worktreePath: worker.worktreePath,
|
|
1545
|
+
reason: failureReason,
|
|
1546
|
+
});
|
|
1547
|
+
}
|
|
1548
|
+
} catch (err) {
|
|
1549
|
+
eventLog.append("worktree_cleanup_failed", {
|
|
1550
|
+
shard: shardName,
|
|
1551
|
+
worktreePath: worker.worktreePath,
|
|
1552
|
+
reason: failureReason || null,
|
|
1553
|
+
error: err.message,
|
|
1554
|
+
});
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
return outcome;
|
|
406
1558
|
}
|
|
407
1559
|
|
|
408
1560
|
/**
|
|
@@ -419,11 +1571,16 @@ export function createSwarmHypervisor(opts) {
|
|
|
419
1571
|
const snap = worker.conductor.getSnapshot();
|
|
420
1572
|
for (const session of snap) {
|
|
421
1573
|
if (session.outPath && existsSync(session.outPath)) {
|
|
422
|
-
const output = readFileSync(session.outPath,
|
|
423
|
-
return extractFilePathsFromOutput(
|
|
1574
|
+
const output = readFileSync(session.outPath, "utf8");
|
|
1575
|
+
return extractFilePathsFromOutput(
|
|
1576
|
+
output,
|
|
1577
|
+
plan.leaseMap.get(shardName) || [],
|
|
1578
|
+
);
|
|
424
1579
|
}
|
|
425
1580
|
}
|
|
426
|
-
} catch {
|
|
1581
|
+
} catch {
|
|
1582
|
+
/* best-effort */
|
|
1583
|
+
}
|
|
427
1584
|
|
|
428
1585
|
// Fallback: trust the lease map (shard was allowed these files)
|
|
429
1586
|
return plan.leaseMap.get(shardName) || [];
|
|
@@ -446,8 +1603,8 @@ export function createSwarmHypervisor(opts) {
|
|
|
446
1603
|
// Match common patterns
|
|
447
1604
|
const patterns = [
|
|
448
1605
|
/(?:wrote|created|modified|updated|edited)\s+['"]?([^\s'"]+\.\w+)/i,
|
|
449
|
-
/^[+-]{3}\s+[ab]\/(.+)/,
|
|
450
|
-
/^diff --git a\/(.+)\s+b\//,
|
|
1606
|
+
/^[+-]{3}\s+[ab]\/(.+)/, // diff headers
|
|
1607
|
+
/^diff --git a\/(.+)\s+b\//, // git diff headers
|
|
451
1608
|
];
|
|
452
1609
|
|
|
453
1610
|
for (const re of patterns) {
|
|
@@ -458,9 +1615,9 @@ export function createSwarmHypervisor(opts) {
|
|
|
458
1615
|
|
|
459
1616
|
// Intersect with allowed files if we found anything
|
|
460
1617
|
if (found.size > 0) {
|
|
461
|
-
return [...found].filter((f) =>
|
|
462
|
-
(a) => f.endsWith(a) || a.endsWith(f) || f === a,
|
|
463
|
-
)
|
|
1618
|
+
return [...found].filter((f) =>
|
|
1619
|
+
allowedFiles.some((a) => f.endsWith(a) || a.endsWith(f) || f === a),
|
|
1620
|
+
);
|
|
464
1621
|
}
|
|
465
1622
|
|
|
466
1623
|
return allowedFiles;
|
|
@@ -477,6 +1634,7 @@ export function createSwarmHypervisor(opts) {
|
|
|
477
1634
|
|
|
478
1635
|
for (const [name, w] of workers) {
|
|
479
1636
|
const snap = w.conductor.getSnapshot();
|
|
1637
|
+
const authoritative = computeAuthoritativeStatus(name, w, snap);
|
|
480
1638
|
workerStatuses.push({
|
|
481
1639
|
shard: name,
|
|
482
1640
|
agent: w.shardConfig.agent,
|
|
@@ -484,18 +1642,40 @@ export function createSwarmHypervisor(opts) {
|
|
|
484
1642
|
failed: failures.has(name),
|
|
485
1643
|
failureInfo: failures.get(name) || null,
|
|
486
1644
|
integrated: results.has(name),
|
|
1645
|
+
authoritativeStatus: authoritative.status,
|
|
1646
|
+
authoritativeReason: authoritative.reason,
|
|
1647
|
+
commitEvidence: w.commitEvidence || null,
|
|
487
1648
|
});
|
|
488
1649
|
}
|
|
489
1650
|
|
|
490
1651
|
return Object.freeze({
|
|
491
1652
|
state,
|
|
492
1653
|
totalShards: plan?.shards.length || 0,
|
|
493
|
-
completedShards:
|
|
1654
|
+
completedShards: completedShards.size,
|
|
494
1655
|
failedShards: failures.size,
|
|
495
1656
|
workers: workerStatuses,
|
|
496
1657
|
mergeOrder: plan?.mergeOrder || [],
|
|
497
1658
|
criticalShards: plan?.criticalShards || [],
|
|
498
1659
|
locks: lockManager?.snapshot() || [],
|
|
1660
|
+
integrationPromise: Object.freeze({
|
|
1661
|
+
state: integrationPromiseState.state,
|
|
1662
|
+
startedAt: integrationPromiseState.startedAt,
|
|
1663
|
+
settledAt: integrationPromiseState.settledAt,
|
|
1664
|
+
partial: integrationPromiseState.partial,
|
|
1665
|
+
integrated: [...integrationPromiseState.integrated],
|
|
1666
|
+
failed: [...integrationPromiseState.failed],
|
|
1667
|
+
integrationFailures: [...integrationPromiseState.integrationFailures],
|
|
1668
|
+
skipped: [...integrationPromiseState.skipped],
|
|
1669
|
+
integrationBranch: integrationPromiseState.integrationBranch,
|
|
1670
|
+
report: integrationPromiseState.report.map((row) => ({ ...row })),
|
|
1671
|
+
processCleanup: Object.freeze({
|
|
1672
|
+
totalKilled: integrationPromiseState.processCleanup.totalKilled,
|
|
1673
|
+
byCategory: Object.freeze({
|
|
1674
|
+
...integrationPromiseState.processCleanup.byCategory,
|
|
1675
|
+
}),
|
|
1676
|
+
}),
|
|
1677
|
+
error: integrationPromiseState.error,
|
|
1678
|
+
}),
|
|
499
1679
|
});
|
|
500
1680
|
}
|
|
501
1681
|
|
|
@@ -504,20 +1684,142 @@ export function createSwarmHypervisor(opts) {
|
|
|
504
1684
|
/**
|
|
505
1685
|
* Launch the swarm from a pre-built plan.
|
|
506
1686
|
* @param {SwarmPlan} swarmPlan — from planSwarm()
|
|
507
|
-
* @returns {SwarmStatus}
|
|
1687
|
+
* @returns {Promise<SwarmStatus>}
|
|
508
1688
|
*/
|
|
509
|
-
|
|
1689
|
+
/** Hub keepalive — Hub crash recovery (idle timeout은 기본 비활성이므로 crash 복구 용도) */
|
|
1690
|
+
let hubKeepaliveTimer = null;
|
|
1691
|
+
function startHubKeepalive() {
|
|
1692
|
+
// 5분마다 Hub /status 핑 — crash 감지 시 ensureHubAlive로 재시작
|
|
1693
|
+
hubKeepaliveTimer = setInterval(
|
|
1694
|
+
async () => {
|
|
1695
|
+
try {
|
|
1696
|
+
const hub = getHubInfoImpl ? await getHubInfoImpl() : null;
|
|
1697
|
+
const statusUrl = resolveHubStatusUrl(hub);
|
|
1698
|
+
if (!statusUrl) {
|
|
1699
|
+
if (ensureHubAliveImpl) {
|
|
1700
|
+
eventLog.append("hub_keepalive_restart", {
|
|
1701
|
+
reason: "hub_url_unresolved",
|
|
1702
|
+
});
|
|
1703
|
+
await ensureHubAliveImpl();
|
|
1704
|
+
}
|
|
1705
|
+
return;
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
const resp = await fetch(statusUrl, {
|
|
1709
|
+
signal: AbortSignal.timeout(5000),
|
|
1710
|
+
});
|
|
1711
|
+
if (!resp.ok && ensureHubAliveImpl) {
|
|
1712
|
+
eventLog.append("hub_keepalive_restart", {});
|
|
1713
|
+
await ensureHubAliveImpl();
|
|
1714
|
+
}
|
|
1715
|
+
} catch {
|
|
1716
|
+
// Hub 다운 — 재시작 시도
|
|
1717
|
+
if (ensureHubAliveImpl) {
|
|
1718
|
+
eventLog.append("hub_keepalive_restart", {
|
|
1719
|
+
reason: "fetch_failed",
|
|
1720
|
+
});
|
|
1721
|
+
try {
|
|
1722
|
+
await ensureHubAliveImpl();
|
|
1723
|
+
} catch {
|
|
1724
|
+
eventLog.append("hub_restart_failed", {});
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
},
|
|
1729
|
+
5 * 60 * 1000,
|
|
1730
|
+
);
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
function stopHubKeepalive() {
|
|
1734
|
+
if (hubKeepaliveTimer) {
|
|
1735
|
+
clearInterval(hubKeepaliveTimer);
|
|
1736
|
+
hubKeepaliveTimer = null;
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
async function launch(swarmPlan) {
|
|
510
1741
|
if (state !== SWARM_STATES.PLANNING) {
|
|
511
1742
|
throw new Error(`Cannot launch in state "${state}"`);
|
|
512
1743
|
}
|
|
513
1744
|
|
|
514
1745
|
plan = swarmPlan;
|
|
1746
|
+
markIntegrationPromisePending();
|
|
1747
|
+
|
|
1748
|
+
// Phase 1 dynamic routing wire-up (opt-in env TRIFLUX_DYNAMIC_ROUTING).
|
|
1749
|
+
// launch() 는 plan-time, 이미 async 라 D-1 facade 의 await routeRequest 를
|
|
1750
|
+
// 그대로 사용한다 (conductor.spawnSession 의 sync invariant 충돌 없음).
|
|
1751
|
+
// team_size = plan.shards.length 매핑 → S5 leaseMany 시나리오 자연 매핑.
|
|
1752
|
+
// decision.shards[i].cli 가 plan.shards[i].agent 와 다르면 1:1 override.
|
|
1753
|
+
if (
|
|
1754
|
+
isDynamicRoutingEnabled(process.env) &&
|
|
1755
|
+
Array.isArray(plan?.shards) &&
|
|
1756
|
+
plan.shards.length > 0
|
|
1757
|
+
) {
|
|
1758
|
+
try {
|
|
1759
|
+
const router = createDynamicRouter();
|
|
1760
|
+
const decision = await router.routeRequest({
|
|
1761
|
+
task_id: runId,
|
|
1762
|
+
team_size: plan.shards.length,
|
|
1763
|
+
agent_hint: plan.shards[0]?.agent || "codex",
|
|
1764
|
+
});
|
|
1765
|
+
eventLog.append("dynamic_route_plan_decision", {
|
|
1766
|
+
decisionId: decision.decisionId ?? null,
|
|
1767
|
+
scenario: decision.scenario ?? "unknown",
|
|
1768
|
+
lane: decision.lane ?? null,
|
|
1769
|
+
mode: decision.mode ?? null,
|
|
1770
|
+
shardCount: decision.shards?.length ?? 0,
|
|
1771
|
+
planShardCount: plan.shards.length,
|
|
1772
|
+
snapshotAgeMs: decision.snapshotAgeMs ?? null,
|
|
1773
|
+
});
|
|
1774
|
+
const overrides = [];
|
|
1775
|
+
const decShards = Array.isArray(decision.shards) ? decision.shards : [];
|
|
1776
|
+
for (let i = 0; i < plan.shards.length; i++) {
|
|
1777
|
+
const shard = plan.shards[i];
|
|
1778
|
+
const decShard = decShards[i];
|
|
1779
|
+
if (decShard?.cli && decShard.cli !== shard.agent) {
|
|
1780
|
+
overrides.push({
|
|
1781
|
+
shard: shard.name,
|
|
1782
|
+
original: shard.agent,
|
|
1783
|
+
override: decShard.cli,
|
|
1784
|
+
accountId: decShard.accountId ?? null,
|
|
1785
|
+
});
|
|
1786
|
+
shard.agent = decShard.cli;
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
if (overrides.length > 0) {
|
|
1790
|
+
eventLog.append("dynamic_route_plan_overrides", {
|
|
1791
|
+
decisionId: decision.decisionId ?? null,
|
|
1792
|
+
count: overrides.length,
|
|
1793
|
+
overrides,
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1796
|
+
} catch (err) {
|
|
1797
|
+
eventLog.append("dynamic_route_plan_error", {
|
|
1798
|
+
error: err?.message || String(err),
|
|
1799
|
+
});
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
// Hub alive 확인 — 죽어있으면 재시작
|
|
1804
|
+
if (ensureHubAliveImpl) {
|
|
1805
|
+
ensureHubAliveImpl()
|
|
1806
|
+
.then((hub) => {
|
|
1807
|
+
eventLog.append("hub_ensured", { port: hub?.port });
|
|
1808
|
+
})
|
|
1809
|
+
.catch((err) => {
|
|
1810
|
+
eventLog.append("hub_ensure_failed", { error: err.message });
|
|
1811
|
+
emitter.emit("warning", {
|
|
1812
|
+
type: "hub_unavailable",
|
|
1813
|
+
error: err.message,
|
|
1814
|
+
});
|
|
1815
|
+
});
|
|
1816
|
+
}
|
|
515
1817
|
|
|
516
1818
|
// Warn about file conflicts but don't block
|
|
517
1819
|
if (plan.conflicts.length > 0) {
|
|
518
|
-
eventLog.append(
|
|
519
|
-
emitter.emit(
|
|
520
|
-
type:
|
|
1820
|
+
eventLog.append("file_conflicts_warning", { conflicts: plan.conflicts });
|
|
1821
|
+
emitter.emit("warning", {
|
|
1822
|
+
type: "file_conflicts",
|
|
521
1823
|
conflicts: plan.conflicts,
|
|
522
1824
|
});
|
|
523
1825
|
}
|
|
@@ -525,27 +1827,30 @@ export function createSwarmHypervisor(opts) {
|
|
|
525
1827
|
// Initialize lock manager
|
|
526
1828
|
lockManager = createSwarmLocks({
|
|
527
1829
|
repoRoot: workdir,
|
|
528
|
-
persistPath: join(workdir,
|
|
1830
|
+
persistPath: join(workdir, ".triflux", "swarm-locks.json"),
|
|
529
1831
|
});
|
|
530
1832
|
|
|
1833
|
+
// Hub keepalive 시작
|
|
1834
|
+
startHubKeepalive();
|
|
1835
|
+
|
|
531
1836
|
setState(SWARM_STATES.LAUNCHING, `${plan.shards.length} shards`);
|
|
532
1837
|
|
|
533
1838
|
// Launch shards respecting dependency order
|
|
534
1839
|
const launched = new Set();
|
|
535
1840
|
const pending = new Set(plan.mergeOrder);
|
|
536
1841
|
|
|
537
|
-
function launchReady() {
|
|
1842
|
+
async function launchReady() {
|
|
538
1843
|
for (const name of pending) {
|
|
539
1844
|
const shard = plan.shards.find((s) => s.name === name);
|
|
540
1845
|
if (!shard) continue;
|
|
541
1846
|
|
|
542
|
-
// Check all dependencies are
|
|
543
|
-
const depsReady = shard.depends.every((d) =>
|
|
1847
|
+
// Check all dependencies are completed (not just launched)
|
|
1848
|
+
const depsReady = shard.depends.every((d) => completedShards.has(d));
|
|
544
1849
|
if (!depsReady) continue;
|
|
545
1850
|
|
|
546
1851
|
pending.delete(name);
|
|
547
1852
|
launched.add(name);
|
|
548
|
-
launchShard(shard);
|
|
1853
|
+
await launchShard(shard);
|
|
549
1854
|
|
|
550
1855
|
// Launch redundant worker for critical shards
|
|
551
1856
|
if (shard.critical) {
|
|
@@ -553,40 +1858,74 @@ export function createSwarmHypervisor(opts) {
|
|
|
553
1858
|
...shard,
|
|
554
1859
|
agent: FALLBACK_AGENTS[shard.agent] || shard.agent,
|
|
555
1860
|
};
|
|
556
|
-
launchShard(redundantShard, true);
|
|
1861
|
+
await launchShard(redundantShard, true);
|
|
557
1862
|
}
|
|
558
1863
|
}
|
|
559
1864
|
}
|
|
560
1865
|
|
|
561
|
-
launchReady();
|
|
1866
|
+
await launchReady();
|
|
562
1867
|
|
|
563
1868
|
// Re-check pending on each shard completion (dependency chains)
|
|
564
|
-
emitter.on(
|
|
565
|
-
if (pending.size > 0) launchReady();
|
|
1869
|
+
emitter.on("shardCompleted", () => {
|
|
1870
|
+
if (pending.size > 0) void launchReady();
|
|
566
1871
|
});
|
|
567
1872
|
|
|
568
|
-
setState(
|
|
1873
|
+
setState(
|
|
1874
|
+
SWARM_STATES.RUNNING,
|
|
1875
|
+
`${launched.size} launched, ${pending.size} pending deps`,
|
|
1876
|
+
);
|
|
569
1877
|
|
|
570
|
-
return getStatus();
|
|
1878
|
+
return { ...getStatus(), done: integrationPromise };
|
|
571
1879
|
}
|
|
572
1880
|
|
|
573
1881
|
/**
|
|
574
1882
|
* Graceful shutdown — kill all workers and release locks.
|
|
575
1883
|
* @param {string} [reason]
|
|
576
1884
|
*/
|
|
577
|
-
async function shutdown(reason =
|
|
578
|
-
|
|
1885
|
+
async function shutdown(reason = "shutdown") {
|
|
1886
|
+
stopHubKeepalive();
|
|
1887
|
+
eventLog.append("swarm_shutdown", { reason, state });
|
|
579
1888
|
|
|
580
1889
|
const shutdowns = [];
|
|
581
1890
|
for (const [, w] of workers) {
|
|
582
1891
|
shutdowns.push(w.conductor.shutdown(reason));
|
|
1892
|
+
shutdowns.push(
|
|
1893
|
+
closeNativeBridgeRegistration(w, w.shardConfig?.name, reason),
|
|
1894
|
+
);
|
|
583
1895
|
}
|
|
584
1896
|
for (const [, w] of redundantWorkers) {
|
|
585
1897
|
shutdowns.push(w.conductor.shutdown(reason));
|
|
1898
|
+
shutdowns.push(
|
|
1899
|
+
closeNativeBridgeRegistration(w, w.shardConfig?.name, reason),
|
|
1900
|
+
);
|
|
586
1901
|
}
|
|
587
1902
|
|
|
588
1903
|
await Promise.allSettled(shutdowns);
|
|
589
1904
|
|
|
1905
|
+
if (!keepFailedWorktrees) {
|
|
1906
|
+
const cleanupCandidates = new Map();
|
|
1907
|
+
for (const [shardName, worker] of workers) {
|
|
1908
|
+
cleanupCandidates.set(shardName, worker);
|
|
1909
|
+
}
|
|
1910
|
+
for (const [shardName, worker] of redundantWorkers) {
|
|
1911
|
+
cleanupCandidates.set(`${shardName}:redundant`, worker);
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
for (const [candidateName, worker] of cleanupCandidates) {
|
|
1915
|
+
const shardName = worker?.shardConfig?.name || candidateName;
|
|
1916
|
+
const failureInfo = failures.get(shardName);
|
|
1917
|
+
const shard =
|
|
1918
|
+
worker?.shardConfig ||
|
|
1919
|
+
plan?.shards.find((item) => item.name === shardName);
|
|
1920
|
+
if (!worker?.worktreePath) continue;
|
|
1921
|
+
await maybeCleanupWorktree(shardName, worker, shard, {
|
|
1922
|
+
force: true,
|
|
1923
|
+
failureReason: failureInfo?.mode || failureInfo?.reason || reason,
|
|
1924
|
+
});
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
sharedRegistry.clear();
|
|
590
1929
|
lockManager?.releaseAll();
|
|
591
1930
|
await eventLog.flush();
|
|
592
1931
|
await eventLog.close();
|
|
@@ -595,18 +1934,30 @@ export function createSwarmHypervisor(opts) {
|
|
|
595
1934
|
setState(SWARM_STATES.FAILED, reason);
|
|
596
1935
|
}
|
|
597
1936
|
|
|
598
|
-
emitter.emit(
|
|
1937
|
+
emitter.emit("shutdown", { reason });
|
|
599
1938
|
}
|
|
600
1939
|
|
|
601
1940
|
return Object.freeze({
|
|
602
1941
|
launch,
|
|
603
1942
|
shutdown,
|
|
604
1943
|
getStatus,
|
|
1944
|
+
integrationComplete() {
|
|
1945
|
+
return integrationPromise;
|
|
1946
|
+
},
|
|
1947
|
+
getMeshRegistry() {
|
|
1948
|
+
return sharedRegistry;
|
|
1949
|
+
},
|
|
605
1950
|
validateResult,
|
|
606
1951
|
on: emitter.on.bind(emitter),
|
|
607
1952
|
off: emitter.off.bind(emitter),
|
|
608
|
-
get state() {
|
|
609
|
-
|
|
610
|
-
|
|
1953
|
+
get state() {
|
|
1954
|
+
return state;
|
|
1955
|
+
},
|
|
1956
|
+
get plan() {
|
|
1957
|
+
return plan;
|
|
1958
|
+
},
|
|
1959
|
+
get eventLogPath() {
|
|
1960
|
+
return eventLog.filePath;
|
|
1961
|
+
},
|
|
611
1962
|
});
|
|
612
1963
|
}
|