@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.
- package/cto/hygiene-actions.mjs +355 -0
- package/cto/hygiene.mjs +79 -9
- package/cto/steward.mjs +362 -0
- package/hub/log-retention.mjs +726 -0
- package/hub/pipe.mjs +5 -1
- package/hub/public/tray.html +50 -53
- package/hub/schema.sql +149 -0
- package/hub/server.mjs +44 -22
- package/hub/store.mjs +37 -19
- package/hub/team/claude-daemon-control.mjs +33 -12
- package/hub/team/cli/commands/start/parse-args.mjs +2 -2
- package/hub/team/headless.mjs +275 -24
- package/hub/team/intervention.mjs +530 -0
- package/hub/team/retry-state-machine.mjs +6 -2
- package/hub/team/swarm-hypervisor.mjs +19 -2
- package/hub/team/swarm-locks.mjs +17 -1
- package/hub/team/uds-orchestrator.mjs +62 -9
- package/hub/tools.mjs +5 -7
- package/hub/workers/codex-app-server-worker.mjs +54 -5
- package/hub/workers/codex-mcp.mjs +1 -1
- package/hub/workers/delegator-mcp.mjs +18 -18
- package/package.json +1 -1
- package/scripts/lib/cli-agy.mjs +1 -0
- package/scripts/lib/cli-claude.mjs +1 -0
- package/scripts/lib/cli-codex.mjs +22 -21
- package/scripts/lib/codex-profile-config.mjs +2 -2
package/hub/team/headless.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 -
|
|
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
|
-
|
|
1159
|
-
|
|
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 =
|
|
1277
|
-
() => {
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
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(
|
|
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=
|
|
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
|
-
|
|
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);
|