triflux 10.14.1 → 10.14.2
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 +3 -1
- package/hub/team/health-probe.mjs +122 -33
- package/package.json +1 -1
- package/scripts/tfx-route.sh +27 -5
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
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
|