@triflux/remote 10.0.1 → 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 +68 -7
- package/hub/server.mjs +2181 -701
- package/hub/store-adapter.mjs +101 -37
- 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 +127 -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 +45 -17
- 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 +21 -119
- 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 +1067 -264
- package/hub/team/recovery-store.mjs +149 -0
- package/hub/team/remote-probe.mjs +69 -53
- package/hub/team/remote-session.mjs +131 -59
- package/hub/team/remote-watcher.mjs +62 -34
- 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 +1526 -198
- 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 +21 -37
- package/hub/team/tui-synapse.mjs +223 -0
- package/hub/team/tui-viewer.mjs +104 -45
- package/hub/team/tui-widgets.mjs +262 -0
- package/hub/team/tui.mjs +936 -449
- 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 +518 -64
- 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 +663 -337
- 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,54 +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 {
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
import {
|
|
23
|
-
import { getHostConfig } from
|
|
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
|
+
}
|
|
52
|
+
|
|
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
|
+
}
|
|
24
62
|
|
|
25
63
|
// ── Swarm states ──────────────────────────────────────────────
|
|
26
64
|
|
|
27
65
|
export const SWARM_STATES = Object.freeze({
|
|
28
|
-
PLANNING:
|
|
29
|
-
LAUNCHING:
|
|
30
|
-
RUNNING:
|
|
31
|
-
INTEGRATING:
|
|
32
|
-
VALIDATING:
|
|
33
|
-
COMPLETED:
|
|
34
|
-
FAILED:
|
|
66
|
+
PLANNING: "planning",
|
|
67
|
+
LAUNCHING: "launching",
|
|
68
|
+
RUNNING: "running",
|
|
69
|
+
INTEGRATING: "integrating",
|
|
70
|
+
VALIDATING: "validating",
|
|
71
|
+
COMPLETED: "completed",
|
|
72
|
+
FAILED: "failed",
|
|
35
73
|
});
|
|
36
74
|
|
|
37
75
|
// ── Failure mode classification ───────────────────────────────
|
|
38
76
|
|
|
39
77
|
const FAILURE_MODES = Object.freeze({
|
|
40
|
-
F1_CRASH:
|
|
41
|
-
F2_RATE_LIMIT:
|
|
42
|
-
F3_STALL:
|
|
43
|
-
F4_LEASE_VIOLATION:
|
|
44
|
-
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",
|
|
45
85
|
});
|
|
46
86
|
|
|
47
87
|
const FALLBACK_AGENTS = Object.freeze({
|
|
48
|
-
codex:
|
|
49
|
-
gemini:
|
|
50
|
-
claude:
|
|
88
|
+
codex: "gemini",
|
|
89
|
+
gemini: "codex",
|
|
90
|
+
claude: "codex",
|
|
51
91
|
});
|
|
52
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
|
+
|
|
53
159
|
/**
|
|
54
160
|
* Create a swarm hypervisor.
|
|
55
161
|
* @param {object} opts
|
|
56
162
|
* @param {string} opts.workdir — repository root / working directory
|
|
57
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
|
|
58
166
|
* @param {number} [opts.maxRestarts=2] — per-shard max restarts
|
|
167
|
+
* @param {boolean} [opts.nativeBridge=false] — expose local shards in Claude agents
|
|
59
168
|
* @param {number} [opts.graceMs=10000] — conductor shutdown grace period
|
|
60
169
|
* @param {number} [opts.integrationTimeoutMs=60000] — max time for integration phase
|
|
170
|
+
* @param {boolean} [opts.keepFailedWorktrees=false] — preserve failed shard worktrees for debugging
|
|
61
171
|
* @param {object} [opts.probeOpts] — health probe overrides
|
|
62
172
|
* @param {object} [opts.deps] — dependency injection for testing
|
|
63
173
|
* @returns {SwarmHypervisor}
|
|
@@ -66,20 +176,47 @@ export function createSwarmHypervisor(opts) {
|
|
|
66
176
|
const {
|
|
67
177
|
workdir,
|
|
68
178
|
logsDir,
|
|
179
|
+
baseBranch = "main",
|
|
180
|
+
runId = `swarm-${Date.now()}`,
|
|
69
181
|
maxRestarts = 2,
|
|
182
|
+
nativeBridge = false,
|
|
70
183
|
graceMs = 10_000,
|
|
71
|
-
|
|
184
|
+
keepFailedWorktrees = false,
|
|
185
|
+
_integrationTimeoutMs = 60_000,
|
|
72
186
|
probeOpts = {},
|
|
73
|
-
|
|
187
|
+
_deps = {},
|
|
74
188
|
} = opts;
|
|
75
189
|
|
|
76
|
-
if (!workdir) throw new Error(
|
|
77
|
-
if (!logsDir) throw new Error(
|
|
190
|
+
if (!workdir) throw new Error("workdir is required");
|
|
191
|
+
if (!logsDir) throw new Error("logsDir is required");
|
|
78
192
|
|
|
79
193
|
mkdirSync(logsDir, { recursive: true });
|
|
80
|
-
|
|
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;
|
|
81
216
|
const emitter = new EventEmitter();
|
|
82
|
-
const eventLog = createEventLog(join(logsDir,
|
|
217
|
+
const eventLog = createEventLog(join(logsDir, "swarm-events.jsonl"));
|
|
218
|
+
const { registry: sharedRegistry, fallbackReason: meshRegistryFallback } =
|
|
219
|
+
createSharedRegistry(createRegistryImpl);
|
|
83
220
|
|
|
84
221
|
let state = SWARM_STATES.PLANNING;
|
|
85
222
|
let plan = null;
|
|
@@ -91,38 +228,452 @@ export function createSwarmHypervisor(opts) {
|
|
|
91
228
|
/** @type {Map<string, { conductor, shardConfig }>} redundant workers for critical shards */
|
|
92
229
|
const redundantWorkers = new Map();
|
|
93
230
|
|
|
94
|
-
|
|
95
|
-
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
|
+
}
|
|
96
267
|
|
|
97
268
|
// ── State machine ───────────────────────────────────────────
|
|
98
269
|
|
|
99
|
-
function setState(next, reason =
|
|
270
|
+
function setState(next, reason = "") {
|
|
100
271
|
const prev = state;
|
|
101
272
|
state = next;
|
|
102
|
-
eventLog.append(
|
|
103
|
-
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;
|
|
104
534
|
}
|
|
105
535
|
|
|
106
536
|
// ── Worker lifecycle ────────────────────────────────────────
|
|
107
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
|
+
|
|
108
643
|
function buildSessionConfig(shard) {
|
|
109
644
|
const config = {
|
|
110
|
-
id:
|
|
645
|
+
id: buildSwarmSessionId(shard),
|
|
111
646
|
agent: shard.agent,
|
|
112
|
-
|
|
113
|
-
|
|
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,
|
|
114
651
|
mcpServers: shard.mcp,
|
|
652
|
+
worktreePath: shard.worktreePath || null,
|
|
653
|
+
branchName: shard.branchName || null,
|
|
654
|
+
// #90: shard는 worktree 격리가 본질. main 직접 커밋 방지 가드 env 주입.
|
|
655
|
+
branchGuard: true,
|
|
115
656
|
};
|
|
116
657
|
|
|
117
|
-
|
|
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
|
|
118
669
|
if (shard.host && shard._remoteEnv) {
|
|
119
|
-
const
|
|
670
|
+
const hostConfig = getHostConfig(shard.host);
|
|
671
|
+
const remoteSrc = hostConfig?.default_dir || workdir;
|
|
672
|
+
const remoteDir = resolveRemoteDir(remoteSrc, shard._remoteEnv);
|
|
120
673
|
return {
|
|
121
674
|
...config,
|
|
122
675
|
remote: true,
|
|
123
676
|
host: shard.host,
|
|
124
|
-
sessionName: `swarm-${shard.name}-${Date.now()}`,
|
|
125
|
-
paneTarget: `swarm-${shard.name}-${Date.now()}:0.0`,
|
|
126
677
|
workdir: remoteDir,
|
|
127
678
|
};
|
|
128
679
|
}
|
|
@@ -130,109 +681,303 @@ export function createSwarmHypervisor(opts) {
|
|
|
130
681
|
return config;
|
|
131
682
|
}
|
|
132
683
|
|
|
133
|
-
function launchShard(shard, isRedundant = false) {
|
|
134
|
-
|
|
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
|
+
);
|
|
135
693
|
mkdirSync(shardLogsDir, { recursive: true });
|
|
136
694
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
+
}
|
|
144
730
|
return null;
|
|
145
731
|
}
|
|
146
|
-
eventLog.append('remote_probe_ok', { shard: shard.name, host: shard.host, env: shard._remoteEnv });
|
|
147
|
-
} catch (err) {
|
|
148
|
-
eventLog.append('remote_probe_failed', { shard: shard.name, host: shard.host, error: err.message });
|
|
149
|
-
failures.set(shard.name, { mode: FAILURE_MODES.F1_CRASH, reason: `remote probe failed: ${err.message}` });
|
|
150
|
-
return null;
|
|
151
732
|
}
|
|
152
|
-
}
|
|
153
733
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
+
}
|
|
161
749
|
|
|
162
|
-
|
|
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
|
+
});
|
|
163
769
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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) {
|
|
172
845
|
failures.set(shard.name, {
|
|
173
|
-
mode: FAILURE_MODES.
|
|
174
|
-
|
|
846
|
+
mode: FAILURE_MODES.F1_CRASH,
|
|
847
|
+
reason: err.message,
|
|
175
848
|
});
|
|
176
|
-
return null;
|
|
177
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;
|
|
178
869
|
}
|
|
870
|
+
}
|
|
179
871
|
|
|
180
|
-
|
|
872
|
+
// ── Completion handling ─────────────────────────────────────
|
|
181
873
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
874
|
+
function handleShardCompleted(
|
|
875
|
+
shardName,
|
|
876
|
+
sessionId,
|
|
877
|
+
isRedundant,
|
|
878
|
+
completionPayload,
|
|
879
|
+
) {
|
|
880
|
+
eventLog.append("shard_completed", {
|
|
881
|
+
shard: shardName,
|
|
882
|
+
sessionId,
|
|
186
883
|
isRedundant,
|
|
187
|
-
|
|
188
|
-
remote: Boolean(shard.host),
|
|
189
|
-
host: shard.host || null,
|
|
884
|
+
hasPayload: completionPayload !== undefined,
|
|
190
885
|
});
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if (
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
+
);
|
|
198
895
|
}
|
|
199
896
|
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
+
}
|
|
204
933
|
|
|
205
|
-
|
|
206
|
-
|
|
934
|
+
const redundant = redundantWorkers.get(shardName);
|
|
935
|
+
if (redundant) {
|
|
936
|
+
void redundant.conductor.shutdown("primary_failed_f7");
|
|
937
|
+
}
|
|
207
938
|
|
|
208
|
-
|
|
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
|
+
}
|
|
209
950
|
|
|
210
|
-
|
|
211
|
-
eventLog.append('shard_completed', { shard: shardName, sessionId, isRedundant });
|
|
951
|
+
completedShards.add(shardName);
|
|
212
952
|
|
|
213
953
|
if (isRedundant) {
|
|
214
954
|
// Redundant worker completed first — kill primary if still running
|
|
215
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
|
+
}
|
|
216
961
|
if (primary && !isTerminal(primary)) {
|
|
217
|
-
eventLog.append(
|
|
218
|
-
void primary.conductor.shutdown(
|
|
962
|
+
eventLog.append("redundant_wins", { shard: shardName });
|
|
963
|
+
void primary.conductor.shutdown("redundant_completed_first");
|
|
219
964
|
}
|
|
220
965
|
} else {
|
|
221
966
|
// Primary completed — kill redundant if exists
|
|
222
967
|
const redundant = redundantWorkers.get(shardName);
|
|
223
968
|
if (redundant) {
|
|
224
|
-
void redundant.conductor.shutdown(
|
|
969
|
+
void redundant.conductor.shutdown("primary_completed_first");
|
|
225
970
|
}
|
|
226
971
|
}
|
|
227
972
|
|
|
228
|
-
emitter.emit(
|
|
973
|
+
emitter.emit("shardCompleted", { shardName, sessionId, isRedundant });
|
|
229
974
|
checkAllShardsCompleted();
|
|
230
975
|
}
|
|
231
976
|
|
|
232
977
|
function handleShardFailed(shardName, sessionId, reason, isRedundant) {
|
|
233
978
|
const failureMode = classifyFailure(reason);
|
|
234
979
|
|
|
235
|
-
eventLog.append(
|
|
980
|
+
eventLog.append("shard_failed", {
|
|
236
981
|
shard: shardName,
|
|
237
982
|
sessionId,
|
|
238
983
|
reason,
|
|
@@ -241,6 +986,10 @@ export function createSwarmHypervisor(opts) {
|
|
|
241
986
|
});
|
|
242
987
|
|
|
243
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
|
+
}
|
|
244
993
|
|
|
245
994
|
// F2: Rate limit — try fallback agent
|
|
246
995
|
if (failureMode === FAILURE_MODES.F2_RATE_LIMIT) {
|
|
@@ -248,14 +997,14 @@ export function createSwarmHypervisor(opts) {
|
|
|
248
997
|
if (shard) {
|
|
249
998
|
const fallbackAgent = FALLBACK_AGENTS[shard.agent];
|
|
250
999
|
if (fallbackAgent) {
|
|
251
|
-
eventLog.append(
|
|
1000
|
+
eventLog.append("fallback_agent", {
|
|
252
1001
|
shard: shardName,
|
|
253
1002
|
from: shard.agent,
|
|
254
1003
|
to: fallbackAgent,
|
|
255
1004
|
});
|
|
256
1005
|
const fallbackShard = { ...shard, agent: fallbackAgent };
|
|
257
1006
|
lockManager.release(shardName);
|
|
258
|
-
launchShard(fallbackShard);
|
|
1007
|
+
void launchShard(fallbackShard);
|
|
259
1008
|
return;
|
|
260
1009
|
}
|
|
261
1010
|
}
|
|
@@ -264,10 +1013,48 @@ export function createSwarmHypervisor(opts) {
|
|
|
264
1013
|
failures.set(shardName, { mode: failureMode, reason, sessionId });
|
|
265
1014
|
lockManager.release(shardName);
|
|
266
1015
|
|
|
267
|
-
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
|
+
|
|
268
1023
|
checkAllShardsCompleted();
|
|
269
1024
|
}
|
|
270
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
|
+
|
|
271
1058
|
function classifyFailure(reason) {
|
|
272
1059
|
if (!reason) return FAILURE_MODES.F1_CRASH;
|
|
273
1060
|
const r = String(reason).toLowerCase();
|
|
@@ -275,12 +1062,15 @@ export function createSwarmHypervisor(opts) {
|
|
|
275
1062
|
if (/stall|l1_stall|timeout/u.test(r)) return FAILURE_MODES.F3_STALL;
|
|
276
1063
|
if (/lease|violation/u.test(r)) return FAILURE_MODES.F4_LEASE_VIOLATION;
|
|
277
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;
|
|
278
1066
|
return FAILURE_MODES.F1_CRASH;
|
|
279
1067
|
}
|
|
280
1068
|
|
|
281
1069
|
function isTerminal(entry) {
|
|
282
1070
|
const snap = entry.conductor.getSnapshot();
|
|
283
|
-
return snap.every(
|
|
1071
|
+
return snap.every(
|
|
1072
|
+
(s) => s.state === STATES.COMPLETED || s.state === STATES.DEAD,
|
|
1073
|
+
);
|
|
284
1074
|
}
|
|
285
1075
|
|
|
286
1076
|
// ── Integration ─────────────────────────────────────────────
|
|
@@ -288,10 +1078,16 @@ export function createSwarmHypervisor(opts) {
|
|
|
288
1078
|
function checkAllShardsCompleted() {
|
|
289
1079
|
if (state !== SWARM_STATES.RUNNING) return;
|
|
290
1080
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
+
);
|
|
295
1091
|
|
|
296
1092
|
if (allDone) {
|
|
297
1093
|
void integrateResults();
|
|
@@ -305,9 +1101,16 @@ export function createSwarmHypervisor(opts) {
|
|
|
305
1101
|
* @returns {{ ok: boolean, violations: Array }}
|
|
306
1102
|
*/
|
|
307
1103
|
function validateResult(shardName, changedFiles) {
|
|
308
|
-
|
|
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
|
+
});
|
|
309
1112
|
|
|
310
|
-
eventLog.append(
|
|
1113
|
+
eventLog.append("validate_result", {
|
|
311
1114
|
shard: shardName,
|
|
312
1115
|
changedFiles,
|
|
313
1116
|
violations,
|
|
@@ -325,85 +1128,411 @@ export function createSwarmHypervisor(opts) {
|
|
|
325
1128
|
* Uses git operations for conflict detection.
|
|
326
1129
|
*/
|
|
327
1130
|
async function integrateResults() {
|
|
328
|
-
setState(SWARM_STATES.INTEGRATING,
|
|
1131
|
+
setState(SWARM_STATES.INTEGRATING, "all_shards_done");
|
|
329
1132
|
|
|
330
1133
|
const integrated = [];
|
|
331
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
|
+
};
|
|
332
1145
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
+
}
|
|
338
1171
|
|
|
339
|
-
|
|
340
|
-
|
|
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
|
+
});
|
|
341
1188
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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,
|
|
354
1227
|
});
|
|
355
1228
|
|
|
356
|
-
|
|
357
|
-
|
|
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
|
+
});
|
|
358
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
|
+
});
|
|
359
1259
|
continue;
|
|
360
1260
|
}
|
|
361
|
-
eventLog.append('remote_fetch_ok', { shard: shardName, headCommit: fetchResult.headCommit });
|
|
362
|
-
}
|
|
363
1261
|
|
|
364
|
-
|
|
365
|
-
|
|
1262
|
+
// Read shard output log for changed files
|
|
1263
|
+
const changedFiles = detectChangedFiles(shardName, worker);
|
|
366
1264
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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,
|
|
373
1343
|
});
|
|
374
|
-
|
|
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({
|
|
375
1366
|
shard: shardName,
|
|
376
|
-
|
|
1367
|
+
strategy: "failed",
|
|
1368
|
+
notes: `integration_error: ${err.message}`,
|
|
377
1369
|
});
|
|
378
|
-
integrationFailures.push(shardName);
|
|
379
|
-
continue;
|
|
380
1370
|
}
|
|
381
1371
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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,
|
|
386
1393
|
});
|
|
387
|
-
|
|
1394
|
+
emitter.emit("integrationComplete", payload);
|
|
1395
|
+
return payload;
|
|
388
1396
|
}
|
|
389
1397
|
|
|
390
|
-
|
|
1398
|
+
const skipped = preIntegrationFailures.filter(
|
|
1399
|
+
(name) => !integrationFailures.includes(name),
|
|
1400
|
+
);
|
|
1401
|
+
const failed = [...new Set([...skipped, ...integrationFailures])];
|
|
1402
|
+
|
|
1403
|
+
eventLog.append("integration_complete", {
|
|
391
1404
|
integrated,
|
|
392
|
-
failed
|
|
393
|
-
|
|
1405
|
+
failed,
|
|
1406
|
+
integrationFailures,
|
|
1407
|
+
integrationBranch,
|
|
1408
|
+
skipped,
|
|
1409
|
+
report: integrationReport,
|
|
394
1410
|
});
|
|
395
1411
|
|
|
396
|
-
if (
|
|
397
|
-
setState(SWARM_STATES.FAILED,
|
|
1412
|
+
if (failed.length > 0 && integrated.length === 0) {
|
|
1413
|
+
setState(SWARM_STATES.FAILED, "all_shards_failed_integration");
|
|
398
1414
|
} else {
|
|
399
|
-
setState(
|
|
1415
|
+
setState(
|
|
1416
|
+
SWARM_STATES.COMPLETED,
|
|
1417
|
+
`${integrated.length}/${plan.shards.length} integrated`,
|
|
1418
|
+
);
|
|
400
1419
|
}
|
|
401
1420
|
|
|
402
|
-
|
|
1421
|
+
const payload = settleIntegrationPromise({
|
|
403
1422
|
integrated,
|
|
404
|
-
failed
|
|
1423
|
+
failed,
|
|
1424
|
+
integrationFailures,
|
|
1425
|
+
integrationBranch,
|
|
1426
|
+
skipped,
|
|
1427
|
+
report: integrationReport,
|
|
405
1428
|
results: [...results.values()],
|
|
1429
|
+
partial: failed.length > 0,
|
|
406
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;
|
|
407
1536
|
}
|
|
408
1537
|
|
|
409
1538
|
/**
|
|
@@ -415,16 +1544,21 @@ export function createSwarmHypervisor(opts) {
|
|
|
415
1544
|
*/
|
|
416
1545
|
function detectChangedFiles(shardName, worker) {
|
|
417
1546
|
// Best-effort: parse output log for file paths
|
|
418
|
-
const
|
|
1547
|
+
const _outPath = join(logsDir, shardName);
|
|
419
1548
|
try {
|
|
420
1549
|
const snap = worker.conductor.getSnapshot();
|
|
421
1550
|
for (const session of snap) {
|
|
422
1551
|
if (session.outPath && existsSync(session.outPath)) {
|
|
423
|
-
const output = readFileSync(session.outPath,
|
|
424
|
-
return extractFilePathsFromOutput(
|
|
1552
|
+
const output = readFileSync(session.outPath, "utf8");
|
|
1553
|
+
return extractFilePathsFromOutput(
|
|
1554
|
+
output,
|
|
1555
|
+
plan.leaseMap.get(shardName) || [],
|
|
1556
|
+
);
|
|
425
1557
|
}
|
|
426
1558
|
}
|
|
427
|
-
} catch {
|
|
1559
|
+
} catch {
|
|
1560
|
+
/* best-effort */
|
|
1561
|
+
}
|
|
428
1562
|
|
|
429
1563
|
// Fallback: trust the lease map (shard was allowed these files)
|
|
430
1564
|
return plan.leaseMap.get(shardName) || [];
|
|
@@ -447,8 +1581,8 @@ export function createSwarmHypervisor(opts) {
|
|
|
447
1581
|
// Match common patterns
|
|
448
1582
|
const patterns = [
|
|
449
1583
|
/(?:wrote|created|modified|updated|edited)\s+['"]?([^\s'"]+\.\w+)/i,
|
|
450
|
-
/^[+-]{3}\s+[ab]\/(.+)/,
|
|
451
|
-
/^diff --git a\/(.+)\s+b\//,
|
|
1584
|
+
/^[+-]{3}\s+[ab]\/(.+)/, // diff headers
|
|
1585
|
+
/^diff --git a\/(.+)\s+b\//, // git diff headers
|
|
452
1586
|
];
|
|
453
1587
|
|
|
454
1588
|
for (const re of patterns) {
|
|
@@ -459,9 +1593,9 @@ export function createSwarmHypervisor(opts) {
|
|
|
459
1593
|
|
|
460
1594
|
// Intersect with allowed files if we found anything
|
|
461
1595
|
if (found.size > 0) {
|
|
462
|
-
return [...found].filter((f) =>
|
|
463
|
-
(a) => f.endsWith(a) || a.endsWith(f) || f === a,
|
|
464
|
-
)
|
|
1596
|
+
return [...found].filter((f) =>
|
|
1597
|
+
allowedFiles.some((a) => f.endsWith(a) || a.endsWith(f) || f === a),
|
|
1598
|
+
);
|
|
465
1599
|
}
|
|
466
1600
|
|
|
467
1601
|
return allowedFiles;
|
|
@@ -478,6 +1612,7 @@ export function createSwarmHypervisor(opts) {
|
|
|
478
1612
|
|
|
479
1613
|
for (const [name, w] of workers) {
|
|
480
1614
|
const snap = w.conductor.getSnapshot();
|
|
1615
|
+
const authoritative = computeAuthoritativeStatus(name, w, snap);
|
|
481
1616
|
workerStatuses.push({
|
|
482
1617
|
shard: name,
|
|
483
1618
|
agent: w.shardConfig.agent,
|
|
@@ -485,18 +1620,40 @@ export function createSwarmHypervisor(opts) {
|
|
|
485
1620
|
failed: failures.has(name),
|
|
486
1621
|
failureInfo: failures.get(name) || null,
|
|
487
1622
|
integrated: results.has(name),
|
|
1623
|
+
authoritativeStatus: authoritative.status,
|
|
1624
|
+
authoritativeReason: authoritative.reason,
|
|
1625
|
+
commitEvidence: w.commitEvidence || null,
|
|
488
1626
|
});
|
|
489
1627
|
}
|
|
490
1628
|
|
|
491
1629
|
return Object.freeze({
|
|
492
1630
|
state,
|
|
493
1631
|
totalShards: plan?.shards.length || 0,
|
|
494
|
-
completedShards:
|
|
1632
|
+
completedShards: completedShards.size,
|
|
495
1633
|
failedShards: failures.size,
|
|
496
1634
|
workers: workerStatuses,
|
|
497
1635
|
mergeOrder: plan?.mergeOrder || [],
|
|
498
1636
|
criticalShards: plan?.criticalShards || [],
|
|
499
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
|
+
}),
|
|
500
1657
|
});
|
|
501
1658
|
}
|
|
502
1659
|
|
|
@@ -505,20 +1662,142 @@ export function createSwarmHypervisor(opts) {
|
|
|
505
1662
|
/**
|
|
506
1663
|
* Launch the swarm from a pre-built plan.
|
|
507
1664
|
* @param {SwarmPlan} swarmPlan — from planSwarm()
|
|
508
|
-
* @returns {SwarmStatus}
|
|
1665
|
+
* @returns {Promise<SwarmStatus>}
|
|
509
1666
|
*/
|
|
510
|
-
|
|
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) {
|
|
511
1719
|
if (state !== SWARM_STATES.PLANNING) {
|
|
512
1720
|
throw new Error(`Cannot launch in state "${state}"`);
|
|
513
1721
|
}
|
|
514
1722
|
|
|
515
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
|
+
}
|
|
516
1795
|
|
|
517
1796
|
// Warn about file conflicts but don't block
|
|
518
1797
|
if (plan.conflicts.length > 0) {
|
|
519
|
-
eventLog.append(
|
|
520
|
-
emitter.emit(
|
|
521
|
-
type:
|
|
1798
|
+
eventLog.append("file_conflicts_warning", { conflicts: plan.conflicts });
|
|
1799
|
+
emitter.emit("warning", {
|
|
1800
|
+
type: "file_conflicts",
|
|
522
1801
|
conflicts: plan.conflicts,
|
|
523
1802
|
});
|
|
524
1803
|
}
|
|
@@ -526,27 +1805,30 @@ export function createSwarmHypervisor(opts) {
|
|
|
526
1805
|
// Initialize lock manager
|
|
527
1806
|
lockManager = createSwarmLocks({
|
|
528
1807
|
repoRoot: workdir,
|
|
529
|
-
persistPath: join(workdir,
|
|
1808
|
+
persistPath: join(workdir, ".triflux", "swarm-locks.json"),
|
|
530
1809
|
});
|
|
531
1810
|
|
|
1811
|
+
// Hub keepalive 시작
|
|
1812
|
+
startHubKeepalive();
|
|
1813
|
+
|
|
532
1814
|
setState(SWARM_STATES.LAUNCHING, `${plan.shards.length} shards`);
|
|
533
1815
|
|
|
534
1816
|
// Launch shards respecting dependency order
|
|
535
1817
|
const launched = new Set();
|
|
536
1818
|
const pending = new Set(plan.mergeOrder);
|
|
537
1819
|
|
|
538
|
-
function launchReady() {
|
|
1820
|
+
async function launchReady() {
|
|
539
1821
|
for (const name of pending) {
|
|
540
1822
|
const shard = plan.shards.find((s) => s.name === name);
|
|
541
1823
|
if (!shard) continue;
|
|
542
1824
|
|
|
543
|
-
// Check all dependencies are
|
|
544
|
-
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));
|
|
545
1827
|
if (!depsReady) continue;
|
|
546
1828
|
|
|
547
1829
|
pending.delete(name);
|
|
548
1830
|
launched.add(name);
|
|
549
|
-
launchShard(shard);
|
|
1831
|
+
await launchShard(shard);
|
|
550
1832
|
|
|
551
1833
|
// Launch redundant worker for critical shards
|
|
552
1834
|
if (shard.critical) {
|
|
@@ -554,40 +1836,74 @@ export function createSwarmHypervisor(opts) {
|
|
|
554
1836
|
...shard,
|
|
555
1837
|
agent: FALLBACK_AGENTS[shard.agent] || shard.agent,
|
|
556
1838
|
};
|
|
557
|
-
launchShard(redundantShard, true);
|
|
1839
|
+
await launchShard(redundantShard, true);
|
|
558
1840
|
}
|
|
559
1841
|
}
|
|
560
1842
|
}
|
|
561
1843
|
|
|
562
|
-
launchReady();
|
|
1844
|
+
await launchReady();
|
|
563
1845
|
|
|
564
1846
|
// Re-check pending on each shard completion (dependency chains)
|
|
565
|
-
emitter.on(
|
|
566
|
-
if (pending.size > 0) launchReady();
|
|
1847
|
+
emitter.on("shardCompleted", () => {
|
|
1848
|
+
if (pending.size > 0) void launchReady();
|
|
567
1849
|
});
|
|
568
1850
|
|
|
569
|
-
setState(
|
|
1851
|
+
setState(
|
|
1852
|
+
SWARM_STATES.RUNNING,
|
|
1853
|
+
`${launched.size} launched, ${pending.size} pending deps`,
|
|
1854
|
+
);
|
|
570
1855
|
|
|
571
|
-
return getStatus();
|
|
1856
|
+
return { ...getStatus(), done: integrationPromise };
|
|
572
1857
|
}
|
|
573
1858
|
|
|
574
1859
|
/**
|
|
575
1860
|
* Graceful shutdown — kill all workers and release locks.
|
|
576
1861
|
* @param {string} [reason]
|
|
577
1862
|
*/
|
|
578
|
-
async function shutdown(reason =
|
|
579
|
-
|
|
1863
|
+
async function shutdown(reason = "shutdown") {
|
|
1864
|
+
stopHubKeepalive();
|
|
1865
|
+
eventLog.append("swarm_shutdown", { reason, state });
|
|
580
1866
|
|
|
581
1867
|
const shutdowns = [];
|
|
582
1868
|
for (const [, w] of workers) {
|
|
583
1869
|
shutdowns.push(w.conductor.shutdown(reason));
|
|
1870
|
+
shutdowns.push(
|
|
1871
|
+
closeNativeBridgeRegistration(w, w.shardConfig?.name, reason),
|
|
1872
|
+
);
|
|
584
1873
|
}
|
|
585
1874
|
for (const [, w] of redundantWorkers) {
|
|
586
1875
|
shutdowns.push(w.conductor.shutdown(reason));
|
|
1876
|
+
shutdowns.push(
|
|
1877
|
+
closeNativeBridgeRegistration(w, w.shardConfig?.name, reason),
|
|
1878
|
+
);
|
|
587
1879
|
}
|
|
588
1880
|
|
|
589
1881
|
await Promise.allSettled(shutdowns);
|
|
590
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();
|
|
591
1907
|
lockManager?.releaseAll();
|
|
592
1908
|
await eventLog.flush();
|
|
593
1909
|
await eventLog.close();
|
|
@@ -596,18 +1912,30 @@ export function createSwarmHypervisor(opts) {
|
|
|
596
1912
|
setState(SWARM_STATES.FAILED, reason);
|
|
597
1913
|
}
|
|
598
1914
|
|
|
599
|
-
emitter.emit(
|
|
1915
|
+
emitter.emit("shutdown", { reason });
|
|
600
1916
|
}
|
|
601
1917
|
|
|
602
1918
|
return Object.freeze({
|
|
603
1919
|
launch,
|
|
604
1920
|
shutdown,
|
|
605
1921
|
getStatus,
|
|
1922
|
+
integrationComplete() {
|
|
1923
|
+
return integrationPromise;
|
|
1924
|
+
},
|
|
1925
|
+
getMeshRegistry() {
|
|
1926
|
+
return sharedRegistry;
|
|
1927
|
+
},
|
|
606
1928
|
validateResult,
|
|
607
1929
|
on: emitter.on.bind(emitter),
|
|
608
1930
|
off: emitter.off.bind(emitter),
|
|
609
|
-
get state() {
|
|
610
|
-
|
|
611
|
-
|
|
1931
|
+
get state() {
|
|
1932
|
+
return state;
|
|
1933
|
+
},
|
|
1934
|
+
get plan() {
|
|
1935
|
+
return plan;
|
|
1936
|
+
},
|
|
1937
|
+
get eventLogPath() {
|
|
1938
|
+
return eventLog.filePath;
|
|
1939
|
+
},
|
|
612
1940
|
});
|
|
613
1941
|
}
|