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.
@@ -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 === "1",
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
- stateFile,
131
- JSON.stringify(
132
- {
133
- pid: session.pid ?? null,
134
- state: deriveState(result),
135
- result,
136
- updatedAt: new Date(result.ts).toISOString(),
137
- },
138
- null,
139
- 2,
140
- ) + "\n",
141
- "utf8",
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
- const result = {
271
- l0: probeL0(),
272
- l1: probeL1(),
273
- l2: await probeL2(),
274
- l3: probeL3(),
275
- inputWaitPattern: status.inputWaitPattern,
276
- ts: Date.now(),
277
- };
278
- status.lastProbeAt = result.ts;
279
- writeState(result);
280
-
281
- if (typeof config.onProbe === "function") {
282
- config.onProbe(result);
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
- return result;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.14.1",
3
+ "version": "10.14.3",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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: 30123 }),
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 hub.pid port before registry fallback when resolving Hub URL", () => {
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
- assert.equal(resolveHubUrl(), "http://127.0.0.1:29991/mcp");
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;
@@ -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 kill (#144/#66 regression guard): stall=threshold+grace 이상 지속 시 SIGTERM→SIGKILL.
1426
- # 땜빵(PLANNING P4 구현 전): default 1 → 0. false kill >> true stuck 비용이 압도적이라
1427
- # opt-in 으로 전환. debug 필요 TFX_STALL_KILL=1 로 명시 활성화. classify mode는 차기.
1428
- local kill_on_stall="${TFX_STALL_KILL:-0}"
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
- if [[ "$kill_on_stall" -eq 1 && "$stall_count" -ge $((stall_threshold + kill_grace)) ]]; then
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