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