triflux 10.14.1 → 10.14.3
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/team/conductor.mjs
CHANGED
|
@@ -696,8 +696,10 @@ export function createConductor(opts = {}) {
|
|
|
696
696
|
},
|
|
697
697
|
{
|
|
698
698
|
...probeOpts,
|
|
699
|
+
// #165: default off → on. atomic write (#162) 로 race 제거됨.
|
|
700
|
+
// opt-out: TFX_PROBE_WRITE_STATE=0 명시.
|
|
699
701
|
writeStateFile:
|
|
700
|
-
probeOpts.writeStateFile ?? process.env.TFX_PROBE_WRITE_STATE
|
|
702
|
+
probeOpts.writeStateFile ?? process.env.TFX_PROBE_WRITE_STATE !== "0",
|
|
701
703
|
onProbe: (result) => handleProbeResult(session, result),
|
|
702
704
|
},
|
|
703
705
|
);
|
|
@@ -2,7 +2,7 @@
|
|
|
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, unlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import { dirname, join } from "node:path";
|
|
8
8
|
|
|
@@ -84,6 +84,16 @@ export function createHealthProbe(session, opts = {}) {
|
|
|
84
84
|
const config = { ...PROBE_DEFAULTS, ...opts };
|
|
85
85
|
let timer = null;
|
|
86
86
|
let started = false;
|
|
87
|
+
// stopped flag는 in-flight probe()가 stop() 사이의 await 점에서
|
|
88
|
+
// writeState/unlink 와 race 하는 것을 막는다. start() 시 false 로 reset.
|
|
89
|
+
let stopped = false;
|
|
90
|
+
// P0 (#167 review): start() 마다 증가. probe() 가 시작 시 캡처 → writeState() 가 epoch
|
|
91
|
+
// 비교로 stop()→start() 사이의 in-flight 가 새 run 의 state 를 덮는 race 차단.
|
|
92
|
+
// stopped flag 만으로는 start() 가 stopped=false 로 reset 한 직후 race 못 막음.
|
|
93
|
+
let runEpoch = 0;
|
|
94
|
+
// P1-1 (#167 review): Set 으로 모든 in-flight probe 추적. interval 이 빨라 N+1 이 N 끝나기
|
|
95
|
+
// 전에 시작되면 단일 var 는 N 을 덮어써 stopAndDrain() 시 N 이 누락된다.
|
|
96
|
+
const inFlightProbes = new Set();
|
|
87
97
|
|
|
88
98
|
// L1 tracking
|
|
89
99
|
let lastOutputBytes = 0;
|
|
@@ -120,28 +130,76 @@ export function createHealthProbe(session, opts = {}) {
|
|
|
120
130
|
return "active";
|
|
121
131
|
}
|
|
122
132
|
|
|
123
|
-
function writeState(result) {
|
|
133
|
+
function writeState(result, probeEpoch) {
|
|
134
|
+
// stop() 직후 in-flight probe()의 재생성 방지.
|
|
135
|
+
// P0 (#167): probeEpoch !== runEpoch 면 이전 run 의 stale probe 가 새 run 의 state 를
|
|
136
|
+
// 덮으려는 시도 → skip.
|
|
137
|
+
if (stopped) return;
|
|
138
|
+
if (probeEpoch !== undefined && probeEpoch !== runEpoch) return;
|
|
124
139
|
if (!config.writeStateFile && !config.stateFile) return;
|
|
125
140
|
const stateFile = getStateFilePath();
|
|
126
141
|
if (!stateFile) return;
|
|
142
|
+
const payload =
|
|
143
|
+
JSON.stringify(
|
|
144
|
+
{
|
|
145
|
+
pid: session.pid ?? null,
|
|
146
|
+
state: deriveState(result),
|
|
147
|
+
result,
|
|
148
|
+
updatedAt: new Date(result.ts).toISOString(),
|
|
149
|
+
},
|
|
150
|
+
null,
|
|
151
|
+
2,
|
|
152
|
+
) + "\n";
|
|
153
|
+
// tmp+rename 으로 atomic write — heartbeat 의 sed 가 부분 파일을 읽는 race 제거.
|
|
154
|
+
// tmp 는 같은 디렉토리에 둬야 EXDEV (cross-device link) 가 안 난다.
|
|
155
|
+
const tmpPath = `${stateFile}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
127
156
|
try {
|
|
128
157
|
mkdirSync(dirname(stateFile), { recursive: true });
|
|
129
|
-
writeFileSync(
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
)
|
|
141
|
-
|
|
142
|
-
|
|
158
|
+
writeFileSync(tmpPath, payload, "utf8");
|
|
159
|
+
try {
|
|
160
|
+
renameSync(tmpPath, stateFile);
|
|
161
|
+
} catch (renameErr) {
|
|
162
|
+
// Windows: 대상이 존재할 때 EPERM/EACCES.
|
|
163
|
+
// P1-2 (#167 review): backup-then-swap 으로 기존 파일 보존. 옛 unlinkSync→renameSync
|
|
164
|
+
// 패턴은 1차 unlink 후 2차 rename 실패 시 기존 파일과 tmp 둘 다 잃었다.
|
|
165
|
+
if (
|
|
166
|
+
renameErr?.code === "EEXIST" ||
|
|
167
|
+
renameErr?.code === "EPERM" ||
|
|
168
|
+
renameErr?.code === "EACCES"
|
|
169
|
+
) {
|
|
170
|
+
const backupPath = `${stateFile}.old-${process.pid}-${Date.now()}`;
|
|
171
|
+
let backupCreated = false;
|
|
172
|
+
try {
|
|
173
|
+
renameSync(stateFile, backupPath);
|
|
174
|
+
backupCreated = true;
|
|
175
|
+
} catch {
|
|
176
|
+
// backup 실패 (대상 없음/잠김) — 그래도 진행 (tmp→stateFile 시도)
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
renameSync(tmpPath, stateFile);
|
|
180
|
+
if (backupCreated) {
|
|
181
|
+
try {
|
|
182
|
+
unlinkSync(backupPath);
|
|
183
|
+
} catch {}
|
|
184
|
+
}
|
|
185
|
+
} catch (secondErr) {
|
|
186
|
+
// 2차도 실패 → backup 복구해서 기존 파일 보존
|
|
187
|
+
if (backupCreated) {
|
|
188
|
+
try {
|
|
189
|
+
renameSync(backupPath, stateFile);
|
|
190
|
+
} catch {}
|
|
191
|
+
}
|
|
192
|
+
throw secondErr;
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
throw renameErr;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
143
198
|
} catch {
|
|
144
|
-
// probe state is advisory only
|
|
199
|
+
// probe state is advisory only — tmp cleanup
|
|
200
|
+
try {
|
|
201
|
+
unlinkSync(tmpPath);
|
|
202
|
+
} catch {}
|
|
145
203
|
}
|
|
146
204
|
}
|
|
147
205
|
|
|
@@ -264,30 +322,44 @@ export function createHealthProbe(session, opts = {}) {
|
|
|
264
322
|
|
|
265
323
|
/**
|
|
266
324
|
* 전체 probe 실행 (L0→L1→L2→L3).
|
|
267
|
-
* @returns {Promise<object>} probe
|
|
325
|
+
* @returns {Promise<object|null>} probe 결과. stop() 이후 호출이면 null.
|
|
268
326
|
*/
|
|
269
327
|
async function probe() {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
328
|
+
if (stopped) return null;
|
|
329
|
+
// P0 (#167): probe 시작 시 epoch 캡처. start() 가 새 epoch 로 바꾸면 이 probe 의
|
|
330
|
+
// writeState 는 stale 로 판정 → skip.
|
|
331
|
+
const probeEpoch = runEpoch;
|
|
332
|
+
const promise = (async () => {
|
|
333
|
+
const result = {
|
|
334
|
+
l0: probeL0(),
|
|
335
|
+
l1: probeL1(),
|
|
336
|
+
l2: await probeL2(),
|
|
337
|
+
l3: probeL3(),
|
|
338
|
+
inputWaitPattern: status.inputWaitPattern,
|
|
339
|
+
ts: Date.now(),
|
|
340
|
+
};
|
|
341
|
+
status.lastProbeAt = result.ts;
|
|
342
|
+
writeState(result, probeEpoch);
|
|
343
|
+
|
|
344
|
+
if (typeof config.onProbe === "function") {
|
|
345
|
+
config.onProbe(result);
|
|
346
|
+
}
|
|
284
347
|
|
|
285
|
-
|
|
348
|
+
return result;
|
|
349
|
+
})();
|
|
350
|
+
// P1-1 (#167): Set 으로 모든 in-flight 추적. 단일 var 패턴은 N+1 이 N 끝나기 전에
|
|
351
|
+
// 시작되면 N 을 덮어써 stopAndDrain() 시 N 이 누락된다.
|
|
352
|
+
inFlightProbes.add(promise);
|
|
353
|
+
promise.finally(() => inFlightProbes.delete(promise));
|
|
354
|
+
return promise;
|
|
286
355
|
}
|
|
287
356
|
|
|
288
357
|
function start() {
|
|
289
358
|
if (started) return;
|
|
290
359
|
started = true;
|
|
360
|
+
stopped = false;
|
|
361
|
+
// P0 (#167): epoch 증가 — 이전 run 의 in-flight probe 가 새 run 의 state 를 덮지 못하게.
|
|
362
|
+
runEpoch += 1;
|
|
291
363
|
spawnedAt = Date.now();
|
|
292
364
|
lastOutputChangeAt = Date.now();
|
|
293
365
|
lastOutputBytes = 0;
|
|
@@ -305,6 +377,9 @@ export function createHealthProbe(session, opts = {}) {
|
|
|
305
377
|
function stop() {
|
|
306
378
|
if (!started) return;
|
|
307
379
|
started = false;
|
|
380
|
+
// stopped 를 먼저 set 해야 in-flight probe()의 writeState() 가 skip 된다.
|
|
381
|
+
// 이후 unlink — in-flight 가 끝나도 writeState 가 no-op 이므로 재생성 없음.
|
|
382
|
+
stopped = true;
|
|
308
383
|
if (timer) {
|
|
309
384
|
clearInterval(timer);
|
|
310
385
|
timer = null;
|
|
@@ -317,6 +392,19 @@ export function createHealthProbe(session, opts = {}) {
|
|
|
317
392
|
}
|
|
318
393
|
}
|
|
319
394
|
|
|
395
|
+
/**
|
|
396
|
+
* stop() 후 in-flight probe() 가 완료될 때까지 대기.
|
|
397
|
+
* 결정적 종료가 필요한 테스트/teardown 용. conductor 의 sync stop() 호출자는
|
|
398
|
+
* 그대로 stop() 만 호출하면 stopped flag 가 race 를 막는다.
|
|
399
|
+
*/
|
|
400
|
+
async function stopAndDrain() {
|
|
401
|
+
stop();
|
|
402
|
+
// P1-1 (#167): 모든 in-flight probe 대기. allSettled 로 unhandled rejection 방지.
|
|
403
|
+
if (inFlightProbes.size > 0) {
|
|
404
|
+
await Promise.allSettled(Array.from(inFlightProbes));
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
320
408
|
/** L1 tracking 리셋 (restart 후 호출) */
|
|
321
409
|
function resetTracking() {
|
|
322
410
|
lastOutputBytes = 0;
|
|
@@ -333,6 +421,7 @@ export function createHealthProbe(session, opts = {}) {
|
|
|
333
421
|
return Object.freeze({
|
|
334
422
|
start,
|
|
335
423
|
stop,
|
|
424
|
+
stopAndDrain,
|
|
336
425
|
probe,
|
|
337
426
|
resetTracking,
|
|
338
427
|
getStatus: () => ({ ...status }),
|
package/package.json
CHANGED
|
@@ -101,14 +101,16 @@ describe("mcp guard engine", () => {
|
|
|
101
101
|
);
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
-
it("replaces stdio MCP entries with tfx-hub and writes a backup", () => {
|
|
104
|
+
it("replaces stdio MCP entries with tfx-hub and writes a backup (TFX_HUB_PORT env overrides)", () => {
|
|
105
105
|
const homeDir = createHomeDir();
|
|
106
106
|
withHome(homeDir);
|
|
107
|
+
process.env.TFX_HUB_PORT = "30123";
|
|
107
108
|
|
|
109
|
+
// hub.pid port 는 무시되어야 한다 (PR #158: pid = host hint only).
|
|
108
110
|
const pidPath = join(homeDir, ".claude", "cache", "tfx-hub", "hub.pid");
|
|
109
111
|
writeFileSync(
|
|
110
112
|
pidPath,
|
|
111
|
-
JSON.stringify({ host: "127.0.0.1", port:
|
|
113
|
+
JSON.stringify({ host: "127.0.0.1", port: 40404 }),
|
|
112
114
|
"utf8",
|
|
113
115
|
);
|
|
114
116
|
|
|
@@ -141,9 +143,18 @@ describe("mcp guard engine", () => {
|
|
|
141
143
|
assert.equal(Object.hasOwn(updated.mcpServers, "unsafe-stdio"), false);
|
|
142
144
|
});
|
|
143
145
|
|
|
144
|
-
it("uses
|
|
146
|
+
it("uses TFX_HUB_PORT env as single source when resolving Hub URL", () => {
|
|
145
147
|
const homeDir = createHomeDir();
|
|
146
148
|
withHome(homeDir);
|
|
149
|
+
process.env.TFX_HUB_PORT = "29991";
|
|
150
|
+
|
|
151
|
+
assert.equal(resolveHubUrl(), "http://127.0.0.1:29991/mcp");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("ignores hub.pid port (pid is host hint only, PR #158 policy)", () => {
|
|
155
|
+
const homeDir = createHomeDir();
|
|
156
|
+
withHome(homeDir);
|
|
157
|
+
delete process.env.TFX_HUB_PORT;
|
|
147
158
|
|
|
148
159
|
writeFileSync(
|
|
149
160
|
join(homeDir, ".claude", "cache", "tfx-hub", "hub.pid"),
|
|
@@ -151,6 +162,8 @@ describe("mcp guard engine", () => {
|
|
|
151
162
|
"utf8",
|
|
152
163
|
);
|
|
153
164
|
|
|
154
|
-
|
|
165
|
+
// env 없음 + hub.pid port 존재 → registry/default 27888 fallback.
|
|
166
|
+
// pid port cascade 가 제거되어 29991 이 쓰이면 안 됨.
|
|
167
|
+
assert.equal(resolveHubUrl(), "http://127.0.0.1:27888/mcp");
|
|
155
168
|
});
|
|
156
169
|
});
|
|
@@ -851,14 +851,13 @@ export function resolveHubUrl() {
|
|
|
851
851
|
: DEFAULT_HUB_PATH,
|
|
852
852
|
};
|
|
853
853
|
|
|
854
|
+
// PR #158 정책: port = TFX_HUB_PORT env (없으면 registry/default 27888) single source.
|
|
855
|
+
// hub.pid 는 loopback host 힌트 전용. 과거 pid port cascade 는 오염된 port 영속화의
|
|
856
|
+
// 원인이었고 hub-ensure.resolveHubTarget 에서 이미 제거됨. 여기도 일관 적용.
|
|
854
857
|
const hubPidPath = join(homedir(), ".claude", "cache", "tfx-hub", "hub.pid");
|
|
855
858
|
if (existsSync(hubPidPath)) {
|
|
856
859
|
try {
|
|
857
860
|
const info = readJsonFile(hubPidPath);
|
|
858
|
-
if (!envPort) {
|
|
859
|
-
const pidPort = Number(info?.port);
|
|
860
|
-
if (Number.isFinite(pidPort) && pidPort > 0) target.port = pidPort;
|
|
861
|
-
}
|
|
862
861
|
if (typeof info?.host === "string") {
|
|
863
862
|
const host = info.host.trim();
|
|
864
863
|
if (LOOPBACK_HOSTS.has(host)) target.host = host;
|
package/scripts/tfx-route.sh
CHANGED
|
@@ -320,6 +320,12 @@ read_probe_state() {
|
|
|
320
320
|
local pid="$1"
|
|
321
321
|
local state_file="${TFX_PROBE_STATE_FILE:-${TFX_PROBE_DIR}/${pid}.json}"
|
|
322
322
|
[[ -f "$state_file" ]] || return 1
|
|
323
|
+
# 2-step read (#162): health-probe.mjs 의 atomic write (tmp+rename) 가 도입되었지만
|
|
324
|
+
# writer 쪽 OS race 또는 race-free 보장을 못 받는 환경 (예: 일부 FS) 에서 빈/부분 파일
|
|
325
|
+
# 을 sed 가 읽고 stale state 를 반환하는 것을 방지하기 위해 size 가 너무 작으면 무시.
|
|
326
|
+
local size
|
|
327
|
+
size=$(wc -c < "$state_file" 2>/dev/null || printf '0')
|
|
328
|
+
[[ "$size" -ge 20 ]] || return 1
|
|
323
329
|
sed -n 's/.*"state"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$state_file" 2>/dev/null | head -1
|
|
324
330
|
}
|
|
325
331
|
RUN_ID="${TIMESTAMP}-$$-${RANDOM}"
|
|
@@ -1422,12 +1428,25 @@ heartbeat_monitor() {
|
|
|
1422
1428
|
stall_count=0
|
|
1423
1429
|
echo "[tfx-heartbeat] pid=$pid elapsed=${elapsed}s output=${current_size}B${expected_suffix} status=${probe_state}(probe-grace)" >&2
|
|
1424
1430
|
elif [[ "$stall_count" -ge "$stall_threshold" ]]; then
|
|
1425
|
-
# STALL
|
|
1426
|
-
#
|
|
1427
|
-
#
|
|
1428
|
-
|
|
1431
|
+
# STALL 판정 modes (#165 PLANNING P4):
|
|
1432
|
+
# off (alias: 0, disabled) — silent. kill 안 함, STALL_CLASSIFY 로그도 없음
|
|
1433
|
+
# classify (default) — kill 안 함. STALL_CLASSIFY 로그로 evidence 노출
|
|
1434
|
+
# kill (alias: 1, on) — threshold+grace 초과 시 SIGTERM→SIGKILL
|
|
1435
|
+
# PR #160 에서 default 1 → 0 으로 임시 후퇴 (false kill 방지). 본 PR(#165) 에서
|
|
1436
|
+
# classify 로 승격 — evidence 는 남기되 false kill 리스크 없음.
|
|
1437
|
+
local kill_on_stall="${TFX_STALL_KILL:-classify}"
|
|
1438
|
+
[[ -z "$kill_on_stall" ]] && kill_on_stall="classify"
|
|
1429
1439
|
local kill_grace="${TFX_STALL_KILL_GRACE:-30}"
|
|
1430
|
-
|
|
1440
|
+
local _should_kill=0
|
|
1441
|
+
case "$kill_on_stall" in
|
|
1442
|
+
1|on|kill) _should_kill=1 ;;
|
|
1443
|
+
classify|0|off|disabled) ;;
|
|
1444
|
+
*)
|
|
1445
|
+
echo "[tfx-heartbeat] pid=$pid warning TFX_STALL_KILL=$kill_on_stall unknown, fallback classify" >&2
|
|
1446
|
+
kill_on_stall="classify"
|
|
1447
|
+
;;
|
|
1448
|
+
esac
|
|
1449
|
+
if [[ "$_should_kill" -eq 1 && "$stall_count" -ge $((stall_threshold + kill_grace)) ]]; then
|
|
1431
1450
|
echo "[tfx-heartbeat] pid=$pid elapsed=${elapsed}s output=${current_size}B${expected_suffix} status=STALL_KILL stall=${stall_count}s — SIGTERM" >&2
|
|
1432
1451
|
# Snapshot child PIDs before SIGTERM — wrapper 가 SIGTERM 을 수용해 죽으면
|
|
1433
1452
|
# 부모 소멸 후 taskkill /T 가 자식 트리를 탐색하지 못해 codex 자식이 orphan 으로 남는다.
|
|
@@ -1476,6 +1495,9 @@ heartbeat_monitor() {
|
|
|
1476
1495
|
fi
|
|
1477
1496
|
break
|
|
1478
1497
|
fi
|
|
1498
|
+
if [[ "$kill_on_stall" == "classify" && "$stall_count" -ge $((stall_threshold + kill_grace)) ]]; then
|
|
1499
|
+
echo "[tfx-heartbeat] pid=$pid elapsed=${elapsed}s output=${current_size}B${expected_suffix} status=STALL_CLASSIFY stall=${stall_count}s (no-kill — TFX_STALL_KILL=classify)" >&2
|
|
1500
|
+
fi
|
|
1479
1501
|
echo "[tfx-heartbeat] pid=$pid elapsed=${elapsed}s output=${current_size}B${expected_suffix} status=STALL stall=${stall_count}s" >&2
|
|
1480
1502
|
else
|
|
1481
1503
|
echo "[tfx-heartbeat] pid=$pid elapsed=${elapsed}s output=${current_size}B${expected_suffix} status=quiet stall=${stall_count}s" >&2
|