@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.
Files changed (155) hide show
  1. package/hub/pipe.mjs +239 -121
  2. package/hub/public/dashboard.html +68 -7
  3. package/hub/server.mjs +2181 -701
  4. package/hub/store-adapter.mjs +101 -37
  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 +127 -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 +45 -17
  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 +21 -119
  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 +1067 -264
  70. package/hub/team/recovery-store.mjs +149 -0
  71. package/hub/team/remote-probe.mjs +69 -53
  72. package/hub/team/remote-session.mjs +131 -59
  73. package/hub/team/remote-watcher.mjs +62 -34
  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 +1526 -198
  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 +21 -37
  97. package/hub/team/tui-synapse.mjs +223 -0
  98. package/hub/team/tui-viewer.mjs +104 -45
  99. package/hub/team/tui-widgets.mjs +262 -0
  100. package/hub/team/tui.mjs +936 -449
  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 +518 -64
  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 +663 -337
  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,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 { EventEmitter } from 'node:events';
14
- import { join } from 'node:path';
15
- import { mkdirSync, readFileSync, existsSync } from 'node:fs';
16
- import { execSync } from 'node:child_process';
17
-
18
- import { createConductor, STATES } from './conductor.mjs';
19
- import { createSwarmLocks } from './swarm-locks.mjs';
20
- import { createEventLog } from './event-log.mjs';
21
- import { probeRemoteEnv, resolveRemoteDir } from './remote-session.mjs';
22
- import { fetchRemoteShard } from './worktree-lifecycle.mjs';
23
- import { getHostConfig } from '@triflux/core/hub/lib/ssh-command.mjs';
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: 'planning',
29
- LAUNCHING: 'launching',
30
- RUNNING: 'running',
31
- INTEGRATING: 'integrating',
32
- VALIDATING: 'validating',
33
- COMPLETED: 'completed',
34
- FAILED: '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: 'F1_crash',
41
- F2_RATE_LIMIT: 'F2_rate_limit',
42
- F3_STALL: 'F3_stall',
43
- F4_LEASE_VIOLATION: 'F4_lease_violation',
44
- 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",
45
85
  });
46
86
 
47
87
  const FALLBACK_AGENTS = Object.freeze({
48
- codex: 'gemini',
49
- gemini: 'codex',
50
- claude: 'codex',
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
- integrationTimeoutMs = 60_000,
184
+ keepFailedWorktrees = false,
185
+ _integrationTimeoutMs = 60_000,
72
186
  probeOpts = {},
73
- deps = {},
187
+ _deps = {},
74
188
  } = opts;
75
189
 
76
- if (!workdir) throw new Error('workdir is required');
77
- 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");
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, 'swarm-events.jsonl'));
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
- const results = new Map(); // shardName → validated result
95
- 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
+ }
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('swarm_state', { from: prev, to: next, reason });
103
- 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;
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: `swarm-${shard.name}-${Date.now()}`,
645
+ id: buildSwarmSessionId(shard),
111
646
  agent: shard.agent,
112
- prompt: shard.prompt,
113
- 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,
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
- // 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
118
669
  if (shard.host && shard._remoteEnv) {
119
- 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);
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
- 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
+ );
135
693
  mkdirSync(shardLogsDir, { recursive: true });
136
694
 
137
- // Remote shard: probe environment before conductor creation
138
- if (shard.host && !shard._remoteEnv) {
139
- try {
140
- shard._remoteEnv = probeRemoteEnv(shard.host);
141
- if (!shard._remoteEnv.claudePath) {
142
- eventLog.append('remote_probe_no_claude', { shard: shard.name, host: shard.host });
143
- 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
+ }
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
- const conductor = createConductor({
155
- logsDir: shardLogsDir,
156
- maxRestarts,
157
- graceMs,
158
- probeOpts,
159
- onCompleted: (sessionId) => handleShardCompleted(shard.name, sessionId, isRedundant),
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
- 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
+ });
163
769
 
164
- // Acquire file leases
165
- if (!isRedundant) {
166
- const leaseResult = lockManager.acquire(shard.name, shard.files);
167
- if (!leaseResult.ok) {
168
- eventLog.append('lease_denied', {
169
- shard: shard.name,
170
- conflicts: leaseResult.conflicts,
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.F4_LEASE_VIOLATION,
174
- conflicts: leaseResult.conflicts,
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
- conductor.spawnSession(sessionConfig);
872
+ // ── Completion handling ─────────────────────────────────────
181
873
 
182
- eventLog.append('shard_launched', {
183
- shard: shard.name,
184
- agent: shard.agent,
185
- 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,
186
883
  isRedundant,
187
- files: shard.files,
188
- remote: Boolean(shard.host),
189
- host: shard.host || null,
884
+ hasPayload: completionPayload !== undefined,
190
885
  });
191
-
192
- const entry = { conductor, shardConfig: shard, sessionConfig, startedAt: Date.now() };
193
-
194
- if (isRedundant) {
195
- redundantWorkers.set(shard.name, entry);
196
- } else {
197
- 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
+ );
198
895
  }
199
896
 
200
- // Listen for dead events (F1/F2/F3)
201
- conductor.on('dead', ({ sessionId, reason }) => {
202
- handleShardFailed(shard.name, sessionId, reason, isRedundant);
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
- return entry;
206
- }
934
+ const redundant = redundantWorkers.get(shardName);
935
+ if (redundant) {
936
+ void redundant.conductor.shutdown("primary_failed_f7");
937
+ }
207
938
 
208
- // ── 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
+ }
209
950
 
210
- function handleShardCompleted(shardName, sessionId, isRedundant) {
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('redundant_wins', { shard: shardName });
218
- void primary.conductor.shutdown('redundant_completed_first');
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('primary_completed_first');
969
+ void redundant.conductor.shutdown("primary_completed_first");
225
970
  }
226
971
  }
227
972
 
228
- emitter.emit('shardCompleted', { shardName, sessionId, isRedundant });
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('shard_failed', {
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('fallback_agent', {
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('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
+
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((s) => s.state === STATES.COMPLETED || s.state === STATES.DEAD);
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
- const allDone = plan.mergeOrder.every((name) => {
292
- const w = workers.get(name);
293
- return (w && isTerminal(w)) || failures.has(name);
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
- 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
+ });
309
1112
 
310
- eventLog.append('validate_result', {
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, 'all_shards_done');
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
- for (const shardName of plan.mergeOrder) {
334
- if (failures.has(shardName)) {
335
- eventLog.append('skip_failed_shard', { shard: shardName });
336
- continue;
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
- const worker = workers.get(shardName);
340
- 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
+ });
341
1188
 
342
- // Fetch remote shard branch to local (push-blocked hosts like Ultra4)
343
- const shard = plan.shards.find((s) => s.name === shardName);
344
- if (shard?.host && shard._remoteEnv) {
345
- const hostConfig = getHostConfig(shard.host, config.rootDir);
346
- const sshUser = hostConfig?.ssh_user || shard.host;
347
- const remoteRepoPath = resolveRemoteDir(config.rootDir || process.cwd(), shard._remoteEnv);
348
- const fetchResult = await fetchRemoteShard({
349
- host: shard.host,
350
- sshUser,
351
- remoteRepoPath,
352
- branchName: worker.branchName || `swarm/${config.runId}/${shardName}`,
353
- 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,
354
1227
  });
355
1228
 
356
- if (!fetchResult.ok) {
357
- 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
+ });
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
- // Read shard output log for changed files
365
- const changedFiles = detectChangedFiles(shardName, worker);
1262
+ // Read shard output log for changed files
1263
+ const changedFiles = detectChangedFiles(shardName, worker);
366
1264
 
367
- // Validate against lease map
368
- const validation = validateResult(shardName, changedFiles);
369
- if (!validation.ok) {
370
- failures.set(shardName, {
371
- mode: FAILURE_MODES.F4_LEASE_VIOLATION,
372
- 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,
373
1343
  });
374
- 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({
375
1366
  shard: shardName,
376
- violations: validation.violations,
1367
+ strategy: "failed",
1368
+ notes: `integration_error: ${err.message}`,
377
1369
  });
378
- integrationFailures.push(shardName);
379
- continue;
380
1370
  }
381
1371
 
382
- results.set(shardName, {
383
- shard: shardName,
384
- changedFiles,
385
- 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,
386
1393
  });
387
- integrated.push(shardName);
1394
+ emitter.emit("integrationComplete", payload);
1395
+ return payload;
388
1396
  }
389
1397
 
390
- 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", {
391
1404
  integrated,
392
- failed: integrationFailures,
393
- skipped: [...failures.keys()].filter((n) => !integrationFailures.includes(n)),
1405
+ failed,
1406
+ integrationFailures,
1407
+ integrationBranch,
1408
+ skipped,
1409
+ report: integrationReport,
394
1410
  });
395
1411
 
396
- if (integrationFailures.length > 0 && integrated.length === 0) {
397
- 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");
398
1414
  } else {
399
- setState(SWARM_STATES.COMPLETED, `${integrated.length}/${plan.shards.length} integrated`);
1415
+ setState(
1416
+ SWARM_STATES.COMPLETED,
1417
+ `${integrated.length}/${plan.shards.length} integrated`,
1418
+ );
400
1419
  }
401
1420
 
402
- emitter.emit('integrationComplete', {
1421
+ const payload = settleIntegrationPromise({
403
1422
  integrated,
404
- failed: integrationFailures,
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 outPath = join(logsDir, shardName);
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, 'utf8');
424
- 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
+ );
425
1557
  }
426
1558
  }
427
- } catch { /* best-effort */ }
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]\/(.+)/, // diff headers
451
- /^diff --git a\/(.+)\s+b\//, // git diff headers
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) => allowedFiles.some(
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: results.size,
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
- 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) {
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('file_conflicts_warning', { conflicts: plan.conflicts });
520
- emitter.emit('warning', {
521
- type: 'file_conflicts',
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, '.triflux', 'swarm-locks.json'),
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 launched (not necessarily completed)
544
- 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));
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('shardCompleted', () => {
566
- if (pending.size > 0) launchReady();
1847
+ emitter.on("shardCompleted", () => {
1848
+ if (pending.size > 0) void launchReady();
567
1849
  });
568
1850
 
569
- 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
+ );
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 = 'shutdown') {
579
- eventLog.append('swarm_shutdown', { reason, state });
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('shutdown', { reason });
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() { return state; },
610
- get plan() { return plan; },
611
- 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
+ },
612
1940
  });
613
1941
  }