@chllming/wave-orchestration 0.7.0 → 0.7.2

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 (42) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +9 -8
  3. package/docs/guides/planner.md +19 -0
  4. package/docs/guides/terminal-surfaces.md +12 -0
  5. package/docs/plans/component-cutover-matrix.json +50 -3
  6. package/docs/plans/current-state.md +1 -1
  7. package/docs/plans/end-state-architecture.md +927 -0
  8. package/docs/plans/examples/wave-example-live-proof.md +1 -1
  9. package/docs/plans/migration.md +26 -0
  10. package/docs/plans/wave-orchestrator.md +4 -7
  11. package/docs/plans/waves/wave-1.md +376 -0
  12. package/docs/plans/waves/wave-2.md +292 -0
  13. package/docs/plans/waves/wave-3.md +342 -0
  14. package/docs/plans/waves/wave-4.md +391 -0
  15. package/docs/plans/waves/wave-5.md +382 -0
  16. package/docs/plans/waves/wave-6.md +321 -0
  17. package/docs/reference/cli-reference.md +547 -0
  18. package/docs/reference/coordination-and-closure.md +1 -1
  19. package/docs/reference/npmjs-trusted-publishing.md +2 -2
  20. package/docs/reference/runtime-config/README.md +2 -2
  21. package/docs/reference/runtime-config/codex.md +2 -1
  22. package/docs/reference/sample-waves.md +4 -4
  23. package/package.json +1 -1
  24. package/releases/manifest.json +43 -2
  25. package/scripts/wave-orchestrator/agent-state.mjs +458 -35
  26. package/scripts/wave-orchestrator/artifact-schemas.mjs +81 -0
  27. package/scripts/wave-orchestrator/control-cli.mjs +119 -20
  28. package/scripts/wave-orchestrator/coordination.mjs +11 -10
  29. package/scripts/wave-orchestrator/dashboard-renderer.mjs +82 -2
  30. package/scripts/wave-orchestrator/human-input-workflow.mjs +289 -0
  31. package/scripts/wave-orchestrator/install.mjs +120 -3
  32. package/scripts/wave-orchestrator/launcher-derived-state.mjs +915 -0
  33. package/scripts/wave-orchestrator/launcher-gates.mjs +1061 -0
  34. package/scripts/wave-orchestrator/launcher-retry.mjs +873 -0
  35. package/scripts/wave-orchestrator/launcher-runtime.mjs +9 -9
  36. package/scripts/wave-orchestrator/launcher-supervisor.mjs +704 -0
  37. package/scripts/wave-orchestrator/launcher.mjs +317 -2999
  38. package/scripts/wave-orchestrator/task-entity.mjs +557 -0
  39. package/scripts/wave-orchestrator/terminals.mjs +1 -1
  40. package/scripts/wave-orchestrator/wave-files.mjs +138 -20
  41. package/scripts/wave-orchestrator/wave-state-reducer.mjs +566 -0
  42. package/wave.config.json +1 -1
@@ -0,0 +1,704 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import {
5
+ appendOrchestratorBoardEntry,
6
+ buildResidentOrchestratorPrompt,
7
+ feedbackStateSignature,
8
+ readWaveHumanFeedbackRequests,
9
+ } from "./coordination.mjs";
10
+ import {
11
+ appendCoordinationRecord,
12
+ readMaterializedCoordinationState,
13
+ } from "./coordination-store.mjs";
14
+ import {
15
+ readStatusCodeIfPresent,
16
+ } from "./dashboard-state.mjs";
17
+ import {
18
+ REPO_ROOT,
19
+ readJsonOrNull,
20
+ ensureDirectory,
21
+ shellQuote,
22
+ PACKAGE_ROOT,
23
+ TMUX_COMMAND_TIMEOUT_MS,
24
+ toIsoTimestamp,
25
+ writeJsonAtomic,
26
+ } from "./shared.mjs";
27
+ import {
28
+ killTmuxSessionIfExists,
29
+ terminalSurfaceUsesTerminalRegistry,
30
+ pruneOrphanLaneTemporaryTerminalEntries,
31
+ } from "./terminals.mjs";
32
+ import {
33
+ recordGlobalDashboardEvent,
34
+ writeGlobalDashboard,
35
+ } from "./dashboard-state.mjs";
36
+ import {
37
+ collectUnexpectedSessionFailures as collectUnexpectedSessionFailuresImpl,
38
+ launchAgentSession as launchAgentSessionImpl,
39
+ waitForWaveCompletion as waitForWaveCompletionImpl,
40
+ } from "./launcher-runtime.mjs";
41
+
42
+ function isProcessAlive(pid) {
43
+ if (!Number.isInteger(pid) || pid <= 0) {
44
+ return false;
45
+ }
46
+ try {
47
+ process.kill(pid, 0);
48
+ return true;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ export function markLauncherFailed(
55
+ globalDashboard,
56
+ lanePaths,
57
+ selectedWaves,
58
+ appendCoordination,
59
+ error,
60
+ ) {
61
+ if (globalDashboard) {
62
+ globalDashboard.status = "failed";
63
+ recordGlobalDashboardEvent(globalDashboard, {
64
+ level: "error",
65
+ message: error instanceof Error ? error.message : String(error),
66
+ });
67
+ writeGlobalDashboard(lanePaths.globalDashboardPath, globalDashboard);
68
+ }
69
+ appendCoordination({
70
+ event: "launcher_finish",
71
+ waves: selectedWaves,
72
+ status: "failed",
73
+ details: error instanceof Error ? error.message : String(error),
74
+ actionRequested: `Lane ${lanePaths.lane} owners should inspect the failing wave logs and dashboards before retrying.`,
75
+ });
76
+ }
77
+
78
+ export function acquireLauncherLock(lockPath, options) {
79
+ ensureDirectory(path.dirname(lockPath));
80
+ const payload = {
81
+ lane: options.lane,
82
+ pid: process.pid,
83
+ startedAt: toIsoTimestamp(),
84
+ argv: process.argv.slice(2),
85
+ cwd: REPO_ROOT,
86
+ mode: options.reconcileStatus ? "reconcile" : "launch",
87
+ };
88
+ try {
89
+ const fd = fs.openSync(lockPath, "wx");
90
+ fs.writeFileSync(fd, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
91
+ fs.closeSync(fd);
92
+ return payload;
93
+ } catch (error) {
94
+ if (error?.code !== "EEXIST") {
95
+ throw error;
96
+ }
97
+ const existing = readJsonOrNull(lockPath);
98
+ const existingPid = Number.parseInt(String(existing?.pid ?? ""), 10);
99
+ if (isProcessAlive(existingPid)) {
100
+ const lockError = new Error(
101
+ `Another launcher is active (pid ${existingPid}, started ${existing?.startedAt || "unknown"}). Lock: ${path.relative(REPO_ROOT, lockPath)}`,
102
+ { cause: error },
103
+ );
104
+ lockError.exitCode = 32;
105
+ throw lockError;
106
+ }
107
+ fs.rmSync(lockPath, { force: true });
108
+ const retryFd = fs.openSync(lockPath, "wx");
109
+ fs.writeFileSync(retryFd, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
110
+ fs.closeSync(retryFd);
111
+ return payload;
112
+ }
113
+ }
114
+
115
+ export function releaseLauncherLock(lockPath) {
116
+ fs.rmSync(lockPath, { force: true });
117
+ }
118
+
119
+ function isLaneSessionName(lanePaths, sessionName) {
120
+ return (
121
+ sessionName.startsWith(lanePaths.tmuxSessionPrefix) ||
122
+ sessionName.startsWith(lanePaths.tmuxDashboardSessionPrefix) ||
123
+ sessionName.startsWith(lanePaths.tmuxGlobalDashboardSessionPrefix)
124
+ );
125
+ }
126
+
127
+ function listLaneTmuxSessionNames(lanePaths) {
128
+ return listTmuxSessionNames(lanePaths).filter((sessionName) =>
129
+ isLaneSessionName(lanePaths, sessionName),
130
+ );
131
+ }
132
+
133
+ function residentOrchestratorRolePromptPath() {
134
+ return path.join(REPO_ROOT, "docs", "agents", "wave-orchestrator-role.md");
135
+ }
136
+
137
+ function loadResidentOrchestratorRolePrompt() {
138
+ const filePath = residentOrchestratorRolePromptPath();
139
+ if (!fs.existsSync(filePath)) {
140
+ return "Monitor the wave, triage clarification timing, and intervene through coordination records only.";
141
+ }
142
+ return fs.readFileSync(filePath, "utf8");
143
+ }
144
+
145
+ function defaultResidentExecutorState(options) {
146
+ if (options.executorMode === "claude") {
147
+ return {
148
+ id: "claude",
149
+ role: "orchestrator",
150
+ selectedBy: "resident-orchestrator",
151
+ budget: { minutes: options.timeoutMinutes },
152
+ claude: {
153
+ command: "claude",
154
+ },
155
+ };
156
+ }
157
+ if (options.executorMode === "opencode") {
158
+ return {
159
+ id: "opencode",
160
+ role: "orchestrator",
161
+ selectedBy: "resident-orchestrator",
162
+ budget: { minutes: options.timeoutMinutes },
163
+ opencode: {
164
+ command: "opencode",
165
+ },
166
+ };
167
+ }
168
+ return {
169
+ id: "codex",
170
+ role: "orchestrator",
171
+ selectedBy: "resident-orchestrator",
172
+ budget: { minutes: options.timeoutMinutes },
173
+ codex: {
174
+ command: "codex",
175
+ sandbox: options.codexSandboxMode,
176
+ },
177
+ };
178
+ }
179
+
180
+ function buildResidentExecutorState(executorTemplate, options) {
181
+ const source = executorTemplate
182
+ ? JSON.parse(JSON.stringify(executorTemplate))
183
+ : defaultResidentExecutorState(options);
184
+ source.role = "orchestrator";
185
+ source.selectedBy = "resident-orchestrator";
186
+ source.budget = {
187
+ ...(source.budget || {}),
188
+ minutes: Math.max(
189
+ Number.parseInt(String(source?.budget?.minutes || 0), 10) || 0,
190
+ options.timeoutMinutes,
191
+ ),
192
+ };
193
+ if (source.id === "codex") {
194
+ source.codex = {
195
+ ...(source.codex || {}),
196
+ command: source?.codex?.command || "codex",
197
+ sandbox: source?.codex?.sandbox || options.codexSandboxMode,
198
+ };
199
+ } else if (source.id === "claude") {
200
+ source.claude = {
201
+ ...(source.claude || {}),
202
+ command: source?.claude?.command || "claude",
203
+ };
204
+ } else if (source.id === "opencode") {
205
+ source.opencode = {
206
+ ...(source.opencode || {}),
207
+ command: source?.opencode?.command || "opencode",
208
+ };
209
+ }
210
+ return source;
211
+ }
212
+
213
+ export function buildResidentOrchestratorRun({
214
+ lanePaths,
215
+ wave,
216
+ agentRuns,
217
+ derivedState,
218
+ dashboardPath,
219
+ runTag,
220
+ options,
221
+ }) {
222
+ const executorTemplate =
223
+ agentRuns.find((run) => run.agent.executorResolved?.id === options.executorMode)?.agent
224
+ ?.executorResolved ||
225
+ agentRuns.find((run) => run.agent.executorResolved)?.agent?.executorResolved ||
226
+ null;
227
+ const executorResolved = buildResidentExecutorState(executorTemplate, options);
228
+ if (executorResolved.id === "local") {
229
+ return {
230
+ run: null,
231
+ skipReason: "Resident orchestrator requires codex, claude, or opencode; local executor is not suitable.",
232
+ };
233
+ }
234
+ const agent = {
235
+ agentId: "ORCH",
236
+ title: "Resident Orchestrator",
237
+ slug: `${wave.wave}-resident-orchestrator`,
238
+ prompt: loadResidentOrchestratorRolePrompt(),
239
+ executorResolved,
240
+ };
241
+ const baseName = `wave-${wave.wave}-resident-orchestrator`;
242
+ const sessionName = `${lanePaths.tmuxSessionPrefix}${wave.wave}_resident_orchestrator_${runTag}`.replace(
243
+ /[^a-zA-Z0-9_-]/g,
244
+ "_",
245
+ );
246
+ return {
247
+ run: {
248
+ agent,
249
+ sessionName,
250
+ promptPath: path.join(lanePaths.promptsDir, `${baseName}.prompt.md`),
251
+ logPath: path.join(lanePaths.logsDir, `${baseName}.log`),
252
+ statusPath: path.join(lanePaths.statusDir, `${baseName}.status`),
253
+ promptOverride: buildResidentOrchestratorPrompt({
254
+ lane: lanePaths.lane,
255
+ wave: wave.wave,
256
+ waveFile: wave.file,
257
+ orchestratorId: options.orchestratorId,
258
+ coordinationLogPath: derivedState.coordinationLogPath,
259
+ messageBoardPath: derivedState.messageBoardPath,
260
+ sharedSummaryPath: derivedState.sharedSummaryPath,
261
+ dashboardPath,
262
+ triagePath: derivedState.clarificationTriage?.triagePath || null,
263
+ rolePrompt: agent.prompt,
264
+ }),
265
+ },
266
+ skipReason: "",
267
+ };
268
+ }
269
+
270
+ export function monitorResidentOrchestratorSession({
271
+ lanePaths,
272
+ run,
273
+ waveNumber,
274
+ recordCombinedEvent,
275
+ appendCoordination,
276
+ sessionState,
277
+ }) {
278
+ if (!run || sessionState?.closed === true) {
279
+ return false;
280
+ }
281
+ if (fs.existsSync(run.statusPath)) {
282
+ sessionState.closed = true;
283
+ const exitCode = readStatusCodeIfPresent(run.statusPath);
284
+ recordCombinedEvent({
285
+ level: exitCode === 0 ? "info" : "warn",
286
+ agentId: run.agent.agentId,
287
+ message:
288
+ exitCode === 0
289
+ ? "Resident orchestrator exited; launcher continues as the control plane."
290
+ : `Resident orchestrator exited with code ${exitCode}; launcher continues as the control plane.`,
291
+ });
292
+ appendCoordination({
293
+ event: "resident_orchestrator_exit",
294
+ waves: [waveNumber],
295
+ status: exitCode === 0 ? "resolved" : "warn",
296
+ details:
297
+ exitCode === 0
298
+ ? "Resident orchestrator session ended before wave completion."
299
+ : `Resident orchestrator session ended with code ${exitCode} before wave completion.`,
300
+ actionRequested: "None",
301
+ });
302
+ return true;
303
+ }
304
+ const activeSessions = new Set(listLaneTmuxSessionNames(lanePaths));
305
+ if (!activeSessions.has(run.sessionName)) {
306
+ sessionState.closed = true;
307
+ recordCombinedEvent({
308
+ level: "warn",
309
+ agentId: run.agent.agentId,
310
+ message:
311
+ "Resident orchestrator session disappeared before writing a status file; launcher continues as the control plane.",
312
+ });
313
+ appendCoordination({
314
+ event: "resident_orchestrator_missing",
315
+ waves: [waveNumber],
316
+ status: "warn",
317
+ details: `tmux session ${run.sessionName} disappeared before ${path.relative(REPO_ROOT, run.statusPath)} was written.`,
318
+ actionRequested: "None",
319
+ });
320
+ return true;
321
+ }
322
+ return false;
323
+ }
324
+
325
+ function isWaveDashboardBackedByLiveSession(lanePaths, dashboardPath, activeSessionNames) {
326
+ const waveMatch = path.basename(dashboardPath).match(/^wave-(\d+)\.json$/);
327
+ if (!waveMatch) {
328
+ return false;
329
+ }
330
+ const waveNumber = Number.parseInt(waveMatch[1], 10);
331
+ if (!Number.isFinite(waveNumber)) {
332
+ return false;
333
+ }
334
+ const dashboardState = readJsonOrNull(dashboardPath);
335
+ const runTag = String(dashboardState?.runTag || "").trim();
336
+ const agentPrefix = `${lanePaths.tmuxSessionPrefix}${waveNumber}_`;
337
+ const dashboardPrefix = `${lanePaths.tmuxDashboardSessionPrefix}${waveNumber}_`;
338
+ for (const sessionName of activeSessionNames) {
339
+ if (!(sessionName.startsWith(agentPrefix) || sessionName.startsWith(dashboardPrefix))) {
340
+ continue;
341
+ }
342
+ if (!runTag || sessionName.endsWith(`_${runTag}`)) {
343
+ return true;
344
+ }
345
+ }
346
+ return false;
347
+ }
348
+
349
+ function removeOrphanWaveDashboards(lanePaths, activeSessionNames) {
350
+ if (!fs.existsSync(lanePaths.dashboardsDir)) {
351
+ return [];
352
+ }
353
+ const removedDashboardPaths = [];
354
+ for (const fileName of fs.readdirSync(lanePaths.dashboardsDir)) {
355
+ if (!/^wave-\d+\.json$/.test(fileName)) {
356
+ continue;
357
+ }
358
+ const dashboardPath = path.join(lanePaths.dashboardsDir, fileName);
359
+ if (isWaveDashboardBackedByLiveSession(lanePaths, dashboardPath, activeSessionNames)) {
360
+ continue;
361
+ }
362
+ fs.rmSync(dashboardPath, { force: true });
363
+ removedDashboardPaths.push(path.relative(REPO_ROOT, dashboardPath));
364
+ }
365
+ return removedDashboardPaths;
366
+ }
367
+
368
+ export function pruneDryRunExecutorPreviewDirs(lanePaths, waves) {
369
+ if (!fs.existsSync(lanePaths.executorOverlaysDir)) {
370
+ return [];
371
+ }
372
+ const expectedSlugsByWave = new Map(
373
+ (waves || []).map((wave) => [wave.wave, new Set((wave.agents || []).map((agent) => agent.slug))]),
374
+ );
375
+ const removedPaths = [];
376
+ for (const entry of fs.readdirSync(lanePaths.executorOverlaysDir, { withFileTypes: true })) {
377
+ if (!entry.isDirectory() || !/^wave-\d+$/.test(entry.name)) {
378
+ continue;
379
+ }
380
+ const waveNumber = Number.parseInt(entry.name.slice("wave-".length), 10);
381
+ const waveDir = path.join(lanePaths.executorOverlaysDir, entry.name);
382
+ const expectedSlugs = expectedSlugsByWave.get(waveNumber);
383
+ if (!expectedSlugs) {
384
+ fs.rmSync(waveDir, { recursive: true, force: true });
385
+ removedPaths.push(path.relative(REPO_ROOT, waveDir));
386
+ continue;
387
+ }
388
+ for (const child of fs.readdirSync(waveDir, { withFileTypes: true })) {
389
+ if (!child.isDirectory() || expectedSlugs.has(child.name)) {
390
+ continue;
391
+ }
392
+ const childPath = path.join(waveDir, child.name);
393
+ fs.rmSync(childPath, { recursive: true, force: true });
394
+ removedPaths.push(path.relative(REPO_ROOT, childPath));
395
+ }
396
+ }
397
+ return removedPaths.toSorted();
398
+ }
399
+
400
+ export function reconcileStaleLauncherArtifacts(lanePaths, options = {}) {
401
+ const outcome = {
402
+ removedLock: false,
403
+ removedSessions: [],
404
+ removedTerminalNames: [],
405
+ clearedDashboards: false,
406
+ removedDashboardPaths: [],
407
+ staleWaves: [],
408
+ activeLockPid: null,
409
+ };
410
+
411
+ if (fs.existsSync(lanePaths.launcherLockPath)) {
412
+ const existing = readJsonOrNull(lanePaths.launcherLockPath);
413
+ const existingPid = Number.parseInt(String(existing?.pid ?? ""), 10);
414
+ if (isProcessAlive(existingPid)) {
415
+ outcome.activeLockPid = existingPid;
416
+ return outcome;
417
+ }
418
+ fs.rmSync(lanePaths.launcherLockPath, { force: true });
419
+ outcome.removedLock = true;
420
+ }
421
+
422
+ outcome.removedSessions = cleanupLaneTmuxSessions(lanePaths);
423
+ const activeSessionNames = new Set(listLaneTmuxSessionNames(lanePaths));
424
+ if (terminalSurfaceUsesTerminalRegistry(options.terminalSurface || "vscode")) {
425
+ const terminalCleanup = pruneOrphanLaneTemporaryTerminalEntries(
426
+ lanePaths.terminalsPath,
427
+ lanePaths,
428
+ activeSessionNames,
429
+ );
430
+ outcome.removedTerminalNames = terminalCleanup.removedNames;
431
+ }
432
+
433
+ const globalDashboard = readJsonOrNull(lanePaths.globalDashboardPath);
434
+ if (globalDashboard && typeof globalDashboard === "object" && Array.isArray(globalDashboard.waves)) {
435
+ const staleWaves = new Set();
436
+ for (const waveEntry of globalDashboard.waves) {
437
+ const waveNumber = Number.parseInt(String(waveEntry?.wave ?? ""), 10);
438
+ if (Number.isFinite(waveNumber)) {
439
+ staleWaves.add(waveNumber);
440
+ }
441
+ }
442
+ outcome.staleWaves = Array.from(staleWaves).toSorted((a, b) => a - b);
443
+ }
444
+
445
+ if (fs.existsSync(lanePaths.globalDashboardPath)) {
446
+ fs.rmSync(lanePaths.globalDashboardPath, { force: true });
447
+ outcome.removedDashboardPaths.push(path.relative(REPO_ROOT, lanePaths.globalDashboardPath));
448
+ }
449
+ outcome.removedDashboardPaths.push(
450
+ ...removeOrphanWaveDashboards(lanePaths, activeSessionNames),
451
+ );
452
+ outcome.clearedDashboards = outcome.removedDashboardPaths.length > 0;
453
+ return outcome;
454
+ }
455
+
456
+ export function runTmux(lanePaths, args, description) {
457
+ const result = spawnSync("tmux", ["-L", lanePaths.tmuxSocketName, ...args], {
458
+ cwd: REPO_ROOT,
459
+ encoding: "utf8",
460
+ env: { ...process.env, TMUX: "" },
461
+ timeout: TMUX_COMMAND_TIMEOUT_MS,
462
+ });
463
+ if (result.error) {
464
+ if (result.error.code === "ETIMEDOUT") {
465
+ throw new Error(
466
+ `${description} failed: tmux command timed out after ${TMUX_COMMAND_TIMEOUT_MS}ms`,
467
+ );
468
+ }
469
+ throw new Error(`${description} failed: ${result.error.message}`);
470
+ }
471
+ if (result.status !== 0) {
472
+ throw new Error(
473
+ `${description} failed: ${(result.stderr || "").trim() || "tmux command failed"}`,
474
+ );
475
+ }
476
+ }
477
+
478
+ function listTmuxSessionNames(lanePaths) {
479
+ const result = spawnSync(
480
+ "tmux",
481
+ ["-L", lanePaths.tmuxSocketName, "list-sessions", "-F", "#{session_name}"],
482
+ {
483
+ cwd: REPO_ROOT,
484
+ encoding: "utf8",
485
+ env: { ...process.env, TMUX: "" },
486
+ timeout: TMUX_COMMAND_TIMEOUT_MS,
487
+ },
488
+ );
489
+ if (result.error) {
490
+ if (result.error.code === "ENOENT") {
491
+ return [];
492
+ }
493
+ if (result.error.code === "ETIMEDOUT") {
494
+ throw new Error(`list tmux sessions failed: timed out after ${TMUX_COMMAND_TIMEOUT_MS}ms`);
495
+ }
496
+ throw new Error(`list tmux sessions failed: ${result.error.message}`);
497
+ }
498
+ if (result.status !== 0) {
499
+ const combined = `${String(result.stderr || "").toLowerCase()}\n${String(result.stdout || "").toLowerCase()}`;
500
+ if (
501
+ combined.includes("no server running") ||
502
+ combined.includes("failed to connect") ||
503
+ combined.includes("error connecting")
504
+ ) {
505
+ return [];
506
+ }
507
+ throw new Error(
508
+ `list tmux sessions failed: ${(result.stderr || "").trim() || "unknown error"}`,
509
+ );
510
+ }
511
+ return String(result.stdout || "")
512
+ .split(/\r?\n/)
513
+ .map((line) => line.trim())
514
+ .filter(Boolean);
515
+ }
516
+
517
+ export function cleanupLaneTmuxSessions(lanePaths, { excludeSessionNames = new Set() } = {}) {
518
+ const sessionNames = listTmuxSessionNames(lanePaths);
519
+ const killed = [];
520
+ for (const sessionName of sessionNames) {
521
+ if (excludeSessionNames.has(sessionName) || !isLaneSessionName(lanePaths, sessionName)) {
522
+ continue;
523
+ }
524
+ killTmuxSessionIfExists(lanePaths.tmuxSocketName, sessionName);
525
+ killed.push(sessionName);
526
+ }
527
+ return killed;
528
+ }
529
+
530
+ export function collectUnexpectedSessionFailures(lanePaths, agentRuns, pendingAgentIds) {
531
+ return collectUnexpectedSessionFailuresImpl(lanePaths, agentRuns, pendingAgentIds, {
532
+ listLaneTmuxSessionNamesFn: listLaneTmuxSessionNames,
533
+ });
534
+ }
535
+
536
+ export function launchWaveDashboardSession(lanePaths, { sessionName, dashboardPath, messageBoardPath }) {
537
+ killTmuxSessionIfExists(lanePaths.tmuxSocketName, sessionName);
538
+ const messageBoardArg = messageBoardPath
539
+ ? ` --message-board ${shellQuote(messageBoardPath)}`
540
+ : "";
541
+ const command = [
542
+ `cd ${shellQuote(REPO_ROOT)}`,
543
+ `node ${shellQuote(path.join(PACKAGE_ROOT, "scripts", "wave-dashboard.mjs"))} --dashboard-file ${shellQuote(
544
+ dashboardPath,
545
+ )}${messageBoardArg} --lane ${shellQuote(lanePaths.lane)} --watch`,
546
+ "exec bash -l",
547
+ ].join("; ");
548
+ runTmux(
549
+ lanePaths,
550
+ ["new-session", "-d", "-s", sessionName, `bash -lc ${shellQuote(command)}`],
551
+ `launch dashboard session ${sessionName}`,
552
+ );
553
+ }
554
+
555
+ export async function launchAgentSession(lanePaths, params) {
556
+ return launchAgentSessionImpl(lanePaths, params, { runTmuxFn: runTmux });
557
+ }
558
+
559
+ export async function waitForWaveCompletion(lanePaths, agentRuns, timeoutMinutes, onProgress = null) {
560
+ return waitForWaveCompletionImpl(lanePaths, agentRuns, timeoutMinutes, onProgress, {
561
+ collectUnexpectedSessionFailuresFn: collectUnexpectedSessionFailures,
562
+ });
563
+ }
564
+
565
+ export function monitorWaveHumanFeedback({
566
+ lanePaths,
567
+ waveNumber,
568
+ agentRuns,
569
+ orchestratorId,
570
+ coordinationLogPath,
571
+ feedbackStateByRequestId,
572
+ recordCombinedEvent,
573
+ appendCoordination,
574
+ }) {
575
+ const triageLogPath = path.join(lanePaths.feedbackTriageDir, `wave-${waveNumber}.jsonl`);
576
+ const requests = readWaveHumanFeedbackRequests({
577
+ feedbackRequestsDir: lanePaths.feedbackRequestsDir,
578
+ lane: lanePaths.lane,
579
+ waveNumber,
580
+ agentIds: agentRuns.map((run) => run.agent.agentId),
581
+ orchestratorId,
582
+ });
583
+ let changed = false;
584
+ for (const request of requests) {
585
+ const signature = feedbackStateSignature(request);
586
+ if (feedbackStateByRequestId.get(request.id) === signature) {
587
+ continue;
588
+ }
589
+ feedbackStateByRequestId.set(request.id, signature);
590
+ changed = true;
591
+ const question = request.question || "n/a";
592
+ const context = request.context ? `; context=${request.context}` : "";
593
+ const responseOperator = request.responseOperator || "human-operator";
594
+ const responseText = request.responseText || "(empty response)";
595
+ if (request.status === "pending") {
596
+ recordCombinedEvent({
597
+ level: "warn",
598
+ agentId: request.agentId,
599
+ message: `Human feedback requested (${request.id}): ${question}`,
600
+ });
601
+ console.warn(
602
+ `[human-feedback] wave=${waveNumber} agent=${request.agentId} request=${request.id} pending: ${question}`,
603
+ );
604
+ console.warn(
605
+ `[human-feedback] respond with: pnpm exec wave control task act answer --lane ${lanePaths.lane} --wave ${waveNumber} --id ${request.id} --response "<answer>" --operator "<name>"`,
606
+ );
607
+ appendCoordination({
608
+ event: "human_feedback_requested",
609
+ waves: [waveNumber],
610
+ status: "waiting-human",
611
+ details: `request_id=${request.id}; agent=${request.agentId}; question=${question}${context}`,
612
+ actionRequested: `Launcher operator should ask or answer in the parent session, then run: pnpm exec wave control task act answer --lane ${lanePaths.lane} --wave ${waveNumber} --id ${request.id} --response "<answer>" --operator "<name>"`,
613
+ });
614
+ if (coordinationLogPath) {
615
+ appendCoordinationRecord(coordinationLogPath, {
616
+ id: request.id,
617
+ lane: lanePaths.lane,
618
+ wave: waveNumber,
619
+ agentId: request.agentId || "human",
620
+ kind: "human-feedback",
621
+ targets: request.agentId ? [`agent:${request.agentId}`] : [],
622
+ priority: "high",
623
+ summary: question,
624
+ detail: request.context || "",
625
+ status: "open",
626
+ source: "feedback",
627
+ });
628
+ }
629
+ } else if (request.status === "answered") {
630
+ recordCombinedEvent({
631
+ level: "info",
632
+ agentId: request.agentId,
633
+ message: `Human feedback answered (${request.id}) by ${responseOperator}: ${responseText}`,
634
+ });
635
+ appendCoordination({
636
+ event: "human_feedback_answered",
637
+ waves: [waveNumber],
638
+ status: "resolved",
639
+ details: `request_id=${request.id}; agent=${request.agentId}; operator=${responseOperator}; response=${responseText}`,
640
+ });
641
+ if (coordinationLogPath) {
642
+ const escalationId = `escalation-${request.id}`;
643
+ const existingEscalation =
644
+ (fs.existsSync(triageLogPath)
645
+ ? readMaterializedCoordinationState(triageLogPath).byId.get(escalationId)
646
+ : null) ||
647
+ readMaterializedCoordinationState(coordinationLogPath).byId.get(escalationId) ||
648
+ null;
649
+ if (fs.existsSync(triageLogPath)) {
650
+ appendCoordinationRecord(triageLogPath, {
651
+ id: escalationId,
652
+ lane: lanePaths.lane,
653
+ wave: waveNumber,
654
+ agentId: request.agentId || "human",
655
+ kind: "human-escalation",
656
+ targets:
657
+ existingEscalation?.targets ||
658
+ (request.agentId ? [`agent:${request.agentId}`] : []),
659
+ dependsOn: existingEscalation?.dependsOn || [],
660
+ closureCondition: existingEscalation?.closureCondition || "",
661
+ priority: "high",
662
+ summary: question,
663
+ detail: responseText,
664
+ artifactRefs: [request.id],
665
+ status: "resolved",
666
+ source: "feedback",
667
+ });
668
+ }
669
+ appendCoordinationRecord(coordinationLogPath, {
670
+ id: escalationId,
671
+ lane: lanePaths.lane,
672
+ wave: waveNumber,
673
+ agentId: request.agentId || "human",
674
+ kind: "human-escalation",
675
+ targets:
676
+ existingEscalation?.targets ||
677
+ (request.agentId ? [`agent:${request.agentId}`] : []),
678
+ dependsOn: existingEscalation?.dependsOn || [],
679
+ closureCondition: existingEscalation?.closureCondition || "",
680
+ priority: "high",
681
+ summary: question,
682
+ detail: responseText,
683
+ artifactRefs: [request.id],
684
+ status: "resolved",
685
+ source: "feedback",
686
+ });
687
+ appendCoordinationRecord(coordinationLogPath, {
688
+ id: request.id,
689
+ lane: lanePaths.lane,
690
+ wave: waveNumber,
691
+ agentId: request.agentId || "human",
692
+ kind: "human-feedback",
693
+ targets: request.agentId ? [`agent:${request.agentId}`] : [],
694
+ priority: "high",
695
+ summary: question,
696
+ detail: responseText,
697
+ status: "resolved",
698
+ source: "feedback",
699
+ });
700
+ }
701
+ }
702
+ }
703
+ return changed;
704
+ }