@triflux/remote 10.0.0 → 10.1.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.
@@ -0,0 +1,612 @@
1
+ // hub/team/swarm-hypervisor.mjs — Multi-model swarm orchestration hypervisor
2
+ // Consumes a SwarmPlan (from swarm-planner.mjs) and orchestrates parallel
3
+ // conductor sessions with file-lease enforcement, result validation,
4
+ // and ordered integration.
5
+ //
6
+ // Failure modes handled:
7
+ // F1: Worker crash → conductor auto-restart (maxRestarts)
8
+ // F2: Rate limit → account-broker cooldown + fallback agent
9
+ // F3: Stall → health probe L1 detection + kill + restart
10
+ // F4: File lease violation → revert worker changes, flag shard as failed
11
+ // F5: Merge conflict → retry integration with conflict resolution
12
+
13
+ import { EventEmitter } from 'node:events';
14
+ import { join } from 'node:path';
15
+ import { mkdirSync, readFileSync, existsSync } from 'node:fs';
16
+
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';
23
+
24
+ // ── Swarm states ──────────────────────────────────────────────
25
+
26
+ 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',
34
+ });
35
+
36
+ // ── Failure mode classification ───────────────────────────────
37
+
38
+ 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',
44
+ });
45
+
46
+ const FALLBACK_AGENTS = Object.freeze({
47
+ codex: 'gemini',
48
+ gemini: 'codex',
49
+ claude: 'codex',
50
+ });
51
+
52
+ /**
53
+ * Create a swarm hypervisor.
54
+ * @param {object} opts
55
+ * @param {string} opts.workdir — repository root / working directory
56
+ * @param {string} opts.logsDir — base directory for all logs
57
+ * @param {number} [opts.maxRestarts=2] — per-shard max restarts
58
+ * @param {number} [opts.graceMs=10000] — conductor shutdown grace period
59
+ * @param {number} [opts.integrationTimeoutMs=60000] — max time for integration phase
60
+ * @param {object} [opts.probeOpts] — health probe overrides
61
+ * @param {object} [opts.deps] — dependency injection for testing
62
+ * @returns {SwarmHypervisor}
63
+ */
64
+ export function createSwarmHypervisor(opts) {
65
+ const {
66
+ workdir,
67
+ logsDir,
68
+ maxRestarts = 2,
69
+ graceMs = 10_000,
70
+ _integrationTimeoutMs = 60_000,
71
+ probeOpts = {},
72
+ _deps = {},
73
+ } = opts;
74
+
75
+ if (!workdir) throw new Error('workdir is required');
76
+ if (!logsDir) throw new Error('logsDir is required');
77
+
78
+ mkdirSync(logsDir, { recursive: true });
79
+
80
+ const emitter = new EventEmitter();
81
+ const eventLog = createEventLog(join(logsDir, 'swarm-events.jsonl'));
82
+
83
+ let state = SWARM_STATES.PLANNING;
84
+ let plan = null;
85
+ let lockManager = null;
86
+
87
+ /** @type {Map<string, { conductor, shardConfig, result, status }>} */
88
+ const workers = new Map();
89
+
90
+ /** @type {Map<string, { conductor, shardConfig }>} redundant workers for critical shards */
91
+ const redundantWorkers = new Map();
92
+
93
+ const results = new Map(); // shardName → validated result
94
+ const failures = new Map(); // shardName → failure info
95
+
96
+ // ── State machine ───────────────────────────────────────────
97
+
98
+ function setState(next, reason = '') {
99
+ const prev = state;
100
+ state = next;
101
+ eventLog.append('swarm_state', { from: prev, to: next, reason });
102
+ emitter.emit('stateChange', { from: prev, to: next, reason });
103
+ }
104
+
105
+ // ── Worker lifecycle ────────────────────────────────────────
106
+
107
+ function buildSessionConfig(shard) {
108
+ const config = {
109
+ id: `swarm-${shard.name}-${Date.now()}`,
110
+ agent: shard.agent,
111
+ prompt: shard.prompt,
112
+ workdir,
113
+ mcpServers: shard.mcp,
114
+ };
115
+
116
+ // Remote shard: add conductor remote fields
117
+ if (shard.host && shard._remoteEnv) {
118
+ const remoteDir = resolveRemoteDir(workdir, shard._remoteEnv);
119
+ return {
120
+ ...config,
121
+ remote: true,
122
+ host: shard.host,
123
+ sessionName: `swarm-${shard.name}-${Date.now()}`,
124
+ paneTarget: `swarm-${shard.name}-${Date.now()}:0.0`,
125
+ workdir: remoteDir,
126
+ };
127
+ }
128
+
129
+ return config;
130
+ }
131
+
132
+ function launchShard(shard, isRedundant = false) {
133
+ const shardLogsDir = join(logsDir, isRedundant ? `${shard.name}-redundant` : shard.name);
134
+ mkdirSync(shardLogsDir, { recursive: true });
135
+
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}` });
143
+ return null;
144
+ }
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
+ }
151
+ }
152
+
153
+ const conductor = createConductor({
154
+ logsDir: shardLogsDir,
155
+ maxRestarts,
156
+ graceMs,
157
+ probeOpts,
158
+ onCompleted: (sessionId) => handleShardCompleted(shard.name, sessionId, isRedundant),
159
+ });
160
+
161
+ const sessionConfig = buildSessionConfig(shard);
162
+
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
+ });
171
+ failures.set(shard.name, {
172
+ mode: FAILURE_MODES.F4_LEASE_VIOLATION,
173
+ conflicts: leaseResult.conflicts,
174
+ });
175
+ return null;
176
+ }
177
+ }
178
+
179
+ conductor.spawnSession(sessionConfig);
180
+
181
+ eventLog.append('shard_launched', {
182
+ shard: shard.name,
183
+ agent: shard.agent,
184
+ sessionId: sessionConfig.id,
185
+ isRedundant,
186
+ files: shard.files,
187
+ remote: Boolean(shard.host),
188
+ host: shard.host || null,
189
+ });
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);
197
+ }
198
+
199
+ // Listen for dead events (F1/F2/F3)
200
+ conductor.on('dead', ({ sessionId, reason }) => {
201
+ handleShardFailed(shard.name, sessionId, reason, isRedundant);
202
+ });
203
+
204
+ return entry;
205
+ }
206
+
207
+ // ── Completion handling ─────────────────────────────────────
208
+
209
+ function handleShardCompleted(shardName, sessionId, isRedundant) {
210
+ eventLog.append('shard_completed', { shard: shardName, sessionId, isRedundant });
211
+
212
+ if (isRedundant) {
213
+ // Redundant worker completed first — kill primary if still running
214
+ const primary = workers.get(shardName);
215
+ if (primary && !isTerminal(primary)) {
216
+ eventLog.append('redundant_wins', { shard: shardName });
217
+ void primary.conductor.shutdown('redundant_completed_first');
218
+ }
219
+ } else {
220
+ // Primary completed — kill redundant if exists
221
+ const redundant = redundantWorkers.get(shardName);
222
+ if (redundant) {
223
+ void redundant.conductor.shutdown('primary_completed_first');
224
+ }
225
+ }
226
+
227
+ emitter.emit('shardCompleted', { shardName, sessionId, isRedundant });
228
+ checkAllShardsCompleted();
229
+ }
230
+
231
+ function handleShardFailed(shardName, sessionId, reason, isRedundant) {
232
+ const failureMode = classifyFailure(reason);
233
+
234
+ eventLog.append('shard_failed', {
235
+ shard: shardName,
236
+ sessionId,
237
+ reason,
238
+ failureMode,
239
+ isRedundant,
240
+ });
241
+
242
+ if (isRedundant) return; // redundant failure is non-critical
243
+
244
+ // F2: Rate limit — try fallback agent
245
+ if (failureMode === FAILURE_MODES.F2_RATE_LIMIT) {
246
+ const shard = plan.shards.find((s) => s.name === shardName);
247
+ if (shard) {
248
+ const fallbackAgent = FALLBACK_AGENTS[shard.agent];
249
+ if (fallbackAgent) {
250
+ eventLog.append('fallback_agent', {
251
+ shard: shardName,
252
+ from: shard.agent,
253
+ to: fallbackAgent,
254
+ });
255
+ const fallbackShard = { ...shard, agent: fallbackAgent };
256
+ lockManager.release(shardName);
257
+ launchShard(fallbackShard);
258
+ return;
259
+ }
260
+ }
261
+ }
262
+
263
+ failures.set(shardName, { mode: failureMode, reason, sessionId });
264
+ lockManager.release(shardName);
265
+
266
+ emitter.emit('shardFailed', { shardName, failureMode, reason });
267
+ checkAllShardsCompleted();
268
+ }
269
+
270
+ function classifyFailure(reason) {
271
+ if (!reason) return FAILURE_MODES.F1_CRASH;
272
+ const r = String(reason).toLowerCase();
273
+ if (/rate.?limit|cooldown/u.test(r)) return FAILURE_MODES.F2_RATE_LIMIT;
274
+ if (/stall|l1_stall|timeout/u.test(r)) return FAILURE_MODES.F3_STALL;
275
+ if (/lease|violation/u.test(r)) return FAILURE_MODES.F4_LEASE_VIOLATION;
276
+ if (/merge|conflict/u.test(r)) return FAILURE_MODES.F5_MERGE_CONFLICT;
277
+ return FAILURE_MODES.F1_CRASH;
278
+ }
279
+
280
+ function isTerminal(entry) {
281
+ const snap = entry.conductor.getSnapshot();
282
+ return snap.every((s) => s.state === STATES.COMPLETED || s.state === STATES.DEAD);
283
+ }
284
+
285
+ // ── Integration ─────────────────────────────────────────────
286
+
287
+ function checkAllShardsCompleted() {
288
+ if (state !== SWARM_STATES.RUNNING) return;
289
+
290
+ const allDone = plan.mergeOrder.every((name) => {
291
+ const w = workers.get(name);
292
+ return (w && isTerminal(w)) || failures.has(name);
293
+ });
294
+
295
+ if (allDone) {
296
+ void integrateResults();
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Validate a shard's output — check for file lease violations.
302
+ * @param {string} shardName
303
+ * @param {string[]} changedFiles — files the shard actually modified
304
+ * @returns {{ ok: boolean, violations: Array }}
305
+ */
306
+ function validateResult(shardName, changedFiles) {
307
+ const violations = lockManager.validateChanges(shardName, changedFiles);
308
+
309
+ eventLog.append('validate_result', {
310
+ shard: shardName,
311
+ changedFiles,
312
+ violations,
313
+ ok: violations.length === 0,
314
+ });
315
+
316
+ return {
317
+ ok: violations.length === 0,
318
+ violations,
319
+ };
320
+ }
321
+
322
+ /**
323
+ * Integrate results from all completed shards in merge order.
324
+ * Uses git operations for conflict detection.
325
+ */
326
+ async function integrateResults() {
327
+ setState(SWARM_STATES.INTEGRATING, 'all_shards_done');
328
+
329
+ const integrated = [];
330
+ const integrationFailures = [];
331
+
332
+ for (const shardName of plan.mergeOrder) {
333
+ if (failures.has(shardName)) {
334
+ eventLog.append('skip_failed_shard', { shard: shardName });
335
+ continue;
336
+ }
337
+
338
+ const worker = workers.get(shardName);
339
+ if (!worker) continue;
340
+
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(),
353
+ });
354
+
355
+ if (!fetchResult.ok) {
356
+ eventLog.append('remote_fetch_failed', { shard: shardName, error: fetchResult.error });
357
+ integrationFailures.push(shardName);
358
+ continue;
359
+ }
360
+ eventLog.append('remote_fetch_ok', { shard: shardName, headCommit: fetchResult.headCommit });
361
+ }
362
+
363
+ // Read shard output log for changed files
364
+ const changedFiles = detectChangedFiles(shardName, worker);
365
+
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,
372
+ });
373
+ eventLog.append('lease_violation_revert', {
374
+ shard: shardName,
375
+ violations: validation.violations,
376
+ });
377
+ integrationFailures.push(shardName);
378
+ continue;
379
+ }
380
+
381
+ results.set(shardName, {
382
+ shard: shardName,
383
+ changedFiles,
384
+ completedAt: Date.now(),
385
+ });
386
+ integrated.push(shardName);
387
+ }
388
+
389
+ eventLog.append('integration_complete', {
390
+ integrated,
391
+ failed: integrationFailures,
392
+ skipped: [...failures.keys()].filter((n) => !integrationFailures.includes(n)),
393
+ });
394
+
395
+ if (integrationFailures.length > 0 && integrated.length === 0) {
396
+ setState(SWARM_STATES.FAILED, 'all_shards_failed_integration');
397
+ } else {
398
+ setState(SWARM_STATES.COMPLETED, `${integrated.length}/${plan.shards.length} integrated`);
399
+ }
400
+
401
+ emitter.emit('integrationComplete', {
402
+ integrated,
403
+ failed: integrationFailures,
404
+ results: [...results.values()],
405
+ });
406
+ }
407
+
408
+ /**
409
+ * Detect which files a shard modified by reading its output logs.
410
+ * Falls back to an empty list if detection fails.
411
+ * @param {string} shardName
412
+ * @param {object} worker
413
+ * @returns {string[]}
414
+ */
415
+ function detectChangedFiles(shardName, worker) {
416
+ // Best-effort: parse output log for file paths
417
+ const _outPath = join(logsDir, shardName);
418
+ try {
419
+ const snap = worker.conductor.getSnapshot();
420
+ for (const session of snap) {
421
+ if (session.outPath && existsSync(session.outPath)) {
422
+ const output = readFileSync(session.outPath, 'utf8');
423
+ return extractFilePathsFromOutput(output, plan.leaseMap.get(shardName) || []);
424
+ }
425
+ }
426
+ } catch { /* best-effort */ }
427
+
428
+ // Fallback: trust the lease map (shard was allowed these files)
429
+ return plan.leaseMap.get(shardName) || [];
430
+ }
431
+
432
+ /**
433
+ * Extract modified file paths from worker output text.
434
+ * Looks for common patterns: "wrote file.mjs", "modified file.mjs", diff headers.
435
+ * @param {string} output
436
+ * @param {string[]} allowedFiles — lease map files to match against
437
+ * @returns {string[]}
438
+ */
439
+ function extractFilePathsFromOutput(output, allowedFiles) {
440
+ if (!output) return allowedFiles;
441
+
442
+ const found = new Set();
443
+ const lines = output.split(/\r?\n/);
444
+
445
+ for (const line of lines) {
446
+ // Match common patterns
447
+ const patterns = [
448
+ /(?:wrote|created|modified|updated|edited)\s+['"]?([^\s'"]+\.\w+)/i,
449
+ /^[+-]{3}\s+[ab]\/(.+)/, // diff headers
450
+ /^diff --git a\/(.+)\s+b\//, // git diff headers
451
+ ];
452
+
453
+ for (const re of patterns) {
454
+ const match = line.match(re);
455
+ if (match) found.add(match[1]);
456
+ }
457
+ }
458
+
459
+ // Intersect with allowed files if we found anything
460
+ if (found.size > 0) {
461
+ return [...found].filter((f) => allowedFiles.some(
462
+ (a) => f.endsWith(a) || a.endsWith(f) || f === a,
463
+ ));
464
+ }
465
+
466
+ return allowedFiles;
467
+ }
468
+
469
+ // ── Status monitor ──────────────────────────────────────────
470
+
471
+ /**
472
+ * Get current swarm status snapshot.
473
+ * @returns {SwarmStatus}
474
+ */
475
+ function getStatus() {
476
+ const workerStatuses = [];
477
+
478
+ for (const [name, w] of workers) {
479
+ const snap = w.conductor.getSnapshot();
480
+ workerStatuses.push({
481
+ shard: name,
482
+ agent: w.shardConfig.agent,
483
+ sessions: snap,
484
+ failed: failures.has(name),
485
+ failureInfo: failures.get(name) || null,
486
+ integrated: results.has(name),
487
+ });
488
+ }
489
+
490
+ return Object.freeze({
491
+ state,
492
+ totalShards: plan?.shards.length || 0,
493
+ completedShards: results.size,
494
+ failedShards: failures.size,
495
+ workers: workerStatuses,
496
+ mergeOrder: plan?.mergeOrder || [],
497
+ criticalShards: plan?.criticalShards || [],
498
+ locks: lockManager?.snapshot() || [],
499
+ });
500
+ }
501
+
502
+ // ── Public API ──────────────────────────────────────────────
503
+
504
+ /**
505
+ * Launch the swarm from a pre-built plan.
506
+ * @param {SwarmPlan} swarmPlan — from planSwarm()
507
+ * @returns {SwarmStatus}
508
+ */
509
+ function launch(swarmPlan) {
510
+ if (state !== SWARM_STATES.PLANNING) {
511
+ throw new Error(`Cannot launch in state "${state}"`);
512
+ }
513
+
514
+ plan = swarmPlan;
515
+
516
+ // Warn about file conflicts but don't block
517
+ if (plan.conflicts.length > 0) {
518
+ eventLog.append('file_conflicts_warning', { conflicts: plan.conflicts });
519
+ emitter.emit('warning', {
520
+ type: 'file_conflicts',
521
+ conflicts: plan.conflicts,
522
+ });
523
+ }
524
+
525
+ // Initialize lock manager
526
+ lockManager = createSwarmLocks({
527
+ repoRoot: workdir,
528
+ persistPath: join(workdir, '.triflux', 'swarm-locks.json'),
529
+ });
530
+
531
+ setState(SWARM_STATES.LAUNCHING, `${plan.shards.length} shards`);
532
+
533
+ // Launch shards respecting dependency order
534
+ const launched = new Set();
535
+ const pending = new Set(plan.mergeOrder);
536
+
537
+ function launchReady() {
538
+ for (const name of pending) {
539
+ const shard = plan.shards.find((s) => s.name === name);
540
+ if (!shard) continue;
541
+
542
+ // Check all dependencies are launched (not necessarily completed)
543
+ const depsReady = shard.depends.every((d) => launched.has(d));
544
+ if (!depsReady) continue;
545
+
546
+ pending.delete(name);
547
+ launched.add(name);
548
+ launchShard(shard);
549
+
550
+ // Launch redundant worker for critical shards
551
+ if (shard.critical) {
552
+ const redundantShard = {
553
+ ...shard,
554
+ agent: FALLBACK_AGENTS[shard.agent] || shard.agent,
555
+ };
556
+ launchShard(redundantShard, true);
557
+ }
558
+ }
559
+ }
560
+
561
+ launchReady();
562
+
563
+ // Re-check pending on each shard completion (dependency chains)
564
+ emitter.on('shardCompleted', () => {
565
+ if (pending.size > 0) launchReady();
566
+ });
567
+
568
+ setState(SWARM_STATES.RUNNING, `${launched.size} launched, ${pending.size} pending deps`);
569
+
570
+ return getStatus();
571
+ }
572
+
573
+ /**
574
+ * Graceful shutdown — kill all workers and release locks.
575
+ * @param {string} [reason]
576
+ */
577
+ async function shutdown(reason = 'shutdown') {
578
+ eventLog.append('swarm_shutdown', { reason, state });
579
+
580
+ const shutdowns = [];
581
+ for (const [, w] of workers) {
582
+ shutdowns.push(w.conductor.shutdown(reason));
583
+ }
584
+ for (const [, w] of redundantWorkers) {
585
+ shutdowns.push(w.conductor.shutdown(reason));
586
+ }
587
+
588
+ await Promise.allSettled(shutdowns);
589
+
590
+ lockManager?.releaseAll();
591
+ await eventLog.flush();
592
+ await eventLog.close();
593
+
594
+ if (state !== SWARM_STATES.COMPLETED && state !== SWARM_STATES.FAILED) {
595
+ setState(SWARM_STATES.FAILED, reason);
596
+ }
597
+
598
+ emitter.emit('shutdown', { reason });
599
+ }
600
+
601
+ return Object.freeze({
602
+ launch,
603
+ shutdown,
604
+ getStatus,
605
+ validateResult,
606
+ on: emitter.on.bind(emitter),
607
+ off: emitter.off.bind(emitter),
608
+ get state() { return state; },
609
+ get plan() { return plan; },
610
+ get eventLogPath() { return eventLog.filePath; },
611
+ });
612
+ }