triflux 10.13.0 → 10.13.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/bridge.mjs CHANGED
@@ -23,7 +23,6 @@ import { parseArgs as nodeParseArgs } from "node:util";
23
23
  import { getPipelineStateDbPath } from "./pipeline/state.mjs";
24
24
  import {
25
25
  createRetryStateMachine,
26
- DEFAULT_ESCALATION_CHAIN,
27
26
  loadSnapshot,
28
27
  saveSnapshot,
29
28
  } from "./team/retry-state-machine.mjs";
@@ -45,7 +45,7 @@ export async function startHeadlessTeam({
45
45
  ok(`headless ${assignments.length}워커 시작`);
46
46
 
47
47
  const handle = await runHeadlessInteractive(sessionId, assignments, {
48
- timeoutSec: timeoutSec || 300,
48
+ timeoutSec: timeoutSec || 900,
49
49
  layout,
50
50
  autoAttach: !!autoAttach,
51
51
  dashboard: !!dashboard,
@@ -11,6 +11,7 @@ import {
11
11
  mkdirSync,
12
12
  readFileSync,
13
13
  renameSync,
14
+ rmSync,
14
15
  statSync,
15
16
  writeFileSync,
16
17
  } from "node:fs";
@@ -240,16 +241,53 @@ export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
240
241
  return `cd '${safeCwd.replace(/'/g, "'\\''")}' && ${backendCommand}`;
241
242
  }
242
243
 
244
+ /**
245
+ * 이전 run 의 stale .txt / .partial / .err 를 제거한다.
246
+ * Issue #118 Codex review R1 HIGH: resultFile 경로 재사용 시 (워커 restart 또는
247
+ * 동일 세션명 재실행) 이전 run 의 HANDOFF 가 새 run 결과로 오인될 수 있다.
248
+ *
249
+ * Issue #118 R2 MEDIUM: Windows 에서 locked/open 파일로 인한 삭제 실패를
250
+ * silent swallow 하면 stale artifact 가 살아남아 bug 가 non-deterministic 하게
251
+ * 재발한다. ENOENT 외 에러는 경고 로그 + 재시도(1회) 후 계속.
252
+ * @param {string} resultFile
253
+ */
254
+ export function cleanStaleResultArtifacts(resultFile) {
255
+ for (const suffix of ["", ".partial", ".err"]) {
256
+ const path = `${resultFile}${suffix}`;
257
+ for (let attempt = 0; attempt < 2; attempt++) {
258
+ try {
259
+ rmSync(path);
260
+ break;
261
+ } catch (err) {
262
+ if (err?.code === "ENOENT") break; // 이미 없음 — 정상
263
+ if (attempt === 0) continue; // 1회 재시도
264
+ // 재시도도 실패: locked 파일일 수 있음. 경고 로그 후 계속 진행
265
+ // (dispatch 중단 시 워커 자체가 시작 못하므로 best-effort 유지)
266
+ console.warn(
267
+ `[headless] cleanStaleResultArtifacts: ${path} 삭제 실패 (${err?.code || "unknown"}). stale leak 가능.`,
268
+ );
269
+ }
270
+ }
271
+ }
272
+ }
273
+
243
274
  /**
244
275
  * 결과 파일 읽기 (없으면 capture-pane fallback)
276
+ * Issue #118 fallback chain: .txt → .partial ([partial] prefix) → .err → capture-pane
245
277
  * @param {string} resultFile
246
278
  * @param {string} paneId
247
279
  * @returns {string}
248
280
  */
249
- function readResult(resultFile, paneId) {
281
+ export function readResult(resultFile, paneId) {
250
282
  if (existsSync(resultFile)) {
251
283
  return readFileSync(resultFile, "utf8").trim();
252
284
  }
285
+ // Issue #118 fallback 0: timeout 직전 persist 된 부분 출력 (.partial)
286
+ const partialFile = `${resultFile}.partial`;
287
+ if (existsSync(partialFile)) {
288
+ const partial = readFileSync(partialFile, "utf8").trim();
289
+ if (partial) return `[partial] ${partial}`;
290
+ }
253
291
  // fallback 1: stderr 파일 (codex 실패 시 원인 추적)
254
292
  const errFile = `${resultFile}.err`;
255
293
  if (existsSync(errFile)) {
@@ -417,6 +455,17 @@ export async function waitForCompletionWithStallDetect(
417
455
 
418
456
  // 전체 타임아웃
419
457
  if (now - startedAt > completionTimeout) {
458
+ // Issue #118: timeout kill 전에 capture-pane 출력을 .partial 파일로 persist.
459
+ // resultFile(`.txt`)이 아직 생성되지 않았을 수 있으므로 readResult 가 fallback 으로 읽는다.
460
+ try {
461
+ const partialSnapshot = _capture(currentPaneId, 200);
462
+ if (partialSnapshot && partialSnapshot.trim().length > 0) {
463
+ const writer = deps.writeFileSync || writeFileSync;
464
+ writer(`${resultFile}.partial`, partialSnapshot, "utf8");
465
+ }
466
+ } catch {
467
+ /* best-effort, timeout 은 이미 확정 */
468
+ }
420
469
  return {
421
470
  matched: false,
422
471
  exitCode: null,
@@ -610,6 +659,8 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
610
659
  RESULT_DIR,
611
660
  `${sessionName}-${paneName}.txt`,
612
661
  ).replace(/\\/g, "/");
662
+ // Issue #118 review R1 HIGH: stale artifact 제거 (이전 run / restart 잔재)
663
+ cleanStaleResultArtifacts(resultFile);
613
664
  const cmd = buildHeadlessCommand(
614
665
  assignment.cli,
615
666
  assignment.prompt,
@@ -686,6 +737,8 @@ async function dispatchBatch(sessionName, assignments, opts = {}) {
686
737
  RESULT_DIR,
687
738
  `${sessionName}-${paneName}.txt`,
688
739
  ).replace(/\\/g, "/");
740
+ // Issue #118 review R1 HIGH: stale artifact 제거 (이전 run / restart 잔재)
741
+ cleanStaleResultArtifacts(resultFile);
689
742
  const cmd = buildHeadlessCommand(
690
743
  assignment.cli,
691
744
  assignment.prompt,
@@ -832,9 +885,20 @@ async function awaitAll(
832
885
  );
833
886
  }
834
887
 
835
- const output = completion.matched
836
- ? readResult(d.resultFile, d.paneId)
837
- : "";
888
+ // Issue #118: matched=false(timeout kill) 에도 capture-pane 스냅샷을
889
+ // .partial 로 persist 하여 readResult fallback chain 이 복구할 수 있도록 한다.
890
+ if (!completion.matched) {
891
+ try {
892
+ const snap = capturePsmuxPane(d.paneId || d.paneName, 200);
893
+ if (snap && snap.trim().length > 0) {
894
+ writeFileSync(`${d.resultFile}.partial`, snap, "utf8");
895
+ }
896
+ } catch {
897
+ /* best-effort */
898
+ }
899
+ }
900
+
901
+ const output = readResult(d.resultFile, d.paneId);
838
902
  unregisterHeadlessSynapseWorker(d.workerId);
839
903
 
840
904
  if (safeProgress) {
@@ -945,7 +1009,7 @@ function collectGitDiffFiles(cwd) {
945
1009
  */
946
1010
  export async function runHeadless(sessionName, assignments, opts = {}) {
947
1011
  const {
948
- timeoutSec = 300,
1012
+ timeoutSec = 900,
949
1013
  layout = "2x2",
950
1014
  onProgress,
951
1015
  progressIntervalSec = 0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.13.0",
3
+ "version": "10.13.1",
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": {
package/scripts/setup.mjs CHANGED
@@ -56,10 +56,45 @@ const REQUIRED_CODEX_PROFILES = [
56
56
  name: "codex53_xhigh",
57
57
  lines: ['model = "gpt-5.3-codex"', 'model_reasoning_effort = "xhigh"'],
58
58
  },
59
+ {
60
+ name: "codex53_med",
61
+ lines: ['model = "gpt-5.3-codex"', 'model_reasoning_effort = "medium"'],
62
+ },
59
63
  {
60
64
  name: "spark53_low",
61
65
  lines: ['model = "gpt-5.3-codex-spark"', 'model_reasoning_effort = "low"'],
62
66
  },
67
+ {
68
+ name: "spark53_med",
69
+ lines: [
70
+ 'model = "gpt-5.3-codex-spark"',
71
+ 'model_reasoning_effort = "medium"',
72
+ ],
73
+ },
74
+ {
75
+ name: "gpt54_xhigh",
76
+ lines: ['model = "gpt-5.4"', 'model_reasoning_effort = "xhigh"'],
77
+ },
78
+ {
79
+ name: "gpt54_high",
80
+ lines: ['model = "gpt-5.4"', 'model_reasoning_effort = "high"'],
81
+ },
82
+ {
83
+ name: "gpt54_low",
84
+ lines: ['model = "gpt-5.4"', 'model_reasoning_effort = "low"'],
85
+ },
86
+ {
87
+ name: "mini54_low",
88
+ lines: ['model = "gpt-5.4-mini"', 'model_reasoning_effort = "low"'],
89
+ },
90
+ {
91
+ name: "mini54_med",
92
+ lines: ['model = "gpt-5.4-mini"', 'model_reasoning_effort = "medium"'],
93
+ },
94
+ {
95
+ name: "mini54_high",
96
+ lines: ['model = "gpt-5.4-mini"', 'model_reasoning_effort = "high"'],
97
+ },
63
98
  ];
64
99
 
65
100
  const HUD_SYNC_EXCLUDES = new Set(["omc-hud.mjs", "omc-hud.mjs.bak"]);
@@ -556,6 +591,15 @@ function ensureCodexHubServerConfig({
556
591
  }
557
592
  }
558
593
 
594
+ // Top-level config.toml keys that must exist with these defaults.
595
+ // Only injected when the key is completely absent — existing user values are
596
+ // never overwritten, regardless of what value was set.
597
+ const REQUIRED_TOP_LEVEL_SETTINGS = [
598
+ { key: "model", value: '"gpt-5.4"' },
599
+ { key: "model_reasoning_effort", value: '"high"' },
600
+ { key: "service_tier", value: '"fast"' },
601
+ ];
602
+
559
603
  function ensureCodexProfiles() {
560
604
  try {
561
605
  if (!existsSync(CODEX_DIR)) mkdirSync(CODEX_DIR, { recursive: true });
@@ -564,9 +608,45 @@ function ensureCodexProfiles() {
564
608
  ? readFileSync(CODEX_CONFIG_PATH, "utf8")
565
609
  : "";
566
610
 
611
+ // Safety guard: if the file exists but is suspiciously small (< 100 bytes)
612
+ // skip all writes to avoid perpetuating a corrupted state.
613
+ if (original.length > 0 && original.length < 100) {
614
+ process.stderr.write(
615
+ `[tfx-setup] config.toml 크기 이상 (${original.length} bytes) — 쓰기 스킵. 수동 확인 필요: ${CODEX_CONFIG_PATH}\n`,
616
+ );
617
+ return { ok: false, changed: 0, message: "config too small, skipped" };
618
+ }
619
+
567
620
  let updated = original;
568
621
  let changed = 0;
569
622
 
623
+ // ── 1. top-level 필수 설정 주입 (없을 때만, 기존 값 보존) ──
624
+ // 파일 상단 [profiles.*] / [mcp_servers.*] 이전 영역만 검사한다.
625
+ // 프로필 섹션 내부의 동명 키(예: model = "gpt-5.3-codex")는 무시한다.
626
+ // 이미 존재하는 키는 절대 덮어쓰지 않는다.
627
+ for (const { key, value } of REQUIRED_TOP_LEVEL_SETTINGS) {
628
+ // top-level 영역 = 첫 번째 [profiles.*] / [mcp_servers.*] 헤더 이전
629
+ const firstSectionIdx = updated.search(/^\[(?:profiles|mcp_servers)\./m);
630
+ const topLevelRegion =
631
+ firstSectionIdx === -1 ? updated : updated.slice(0, firstSectionIdx);
632
+ const topLevelKeyRe = new RegExp(`^${key}\\s*=`, "m");
633
+ if (!topLevelKeyRe.test(topLevelRegion)) {
634
+ // firstSectionIdx already computed above for this iteration
635
+ const line = `${key} = ${value}\n`;
636
+ if (firstSectionIdx === -1) {
637
+ // 섹션이 없으면 파일 맨 앞에 추가
638
+ updated = line + updated;
639
+ } else {
640
+ updated =
641
+ updated.slice(0, firstSectionIdx) +
642
+ line +
643
+ updated.slice(firstSectionIdx);
644
+ }
645
+ changed++;
646
+ }
647
+ }
648
+
649
+ // ── 2. 필수 프로필 보장 ──
570
650
  for (const profile of REQUIRED_CODEX_PROFILES) {
571
651
  const desired = `[profiles.${profile.name}]\n${profile.lines.join("\n")}\n`;
572
652
 
@@ -898,6 +978,7 @@ export {
898
978
  LEGACY_CODEX_MODELS,
899
979
  PLUGIN_ROOT,
900
980
  REQUIRED_CODEX_PROFILES,
981
+ REQUIRED_TOP_LEVEL_SETTINGS,
901
982
  readMarker,
902
983
  replaceProfileSection,
903
984
  SETUP_MARKER_PATH,
@@ -1445,6 +1445,14 @@ _codex_config_swap() {
1445
1445
  return 0
1446
1446
  fi
1447
1447
 
1448
+ # Pre-validation: config.toml이 500 bytes 미만이면 이미 손상된 상태일 수 있음 — 스킵
1449
+ local config_size
1450
+ config_size=$(wc -c < "$config" 2>/dev/null | tr -d ' ') || config_size=0
1451
+ if [[ "$config_size" -lt 500 ]]; then
1452
+ echo "[tfx-route] 경고: config.toml 크기 ${config_size} bytes — 손상 의심, swap 스킵 (수동 확인 필요)" >&2
1453
+ return 0
1454
+ fi
1455
+
1448
1456
  # 백업 생성 (이미 있으면 다른 워커가 swap 중 — 건드리지 않음)
1449
1457
  if [[ -f "$backup" ]]; then
1450
1458
  echo "[tfx-route] config.toml swap 스킵: 다른 워커가 사용 중 ($backup)" >&2
@@ -1454,6 +1462,7 @@ _codex_config_swap() {
1454
1462
 
1455
1463
  # awk로 필터링: 비허용 MCP 서버 섹션 제거, 나머지 그대로 유지.
1456
1464
  # keep="" 은 진입 가드에서 return 됐지만 defense-in-depth 유지.
1465
+ local tmp_filtered="${config}.filter.$$"
1457
1466
  awk -v keep="$allowed_pat" '
1458
1467
  BEGIN { skip=0 }
1459
1468
  /^\[mcp_servers\./ {
@@ -1464,7 +1473,26 @@ _codex_config_swap() {
1464
1473
  }
1465
1474
  /^\[/ && !/^\[mcp_servers\./ { skip=0 }
1466
1475
  !skip { print }
1467
- ' "$backup" > "$config"
1476
+ ' "$backup" > "$tmp_filtered"
1477
+
1478
+ # Output sanity check: 필터 결과가 비었거나 백업의 30% 미만이면 적용 거부
1479
+ local filtered_size backup_size threshold
1480
+ filtered_size=$(wc -c < "$tmp_filtered" 2>/dev/null | tr -d ' ') || filtered_size=0
1481
+ backup_size=$(wc -c < "$backup" 2>/dev/null | tr -d ' ') || backup_size=1
1482
+ threshold=$(( backup_size * 30 / 100 ))
1483
+ if [[ "$filtered_size" -eq 0 || "$filtered_size" -lt "$threshold" ]]; then
1484
+ echo "[tfx-route] 경고: 필터 결과 크기 ${filtered_size} bytes (백업 ${backup_size} bytes의 30% 미만) — 적용 거부, 백업에서 복원" >&2
1485
+ rm -f "$tmp_filtered" 2>/dev/null
1486
+ rm -f "$backup" 2>/dev/null
1487
+ return 1
1488
+ fi
1489
+
1490
+ # 검증 통과 — atomic rename으로 적용
1491
+ if ! mv "$tmp_filtered" "$config"; then
1492
+ echo "[tfx-route] 경고: 필터 결과 적용 실패 (atomic rename), 백업 보존: $backup" >&2
1493
+ rm -f "$tmp_filtered" 2>/dev/null
1494
+ return 1
1495
+ fi
1468
1496
 
1469
1497
  local kept
1470
1498
  kept=$(echo "$allowed_pat" | tr '|' '\n' | wc -l | tr -d ' ')
@@ -1475,6 +1503,15 @@ _codex_config_swap() {
1475
1503
  # `cat > $config` 는 cat 실행 전에 dest 가 truncate 되어 mid-stream 실패 시
1476
1504
  # 빈/부분 파일이 남는다. 같은 디렉토리 내 mv 는 POSIX 상 atomic 이므로
1477
1505
  # 실패해도 기존 config 와 backup 모두 보존된다.
1506
+
1507
+ # Restore sanity check: 백업 자체가 비었거나 500 bytes 미만이면 복원 중단
1508
+ local backup_restore_size
1509
+ backup_restore_size=$(wc -c < "$backup" 2>/dev/null | tr -d ' ') || backup_restore_size=0
1510
+ if [[ "$backup_restore_size" -lt 500 ]]; then
1511
+ echo "[tfx-route] 경고: backup 크기 ${backup_restore_size} bytes — 손상 의심, 복원 중단. 수동 확인 필요: $backup" >&2
1512
+ return 1
1513
+ fi
1514
+
1478
1515
  local tmp="${config}.restore.$$"
1479
1516
  if ! cp "$backup" "$tmp"; then
1480
1517
  echo "[tfx-route] 경고: config.toml 복원 실패 (temp copy). backup 보존: $backup" >&2