@triflux/remote 10.42.0 → 10.44.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.
@@ -20,9 +20,16 @@ import net from "node:net";
20
20
  import { tmpdir } from "node:os";
21
21
  import { dirname, join, resolve } from "node:path";
22
22
  import { fileURLToPath } from "node:url";
23
+ import { resolveNestedCodexAgentProfile } from "../../scripts/lib/cli-codex.mjs";
23
24
  import { requestJson } from "@triflux/core/hub/bridge.mjs";
24
25
  import { escapePwshSingleQuoted } from "@triflux/core/hub/cli-adapter-base.mjs";
25
26
  import { getMaxSpawnPerSec } from "@triflux/core/hub/lib/spawn-trace.mjs";
27
+ import {
28
+ createActivityLifecycle,
29
+ isActivityLifecycleEnabled,
30
+ resolveHardCeilingMs,
31
+ resolveStallInterventionMs,
32
+ } from "@triflux/core/hub/lib/worker-lifecycle.mjs";
26
33
  import { IS_WINDOWS } from "@triflux/core/hub/platform.mjs";
27
34
  import { getBackend } from "./backend.mjs";
28
35
  import {
@@ -41,6 +48,10 @@ import {
41
48
  HANDOFF_INSTRUCTION_SHORT,
42
49
  processHandoff,
43
50
  } from "./handoff.mjs";
51
+ import {
52
+ createFileActivitySource,
53
+ createInterventionLadder,
54
+ } from "./intervention.mjs";
44
55
  import {
45
56
  capturePsmuxPane,
46
57
  createPsmuxSession,
@@ -179,9 +190,17 @@ function buildRouteBackedHeadlessCommand(
179
190
  : "";
180
191
  const timeoutArg = timeout ? ` ${shellQuote(timeout)}` : "";
181
192
  const routeMode = resolvedCli === "antigravity" ? "antigravity" : resolvedCli;
193
+ const lifecycleEnv = [
194
+ "TFX_HARD_CEILING_SEC",
195
+ "TFX_STALL_THRESHOLD",
196
+ "TFX_STALL_INTERVENTION_SEC",
197
+ ]
198
+ .filter((key) => process.env[key])
199
+ .map((key) => `${key}=${shellQuote(process.env[key])} `)
200
+ .join("");
182
201
  const script =
183
202
  `__tfx_prompt=$(cat ${shellQuote(promptFile)}); ` +
184
- `TFX_CLI_MODE=${shellQuote(routeMode)} ` +
203
+ `${lifecycleEnv}TFX_CLI_MODE=${shellQuote(routeMode)} ` +
185
204
  `bash ${shellQuote(routeScript)} ${shellQuote(routeAgent)} "$__tfx_prompt" ${shellQuote(mcp)}${timeoutArg} ` +
186
205
  `> ${shellQuote(resultFile)} 2>${shellQuote(`${resultFile}.err`)} < /dev/null`;
187
206
  return `bash -lc ${shellQuote(script)}`;
@@ -195,6 +214,11 @@ export function getHeadlessLeadAgentId(sessionName) {
195
214
  return `headless-${sessionName}-lead`;
196
215
  }
197
216
 
217
+ const HUB_CLI_VALUES = new Set(["codex", "gemini", "claude", "other"]);
218
+ function normalizeCliForHub(cli) {
219
+ return HUB_CLI_VALUES.has(cli) ? cli : "other";
220
+ }
221
+
198
222
  export async function registerHeadlessWorker(
199
223
  sessionName,
200
224
  index,
@@ -203,7 +227,8 @@ export async function registerHeadlessWorker(
203
227
  ) {
204
228
  await requestJsonFn("/bridge/register", {
205
229
  body: {
206
- agentId: getHeadlessWorkerAgentId(sessionName, index),
230
+ agent_id: getHeadlessWorkerAgentId(sessionName, index),
231
+ cli: normalizeCliForHub(cli),
207
232
  topics: ["headless.worker"],
208
233
  capabilities: [cli],
209
234
  },
@@ -342,6 +367,14 @@ export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
342
367
  writeFileSync(promptFile, fullPrompt, "utf8");
343
368
  void IS_WINDOWS; // referenced for diagnostic guard chain below
344
369
 
370
+ const codexProfile =
371
+ resolvedCli === "codex"
372
+ ? resolveNestedCodexAgentProfile(opts.role || cli, {
373
+ profileOverride: opts.profile ?? "auto",
374
+ globalProfile: process.env.TFX_CODEX_PROFILE,
375
+ codexHome: process.env.CODEX_HOME,
376
+ })
377
+ : undefined;
345
378
  const backendCommand =
346
379
  resolvedCli === "antigravity"
347
380
  ? buildRouteBackedHeadlessCommand(resolvedCli, promptFile, resultFile, {
@@ -351,6 +384,10 @@ export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
351
384
  : getBackend(resolvedCli).buildArgs(fullPrompt, resultFile, {
352
385
  ...opts,
353
386
  model,
387
+ profile: codexProfile,
388
+ codexHome: process.env.CODEX_HOME,
389
+ disallowUltra: true,
390
+ enforceCanonicalProfile: true,
354
391
  promptFile,
355
392
  });
356
393
  const safeCwd =
@@ -474,8 +511,10 @@ export function readResult(resultFile, paneId) {
474
511
  export const STALL_DEFAULTS = Object.freeze({
475
512
  pollInterval: 5_000,
476
513
  stallTimeout: 120_000,
477
- completionTimeout: 900_000,
514
+ interventionTimeout: resolveStallInterventionMs(),
515
+ hardCeiling: resolveHardCeilingMs(),
478
516
  maxRestarts: 2,
517
+ maxInterventions: 1,
479
518
  });
480
519
 
481
520
  /** CLI pane stall 감지 에러 (STALL_EXHAUSTED | COMPLETION_TIMEOUT) */
@@ -543,6 +582,104 @@ export function createStallMonitor(paneId, resultFile, config, deps = {}) {
543
582
  });
544
583
  }
545
584
 
585
+ function createHeadlessIntervention(dispatch, fallback) {
586
+ return async (context) => {
587
+ if (typeof fallback === "function")
588
+ return (await fallback(context)) === true;
589
+
590
+ const isDaemon = context.channel === "daemon";
591
+ const readActivitySignature = isDaemon
592
+ ? createFileActivitySource({ files: [dispatch.resultFile] })
593
+ : () => {
594
+ let mtime = 0;
595
+ try {
596
+ mtime = statSync(dispatch.resultFile).mtimeMs;
597
+ } catch {
598
+ /* result file has not been written yet */
599
+ }
600
+ return `${capturePsmuxPane(context.paneId || dispatch.paneId, 50)}\0${mtime}`;
601
+ };
602
+ const ladder = createInterventionLadder({
603
+ target: isDaemon
604
+ ? {
605
+ channel: "claude-daemon",
606
+ cli: dispatch.cli || "claude",
607
+ sessionId: dispatch.sessionId,
608
+ daemon: {
609
+ controlSock: dispatch.controlSock,
610
+ short: dispatch.daemonShort,
611
+ sessionId: dispatch.sessionId,
612
+ configDir: dispatch.daemonPaths?.configDir,
613
+ },
614
+ }
615
+ : {
616
+ channel: "tmux-pane",
617
+ paneId: context.paneId || dispatch.paneId,
618
+ cli: dispatch.cli,
619
+ sessionId: dispatch.sessionId,
620
+ },
621
+ readActivitySignature,
622
+ deps: {
623
+ // 이 트랙이 재실행 소유자다. pane 채널은 기존 completion wrapper를 다시
624
+ // dispatch하여 token/result 계약을 보존한다.
625
+ resumeHandler: async () => {
626
+ if (isDaemon || !dispatch.command) return { ok: false };
627
+ const restarted = dispatchCommand(
628
+ context.sessionName,
629
+ context.paneId || dispatch.paneId,
630
+ dispatch.command,
631
+ );
632
+ return { ok: Boolean(restarted) };
633
+ },
634
+ },
635
+ });
636
+ const outcome = await ladder.intervene();
637
+ return outcome.outcome === "reactivated" || outcome.outcome === "resumed";
638
+ };
639
+ }
640
+
641
+ export function createActivityPollGuard({
642
+ resultFile,
643
+ lifecycle,
644
+ interveneContext,
645
+ statSyncFn = statSync,
646
+ }) {
647
+ const controller = new AbortController();
648
+ let lastMtime = 0;
649
+ let checking = false;
650
+
651
+ const finishIfTimedOut = async () => {
652
+ if (checking || controller.signal.aborted) return;
653
+ checking = true;
654
+ try {
655
+ if (await lifecycle.check(interveneContext)) controller.abort();
656
+ } finally {
657
+ checking = false;
658
+ }
659
+ };
660
+
661
+ return Object.freeze({
662
+ signal: controller.signal,
663
+ observe(content) {
664
+ let mtime = 0;
665
+ try {
666
+ mtime = statSyncFn(resultFile).mtimeMs;
667
+ } catch {
668
+ /* result file has not been created yet */
669
+ }
670
+ lifecycle.observe(`${content}\0${mtime}`);
671
+ lastMtime = mtime || lastMtime;
672
+ void finishIfTimedOut();
673
+ },
674
+ get timeoutReason() {
675
+ return lifecycle.timeoutReason;
676
+ },
677
+ get lastMtime() {
678
+ return lastMtime;
679
+ },
680
+ });
681
+ }
682
+
546
683
  /**
547
684
  * 하이브리드 stall 감지 대기 — output 변화 + resultFile mtime 모니터링.
548
685
  * 2분 무변화 시 pane kill → re-dispatch (최대 2회 재시작).
@@ -553,7 +690,8 @@ export function createStallMonitor(paneId, resultFile, config, deps = {}) {
553
690
  * @param {object} [opts]
554
691
  * @param {number} [opts.pollInterval=5000] — 폴링 간격 ms
555
692
  * @param {number} [opts.stallTimeout=120000] — 무변화 stall 판정 ms
556
- * @param {number} [opts.completionTimeout=900000] — 전체 타임아웃 ms
693
+ * @param {number} [opts.hardCeiling] — 활동과 무관한 절대 상한 ms
694
+ * @param {number} [opts.completionTimeout] — hardCeiling의 하위호환 alias
557
695
  * @param {number} [opts.maxRestarts=2] — 최대 재시작 횟수
558
696
  * @param {string} [opts.command] — re-dispatch용 원본 명령
559
697
  * @param {string} [opts.token] — completion token
@@ -569,13 +707,18 @@ export async function waitForCompletionWithStallDetect(
569
707
  const {
570
708
  pollInterval = 5000,
571
709
  stallTimeout = 120000,
572
- completionTimeout = 900000,
573
710
  maxRestarts = 2,
711
+ maxInterventions = 1,
574
712
  command,
575
713
  token,
576
714
  onPoll,
715
+ onIntervene,
577
716
  _deps,
578
717
  } = opts;
718
+ const hardCeiling =
719
+ opts.hardCeiling ??
720
+ opts.completionTimeout ??
721
+ (isActivityLifecycleEnabled() ? resolveHardCeilingMs() : 900_000);
579
722
 
580
723
  // 의존성 (테스트 시 _deps로 주입 가능)
581
724
  const deps = _deps || {};
@@ -601,6 +744,8 @@ export async function waitForCompletionWithStallDetect(
601
744
  };
602
745
 
603
746
  let restarts = 0;
747
+ let interventions = 0;
748
+ const runStartedAt = Date.now();
604
749
  let currentPaneId = paneId;
605
750
  let stallDetected = false;
606
751
  let currentToken = token;
@@ -610,7 +755,6 @@ export async function waitForCompletionWithStallDetect(
610
755
  let lastOutput = "";
611
756
  let lastMtime = 0;
612
757
  let lastChangeAt = Date.now();
613
- const startedAt = Date.now();
614
758
 
615
759
  // 초기 resultFile mtime
616
760
  try {
@@ -623,8 +767,8 @@ export async function waitForCompletionWithStallDetect(
623
767
  await new Promise((r) => setTimeout(r, pollInterval));
624
768
  const now = Date.now();
625
769
 
626
- // 전체 타임아웃
627
- if (now - startedAt > completionTimeout) {
770
+ // 절대 상한은 재시작과 활동 여부를 관통하는 유일한 wall-clock kill이다.
771
+ if (now - runStartedAt > hardCeiling) {
628
772
  // Issue #118: timeout kill 전에 capture-pane 출력을 .partial 파일로 persist.
629
773
  // resultFile(`.txt`)이 아직 생성되지 않았을 수 있으므로 readResult 가 fallback 으로 읽는다.
630
774
  try {
@@ -642,6 +786,7 @@ export async function waitForCompletionWithStallDetect(
642
786
  restarts,
643
787
  stallDetected,
644
788
  timedOut: true,
789
+ timeoutReason: "hard_ceiling",
645
790
  paneId: currentPaneId,
646
791
  token: currentToken,
647
792
  logPath: currentLogPath,
@@ -721,11 +866,34 @@ export async function waitForCompletionWithStallDetect(
721
866
  stallDetected = true;
722
867
 
723
868
  if (restarts >= maxRestarts) {
869
+ if (onIntervene && interventions < maxInterventions) {
870
+ interventions += 1;
871
+ let handled = false;
872
+ try {
873
+ handled =
874
+ (await onIntervene({
875
+ channel: "pane",
876
+ sessionName,
877
+ paneId: currentPaneId,
878
+ resultFile,
879
+ inactiveMs: now - lastChangeAt,
880
+ restarts,
881
+ interventions,
882
+ })) === true;
883
+ } catch {
884
+ handled = false;
885
+ }
886
+ if (handled) {
887
+ lastChangeAt = Date.now();
888
+ continue;
889
+ }
890
+ }
724
891
  const err = new Error("CLI가 반복적으로 멈춤. 수동 확인 필요.");
725
892
  err.code = "STALL_EXHAUSTED";
726
893
  err.category = "transient";
727
894
  err.recovery = "CLI가 반복적으로 멈춤. 수동 확인 필요.";
728
895
  err.restarts = restarts;
896
+ err.interventions = interventions;
729
897
  throw err;
730
898
  }
731
899
 
@@ -839,6 +1007,7 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
839
1007
  mcp: assignment.mcp,
840
1008
  role: assignment.role,
841
1009
  model: assignment.model,
1010
+ profile: assignment.profile,
842
1011
  cwd: assignment.cwd || assignment.workdir,
843
1012
  },
844
1013
  );
@@ -921,6 +1090,7 @@ async function dispatchBatch(sessionName, assignments, opts = {}) {
921
1090
  mcp: assignment.mcp,
922
1091
  role: assignment.role,
923
1092
  model: assignment.model,
1093
+ profile: assignment.profile,
924
1094
  cwd: assignment.cwd || assignment.workdir,
925
1095
  },
926
1096
  );
@@ -984,6 +1154,7 @@ async function dispatchDaemonBatch(sessionName, assignments, opts = {}) {
984
1154
  mcp: assignment.mcp,
985
1155
  role: assignment.role,
986
1156
  model: assignment.model,
1157
+ profile: assignment.profile,
987
1158
  cwd: assignment.cwd || assignment.workdir,
988
1159
  },
989
1160
  );
@@ -1103,6 +1274,7 @@ async function awaitAll(
1103
1274
  safeProgress,
1104
1275
  progressIntervalSec,
1105
1276
  stallOpts,
1277
+ lifecycle,
1106
1278
  ) {
1107
1279
  // 병렬 대기 (Promise.all — 모든 pane 동시 폴링, 총 시간 = max(개별 시간))
1108
1280
  return Promise.all(
@@ -1155,12 +1327,22 @@ async function awaitAll(
1155
1327
  {
1156
1328
  pollInterval: stallOpts.pollInterval,
1157
1329
  stallTimeout: stallOpts.stallTimeout,
1158
- completionTimeout:
1159
- stallOpts.completionTimeout ?? timeoutSec * 1000,
1330
+ hardCeiling: lifecycle?.enabled
1331
+ ? (stallOpts.hardCeiling ??
1332
+ stallOpts.completionTimeout ??
1333
+ lifecycle.hardCeilingMs)
1334
+ : (stallOpts.completionTimeout ?? timeoutSec * 1000),
1160
1335
  maxRestarts: stallOpts.maxRestarts,
1336
+ maxInterventions: stallOpts.maxInterventions,
1161
1337
  command: d.command,
1162
1338
  token: d.token,
1163
1339
  onPoll: stallPollCb,
1340
+ onIntervene: lifecycle?.enabled
1341
+ ? createHeadlessIntervention(
1342
+ d,
1343
+ stallOpts.onIntervene ?? lifecycle.onIntervene,
1344
+ )
1345
+ : undefined,
1164
1346
  },
1165
1347
  );
1166
1348
  if (stallResult.paneId) d.paneId = stallResult.paneId;
@@ -1184,6 +1366,40 @@ async function awaitAll(
1184
1366
  throw stallErr;
1185
1367
  }
1186
1368
  }
1369
+ } else if (lifecycle?.enabled) {
1370
+ if (d.logPath) pollOpts.logPath = d.logPath;
1371
+ const activity = createActivityLifecycle({
1372
+ interventionMs: lifecycle.interventionMs,
1373
+ hardCeilingMs: lifecycle.hardCeilingMs,
1374
+ onIntervene: createHeadlessIntervention(d, lifecycle.onIntervene),
1375
+ });
1376
+ const guard = createActivityPollGuard({
1377
+ resultFile: d.resultFile,
1378
+ lifecycle: activity,
1379
+ interveneContext: {
1380
+ channel: "pane",
1381
+ sessionName,
1382
+ paneId: d.paneId || d.paneName,
1383
+ resultFile: d.resultFile,
1384
+ },
1385
+ });
1386
+ const userOnPoll = pollOpts.onPoll;
1387
+ pollOpts.onPoll = (info) => {
1388
+ guard.observe(info.content);
1389
+ userOnPoll?.(info);
1390
+ };
1391
+ pollOpts.signal = guard.signal;
1392
+ completion = await waitForCompletion(
1393
+ sessionName,
1394
+ d.paneId || d.paneName,
1395
+ d.token,
1396
+ Math.ceil(lifecycle.hardCeilingMs / 1000),
1397
+ pollOpts,
1398
+ );
1399
+ if (!completion.matched && !completion.sessionDead) {
1400
+ completion.timedOut = true;
1401
+ completion.timeoutReason = guard.timeoutReason || "hard_ceiling";
1402
+ }
1187
1403
  } else {
1188
1404
  // 기존 waitForCompletion 경로
1189
1405
  if (d.logPath) pollOpts.logPath = d.logPath;
@@ -1231,23 +1447,35 @@ async function awaitAll(
1231
1447
  );
1232
1448
  }
1233
1449
 
1234
- async function waitForDaemonCompletion(
1450
+ export async function waitForDaemonCompletion(
1235
1451
  dispatch,
1236
1452
  timeoutSec,
1237
1453
  safeProgress,
1238
1454
  progressIntervalSec,
1455
+ lifecycle = null,
1239
1456
  ) {
1240
1457
  const messages = [];
1241
1458
  const socket = net.connect(dispatch.controlSock);
1242
1459
  let buffer = "";
1243
1460
  let settled = false;
1244
1461
  let lastProgressAt = 0;
1462
+ const activity = lifecycle?.enabled
1463
+ ? createActivityLifecycle({
1464
+ interventionMs: lifecycle.interventionMs,
1465
+ hardCeilingMs: lifecycle.hardCeilingMs,
1466
+ onIntervene: createHeadlessIntervention(
1467
+ dispatch,
1468
+ lifecycle.onIntervene,
1469
+ ),
1470
+ })
1471
+ : null;
1245
1472
 
1246
1473
  return await new Promise((resolve) => {
1247
- const finish = (completion) => {
1474
+ const finish = (completion, timeoutReason = "") => {
1248
1475
  if (settled) return;
1249
1476
  settled = true;
1250
1477
  clearTimeout(timer);
1478
+ clearInterval(timer);
1251
1479
  socket.destroy();
1252
1480
  const finalCompletion =
1253
1481
  completion ||
@@ -1255,6 +1483,10 @@ async function waitForDaemonCompletion(
1255
1483
  token: dispatch.token,
1256
1484
  resultFile: dispatch.resultFile,
1257
1485
  });
1486
+ if (!finalCompletion.matched && timeoutReason) {
1487
+ finalCompletion.timedOut = true;
1488
+ finalCompletion.timeoutReason = timeoutReason;
1489
+ }
1258
1490
  if (finalCompletion.matched) {
1259
1491
  dispatch.daemonCompletionMatched = true;
1260
1492
  cleanupDaemonDispatches([dispatch]).catch(() => {});
@@ -1273,18 +1505,28 @@ async function waitForDaemonCompletion(
1273
1505
  resolve(finalCompletion);
1274
1506
  };
1275
1507
 
1276
- const timer = setTimeout(
1277
- () => {
1278
- finish({ matched: false, exitCode: null });
1279
- },
1280
- Math.max(0, timeoutSec * 1000),
1281
- );
1508
+ const timer = activity
1509
+ ? setInterval(() => {
1510
+ void activity
1511
+ .check({
1512
+ channel: "daemon",
1513
+ controlSock: dispatch.controlSock,
1514
+ daemonShort: dispatch.daemonShort,
1515
+ resultFile: dispatch.resultFile,
1516
+ })
1517
+ .then((reason) => {
1518
+ if (reason) finish(null, reason);
1519
+ });
1520
+ }, lifecycle.checkIntervalMs ?? 5_000)
1521
+ : setTimeout(
1522
+ () => finish(null, "wall_clock"),
1523
+ Math.max(0, timeoutSec * 1000),
1524
+ );
1525
+ if (typeof timer.unref === "function") timer.unref();
1282
1526
 
1283
- socket.on("error", () => {
1284
- finish({ matched: false, exitCode: null });
1285
- });
1527
+ socket.on("error", () => finish(null, "socket_error"));
1286
1528
  socket.on("close", () => {
1287
- if (!settled) finish({ matched: false, exitCode: null });
1529
+ if (!settled) finish(null, "socket_closed");
1288
1530
  });
1289
1531
  socket.on("connect", () => {
1290
1532
  socket.write(
@@ -1305,6 +1547,7 @@ async function waitForDaemonCompletion(
1305
1547
  if (!line.trim()) continue;
1306
1548
  const message = JSON.parse(line);
1307
1549
  messages.push(message);
1550
+ activity?.observe();
1308
1551
  if (
1309
1552
  safeProgress &&
1310
1553
  progressIntervalSec > 0 &&
@@ -1337,6 +1580,7 @@ async function awaitAllDaemon(
1337
1580
  timeoutSec,
1338
1581
  safeProgress,
1339
1582
  progressIntervalSec,
1583
+ lifecycle,
1340
1584
  ) {
1341
1585
  return Promise.all(
1342
1586
  dispatches.map(async (d) => {
@@ -1345,6 +1589,7 @@ async function awaitAllDaemon(
1345
1589
  timeoutSec,
1346
1590
  safeProgress,
1347
1591
  progressIntervalSec,
1592
+ lifecycle,
1348
1593
  );
1349
1594
  const output = readResult(d.resultFile, d.paneId);
1350
1595
  unregisterHeadlessSynapseWorker(d.workerId);
@@ -1449,7 +1694,7 @@ function collectGitDiffFiles(cwd) {
1449
1694
  * @param {string} sessionName — psmux 세션 이름
1450
1695
  * @param {Array<{cli: string, prompt: string, role?: string}>} assignments
1451
1696
  * @param {object} [opts]
1452
- * @param {number} [opts.timeoutSec=300] — 각 워커 타임아웃
1697
+ * @param {number} [opts.timeoutSec=900] — lane 예산; lifecycle 활성 시 kill 판정에는 쓰지 않음
1453
1698
  * @param {string} [opts.layout='2x2'] — pane 레이아웃
1454
1699
  * @param {(event: object) => void} [opts.onProgress] — 진행 콜백
1455
1700
  * @param {number} [opts.progressIntervalSec=0] — N초마다 progress 이벤트 발화 (0=비활성)
@@ -1458,7 +1703,7 @@ function collectGitDiffFiles(cwd) {
1458
1703
  * @returns {{ sessionName: string, results: Array<{cli: string, paneName: string, matched: boolean, exitCode: number|null, output: string, sessionDead?: boolean}> }}
1459
1704
  */
1460
1705
  export async function runHeadless(sessionName, assignments, opts = {}) {
1461
- const {
1706
+ let {
1462
1707
  timeoutSec = 900,
1463
1708
  layout = "2x2",
1464
1709
  onProgress,
@@ -1469,7 +1714,17 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
1469
1714
  stallDetect,
1470
1715
  nativeBridge = false,
1471
1716
  nativeBridgeMode = "roster",
1717
+ onIntervene,
1472
1718
  } = opts;
1719
+ if (!Number.isFinite(Number(timeoutSec)) || Number(timeoutSec) <= 0)
1720
+ timeoutSec = 900;
1721
+ else timeoutSec = Number(timeoutSec);
1722
+ const lifecycle = {
1723
+ enabled: isActivityLifecycleEnabled(),
1724
+ interventionMs: resolveStallInterventionMs(),
1725
+ hardCeilingMs: resolveHardCeilingMs(),
1726
+ onIntervene,
1727
+ };
1473
1728
 
1474
1729
  mkdirSync(RESULT_DIR, { recursive: true });
1475
1730
  const normalizedAssignments = (assignments || []).map((assignment, index) =>
@@ -1632,11 +1887,41 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
1632
1887
  }).catch(() => {});
1633
1888
  };
1634
1889
 
1890
+ const HUB_ACTIVITY_MIN_INTERVAL_MS = 30_000;
1891
+ const lastHubBeatByWorker = new Map();
1892
+ const feedHubActivity = (event) => {
1893
+ if (event?.type !== "progress" || !event.paneName) return;
1894
+ const match = event.paneName.match(/worker-(\d+)/);
1895
+ if (!match) return;
1896
+ const idx = parseInt(match[1], 10) - 1;
1897
+ const agentId = getHeadlessWorkerAgentId(sessionName, idx);
1898
+ const nowMs = Date.now();
1899
+ if (
1900
+ (lastHubBeatByWorker.get(agentId) || 0) + HUB_ACTIVITY_MIN_INTERVAL_MS >
1901
+ nowMs
1902
+ )
1903
+ return;
1904
+ lastHubBeatByWorker.set(agentId, nowMs);
1905
+ requestJson("/bridge/heartbeat", {
1906
+ method: "POST",
1907
+ body: { agent_id: agentId },
1908
+ timeoutMs: 500,
1909
+ }).catch(() => {});
1910
+ const assignJobId = normalizedAssignments[idx]?.assignJobId;
1911
+ if (assignJobId)
1912
+ requestJson("/bridge/assign/result", {
1913
+ method: "POST",
1914
+ body: { job_id: assignJobId, worker_agent: agentId, status: "running" },
1915
+ timeoutMs: 500,
1916
+ }).catch(() => {});
1917
+ };
1918
+
1635
1919
  // onProgress 예외를 삼켜 실행 흐름 보호 (onPoll과 동일 패턴)
1636
1920
  const combinedProgress = (event) => {
1637
1921
  nativeBridgeHandle?.handleProgress(event);
1638
1922
  feedTui(event);
1639
1923
  feedSynapse(event);
1924
+ feedHubActivity(event);
1640
1925
  if (onProgress) {
1641
1926
  try {
1642
1927
  onProgress(event);
@@ -1681,6 +1966,7 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
1681
1966
  timeoutSec,
1682
1967
  safeProgress,
1683
1968
  progressIntervalSec,
1969
+ lifecycle,
1684
1970
  )
1685
1971
  : await awaitAll(
1686
1972
  sessionName,
@@ -1689,6 +1975,7 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
1689
1975
  safeProgress,
1690
1976
  progressIntervalSec,
1691
1977
  stallDetect,
1978
+ lifecycle,
1692
1979
  );
1693
1980
  const collected = await collectResults(sessionName, results);
1694
1981
  for (const result of collected) nativeBridgeHandle?.completeWorker(result);