@triflux/remote 10.1.0 → 10.25.1
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/hub/pipe.mjs +239 -121
- package/hub/public/dashboard.html +61 -0
- package/hub/server.mjs +2165 -708
- package/hub/store-adapter.mjs +59 -26
- package/hub/store.mjs +356 -153
- package/hub/team/agent-map.json +26 -7
- package/hub/team/ansi.mjs +245 -146
- package/hub/team/backend.mjs +84 -17
- package/hub/team/build-worker-prompt.mjs +42 -0
- package/hub/team/check-mcp-hub.mjs +49 -0
- package/hub/team/claude-daemon-control.mjs +219 -0
- package/hub/team/claude-native-bridge.mjs +784 -0
- package/hub/team/claude-session-projection.mjs +65 -0
- package/hub/team/cli/commands/attach.mjs +57 -37
- package/hub/team/cli/commands/control.mjs +29 -8
- package/hub/team/cli/commands/debug.mjs +95 -74
- package/hub/team/cli/commands/focus.mjs +80 -53
- package/hub/team/cli/commands/interrupt.mjs +18 -6
- package/hub/team/cli/commands/kill.mjs +16 -5
- package/hub/team/cli/commands/list.mjs +31 -24
- package/hub/team/cli/commands/send.mjs +19 -6
- package/hub/team/cli/commands/start/index.mjs +154 -33
- package/hub/team/cli/commands/start/parse-args.mjs +89 -12
- package/hub/team/cli/commands/start/start-headless.mjs +125 -37
- package/hub/team/cli/commands/start/start-in-process.mjs +50 -40
- package/hub/team/cli/commands/start/start-mux.mjs +122 -73
- package/hub/team/cli/commands/start/start-wt.mjs +86 -69
- package/hub/team/cli/commands/status.mjs +43 -14
- package/hub/team/cli/commands/stop.mjs +11 -4
- package/hub/team/cli/commands/task.mjs +8 -3
- package/hub/team/cli/commands/tasks.mjs +13 -13
- package/hub/team/cli/index.mjs +2 -2
- package/hub/team/cli/manifest.mjs +38 -8
- package/hub/team/cli/render.mjs +52 -30
- package/hub/team/cli/services/attach-fallback.mjs +74 -54
- package/hub/team/cli/services/hub-client.mjs +44 -16
- package/hub/team/cli/services/member-selector.mjs +37 -30
- package/hub/team/cli/services/native-control.mjs +144 -116
- package/hub/team/cli/services/runtime-mode.mjs +55 -1
- package/hub/team/cli/services/state-store.mjs +66 -9
- package/hub/team/cli/services/task-model.mjs +40 -30
- package/hub/team/codex-review.mjs +521 -0
- package/hub/team/conductor-mesh-bridge.mjs +25 -24
- package/hub/team/conductor-registry.mjs +121 -0
- package/hub/team/conductor.mjs +1379 -671
- package/hub/team/consensus-meta.mjs +176 -0
- package/hub/team/dashboard-anchor.mjs +4 -5
- package/hub/team/dashboard-layout.mjs +3 -1
- package/hub/team/dashboard-open.mjs +20 -118
- package/hub/team/dashboard.mjs +76 -28
- package/hub/team/event-log.mjs +18 -10
- package/hub/team/execution-mode.mjs +241 -0
- package/hub/team/extract-completion-payload.mjs +110 -0
- package/hub/team/git-preflight.mjs +286 -0
- package/hub/team/handoff.mjs +49 -18
- package/hub/team/headless-bridge-session.mjs +17 -0
- package/hub/team/headless.mjs +2065 -1156
- package/hub/team/health-probe.mjs +232 -69
- package/hub/team/launcher-template.mjs +23 -13
- package/hub/team/lead-control.mjs +91 -4
- package/hub/team/mcp-selector.mjs +145 -0
- package/hub/team/native-supervisor.mjs +90 -26
- package/hub/team/native.mjs +74 -35
- package/hub/team/nativeProxy.mjs +184 -113
- package/hub/team/notify.mjs +147 -78
- package/hub/team/orchestrator.mjs +173 -161
- package/hub/team/pane.mjs +43 -15
- package/hub/team/process-cleanup.mjs +69 -25
- package/hub/team/psmux.mjs +1020 -279
- package/hub/team/recovery-store.mjs +149 -0
- package/hub/team/remote-probe.mjs +68 -52
- package/hub/team/remote-session.mjs +131 -59
- package/hub/team/remote-watcher.mjs +61 -33
- package/hub/team/retry-state-machine.mjs +267 -0
- package/hub/team/routing.mjs +51 -25
- package/hub/team/runtime-strategy.mjs +200 -0
- package/hub/team/schemas/worker-completion.json +39 -0
- package/hub/team/sentinel-capture.mjs +127 -0
- package/hub/team/session-sync.mjs +153 -6
- package/hub/team/session.mjs +605 -611
- package/hub/team/shared.mjs +13 -13
- package/hub/team/staleState.mjs +72 -30
- package/hub/team/swarm-cli.mjs +390 -0
- package/hub/team/swarm-hypervisor.mjs +1522 -193
- package/hub/team/swarm-intent.mjs +216 -0
- package/hub/team/swarm-locks.mjs +140 -25
- package/hub/team/swarm-planner.mjs +121 -21
- package/hub/team/swarm-preflight.mjs +317 -0
- package/hub/team/swarm-reconciler.mjs +128 -23
- package/hub/team/synapse-cli.mjs +458 -0
- package/hub/team/synapse-http.mjs +60 -0
- package/hub/team/synapse-registry.mjs +329 -0
- package/hub/team/terminal-opener.mjs +216 -0
- package/hub/team/tui-core.mjs +288 -0
- package/hub/team/tui-lite.mjs +234 -146
- package/hub/team/tui-remote-adapter.mjs +18 -34
- package/hub/team/tui-synapse.mjs +223 -0
- package/hub/team/tui-viewer.mjs +104 -44
- package/hub/team/tui-widgets.mjs +262 -0
- package/hub/team/tui.mjs +927 -437
- package/hub/team/worker-completion-validator.mjs +84 -0
- package/hub/team/worker-sandbox.mjs +93 -0
- package/hub/team/worker-signal.mjs +263 -0
- package/hub/team/worker-signal.types.d.ts +52 -0
- package/hub/team/worktree-lifecycle.mjs +517 -63
- package/hub/team/wt-manager.mjs +297 -26
- package/hub/tools.mjs +474 -252
- package/hub/tray.mjs +411 -368
- package/hub/workers/claude-worker.mjs +162 -118
- package/hub/workers/codex-app-server-worker.mjs +1015 -0
- package/hub/workers/codex-mcp.mjs +724 -507
- package/hub/workers/delegator-mcp.mjs +662 -335
- package/hub/workers/factory.mjs +100 -21
- package/hub/workers/gemini-worker.mjs +179 -86
- package/hub/workers/interface.mjs +23 -2
- package/hub/workers/lib/jsonrpc-stdio.mjs +463 -0
- package/hub/workers/worker-utils.mjs +22 -16
- package/package.json +4 -3
- package/scripts/lib/agent-json.mjs +27 -0
- package/scripts/lib/async.mjs +174 -0
- package/scripts/lib/claudemd-scanner.mjs +223 -0
- package/scripts/lib/cli-agy.mjs +62 -0
- package/scripts/lib/cli-claude.mjs +78 -0
- package/scripts/lib/cli-codex.mjs +199 -0
- package/scripts/lib/cli-gemini.mjs +67 -0
- package/scripts/lib/codex-recovery.sh +50 -0
- package/scripts/lib/context.mjs +67 -0
- package/scripts/lib/cross-review-utils.mjs +59 -0
- package/scripts/lib/doctor-env-checks.mjs +121 -0
- package/scripts/lib/dynamic-route-cli.mjs +107 -0
- package/scripts/lib/env-probe.mjs +269 -0
- package/scripts/lib/env.mjs +14 -0
- package/scripts/lib/gemini-profiles.mjs +119 -0
- package/scripts/lib/handoff.mjs +187 -0
- package/scripts/lib/hook-utils.mjs +16 -0
- package/scripts/lib/hub.mjs +48 -0
- package/scripts/lib/keyword-rules.mjs +195 -0
- package/scripts/lib/logger.mjs +105 -0
- package/scripts/lib/mcp-filter.mjs +883 -0
- package/scripts/lib/mcp-gateway-servers.mjs +70 -0
- package/scripts/lib/mcp-guard-engine.mjs +2120 -0
- package/scripts/lib/mcp-health.mjs +773 -0
- package/scripts/lib/mcp-manifest.mjs +89 -0
- package/scripts/lib/mcp-server-catalog.mjs +355 -0
- package/scripts/lib/pid.mjs +63 -0
- package/scripts/lib/process-utils.mjs +26 -0
- package/scripts/lib/psmux-info.mjs +179 -0
- package/scripts/lib/quota.mjs +47 -0
- package/scripts/lib/remote-spawn-transfer.mjs +226 -0
- package/scripts/lib/skill-state.mjs +220 -0
- package/scripts/lib/skill-template.mjs +375 -0
- package/scripts/lib/team.mjs +75 -0
- package/scripts/lib/timeout.mjs +66 -0
- package/scripts/lib/tmp.mjs +26 -0
- package/scripts/lib/toml.mjs +113 -0
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
// 기존 cli-adapter-base.mjs:stallThresholdMs(30s)와 headless.mjs:STALL_DEFAULTS(120s)를
|
|
3
3
|
// 4단계 probe 모델로 교체. stdout+stderr 통합 스트림으로 평가 (F3 해결).
|
|
4
4
|
|
|
5
|
+
import { mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
|
|
5
9
|
/**
|
|
6
10
|
* Health probe level 정의.
|
|
7
11
|
* L0: Process alive (PID 존재 + exit code 없음)
|
|
@@ -11,10 +15,10 @@
|
|
|
11
15
|
* L3: Prompt acknowledged (첫 tool call/텍스트, 120s)
|
|
12
16
|
*/
|
|
13
17
|
export const PROBE_LEVELS = Object.freeze({
|
|
14
|
-
L0:
|
|
15
|
-
L1:
|
|
16
|
-
L2:
|
|
17
|
-
L3:
|
|
18
|
+
L0: "alive",
|
|
19
|
+
L1: "advancing",
|
|
20
|
+
L2: "mcp_connected",
|
|
21
|
+
L3: "prompt_ack",
|
|
18
22
|
});
|
|
19
23
|
|
|
20
24
|
/** 기본 설정 (기존 stallThresholdMs/stallTimeout 값 계승) */
|
|
@@ -24,7 +28,12 @@ export const PROBE_DEFAULTS = Object.freeze({
|
|
|
24
28
|
l1ThresholdMs: 30_000,
|
|
25
29
|
l2ThresholdMs: 30_000,
|
|
26
30
|
l3ThresholdMs: 120_000,
|
|
27
|
-
|
|
31
|
+
// #168 P3: default off → on. checkMcp 미주입 시에도 probeL2 가 skip 반환하므로
|
|
32
|
+
// safe. conductor wiring 이 checkMcp 를 주입하면 실제 L2 판정이 활성화된다.
|
|
33
|
+
// opt-out: TFX_PROBE_L2=0 (conductor 에서 false 주입).
|
|
34
|
+
enableL2: true,
|
|
35
|
+
writeStateFile: false,
|
|
36
|
+
stateDir: join(tmpdir(), "tfx-probe"),
|
|
28
37
|
});
|
|
29
38
|
|
|
30
39
|
/**
|
|
@@ -32,13 +41,13 @@ export const PROBE_DEFAULTS = Object.freeze({
|
|
|
32
41
|
* Codex가 질문하며 stdin을 기다리는 경우 stall이 아니라 INPUT_WAIT로 분류.
|
|
33
42
|
*/
|
|
34
43
|
const INPUT_WAIT_PATTERNS = [
|
|
35
|
-
/\?\s*$/m,
|
|
36
|
-
/\b(y\/n|yes\/no)\b/i,
|
|
37
|
-
/\b(choose|select|pick)\b.*:/i,
|
|
38
|
-
/\b(confirm|approve|proceed)\b/i,
|
|
39
|
-
/\b(enter|input|type)\b.*:/i,
|
|
40
|
-
/\[.*\]:\s*$/m,
|
|
41
|
-
/>\s*$/m,
|
|
44
|
+
/\?\s*$/m, // 물음표로 끝나는 줄
|
|
45
|
+
/\b(y\/n|yes\/no)\b/i, // y/n 프롬프트
|
|
46
|
+
/\b(choose|select|pick)\b.*:/i, // choose/select 프롬프트
|
|
47
|
+
/\b(confirm|approve|proceed)\b/i, // confirm 프롬프트
|
|
48
|
+
/\b(enter|input|type)\b.*:/i, // 입력 요청
|
|
49
|
+
/\[.*\]:\s*$/m, // [default]: 형태
|
|
50
|
+
/>\s*$/m, // > 프롬프트
|
|
42
51
|
];
|
|
43
52
|
|
|
44
53
|
/**
|
|
@@ -49,7 +58,11 @@ const INPUT_WAIT_PATTERNS = [
|
|
|
49
58
|
export function detectInputWait(recentOutput) {
|
|
50
59
|
if (!recentOutput) return { detected: false, pattern: null };
|
|
51
60
|
// 마지막 5줄만 검사 (전체 output이 아닌 최근 출력)
|
|
52
|
-
const lines = recentOutput
|
|
61
|
+
const lines = recentOutput
|
|
62
|
+
.split(/\r?\n/)
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
.slice(-5)
|
|
65
|
+
.join("\n");
|
|
53
66
|
for (const re of INPUT_WAIT_PATTERNS) {
|
|
54
67
|
if (re.test(lines)) {
|
|
55
68
|
return { detected: true, pattern: re.source };
|
|
@@ -74,6 +87,16 @@ export function createHealthProbe(session, opts = {}) {
|
|
|
74
87
|
const config = { ...PROBE_DEFAULTS, ...opts };
|
|
75
88
|
let timer = null;
|
|
76
89
|
let started = false;
|
|
90
|
+
// stopped flag는 in-flight probe()가 stop() 사이의 await 점에서
|
|
91
|
+
// writeState/unlink 와 race 하는 것을 막는다. start() 시 false 로 reset.
|
|
92
|
+
let stopped = false;
|
|
93
|
+
// P0 (#167 review): start() 마다 증가. probe() 가 시작 시 캡처 → writeState() 가 epoch
|
|
94
|
+
// 비교로 stop()→start() 사이의 in-flight 가 새 run 의 state 를 덮는 race 차단.
|
|
95
|
+
// stopped flag 만으로는 start() 가 stopped=false 로 reset 한 직후 race 못 막음.
|
|
96
|
+
let runEpoch = 0;
|
|
97
|
+
// P1-1 (#167 review): Set 으로 모든 in-flight probe 추적. interval 이 빨라 N+1 이 N 끝나기
|
|
98
|
+
// 전에 시작되면 단일 var 는 N 을 덮어써 stopAndDrain() 시 N 이 누락된다.
|
|
99
|
+
const inFlightProbes = new Set();
|
|
77
100
|
|
|
78
101
|
// L1 tracking
|
|
79
102
|
let lastOutputBytes = 0;
|
|
@@ -84,22 +107,114 @@ export function createHealthProbe(session, opts = {}) {
|
|
|
84
107
|
let spawnedAt = Date.now();
|
|
85
108
|
|
|
86
109
|
const status = {
|
|
87
|
-
l0: null,
|
|
88
|
-
l1: null,
|
|
89
|
-
l2: null,
|
|
90
|
-
l3: null,
|
|
110
|
+
l0: null, // 'ok' | 'fail'
|
|
111
|
+
l1: null, // 'ok' | 'stall' | 'input_wait'
|
|
112
|
+
l2: null, // 'ok' | 'fail' | 'skip'
|
|
113
|
+
l3: null, // 'ok' | 'timeout'
|
|
91
114
|
lastProbeAt: null,
|
|
92
115
|
inputWaitPattern: null,
|
|
93
116
|
};
|
|
94
117
|
|
|
118
|
+
function getStateFilePath() {
|
|
119
|
+
if (typeof config.stateFile === "string" && config.stateFile.length > 0) {
|
|
120
|
+
return config.stateFile;
|
|
121
|
+
}
|
|
122
|
+
const pid = session.pid;
|
|
123
|
+
if (pid == null || pid <= 0) return null;
|
|
124
|
+
return join(config.stateDir, `${pid}.json`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function deriveState(result) {
|
|
128
|
+
if (result.l0 === "fail") return "exited";
|
|
129
|
+
if (result.l1 === "input_wait") return "input_wait";
|
|
130
|
+
if (result.l2 === "fail") return "mcp_initializing";
|
|
131
|
+
if (result.l1 === "stall") return "stalled";
|
|
132
|
+
if (result.l3 === "timeout") return "reasoning";
|
|
133
|
+
return "active";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function writeState(result, probeEpoch) {
|
|
137
|
+
// stop() 직후 in-flight probe()의 재생성 방지.
|
|
138
|
+
// P0 (#167): probeEpoch !== runEpoch 면 이전 run 의 stale probe 가 새 run 의 state 를
|
|
139
|
+
// 덮으려는 시도 → skip.
|
|
140
|
+
if (stopped) return;
|
|
141
|
+
if (probeEpoch !== undefined && probeEpoch !== runEpoch) return;
|
|
142
|
+
if (!config.writeStateFile && !config.stateFile) return;
|
|
143
|
+
const stateFile = getStateFilePath();
|
|
144
|
+
if (!stateFile) return;
|
|
145
|
+
const payload =
|
|
146
|
+
JSON.stringify(
|
|
147
|
+
{
|
|
148
|
+
pid: session.pid ?? null,
|
|
149
|
+
state: deriveState(result),
|
|
150
|
+
result,
|
|
151
|
+
updatedAt: new Date(result.ts).toISOString(),
|
|
152
|
+
},
|
|
153
|
+
null,
|
|
154
|
+
2,
|
|
155
|
+
) + "\n";
|
|
156
|
+
// tmp+rename 으로 atomic write — heartbeat 의 sed 가 부분 파일을 읽는 race 제거.
|
|
157
|
+
// tmp 는 같은 디렉토리에 둬야 EXDEV (cross-device link) 가 안 난다.
|
|
158
|
+
const tmpPath = `${stateFile}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
159
|
+
try {
|
|
160
|
+
mkdirSync(dirname(stateFile), { recursive: true });
|
|
161
|
+
writeFileSync(tmpPath, payload, "utf8");
|
|
162
|
+
try {
|
|
163
|
+
renameSync(tmpPath, stateFile);
|
|
164
|
+
} catch (renameErr) {
|
|
165
|
+
// Windows: 대상이 존재할 때 EPERM/EACCES.
|
|
166
|
+
// P1-2 (#167 review): backup-then-swap 으로 기존 파일 보존. 옛 unlinkSync→renameSync
|
|
167
|
+
// 패턴은 1차 unlink 후 2차 rename 실패 시 기존 파일과 tmp 둘 다 잃었다.
|
|
168
|
+
if (
|
|
169
|
+
renameErr?.code === "EEXIST" ||
|
|
170
|
+
renameErr?.code === "EPERM" ||
|
|
171
|
+
renameErr?.code === "EACCES"
|
|
172
|
+
) {
|
|
173
|
+
const backupPath = `${stateFile}.old-${process.pid}-${Date.now()}`;
|
|
174
|
+
let backupCreated = false;
|
|
175
|
+
try {
|
|
176
|
+
renameSync(stateFile, backupPath);
|
|
177
|
+
backupCreated = true;
|
|
178
|
+
} catch {
|
|
179
|
+
// backup 실패 (대상 없음/잠김) — 그래도 진행 (tmp→stateFile 시도)
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
renameSync(tmpPath, stateFile);
|
|
183
|
+
if (backupCreated) {
|
|
184
|
+
try {
|
|
185
|
+
unlinkSync(backupPath);
|
|
186
|
+
} catch {}
|
|
187
|
+
}
|
|
188
|
+
} catch (secondErr) {
|
|
189
|
+
// 2차도 실패 → backup 복구해서 기존 파일 보존
|
|
190
|
+
if (backupCreated) {
|
|
191
|
+
try {
|
|
192
|
+
renameSync(backupPath, stateFile);
|
|
193
|
+
} catch {}
|
|
194
|
+
}
|
|
195
|
+
throw secondErr;
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
throw renameErr;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} catch {
|
|
202
|
+
// probe state is advisory only — tmp cleanup
|
|
203
|
+
try {
|
|
204
|
+
unlinkSync(tmpPath);
|
|
205
|
+
} catch {}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
95
209
|
/**
|
|
96
210
|
* L0: Process alive check.
|
|
97
211
|
*/
|
|
98
212
|
function probeL0() {
|
|
99
|
-
const alive =
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
213
|
+
const alive =
|
|
214
|
+
session.alive !== undefined
|
|
215
|
+
? session.alive
|
|
216
|
+
: session.pid != null && session.pid > 0;
|
|
217
|
+
status.l0 = alive ? "ok" : "fail";
|
|
103
218
|
return status.l0;
|
|
104
219
|
}
|
|
105
220
|
|
|
@@ -107,43 +222,45 @@ export function createHealthProbe(session, opts = {}) {
|
|
|
107
222
|
* L1 + L1.5: Output advancing + INPUT_WAIT 감지.
|
|
108
223
|
*/
|
|
109
224
|
function probeL1() {
|
|
110
|
-
const currentBytes =
|
|
111
|
-
|
|
112
|
-
|
|
225
|
+
const currentBytes =
|
|
226
|
+
typeof session.getOutputBytes === "function"
|
|
227
|
+
? session.getOutputBytes()
|
|
228
|
+
: 0;
|
|
113
229
|
|
|
114
230
|
const now = Date.now();
|
|
115
231
|
|
|
116
232
|
if (currentBytes !== lastOutputBytes) {
|
|
117
233
|
lastOutputBytes = currentBytes;
|
|
118
234
|
lastOutputChangeAt = now;
|
|
119
|
-
status.l1 =
|
|
235
|
+
status.l1 = "ok";
|
|
120
236
|
status.inputWaitPattern = null;
|
|
121
|
-
return
|
|
237
|
+
return "ok";
|
|
122
238
|
}
|
|
123
239
|
|
|
124
240
|
const silenceMs = now - lastOutputChangeAt;
|
|
125
241
|
|
|
126
242
|
if (silenceMs >= config.l1ThresholdMs) {
|
|
127
243
|
// L1.5: INPUT_WAIT 감지 — stall 전에 질문 패턴 체크
|
|
128
|
-
const recentOutput =
|
|
129
|
-
|
|
130
|
-
|
|
244
|
+
const recentOutput =
|
|
245
|
+
typeof session.getRecentOutput === "function"
|
|
246
|
+
? session.getRecentOutput()
|
|
247
|
+
: "";
|
|
131
248
|
const inputWait = detectInputWait(recentOutput);
|
|
132
249
|
|
|
133
250
|
if (inputWait.detected) {
|
|
134
|
-
status.l1 =
|
|
251
|
+
status.l1 = "input_wait";
|
|
135
252
|
status.inputWaitPattern = inputWait.pattern;
|
|
136
|
-
return
|
|
253
|
+
return "input_wait";
|
|
137
254
|
}
|
|
138
255
|
|
|
139
|
-
status.l1 =
|
|
256
|
+
status.l1 = "stall";
|
|
140
257
|
status.inputWaitPattern = null;
|
|
141
|
-
return
|
|
258
|
+
return "stall";
|
|
142
259
|
}
|
|
143
260
|
|
|
144
261
|
// 아직 threshold 미달
|
|
145
|
-
status.l1 =
|
|
146
|
-
return
|
|
262
|
+
status.l1 = "ok";
|
|
263
|
+
return "ok";
|
|
147
264
|
}
|
|
148
265
|
|
|
149
266
|
/**
|
|
@@ -151,23 +268,26 @@ export function createHealthProbe(session, opts = {}) {
|
|
|
151
268
|
*/
|
|
152
269
|
async function probeL2() {
|
|
153
270
|
if (!config.enableL2) {
|
|
154
|
-
status.l2 =
|
|
155
|
-
return
|
|
271
|
+
status.l2 = "skip";
|
|
272
|
+
return "skip";
|
|
156
273
|
}
|
|
157
|
-
if (typeof config.checkMcp !==
|
|
158
|
-
status.l2 =
|
|
159
|
-
return
|
|
274
|
+
if (typeof config.checkMcp !== "function") {
|
|
275
|
+
status.l2 = "skip";
|
|
276
|
+
return "skip";
|
|
160
277
|
}
|
|
161
278
|
try {
|
|
162
279
|
const connected = await Promise.race([
|
|
163
280
|
config.checkMcp(),
|
|
164
281
|
new Promise((_, reject) =>
|
|
165
|
-
setTimeout(
|
|
282
|
+
setTimeout(
|
|
283
|
+
() => reject(new Error("probe_timeout")),
|
|
284
|
+
config.probeTimeoutMs,
|
|
285
|
+
),
|
|
166
286
|
),
|
|
167
287
|
]);
|
|
168
|
-
status.l2 = connected ?
|
|
288
|
+
status.l2 = connected ? "ok" : "fail";
|
|
169
289
|
} catch {
|
|
170
|
-
status.l2 =
|
|
290
|
+
status.l2 = "fail";
|
|
171
291
|
}
|
|
172
292
|
return status.l2;
|
|
173
293
|
}
|
|
@@ -178,24 +298,25 @@ export function createHealthProbe(session, opts = {}) {
|
|
|
178
298
|
*/
|
|
179
299
|
function probeL3() {
|
|
180
300
|
if (promptAcked) {
|
|
181
|
-
status.l3 =
|
|
182
|
-
return
|
|
301
|
+
status.l3 = "ok";
|
|
302
|
+
return "ok";
|
|
183
303
|
}
|
|
184
304
|
|
|
185
|
-
const currentBytes =
|
|
186
|
-
|
|
187
|
-
|
|
305
|
+
const currentBytes =
|
|
306
|
+
typeof session.getOutputBytes === "function"
|
|
307
|
+
? session.getOutputBytes()
|
|
308
|
+
: 0;
|
|
188
309
|
|
|
189
310
|
if (currentBytes > 0) {
|
|
190
311
|
promptAcked = true;
|
|
191
|
-
status.l3 =
|
|
192
|
-
return
|
|
312
|
+
status.l3 = "ok";
|
|
313
|
+
return "ok";
|
|
193
314
|
}
|
|
194
315
|
|
|
195
316
|
const elapsed = Date.now() - spawnedAt;
|
|
196
317
|
if (elapsed >= config.l3ThresholdMs) {
|
|
197
|
-
status.l3 =
|
|
198
|
-
return
|
|
318
|
+
status.l3 = "timeout";
|
|
319
|
+
return "timeout";
|
|
199
320
|
}
|
|
200
321
|
|
|
201
322
|
status.l3 = null; // 아직 판정 전
|
|
@@ -204,35 +325,52 @@ export function createHealthProbe(session, opts = {}) {
|
|
|
204
325
|
|
|
205
326
|
/**
|
|
206
327
|
* 전체 probe 실행 (L0→L1→L2→L3).
|
|
207
|
-
* @returns {Promise<object>} probe
|
|
328
|
+
* @returns {Promise<object|null>} probe 결과. stop() 이후 호출이면 null.
|
|
208
329
|
*/
|
|
209
330
|
async function probe() {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
331
|
+
if (stopped) return null;
|
|
332
|
+
// P0 (#167): probe 시작 시 epoch 캡처. start() 가 새 epoch 로 바꾸면 이 probe 의
|
|
333
|
+
// writeState 는 stale 로 판정 → skip.
|
|
334
|
+
const probeEpoch = runEpoch;
|
|
335
|
+
const promise = (async () => {
|
|
336
|
+
const result = {
|
|
337
|
+
l0: probeL0(),
|
|
338
|
+
l1: probeL1(),
|
|
339
|
+
l2: await probeL2(),
|
|
340
|
+
l3: probeL3(),
|
|
341
|
+
inputWaitPattern: status.inputWaitPattern,
|
|
342
|
+
ts: Date.now(),
|
|
343
|
+
};
|
|
344
|
+
status.lastProbeAt = result.ts;
|
|
345
|
+
writeState(result, probeEpoch);
|
|
346
|
+
|
|
347
|
+
if (typeof config.onProbe === "function") {
|
|
348
|
+
config.onProbe(result);
|
|
349
|
+
}
|
|
223
350
|
|
|
224
|
-
|
|
351
|
+
return result;
|
|
352
|
+
})();
|
|
353
|
+
// P1-1 (#167): Set 으로 모든 in-flight 추적. 단일 var 패턴은 N+1 이 N 끝나기 전에
|
|
354
|
+
// 시작되면 N 을 덮어써 stopAndDrain() 시 N 이 누락된다.
|
|
355
|
+
inFlightProbes.add(promise);
|
|
356
|
+
promise.finally(() => inFlightProbes.delete(promise));
|
|
357
|
+
return promise;
|
|
225
358
|
}
|
|
226
359
|
|
|
227
360
|
function start() {
|
|
228
361
|
if (started) return;
|
|
229
362
|
started = true;
|
|
363
|
+
stopped = false;
|
|
364
|
+
// P0 (#167): epoch 증가 — 이전 run 의 in-flight probe 가 새 run 의 state 를 덮지 못하게.
|
|
365
|
+
runEpoch += 1;
|
|
230
366
|
spawnedAt = Date.now();
|
|
231
367
|
lastOutputChangeAt = Date.now();
|
|
232
368
|
lastOutputBytes = 0;
|
|
233
369
|
promptAcked = false;
|
|
234
370
|
|
|
235
|
-
timer = setInterval(() => {
|
|
371
|
+
timer = setInterval(() => {
|
|
372
|
+
void probe();
|
|
373
|
+
}, config.intervalMs);
|
|
236
374
|
timer.unref?.();
|
|
237
375
|
|
|
238
376
|
// 즉시 첫 probe 실행
|
|
@@ -242,10 +380,32 @@ export function createHealthProbe(session, opts = {}) {
|
|
|
242
380
|
function stop() {
|
|
243
381
|
if (!started) return;
|
|
244
382
|
started = false;
|
|
383
|
+
// stopped 를 먼저 set 해야 in-flight probe()의 writeState() 가 skip 된다.
|
|
384
|
+
// 이후 unlink — in-flight 가 끝나도 writeState 가 no-op 이므로 재생성 없음.
|
|
385
|
+
stopped = true;
|
|
245
386
|
if (timer) {
|
|
246
387
|
clearInterval(timer);
|
|
247
388
|
timer = null;
|
|
248
389
|
}
|
|
390
|
+
if (config.writeStateFile || config.stateFile) {
|
|
391
|
+
try {
|
|
392
|
+
const stateFile = getStateFilePath();
|
|
393
|
+
if (stateFile) unlinkSync(stateFile);
|
|
394
|
+
} catch {}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* stop() 후 in-flight probe() 가 완료될 때까지 대기.
|
|
400
|
+
* 결정적 종료가 필요한 테스트/teardown 용. conductor 의 sync stop() 호출자는
|
|
401
|
+
* 그대로 stop() 만 호출하면 stopped flag 가 race 를 막는다.
|
|
402
|
+
*/
|
|
403
|
+
async function stopAndDrain() {
|
|
404
|
+
stop();
|
|
405
|
+
// P1-1 (#167): 모든 in-flight probe 대기. allSettled 로 unhandled rejection 방지.
|
|
406
|
+
if (inFlightProbes.size > 0) {
|
|
407
|
+
await Promise.allSettled(Array.from(inFlightProbes));
|
|
408
|
+
}
|
|
249
409
|
}
|
|
250
410
|
|
|
251
411
|
/** L1 tracking 리셋 (restart 후 호출) */
|
|
@@ -264,9 +424,12 @@ export function createHealthProbe(session, opts = {}) {
|
|
|
264
424
|
return Object.freeze({
|
|
265
425
|
start,
|
|
266
426
|
stop,
|
|
427
|
+
stopAndDrain,
|
|
267
428
|
probe,
|
|
268
429
|
resetTracking,
|
|
269
430
|
getStatus: () => ({ ...status }),
|
|
270
|
-
get started() {
|
|
431
|
+
get started() {
|
|
432
|
+
return started;
|
|
433
|
+
},
|
|
271
434
|
});
|
|
272
435
|
}
|
|
@@ -5,28 +5,28 @@
|
|
|
5
5
|
// F4 해결: codex exec "prompt" 인라인 (파이프/리다이렉트 아님)
|
|
6
6
|
// F5 해결: 동일 입력 → 동일 args 배열 (런타임 분기 없음)
|
|
7
7
|
|
|
8
|
-
import { buildExecArgs as buildCodexArgs } from
|
|
9
|
-
import { buildExecArgs as buildGeminiArgs } from
|
|
8
|
+
import { buildExecArgs as buildCodexArgs } from "@triflux/core/hub/codex-adapter.mjs";
|
|
9
|
+
import { buildExecArgs as buildGeminiArgs } from "@triflux/core/hub/gemini-adapter.mjs";
|
|
10
10
|
|
|
11
11
|
/** CLI별 adapter 레지스트리 */
|
|
12
12
|
const ADAPTERS = Object.freeze({
|
|
13
13
|
codex: {
|
|
14
|
-
bin:
|
|
14
|
+
bin: "codex",
|
|
15
15
|
buildArgs: buildCodexArgs,
|
|
16
16
|
env: (profile) => (profile ? { CODEX_PROFILE: profile } : {}),
|
|
17
17
|
},
|
|
18
18
|
gemini: {
|
|
19
|
-
bin:
|
|
19
|
+
bin: "gemini",
|
|
20
20
|
buildArgs: buildGeminiArgs,
|
|
21
21
|
env: () => ({}),
|
|
22
22
|
},
|
|
23
23
|
claude: {
|
|
24
|
-
bin:
|
|
24
|
+
bin: "claude",
|
|
25
25
|
buildArgs: (opts = {}) => {
|
|
26
|
-
const parts = [
|
|
27
|
-
if (opts.model) parts.push(
|
|
28
|
-
parts.push(
|
|
29
|
-
return parts.join(
|
|
26
|
+
const parts = ["claude"];
|
|
27
|
+
if (opts.model) parts.push("--model", opts.model);
|
|
28
|
+
parts.push("-p", JSON.stringify(opts.prompt || ""));
|
|
29
|
+
return parts.join(" ");
|
|
30
30
|
},
|
|
31
31
|
env: () => ({}),
|
|
32
32
|
},
|
|
@@ -41,7 +41,9 @@ const ADAPTERS = Object.freeze({
|
|
|
41
41
|
export function getAdapter(agent) {
|
|
42
42
|
const adapter = ADAPTERS[agent];
|
|
43
43
|
if (!adapter) {
|
|
44
|
-
throw new Error(
|
|
44
|
+
throw new Error(
|
|
45
|
+
`Unknown agent: "${agent}". Supported: ${Object.keys(ADAPTERS).join(", ")}`,
|
|
46
|
+
);
|
|
45
47
|
}
|
|
46
48
|
return adapter;
|
|
47
49
|
}
|
|
@@ -60,10 +62,11 @@ export function getAdapter(agent) {
|
|
|
60
62
|
* @returns {{ bin: string, command: string, env: object, agent: string }}
|
|
61
63
|
*/
|
|
62
64
|
export function buildLauncher(opts) {
|
|
63
|
-
const { agent, profile, prompt, workdir, model, resultFile, mcpServers } =
|
|
65
|
+
const { agent, profile, prompt, workdir, model, resultFile, mcpServers } =
|
|
66
|
+
opts;
|
|
64
67
|
|
|
65
|
-
if (!agent) throw new Error(
|
|
66
|
-
if (!prompt && prompt !==
|
|
68
|
+
if (!agent) throw new Error("agent is required");
|
|
69
|
+
if (!prompt && prompt !== "") throw new Error("prompt is required");
|
|
67
70
|
|
|
68
71
|
const adapter = getAdapter(agent);
|
|
69
72
|
|
|
@@ -73,7 +76,13 @@ export function buildLauncher(opts) {
|
|
|
73
76
|
model,
|
|
74
77
|
resultFile,
|
|
75
78
|
workdir,
|
|
79
|
+
cwd: workdir,
|
|
76
80
|
mcpServers,
|
|
81
|
+
// launcher 는 동일 입력 → 동일 명령 (F5 결정론 보장). buildExecCommand 의
|
|
82
|
+
// stdin redirect 모드는 timestamp/pid/counter 가 들어가 비결정적이라
|
|
83
|
+
// launcher path 에서는 argv-inline 모드로 강제. headless 워커 spawn 은
|
|
84
|
+
// backend.mjs → buildExecArgs (stdinPrompt 미명시) 로 default stdin on.
|
|
85
|
+
stdinPrompt: false,
|
|
77
86
|
});
|
|
78
87
|
|
|
79
88
|
const env = adapter.env(profile);
|
|
@@ -83,6 +92,7 @@ export function buildLauncher(opts) {
|
|
|
83
92
|
command,
|
|
84
93
|
env,
|
|
85
94
|
agent,
|
|
95
|
+
cwd: workdir || null,
|
|
86
96
|
});
|
|
87
97
|
}
|
|
88
98
|
|
|
@@ -1,23 +1,35 @@
|
|
|
1
|
+
import { readState } from "@triflux/core/hub/state.mjs";
|
|
2
|
+
|
|
1
3
|
const DEFAULT_TIMEOUT_MS = 2500;
|
|
2
4
|
const CONTROL_COMMAND_ALIASES = Object.freeze({
|
|
3
5
|
stop: "abort",
|
|
4
6
|
interrupt: "abort",
|
|
5
7
|
});
|
|
6
8
|
|
|
7
|
-
export const LEAD_CONTROL_COMMANDS = Object.freeze([
|
|
9
|
+
export const LEAD_CONTROL_COMMANDS = Object.freeze([
|
|
10
|
+
"pause",
|
|
11
|
+
"resume",
|
|
12
|
+
"abort",
|
|
13
|
+
"reassign",
|
|
14
|
+
]);
|
|
8
15
|
|
|
9
16
|
function resolveFetch(fetchImpl) {
|
|
10
17
|
if (typeof fetchImpl === "function") return fetchImpl;
|
|
11
|
-
if (typeof globalThis.fetch === "function")
|
|
18
|
+
if (typeof globalThis.fetch === "function")
|
|
19
|
+
return globalThis.fetch.bind(globalThis);
|
|
12
20
|
return null;
|
|
13
21
|
}
|
|
14
22
|
|
|
15
23
|
function normalizeHubBaseUrl(hubUrl) {
|
|
16
|
-
return String(hubUrl || "")
|
|
24
|
+
return String(hubUrl || "")
|
|
25
|
+
.replace(/\/+$/, "")
|
|
26
|
+
.replace(/\/mcp$/, "");
|
|
17
27
|
}
|
|
18
28
|
|
|
19
29
|
function normalizeCommand(command) {
|
|
20
|
-
const raw = String(command || "")
|
|
30
|
+
const raw = String(command || "")
|
|
31
|
+
.trim()
|
|
32
|
+
.toLowerCase();
|
|
21
33
|
if (!raw) return "";
|
|
22
34
|
return CONTROL_COMMAND_ALIASES[raw] || raw;
|
|
23
35
|
}
|
|
@@ -27,6 +39,39 @@ function safeAbortSignal(timeoutMs) {
|
|
|
27
39
|
return AbortSignal.timeout(timeoutMs);
|
|
28
40
|
}
|
|
29
41
|
|
|
42
|
+
function isPidAlive(pid) {
|
|
43
|
+
const resolvedPid = Number(pid);
|
|
44
|
+
if (!Number.isFinite(resolvedPid) || resolvedPid <= 0) return false;
|
|
45
|
+
try {
|
|
46
|
+
process.kill(resolvedPid, 0);
|
|
47
|
+
return true;
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolveHeadlessHubUrl() {
|
|
54
|
+
const stateUrl = normalizeHubBaseUrl(readState()?.url);
|
|
55
|
+
if (stateUrl) return stateUrl;
|
|
56
|
+
|
|
57
|
+
const envHubUrl = normalizeHubBaseUrl(process.env.TFX_HUB_URL);
|
|
58
|
+
if (envHubUrl) return envHubUrl;
|
|
59
|
+
|
|
60
|
+
const envPort = Number(process.env.TFX_HUB_PORT || "27888");
|
|
61
|
+
const port = Number.isFinite(envPort) && envPort > 0 ? envPort : 27888;
|
|
62
|
+
return `http://127.0.0.1:${port}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function hasLiveHubState() {
|
|
66
|
+
const state = readState();
|
|
67
|
+
return !!normalizeHubBaseUrl(state?.url) && isPidAlive(state?.pid);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function toHeadlessSessionAgentId(sessionName) {
|
|
71
|
+
const normalizedSessionName = String(sessionName || "").trim();
|
|
72
|
+
return normalizedSessionName ? `session:${normalizedSessionName}` : "";
|
|
73
|
+
}
|
|
74
|
+
|
|
30
75
|
function safeJson(res) {
|
|
31
76
|
return res.json().catch(() => ({}));
|
|
32
77
|
}
|
|
@@ -102,3 +147,45 @@ export async function publishLeadControl({
|
|
|
102
147
|
};
|
|
103
148
|
}
|
|
104
149
|
}
|
|
150
|
+
|
|
151
|
+
export async function publishHeadlessControl(
|
|
152
|
+
sessionName,
|
|
153
|
+
command,
|
|
154
|
+
targetWorker = "*",
|
|
155
|
+
) {
|
|
156
|
+
const sessionAgentId = toHeadlessSessionAgentId(sessionName);
|
|
157
|
+
if (!sessionAgentId) {
|
|
158
|
+
return {
|
|
159
|
+
ok: false,
|
|
160
|
+
noop: true,
|
|
161
|
+
error: "SESSION_NAME_REQUIRED",
|
|
162
|
+
command: String(command || "")
|
|
163
|
+
.trim()
|
|
164
|
+
.toLowerCase(),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!normalizeHubBaseUrl(process.env.TFX_HUB_URL) && !hasLiveHubState()) {
|
|
169
|
+
return {
|
|
170
|
+
ok: false,
|
|
171
|
+
noop: true,
|
|
172
|
+
error: "HUB_UNAVAILABLE",
|
|
173
|
+
command: String(command || "")
|
|
174
|
+
.trim()
|
|
175
|
+
.toLowerCase(),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return await publishLeadControl({
|
|
180
|
+
hubUrl: resolveHeadlessHubUrl(),
|
|
181
|
+
fromAgent: "lead",
|
|
182
|
+
toAgent: sessionAgentId,
|
|
183
|
+
command,
|
|
184
|
+
payload: {
|
|
185
|
+
session_name: String(sessionName || "").trim(),
|
|
186
|
+
target_worker: String(targetWorker || "*").trim() || "*",
|
|
187
|
+
issued_by: "lead",
|
|
188
|
+
issued_at: Date.now(),
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
}
|