@triflux/remote 10.1.0 → 10.26.0

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