@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.
Files changed (155) hide show
  1. package/hub/pipe.mjs +239 -121
  2. package/hub/public/dashboard.html +61 -0
  3. package/hub/server.mjs +2165 -708
  4. package/hub/store-adapter.mjs +59 -26
  5. package/hub/store.mjs +356 -153
  6. package/hub/team/agent-map.json +26 -7
  7. package/hub/team/ansi.mjs +245 -146
  8. package/hub/team/backend.mjs +84 -17
  9. package/hub/team/build-worker-prompt.mjs +42 -0
  10. package/hub/team/check-mcp-hub.mjs +49 -0
  11. package/hub/team/claude-daemon-control.mjs +219 -0
  12. package/hub/team/claude-native-bridge.mjs +784 -0
  13. package/hub/team/claude-session-projection.mjs +65 -0
  14. package/hub/team/cli/commands/attach.mjs +57 -37
  15. package/hub/team/cli/commands/control.mjs +29 -8
  16. package/hub/team/cli/commands/debug.mjs +95 -74
  17. package/hub/team/cli/commands/focus.mjs +80 -53
  18. package/hub/team/cli/commands/interrupt.mjs +18 -6
  19. package/hub/team/cli/commands/kill.mjs +16 -5
  20. package/hub/team/cli/commands/list.mjs +31 -24
  21. package/hub/team/cli/commands/send.mjs +19 -6
  22. package/hub/team/cli/commands/start/index.mjs +154 -33
  23. package/hub/team/cli/commands/start/parse-args.mjs +89 -12
  24. package/hub/team/cli/commands/start/start-headless.mjs +125 -37
  25. package/hub/team/cli/commands/start/start-in-process.mjs +50 -40
  26. package/hub/team/cli/commands/start/start-mux.mjs +122 -73
  27. package/hub/team/cli/commands/start/start-wt.mjs +86 -69
  28. package/hub/team/cli/commands/status.mjs +43 -14
  29. package/hub/team/cli/commands/stop.mjs +11 -4
  30. package/hub/team/cli/commands/task.mjs +8 -3
  31. package/hub/team/cli/commands/tasks.mjs +13 -13
  32. package/hub/team/cli/index.mjs +2 -2
  33. package/hub/team/cli/manifest.mjs +38 -8
  34. package/hub/team/cli/render.mjs +52 -30
  35. package/hub/team/cli/services/attach-fallback.mjs +74 -54
  36. package/hub/team/cli/services/hub-client.mjs +44 -16
  37. package/hub/team/cli/services/member-selector.mjs +37 -30
  38. package/hub/team/cli/services/native-control.mjs +144 -116
  39. package/hub/team/cli/services/runtime-mode.mjs +55 -1
  40. package/hub/team/cli/services/state-store.mjs +66 -9
  41. package/hub/team/cli/services/task-model.mjs +40 -30
  42. package/hub/team/codex-review.mjs +521 -0
  43. package/hub/team/conductor-mesh-bridge.mjs +25 -24
  44. package/hub/team/conductor-registry.mjs +121 -0
  45. package/hub/team/conductor.mjs +1379 -671
  46. package/hub/team/consensus-meta.mjs +176 -0
  47. package/hub/team/dashboard-anchor.mjs +4 -5
  48. package/hub/team/dashboard-layout.mjs +3 -1
  49. package/hub/team/dashboard-open.mjs +20 -118
  50. package/hub/team/dashboard.mjs +76 -28
  51. package/hub/team/event-log.mjs +18 -10
  52. package/hub/team/execution-mode.mjs +241 -0
  53. package/hub/team/extract-completion-payload.mjs +110 -0
  54. package/hub/team/git-preflight.mjs +286 -0
  55. package/hub/team/handoff.mjs +49 -18
  56. package/hub/team/headless-bridge-session.mjs +17 -0
  57. package/hub/team/headless.mjs +2065 -1156
  58. package/hub/team/health-probe.mjs +232 -69
  59. package/hub/team/launcher-template.mjs +23 -13
  60. package/hub/team/lead-control.mjs +91 -4
  61. package/hub/team/mcp-selector.mjs +145 -0
  62. package/hub/team/native-supervisor.mjs +90 -26
  63. package/hub/team/native.mjs +74 -35
  64. package/hub/team/nativeProxy.mjs +184 -113
  65. package/hub/team/notify.mjs +147 -78
  66. package/hub/team/orchestrator.mjs +173 -161
  67. package/hub/team/pane.mjs +43 -15
  68. package/hub/team/process-cleanup.mjs +69 -25
  69. package/hub/team/psmux.mjs +1020 -279
  70. package/hub/team/recovery-store.mjs +149 -0
  71. package/hub/team/remote-probe.mjs +68 -52
  72. package/hub/team/remote-session.mjs +131 -59
  73. package/hub/team/remote-watcher.mjs +61 -33
  74. package/hub/team/retry-state-machine.mjs +267 -0
  75. package/hub/team/routing.mjs +51 -25
  76. package/hub/team/runtime-strategy.mjs +200 -0
  77. package/hub/team/schemas/worker-completion.json +39 -0
  78. package/hub/team/sentinel-capture.mjs +127 -0
  79. package/hub/team/session-sync.mjs +153 -6
  80. package/hub/team/session.mjs +605 -611
  81. package/hub/team/shared.mjs +13 -13
  82. package/hub/team/staleState.mjs +72 -30
  83. package/hub/team/swarm-cli.mjs +390 -0
  84. package/hub/team/swarm-hypervisor.mjs +1522 -193
  85. package/hub/team/swarm-intent.mjs +216 -0
  86. package/hub/team/swarm-locks.mjs +140 -25
  87. package/hub/team/swarm-planner.mjs +121 -21
  88. package/hub/team/swarm-preflight.mjs +317 -0
  89. package/hub/team/swarm-reconciler.mjs +128 -23
  90. package/hub/team/synapse-cli.mjs +458 -0
  91. package/hub/team/synapse-http.mjs +60 -0
  92. package/hub/team/synapse-registry.mjs +329 -0
  93. package/hub/team/terminal-opener.mjs +216 -0
  94. package/hub/team/tui-core.mjs +288 -0
  95. package/hub/team/tui-lite.mjs +234 -146
  96. package/hub/team/tui-remote-adapter.mjs +18 -34
  97. package/hub/team/tui-synapse.mjs +223 -0
  98. package/hub/team/tui-viewer.mjs +104 -44
  99. package/hub/team/tui-widgets.mjs +262 -0
  100. package/hub/team/tui.mjs +927 -437
  101. package/hub/team/worker-completion-validator.mjs +84 -0
  102. package/hub/team/worker-sandbox.mjs +93 -0
  103. package/hub/team/worker-signal.mjs +263 -0
  104. package/hub/team/worker-signal.types.d.ts +52 -0
  105. package/hub/team/worktree-lifecycle.mjs +517 -63
  106. package/hub/team/wt-manager.mjs +297 -26
  107. package/hub/tools.mjs +474 -252
  108. package/hub/tray.mjs +411 -368
  109. package/hub/workers/claude-worker.mjs +162 -118
  110. package/hub/workers/codex-app-server-worker.mjs +1015 -0
  111. package/hub/workers/codex-mcp.mjs +724 -507
  112. package/hub/workers/delegator-mcp.mjs +662 -335
  113. package/hub/workers/factory.mjs +100 -21
  114. package/hub/workers/gemini-worker.mjs +179 -86
  115. package/hub/workers/interface.mjs +23 -2
  116. package/hub/workers/lib/jsonrpc-stdio.mjs +463 -0
  117. package/hub/workers/worker-utils.mjs +22 -16
  118. package/package.json +4 -3
  119. package/scripts/lib/agent-json.mjs +27 -0
  120. package/scripts/lib/async.mjs +174 -0
  121. package/scripts/lib/claudemd-scanner.mjs +223 -0
  122. package/scripts/lib/cli-agy.mjs +62 -0
  123. package/scripts/lib/cli-claude.mjs +78 -0
  124. package/scripts/lib/cli-codex.mjs +199 -0
  125. package/scripts/lib/cli-gemini.mjs +67 -0
  126. package/scripts/lib/codex-recovery.sh +50 -0
  127. package/scripts/lib/context.mjs +67 -0
  128. package/scripts/lib/cross-review-utils.mjs +59 -0
  129. package/scripts/lib/doctor-env-checks.mjs +121 -0
  130. package/scripts/lib/dynamic-route-cli.mjs +107 -0
  131. package/scripts/lib/env-probe.mjs +269 -0
  132. package/scripts/lib/env.mjs +14 -0
  133. package/scripts/lib/gemini-profiles.mjs +119 -0
  134. package/scripts/lib/handoff.mjs +187 -0
  135. package/scripts/lib/hook-utils.mjs +16 -0
  136. package/scripts/lib/hub.mjs +48 -0
  137. package/scripts/lib/keyword-rules.mjs +195 -0
  138. package/scripts/lib/logger.mjs +105 -0
  139. package/scripts/lib/mcp-filter.mjs +883 -0
  140. package/scripts/lib/mcp-gateway-servers.mjs +70 -0
  141. package/scripts/lib/mcp-guard-engine.mjs +2120 -0
  142. package/scripts/lib/mcp-health.mjs +773 -0
  143. package/scripts/lib/mcp-manifest.mjs +89 -0
  144. package/scripts/lib/mcp-server-catalog.mjs +355 -0
  145. package/scripts/lib/pid.mjs +63 -0
  146. package/scripts/lib/process-utils.mjs +26 -0
  147. package/scripts/lib/psmux-info.mjs +179 -0
  148. package/scripts/lib/quota.mjs +47 -0
  149. package/scripts/lib/remote-spawn-transfer.mjs +226 -0
  150. package/scripts/lib/skill-state.mjs +220 -0
  151. package/scripts/lib/skill-template.mjs +375 -0
  152. package/scripts/lib/team.mjs +75 -0
  153. package/scripts/lib/timeout.mjs +66 -0
  154. package/scripts/lib/tmp.mjs +26 -0
  155. 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 { EventEmitter } from 'node:events';
14
- import { join } from 'node:path';
15
- import { mkdirSync, readFileSync, existsSync } from 'node:fs';
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
- import { createConductor, STATES } from './conductor.mjs';
18
- import { createSwarmLocks } from './swarm-locks.mjs';
19
- import { createEventLog } from './event-log.mjs';
20
- import { probeRemoteEnv, resolveRemoteDir } from './remote-session.mjs';
21
- import { fetchRemoteShard } from './worktree-lifecycle.mjs';
22
- import { getHostConfig } from '@triflux/core/hub/lib/ssh-command.mjs';
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: 'planning',
28
- LAUNCHING: 'launching',
29
- RUNNING: 'running',
30
- INTEGRATING: 'integrating',
31
- VALIDATING: 'validating',
32
- COMPLETED: 'completed',
33
- FAILED: '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: 'F1_crash',
40
- F2_RATE_LIMIT: 'F2_rate_limit',
41
- F3_STALL: 'F3_stall',
42
- F4_LEASE_VIOLATION: 'F4_lease_violation',
43
- F5_MERGE_CONFLICT: '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: 'gemini',
48
- gemini: 'codex',
49
- claude: 'codex',
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('workdir is required');
76
- if (!logsDir) throw new Error('logsDir is required');
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, 'swarm-events.jsonl'));
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
- const results = new Map(); // shardName → validated result
94
- const failures = new Map(); // shardName → failure info
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('swarm_state', { from: prev, to: next, reason });
102
- emitter.emit('stateChange', { from: prev, to: next, reason });
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: `swarm-${shard.name}-${Date.now()}`,
645
+ id: buildSwarmSessionId(shard),
110
646
  agent: shard.agent,
111
- prompt: shard.prompt,
112
- workdir,
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
- // Remote shard: add conductor remote fields
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 remoteDir = resolveRemoteDir(workdir, shard._remoteEnv);
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
- const shardLogsDir = join(logsDir, isRedundant ? `${shard.name}-redundant` : shard.name);
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
- // Remote shard: probe environment before conductor creation
137
- if (shard.host && !shard._remoteEnv) {
138
- try {
139
- shard._remoteEnv = probeRemoteEnv(shard.host);
140
- if (!shard._remoteEnv.claudePath) {
141
- eventLog.append('remote_probe_no_claude', { shard: shard.name, host: shard.host });
142
- failures.set(shard.name, { mode: FAILURE_MODES.F1_CRASH, reason: `claude not found on ${shard.host}` });
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
- const conductor = createConductor({
154
- logsDir: shardLogsDir,
155
- maxRestarts,
156
- graceMs,
157
- probeOpts,
158
- onCompleted: (sessionId) => handleShardCompleted(shard.name, sessionId, isRedundant),
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
- const sessionConfig = buildSessionConfig(shard);
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
- // Acquire file leases
164
- if (!isRedundant) {
165
- const leaseResult = lockManager.acquire(shard.name, shard.files);
166
- if (!leaseResult.ok) {
167
- eventLog.append('lease_denied', {
168
- shard: shard.name,
169
- conflicts: leaseResult.conflicts,
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.F4_LEASE_VIOLATION,
173
- conflicts: leaseResult.conflicts,
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
- conductor.spawnSession(sessionConfig);
872
+ // ── Completion handling ─────────────────────────────────────
180
873
 
181
- eventLog.append('shard_launched', {
182
- shard: shard.name,
183
- agent: shard.agent,
184
- sessionId: sessionConfig.id,
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
- files: shard.files,
187
- remote: Boolean(shard.host),
188
- host: shard.host || null,
884
+ hasPayload: completionPayload !== undefined,
189
885
  });
190
-
191
- const entry = { conductor, shardConfig: shard, sessionConfig, startedAt: Date.now() };
192
-
193
- if (isRedundant) {
194
- redundantWorkers.set(shard.name, entry);
195
- } else {
196
- workers.set(shard.name, entry);
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
- // Listen for dead events (F1/F2/F3)
200
- conductor.on('dead', ({ sessionId, reason }) => {
201
- handleShardFailed(shard.name, sessionId, reason, isRedundant);
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
- return entry;
205
- }
934
+ const redundant = redundantWorkers.get(shardName);
935
+ if (redundant) {
936
+ void redundant.conductor.shutdown("primary_failed_f7");
937
+ }
206
938
 
207
- // ── Completion handling ─────────────────────────────────────
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
- function handleShardCompleted(shardName, sessionId, isRedundant) {
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('redundant_wins', { shard: shardName });
217
- void primary.conductor.shutdown('redundant_completed_first');
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('primary_completed_first');
969
+ void redundant.conductor.shutdown("primary_completed_first");
224
970
  }
225
971
  }
226
972
 
227
- emitter.emit('shardCompleted', { shardName, sessionId, isRedundant });
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('shard_failed', {
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('fallback_agent', {
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('shardFailed', { shardName, failureMode, reason });
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((s) => s.state === STATES.COMPLETED || s.state === STATES.DEAD);
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
- const allDone = plan.mergeOrder.every((name) => {
291
- const w = workers.get(name);
292
- return (w && isTerminal(w)) || failures.has(name);
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
- const violations = lockManager.validateChanges(shardName, changedFiles);
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('validate_result', {
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, 'all_shards_done');
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
- for (const shardName of plan.mergeOrder) {
333
- if (failures.has(shardName)) {
334
- eventLog.append('skip_failed_shard', { shard: shardName });
335
- continue;
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
- const worker = workers.get(shardName);
339
- if (!worker) continue;
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
- // Fetch remote shard branch to local (push-blocked hosts like Ultra4)
342
- const shard = plan.shards.find((s) => s.name === shardName);
343
- if (shard?.host && shard._remoteEnv) {
344
- const hostConfig = getHostConfig(shard.host, config.rootDir);
345
- const sshUser = hostConfig?.ssh_user || shard.host;
346
- const remoteRepoPath = resolveRemoteDir(config.rootDir || process.cwd(), shard._remoteEnv);
347
- const fetchResult = await fetchRemoteShard({
348
- host: shard.host,
349
- sshUser,
350
- remoteRepoPath,
351
- branchName: worker.branchName || `swarm/${config.runId}/${shardName}`,
352
- rootDir: config.rootDir || process.cwd(),
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
- if (!fetchResult.ok) {
356
- eventLog.append('remote_fetch_failed', { shard: shardName, error: fetchResult.error });
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
- // Read shard output log for changed files
364
- const changedFiles = detectChangedFiles(shardName, worker);
1262
+ // Read shard output log for changed files
1263
+ const changedFiles = detectChangedFiles(shardName, worker);
365
1264
 
366
- // Validate against lease map
367
- const validation = validateResult(shardName, changedFiles);
368
- if (!validation.ok) {
369
- failures.set(shardName, {
370
- mode: FAILURE_MODES.F4_LEASE_VIOLATION,
371
- violations: validation.violations,
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
- eventLog.append('lease_violation_revert', {
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
- violations: validation.violations,
1367
+ strategy: "failed",
1368
+ notes: `integration_error: ${err.message}`,
376
1369
  });
377
- integrationFailures.push(shardName);
378
- continue;
379
1370
  }
380
1371
 
381
- results.set(shardName, {
382
- shard: shardName,
383
- changedFiles,
384
- completedAt: Date.now(),
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
- integrated.push(shardName);
1394
+ emitter.emit("integrationComplete", payload);
1395
+ return payload;
387
1396
  }
388
1397
 
389
- eventLog.append('integration_complete', {
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: integrationFailures,
392
- skipped: [...failures.keys()].filter((n) => !integrationFailures.includes(n)),
1405
+ failed,
1406
+ integrationFailures,
1407
+ integrationBranch,
1408
+ skipped,
1409
+ report: integrationReport,
393
1410
  });
394
1411
 
395
- if (integrationFailures.length > 0 && integrated.length === 0) {
396
- setState(SWARM_STATES.FAILED, 'all_shards_failed_integration');
1412
+ if (failed.length > 0 && integrated.length === 0) {
1413
+ setState(SWARM_STATES.FAILED, "all_shards_failed_integration");
397
1414
  } else {
398
- setState(SWARM_STATES.COMPLETED, `${integrated.length}/${plan.shards.length} integrated`);
1415
+ setState(
1416
+ SWARM_STATES.COMPLETED,
1417
+ `${integrated.length}/${plan.shards.length} integrated`,
1418
+ );
399
1419
  }
400
1420
 
401
- emitter.emit('integrationComplete', {
1421
+ const payload = settleIntegrationPromise({
402
1422
  integrated,
403
- failed: integrationFailures,
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, 'utf8');
423
- return extractFilePathsFromOutput(output, plan.leaseMap.get(shardName) || []);
1552
+ const output = readFileSync(session.outPath, "utf8");
1553
+ return extractFilePathsFromOutput(
1554
+ output,
1555
+ plan.leaseMap.get(shardName) || [],
1556
+ );
424
1557
  }
425
1558
  }
426
- } catch { /* best-effort */ }
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]\/(.+)/, // diff headers
450
- /^diff --git a\/(.+)\s+b\//, // git diff headers
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) => allowedFiles.some(
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: results.size,
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
- function launch(swarmPlan) {
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('file_conflicts_warning', { conflicts: plan.conflicts });
519
- emitter.emit('warning', {
520
- type: 'file_conflicts',
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, '.triflux', 'swarm-locks.json'),
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 launched (not necessarily completed)
543
- const depsReady = shard.depends.every((d) => launched.has(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('shardCompleted', () => {
565
- if (pending.size > 0) launchReady();
1847
+ emitter.on("shardCompleted", () => {
1848
+ if (pending.size > 0) void launchReady();
566
1849
  });
567
1850
 
568
- setState(SWARM_STATES.RUNNING, `${launched.size} launched, ${pending.size} pending deps`);
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 = 'shutdown') {
578
- eventLog.append('swarm_shutdown', { reason, state });
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('shutdown', { reason });
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() { return state; },
609
- get plan() { return plan; },
610
- get eventLogPath() { return eventLog.filePath; },
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
  }