@triflux/remote 10.41.1 → 10.43.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.
@@ -23,6 +23,12 @@ import { fileURLToPath } from "node:url";
23
23
  import { requestJson } from "@triflux/core/hub/bridge.mjs";
24
24
  import { escapePwshSingleQuoted } from "@triflux/core/hub/cli-adapter-base.mjs";
25
25
  import { getMaxSpawnPerSec } from "@triflux/core/hub/lib/spawn-trace.mjs";
26
+ import {
27
+ createActivityLifecycle,
28
+ isActivityLifecycleEnabled,
29
+ resolveHardCeilingMs,
30
+ resolveStallInterventionMs,
31
+ } from "@triflux/core/hub/lib/worker-lifecycle.mjs";
26
32
  import { IS_WINDOWS } from "@triflux/core/hub/platform.mjs";
27
33
  import { getBackend } from "./backend.mjs";
28
34
  import {
@@ -36,6 +42,10 @@ import {
36
42
  import { startClaudeNativeBridge } from "./claude-native-bridge.mjs";
37
43
  import { removeClaudeSessionProjection } from "./claude-session-projection.mjs";
38
44
  import { resolveDashboardLayout } from "./dashboard-layout.mjs";
45
+ import {
46
+ createFileActivitySource,
47
+ createInterventionLadder,
48
+ } from "./intervention.mjs";
39
49
  import {
40
50
  formatHandoffForLead,
41
51
  HANDOFF_INSTRUCTION_SHORT,
@@ -179,9 +189,17 @@ function buildRouteBackedHeadlessCommand(
179
189
  : "";
180
190
  const timeoutArg = timeout ? ` ${shellQuote(timeout)}` : "";
181
191
  const routeMode = resolvedCli === "antigravity" ? "antigravity" : resolvedCli;
192
+ const lifecycleEnv = [
193
+ "TFX_HARD_CEILING_SEC",
194
+ "TFX_STALL_THRESHOLD",
195
+ "TFX_STALL_INTERVENTION_SEC",
196
+ ]
197
+ .filter((key) => process.env[key])
198
+ .map((key) => `${key}=${shellQuote(process.env[key])} `)
199
+ .join("");
182
200
  const script =
183
201
  `__tfx_prompt=$(cat ${shellQuote(promptFile)}); ` +
184
- `TFX_CLI_MODE=${shellQuote(routeMode)} ` +
202
+ `${lifecycleEnv}TFX_CLI_MODE=${shellQuote(routeMode)} ` +
185
203
  `bash ${shellQuote(routeScript)} ${shellQuote(routeAgent)} "$__tfx_prompt" ${shellQuote(mcp)}${timeoutArg} ` +
186
204
  `> ${shellQuote(resultFile)} 2>${shellQuote(`${resultFile}.err`)} < /dev/null`;
187
205
  return `bash -lc ${shellQuote(script)}`;
@@ -195,6 +213,11 @@ export function getHeadlessLeadAgentId(sessionName) {
195
213
  return `headless-${sessionName}-lead`;
196
214
  }
197
215
 
216
+ const HUB_CLI_VALUES = new Set(["codex", "gemini", "claude", "other"]);
217
+ function normalizeCliForHub(cli) {
218
+ return HUB_CLI_VALUES.has(cli) ? cli : "other";
219
+ }
220
+
198
221
  export async function registerHeadlessWorker(
199
222
  sessionName,
200
223
  index,
@@ -203,7 +226,8 @@ export async function registerHeadlessWorker(
203
226
  ) {
204
227
  await requestJsonFn("/bridge/register", {
205
228
  body: {
206
- agentId: getHeadlessWorkerAgentId(sessionName, index),
229
+ agent_id: getHeadlessWorkerAgentId(sessionName, index),
230
+ cli: normalizeCliForHub(cli),
207
231
  topics: ["headless.worker"],
208
232
  capabilities: [cli],
209
233
  },
@@ -474,8 +498,10 @@ export function readResult(resultFile, paneId) {
474
498
  export const STALL_DEFAULTS = Object.freeze({
475
499
  pollInterval: 5_000,
476
500
  stallTimeout: 120_000,
477
- completionTimeout: 900_000,
501
+ interventionTimeout: resolveStallInterventionMs(),
502
+ hardCeiling: resolveHardCeilingMs(),
478
503
  maxRestarts: 2,
504
+ maxInterventions: 1,
479
505
  });
480
506
 
481
507
  /** CLI pane stall 감지 에러 (STALL_EXHAUSTED | COMPLETION_TIMEOUT) */
@@ -543,6 +569,99 @@ export function createStallMonitor(paneId, resultFile, config, deps = {}) {
543
569
  });
544
570
  }
545
571
 
572
+ function createHeadlessIntervention(dispatch, fallback) {
573
+ return async (context) => {
574
+ if (typeof fallback === "function") return (await fallback(context)) === true;
575
+
576
+ const isDaemon = context.channel === "daemon";
577
+ const readActivitySignature = isDaemon
578
+ ? createFileActivitySource({ files: [dispatch.resultFile] })
579
+ : () => {
580
+ let mtime = 0;
581
+ try {
582
+ mtime = statSync(dispatch.resultFile).mtimeMs;
583
+ } catch {
584
+ /* result file has not been written yet */
585
+ }
586
+ return `${capturePsmuxPane(context.paneId || dispatch.paneId, 50)}\0${mtime}`;
587
+ };
588
+ const ladder = createInterventionLadder({
589
+ target: isDaemon
590
+ ? {
591
+ channel: "claude-daemon",
592
+ cli: dispatch.cli || "claude",
593
+ sessionId: dispatch.sessionId,
594
+ daemon: {
595
+ controlSock: dispatch.controlSock,
596
+ short: dispatch.daemonShort,
597
+ sessionId: dispatch.sessionId,
598
+ configDir: dispatch.daemonPaths?.configDir,
599
+ },
600
+ }
601
+ : {
602
+ channel: "tmux-pane",
603
+ paneId: context.paneId || dispatch.paneId,
604
+ cli: dispatch.cli,
605
+ sessionId: dispatch.sessionId,
606
+ },
607
+ readActivitySignature,
608
+ deps: {
609
+ resumeHandler: async () => {
610
+ if (isDaemon || !dispatch.command) return { ok: false };
611
+ const restarted = dispatchCommand(
612
+ context.sessionName,
613
+ context.paneId || dispatch.paneId,
614
+ dispatch.command,
615
+ );
616
+ return { ok: Boolean(restarted) };
617
+ },
618
+ },
619
+ });
620
+ const outcome = await ladder.intervene();
621
+ return outcome.outcome === "reactivated" || outcome.outcome === "resumed";
622
+ };
623
+ }
624
+
625
+ export function createActivityPollGuard({
626
+ resultFile,
627
+ lifecycle,
628
+ interveneContext,
629
+ statSyncFn = statSync,
630
+ }) {
631
+ const controller = new AbortController();
632
+ let lastMtime = 0;
633
+ let checking = false;
634
+ const finishIfTimedOut = async () => {
635
+ if (checking || controller.signal.aborted) return;
636
+ checking = true;
637
+ try {
638
+ if (await lifecycle.check(interveneContext)) controller.abort();
639
+ } finally {
640
+ checking = false;
641
+ }
642
+ };
643
+ return Object.freeze({
644
+ signal: controller.signal,
645
+ observe(content) {
646
+ let mtime = 0;
647
+ try {
648
+ mtime = statSyncFn(resultFile).mtimeMs;
649
+ } catch {
650
+ /* result file has not been created yet */
651
+ }
652
+ lifecycle.observe(`${content}\0${mtime}`);
653
+ lastMtime = mtime || lastMtime;
654
+ void finishIfTimedOut();
655
+ },
656
+ get timeoutReason() {
657
+ return lifecycle.timeoutReason;
658
+ },
659
+ get lastMtime() {
660
+ return lastMtime;
661
+ },
662
+ });
663
+ }
664
+
546
665
  /**
547
666
  * 하이브리드 stall 감지 대기 — output 변화 + resultFile mtime 모니터링.
548
667
  * 2분 무변화 시 pane kill → re-dispatch (최대 2회 재시작).
@@ -553,7 +672,8 @@ export function createStallMonitor(paneId, resultFile, config, deps = {}) {
553
672
  * @param {object} [opts]
554
673
  * @param {number} [opts.pollInterval=5000] — 폴링 간격 ms
555
674
  * @param {number} [opts.stallTimeout=120000] — 무변화 stall 판정 ms
556
- * @param {number} [opts.completionTimeout=900000] — 전체 타임아웃 ms
675
+ * @param {number} [opts.hardCeiling] — 활동과 무관한 절대 상한 ms
676
+ * @param {number} [opts.completionTimeout] — hardCeiling의 하위호환 alias
557
677
  * @param {number} [opts.maxRestarts=2] — 최대 재시작 횟수
558
678
  * @param {string} [opts.command] — re-dispatch용 원본 명령
559
679
  * @param {string} [opts.token] — completion token
@@ -569,13 +689,18 @@ export async function waitForCompletionWithStallDetect(
569
689
  const {
570
690
  pollInterval = 5000,
571
691
  stallTimeout = 120000,
572
- completionTimeout = 900000,
573
692
  maxRestarts = 2,
693
+ maxInterventions = 1,
574
694
  command,
575
695
  token,
576
696
  onPoll,
697
+ onIntervene,
577
698
  _deps,
578
699
  } = opts;
700
+ const hardCeiling =
701
+ opts.hardCeiling ??
702
+ opts.completionTimeout ??
703
+ (isActivityLifecycleEnabled() ? resolveHardCeilingMs() : 900_000);
579
704
 
580
705
  // 의존성 (테스트 시 _deps로 주입 가능)
581
706
  const deps = _deps || {};
@@ -601,6 +726,8 @@ export async function waitForCompletionWithStallDetect(
601
726
  };
602
727
 
603
728
  let restarts = 0;
729
+ let interventions = 0;
730
+ const runStartedAt = Date.now();
604
731
  let currentPaneId = paneId;
605
732
  let stallDetected = false;
606
733
  let currentToken = token;
@@ -610,7 +737,6 @@ export async function waitForCompletionWithStallDetect(
610
737
  let lastOutput = "";
611
738
  let lastMtime = 0;
612
739
  let lastChangeAt = Date.now();
613
- const startedAt = Date.now();
614
740
 
615
741
  // 초기 resultFile mtime
616
742
  try {
@@ -623,8 +749,8 @@ export async function waitForCompletionWithStallDetect(
623
749
  await new Promise((r) => setTimeout(r, pollInterval));
624
750
  const now = Date.now();
625
751
 
626
- // 전체 타임아웃
627
- if (now - startedAt > completionTimeout) {
752
+ // 절대 상한은 재시작과 활동 여부를 관통하는 유일한 wall-clock kill이다.
753
+ if (now - runStartedAt > hardCeiling) {
628
754
  // Issue #118: timeout kill 전에 capture-pane 출력을 .partial 파일로 persist.
629
755
  // resultFile(`.txt`)이 아직 생성되지 않았을 수 있으므로 readResult 가 fallback 으로 읽는다.
630
756
  try {
@@ -642,6 +768,7 @@ export async function waitForCompletionWithStallDetect(
642
768
  restarts,
643
769
  stallDetected,
644
770
  timedOut: true,
771
+ timeoutReason: "hard_ceiling",
645
772
  paneId: currentPaneId,
646
773
  token: currentToken,
647
774
  logPath: currentLogPath,
@@ -721,11 +848,34 @@ export async function waitForCompletionWithStallDetect(
721
848
  stallDetected = true;
722
849
 
723
850
  if (restarts >= maxRestarts) {
851
+ if (onIntervene && interventions < maxInterventions) {
852
+ interventions += 1;
853
+ let handled = false;
854
+ try {
855
+ handled =
856
+ (await onIntervene({
857
+ channel: "pane",
858
+ sessionName,
859
+ paneId: currentPaneId,
860
+ resultFile,
861
+ inactiveMs: now - lastChangeAt,
862
+ restarts,
863
+ interventions,
864
+ })) === true;
865
+ } catch {
866
+ handled = false;
867
+ }
868
+ if (handled) {
869
+ lastChangeAt = Date.now();
870
+ continue;
871
+ }
872
+ }
724
873
  const err = new Error("CLI가 반복적으로 멈춤. 수동 확인 필요.");
725
874
  err.code = "STALL_EXHAUSTED";
726
875
  err.category = "transient";
727
876
  err.recovery = "CLI가 반복적으로 멈춤. 수동 확인 필요.";
728
877
  err.restarts = restarts;
878
+ err.interventions = interventions;
729
879
  throw err;
730
880
  }
731
881
 
@@ -1103,6 +1253,7 @@ async function awaitAll(
1103
1253
  safeProgress,
1104
1254
  progressIntervalSec,
1105
1255
  stallOpts,
1256
+ lifecycle,
1106
1257
  ) {
1107
1258
  // 병렬 대기 (Promise.all — 모든 pane 동시 폴링, 총 시간 = max(개별 시간))
1108
1259
  return Promise.all(
@@ -1155,12 +1306,23 @@ async function awaitAll(
1155
1306
  {
1156
1307
  pollInterval: stallOpts.pollInterval,
1157
1308
  stallTimeout: stallOpts.stallTimeout,
1158
- completionTimeout:
1159
- stallOpts.completionTimeout ?? timeoutSec * 1000,
1309
+ hardCeiling:
1310
+ lifecycle?.enabled
1311
+ ? (stallOpts.hardCeiling ??
1312
+ stallOpts.completionTimeout ??
1313
+ lifecycle.hardCeilingMs)
1314
+ : (stallOpts.completionTimeout ?? timeoutSec * 1000),
1160
1315
  maxRestarts: stallOpts.maxRestarts,
1316
+ maxInterventions: stallOpts.maxInterventions,
1161
1317
  command: d.command,
1162
1318
  token: d.token,
1163
1319
  onPoll: stallPollCb,
1320
+ onIntervene: lifecycle?.enabled
1321
+ ? createHeadlessIntervention(
1322
+ d,
1323
+ stallOpts.onIntervene ?? lifecycle.onIntervene,
1324
+ )
1325
+ : undefined,
1164
1326
  },
1165
1327
  );
1166
1328
  if (stallResult.paneId) d.paneId = stallResult.paneId;
@@ -1184,6 +1346,40 @@ async function awaitAll(
1184
1346
  throw stallErr;
1185
1347
  }
1186
1348
  }
1349
+ } else if (lifecycle?.enabled) {
1350
+ if (d.logPath) pollOpts.logPath = d.logPath;
1351
+ const activity = createActivityLifecycle({
1352
+ interventionMs: lifecycle.interventionMs,
1353
+ hardCeilingMs: lifecycle.hardCeilingMs,
1354
+ onIntervene: createHeadlessIntervention(d, lifecycle.onIntervene),
1355
+ });
1356
+ const guard = createActivityPollGuard({
1357
+ resultFile: d.resultFile,
1358
+ lifecycle: activity,
1359
+ interveneContext: {
1360
+ channel: "pane",
1361
+ sessionName,
1362
+ paneId: d.paneId || d.paneName,
1363
+ resultFile: d.resultFile,
1364
+ },
1365
+ });
1366
+ const userOnPoll = pollOpts.onPoll;
1367
+ pollOpts.onPoll = (info) => {
1368
+ guard.observe(info.content);
1369
+ userOnPoll?.(info);
1370
+ };
1371
+ pollOpts.signal = guard.signal;
1372
+ completion = await waitForCompletion(
1373
+ sessionName,
1374
+ d.paneId || d.paneName,
1375
+ d.token,
1376
+ Math.ceil(lifecycle.hardCeilingMs / 1000),
1377
+ pollOpts,
1378
+ );
1379
+ if (!completion.matched && !completion.sessionDead) {
1380
+ completion.timedOut = true;
1381
+ completion.timeoutReason = guard.timeoutReason || "hard_ceiling";
1382
+ }
1187
1383
  } else {
1188
1384
  // 기존 waitForCompletion 경로
1189
1385
  if (d.logPath) pollOpts.logPath = d.logPath;
@@ -1231,23 +1427,32 @@ async function awaitAll(
1231
1427
  );
1232
1428
  }
1233
1429
 
1234
- async function waitForDaemonCompletion(
1430
+ export async function waitForDaemonCompletion(
1235
1431
  dispatch,
1236
1432
  timeoutSec,
1237
1433
  safeProgress,
1238
1434
  progressIntervalSec,
1435
+ lifecycle = null,
1239
1436
  ) {
1240
1437
  const messages = [];
1241
1438
  const socket = net.connect(dispatch.controlSock);
1242
1439
  let buffer = "";
1243
1440
  let settled = false;
1244
1441
  let lastProgressAt = 0;
1442
+ const activity = lifecycle?.enabled
1443
+ ? createActivityLifecycle({
1444
+ interventionMs: lifecycle.interventionMs,
1445
+ hardCeilingMs: lifecycle.hardCeilingMs,
1446
+ onIntervene: createHeadlessIntervention(dispatch, lifecycle.onIntervene),
1447
+ })
1448
+ : null;
1245
1449
 
1246
1450
  return await new Promise((resolve) => {
1247
- const finish = (completion) => {
1451
+ const finish = (completion, timeoutReason = "") => {
1248
1452
  if (settled) return;
1249
1453
  settled = true;
1250
1454
  clearTimeout(timer);
1455
+ clearInterval(timer);
1251
1456
  socket.destroy();
1252
1457
  const finalCompletion =
1253
1458
  completion ||
@@ -1255,6 +1460,10 @@ async function waitForDaemonCompletion(
1255
1460
  token: dispatch.token,
1256
1461
  resultFile: dispatch.resultFile,
1257
1462
  });
1463
+ if (!finalCompletion.matched && timeoutReason) {
1464
+ finalCompletion.timedOut = true;
1465
+ finalCompletion.timeoutReason = timeoutReason;
1466
+ }
1258
1467
  if (finalCompletion.matched) {
1259
1468
  dispatch.daemonCompletionMatched = true;
1260
1469
  cleanupDaemonDispatches([dispatch]).catch(() => {});
@@ -1273,18 +1482,28 @@ async function waitForDaemonCompletion(
1273
1482
  resolve(finalCompletion);
1274
1483
  };
1275
1484
 
1276
- const timer = setTimeout(
1277
- () => {
1278
- finish({ matched: false, exitCode: null });
1279
- },
1280
- Math.max(0, timeoutSec * 1000),
1281
- );
1485
+ const timer = activity
1486
+ ? setInterval(() => {
1487
+ void activity
1488
+ .check({
1489
+ channel: "daemon",
1490
+ controlSock: dispatch.controlSock,
1491
+ daemonShort: dispatch.daemonShort,
1492
+ resultFile: dispatch.resultFile,
1493
+ })
1494
+ .then((reason) => {
1495
+ if (reason) finish(null, reason);
1496
+ });
1497
+ }, lifecycle.checkIntervalMs ?? 5_000)
1498
+ : setTimeout(
1499
+ () => finish(null, "wall_clock"),
1500
+ Math.max(0, timeoutSec * 1000),
1501
+ );
1502
+ if (typeof timer.unref === "function") timer.unref();
1282
1503
 
1283
- socket.on("error", () => {
1284
- finish({ matched: false, exitCode: null });
1285
- });
1504
+ socket.on("error", () => finish(null, "socket_error"));
1286
1505
  socket.on("close", () => {
1287
- if (!settled) finish({ matched: false, exitCode: null });
1506
+ if (!settled) finish(null, "socket_closed");
1288
1507
  });
1289
1508
  socket.on("connect", () => {
1290
1509
  socket.write(
@@ -1305,6 +1524,7 @@ async function waitForDaemonCompletion(
1305
1524
  if (!line.trim()) continue;
1306
1525
  const message = JSON.parse(line);
1307
1526
  messages.push(message);
1527
+ activity?.observe();
1308
1528
  if (
1309
1529
  safeProgress &&
1310
1530
  progressIntervalSec > 0 &&
@@ -1337,6 +1557,7 @@ async function awaitAllDaemon(
1337
1557
  timeoutSec,
1338
1558
  safeProgress,
1339
1559
  progressIntervalSec,
1560
+ lifecycle,
1340
1561
  ) {
1341
1562
  return Promise.all(
1342
1563
  dispatches.map(async (d) => {
@@ -1345,6 +1566,7 @@ async function awaitAllDaemon(
1345
1566
  timeoutSec,
1346
1567
  safeProgress,
1347
1568
  progressIntervalSec,
1569
+ lifecycle,
1348
1570
  );
1349
1571
  const output = readResult(d.resultFile, d.paneId);
1350
1572
  unregisterHeadlessSynapseWorker(d.workerId);
@@ -1449,7 +1671,7 @@ function collectGitDiffFiles(cwd) {
1449
1671
  * @param {string} sessionName — psmux 세션 이름
1450
1672
  * @param {Array<{cli: string, prompt: string, role?: string}>} assignments
1451
1673
  * @param {object} [opts]
1452
- * @param {number} [opts.timeoutSec=300] — 각 워커 타임아웃
1674
+ * @param {number} [opts.timeoutSec=900] — lane 예산; lifecycle 활성 시 kill 판정에는 쓰지 않음
1453
1675
  * @param {string} [opts.layout='2x2'] — pane 레이아웃
1454
1676
  * @param {(event: object) => void} [opts.onProgress] — 진행 콜백
1455
1677
  * @param {number} [opts.progressIntervalSec=0] — N초마다 progress 이벤트 발화 (0=비활성)
@@ -1458,7 +1680,7 @@ function collectGitDiffFiles(cwd) {
1458
1680
  * @returns {{ sessionName: string, results: Array<{cli: string, paneName: string, matched: boolean, exitCode: number|null, output: string, sessionDead?: boolean}> }}
1459
1681
  */
1460
1682
  export async function runHeadless(sessionName, assignments, opts = {}) {
1461
- const {
1683
+ let {
1462
1684
  timeoutSec = 900,
1463
1685
  layout = "2x2",
1464
1686
  onProgress,
@@ -1469,7 +1691,17 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
1469
1691
  stallDetect,
1470
1692
  nativeBridge = false,
1471
1693
  nativeBridgeMode = "roster",
1694
+ onIntervene,
1472
1695
  } = opts;
1696
+ if (!Number.isFinite(Number(timeoutSec)) || Number(timeoutSec) <= 0)
1697
+ timeoutSec = 900;
1698
+ else timeoutSec = Number(timeoutSec);
1699
+ const lifecycle = {
1700
+ enabled: isActivityLifecycleEnabled(),
1701
+ interventionMs: resolveStallInterventionMs(),
1702
+ hardCeilingMs: resolveHardCeilingMs(),
1703
+ onIntervene,
1704
+ };
1473
1705
 
1474
1706
  mkdirSync(RESULT_DIR, { recursive: true });
1475
1707
  const normalizedAssignments = (assignments || []).map((assignment, index) =>
@@ -1632,11 +1864,28 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
1632
1864
  }).catch(() => {});
1633
1865
  };
1634
1866
 
1867
+ const HUB_ACTIVITY_MIN_INTERVAL_MS = 30_000;
1868
+ const lastHubBeatByWorker = new Map();
1869
+ const feedHubActivity = (event) => {
1870
+ if (event?.type !== "progress" || !event.paneName) return;
1871
+ const match = event.paneName.match(/worker-(\d+)/);
1872
+ if (!match) return;
1873
+ const idx = parseInt(match[1], 10) - 1;
1874
+ const agentId = getHeadlessWorkerAgentId(sessionName, idx);
1875
+ const nowMs = Date.now();
1876
+ if ((lastHubBeatByWorker.get(agentId) || 0) + HUB_ACTIVITY_MIN_INTERVAL_MS > nowMs) return;
1877
+ lastHubBeatByWorker.set(agentId, nowMs);
1878
+ requestJson("/bridge/heartbeat", { method: "POST", body: { agent_id: agentId }, timeoutMs: 500 }).catch(() => {});
1879
+ const assignJobId = normalizedAssignments[idx]?.assignJobId;
1880
+ if (assignJobId) requestJson("/bridge/assign/result", { method: "POST", body: { job_id: assignJobId, worker_agent: agentId, status: "running" }, timeoutMs: 500 }).catch(() => {});
1881
+ };
1882
+
1635
1883
  // onProgress 예외를 삼켜 실행 흐름 보호 (onPoll과 동일 패턴)
1636
1884
  const combinedProgress = (event) => {
1637
1885
  nativeBridgeHandle?.handleProgress(event);
1638
1886
  feedTui(event);
1639
1887
  feedSynapse(event);
1888
+ feedHubActivity(event);
1640
1889
  if (onProgress) {
1641
1890
  try {
1642
1891
  onProgress(event);
@@ -1681,6 +1930,7 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
1681
1930
  timeoutSec,
1682
1931
  safeProgress,
1683
1932
  progressIntervalSec,
1933
+ lifecycle,
1684
1934
  )
1685
1935
  : await awaitAll(
1686
1936
  sessionName,
@@ -1689,6 +1939,7 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
1689
1939
  safeProgress,
1690
1940
  progressIntervalSec,
1691
1941
  stallDetect,
1942
+ lifecycle,
1692
1943
  );
1693
1944
  const collected = await collectResults(sessionName, results);
1694
1945
  for (const result of collected) nativeBridgeHandle?.completeWorker(result);