@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.
- package/cto/hygiene.mjs +1 -1
- package/hub/lib/cto-env.mjs +1 -0
- package/hub/pipe.mjs +9 -0
- package/hub/schema.sql +62 -2
- package/hub/server.mjs +131 -44
- package/hub/store.mjs +616 -19
- package/hub/team/claude-daemon-control.mjs +33 -12
- package/hub/team/cli/commands/start/parse-args.mjs +2 -2
- package/hub/team/conductor.mjs +23 -2
- package/hub/team/execution-mode.mjs +12 -7
- package/hub/team/headless.mjs +311 -24
- package/hub/team/intervention.mjs +530 -0
- package/hub/team/notify.mjs +31 -11
- package/hub/team/retry-state-machine.mjs +12 -3
- package/hub/team/swarm-hypervisor.mjs +21 -2
- package/hub/team/swarm-locks.mjs +17 -1
- package/hub/team/swarm-preflight.mjs +47 -0
- package/hub/team/uds-orchestrator.mjs +62 -9
- package/hub/team/worktree-lifecycle.mjs +56 -59
- package/hub/tools.mjs +16 -3
- package/hub/workers/codex-app-server-worker.mjs +54 -5
- package/hub/workers/codex-mcp.mjs +21 -5
- package/hub/workers/delegator-mcp.mjs +3 -49
- package/package.json +1 -1
- package/scripts/lib/agent-route-policy.mjs +307 -0
- package/scripts/lib/cli-agy.mjs +1 -0
- package/scripts/lib/cli-claude.mjs +1 -0
- package/scripts/lib/cli-codex.mjs +21 -160
- package/scripts/lib/codex-profile-config.mjs +31 -5
- package/scripts/lib/keyword-rules.mjs +12 -0
- package/cto/steward.mjs +0 -362
- package/hub/log-retention.mjs +0 -726
package/hub/team/headless.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 -
|
|
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
|
-
|
|
1159
|
-
stallOpts.
|
|
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 =
|
|
1277
|
-
() => {
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
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(
|
|
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=
|
|
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
|
-
|
|
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);
|