triflux 10.13.0 → 10.13.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/bin/triflux.mjs +123 -13
- package/hub/bridge.mjs +0 -1
- package/hub/team/cli/commands/start/start-headless.mjs +1 -1
- package/hub/team/headless.mjs +69 -5
- package/package.json +1 -1
- package/scripts/__tests__/setup-cleanup-stale-skills.test.mjs +105 -0
- package/scripts/setup.mjs +88 -8
- package/scripts/tfx-route.sh +107 -4
package/bin/triflux.mjs
CHANGED
|
@@ -134,7 +134,8 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
|
|
|
134
134
|
],
|
|
135
135
|
},
|
|
136
136
|
doctor: {
|
|
137
|
-
usage:
|
|
137
|
+
usage:
|
|
138
|
+
"tfx doctor [--fix] [--reset] [--audit] [--diagnose] [--purge-logs] [--json]",
|
|
138
139
|
description: "설치 상태 진단 및 자동 복구",
|
|
139
140
|
options: [
|
|
140
141
|
{
|
|
@@ -158,6 +159,12 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
|
|
|
158
159
|
description:
|
|
159
160
|
"진단 번들(zip) 생성: spawn-trace + hook timing + system info",
|
|
160
161
|
},
|
|
162
|
+
{
|
|
163
|
+
name: "--purge-logs",
|
|
164
|
+
type: "boolean",
|
|
165
|
+
description:
|
|
166
|
+
"--fix 와 함께 사용. cli-issues.jsonl 에서 7일 초과 항목 물리 삭제 (#144)",
|
|
167
|
+
},
|
|
161
168
|
{
|
|
162
169
|
name: "--json",
|
|
163
170
|
type: "boolean",
|
|
@@ -1742,7 +1749,12 @@ function ensureValidRegistryState() {
|
|
|
1742
1749
|
}
|
|
1743
1750
|
|
|
1744
1751
|
async function cmdDoctor(options = {}) {
|
|
1745
|
-
const {
|
|
1752
|
+
const {
|
|
1753
|
+
fix = false,
|
|
1754
|
+
reset = false,
|
|
1755
|
+
purgeLogs = false,
|
|
1756
|
+
json = false,
|
|
1757
|
+
} = options;
|
|
1746
1758
|
const report = {
|
|
1747
1759
|
status: "ok",
|
|
1748
1760
|
mode: reset ? "reset" : fix ? "fix" : "check",
|
|
@@ -2357,9 +2369,20 @@ async function cmdDoctor(options = {}) {
|
|
|
2357
2369
|
info(`업데이트 권장:\n${formatPsmuxUpdateGuidance(" ")}`);
|
|
2358
2370
|
}
|
|
2359
2371
|
if (psmuxSupport.missingOptionalCommands?.length > 0) {
|
|
2372
|
+
// #144: 단순히 "detach-first hardening 경로에서만 사용" 만으로는 사용자가
|
|
2373
|
+
// 영향 범위와 해결 방법을 알 수 없다. 각 capability 별 영향과 업그레이드 명령을 명시.
|
|
2360
2374
|
info(
|
|
2361
|
-
`선택 capability 미지원: ${psmuxSupport.missingOptionalCommands.join(", ")}
|
|
2375
|
+
`선택 capability 미지원: ${psmuxSupport.missingOptionalCommands.join(", ")}`,
|
|
2362
2376
|
);
|
|
2377
|
+
if (psmuxSupport.missingOptionalCommands.includes("detach-client")) {
|
|
2378
|
+
info(
|
|
2379
|
+
" detach-client: WT 1.24 ConPTY close-race 회피용. WT 기반 병렬 실행(swarm dashboard, tfx-multi wt 모드) 에서 pane freeze/ConPTY hang 위험 증가.",
|
|
2380
|
+
);
|
|
2381
|
+
info(
|
|
2382
|
+
" 해결: psmux v3.4+ 로 업그레이드. 현재 psmux 업그레이드 명령:",
|
|
2383
|
+
);
|
|
2384
|
+
info(`${formatPsmuxUpdateGuidance(" ")}`);
|
|
2385
|
+
}
|
|
2363
2386
|
}
|
|
2364
2387
|
|
|
2365
2388
|
// 기본 셸 확인: psmux 세션의 기본 셸이 PowerShell인지 cmd.exe인지
|
|
@@ -2771,6 +2794,10 @@ async function cmdDoctor(options = {}) {
|
|
|
2771
2794
|
).version;
|
|
2772
2795
|
let cleaned = 0;
|
|
2773
2796
|
|
|
2797
|
+
// #144: 오래된 로그 노이즈 완화 — 7일 초과 항목은 INFO 레벨로 downgrade.
|
|
2798
|
+
// --fix --purge-logs 플래그가 있으면 해당 오래된 항목은 실제 삭제.
|
|
2799
|
+
const STALE_AGE_MS = 7 * 24 * 3600 * 1000;
|
|
2800
|
+
let purged = 0;
|
|
2774
2801
|
for (const [key, g] of Object.entries(groups)) {
|
|
2775
2802
|
const fixVer = KNOWN_FIXES[key];
|
|
2776
2803
|
if (fixVer && semverGte(currentVer, fixVer)) {
|
|
@@ -2785,30 +2812,62 @@ async function cmdDoctor(options = {}) {
|
|
|
2785
2812
|
: age < 86400000
|
|
2786
2813
|
? `${Math.round(age / 3600000)}시간 전`
|
|
2787
2814
|
: `${Math.round(age / 86400000)}일 전`;
|
|
2788
|
-
const
|
|
2789
|
-
|
|
2815
|
+
const isStale = age >= STALE_AGE_MS;
|
|
2816
|
+
if (isStale && fix && purgeLogs) {
|
|
2817
|
+
purged += g.count;
|
|
2818
|
+
continue;
|
|
2819
|
+
}
|
|
2820
|
+
const sev = isStale
|
|
2821
|
+
? `${CYAN}INFO${RESET}`
|
|
2822
|
+
: g.severity === "error"
|
|
2790
2823
|
? `${RED}ERROR${RESET}`
|
|
2791
2824
|
: `${YELLOW}WARN${RESET}`;
|
|
2792
|
-
|
|
2825
|
+
const staleTag = isStale ? " [STALE]" : "";
|
|
2826
|
+
if (isStale) {
|
|
2827
|
+
info(
|
|
2828
|
+
`[${sev}]${staleTag} ${g.cli}/${g.pattern} x${g.count} (최근: ${ago})`,
|
|
2829
|
+
);
|
|
2830
|
+
} else {
|
|
2831
|
+
warn(`[${sev}] ${g.cli}/${g.pattern} x${g.count} (최근: ${ago})`);
|
|
2832
|
+
}
|
|
2793
2833
|
if (g.snippet) info(` ${g.snippet.substring(0, 120)}`);
|
|
2794
2834
|
if (fixVer)
|
|
2795
2835
|
info(` 해결: triflux >= v${fixVer} (npm update -g triflux)`);
|
|
2796
|
-
|
|
2836
|
+
if (isStale && !purgeLogs) {
|
|
2837
|
+
info(` 7일 초과 — 삭제: tfx doctor --fix --purge-logs`);
|
|
2838
|
+
}
|
|
2839
|
+
if (!isStale) issues++;
|
|
2797
2840
|
}
|
|
2798
2841
|
|
|
2799
|
-
//
|
|
2800
|
-
|
|
2842
|
+
// #144 Codex review P2: 두 filter (stale purge + KNOWN_FIXES) 가 각각 원본 entries 로
|
|
2843
|
+
// write 하면 두 번째 write 가 첫 번째 결과를 되살린다. 단일 통합 필터로 한 번만 저장.
|
|
2844
|
+
if (purged > 0 || cleaned > 0) {
|
|
2845
|
+
const now = Date.now();
|
|
2801
2846
|
const remaining = entries.filter((e) => {
|
|
2847
|
+
// purge-logs 로 물리 삭제 대상: 7일 초과
|
|
2848
|
+
if (purged > 0 && now - e.ts >= STALE_AGE_MS) return false;
|
|
2849
|
+
// KNOWN_FIXES 해결된 이슈 제거
|
|
2802
2850
|
const key = `${e.cli}:${e.pattern}`;
|
|
2803
2851
|
const fixVer = KNOWN_FIXES[key];
|
|
2804
|
-
|
|
2852
|
+
if (fixVer && semverGte(currentVer, fixVer)) return false;
|
|
2853
|
+
return true;
|
|
2805
2854
|
});
|
|
2806
2855
|
writeFileSync(
|
|
2807
2856
|
issuesFile,
|
|
2808
2857
|
remaining.map((e) => JSON.stringify(e)).join("\n") +
|
|
2809
2858
|
(remaining.length ? "\n" : ""),
|
|
2810
2859
|
);
|
|
2811
|
-
|
|
2860
|
+
if (purged > 0) {
|
|
2861
|
+
ok(`${purged}개 stale 로그 항목 삭제 (7일 초과)`);
|
|
2862
|
+
report.actions.push({
|
|
2863
|
+
name: "purge-stale-logs",
|
|
2864
|
+
status: "applied",
|
|
2865
|
+
count: purged,
|
|
2866
|
+
});
|
|
2867
|
+
}
|
|
2868
|
+
if (cleaned > 0) {
|
|
2869
|
+
ok(`${cleaned}개 해결된 이슈 자동 정리됨`);
|
|
2870
|
+
}
|
|
2812
2871
|
}
|
|
2813
2872
|
addDoctorCheck(report, {
|
|
2814
2873
|
name: "cli-issues",
|
|
@@ -3373,6 +3432,56 @@ async function cmdDoctor(options = {}) {
|
|
|
3373
3432
|
if (row.actualUrl) info(`actual ${row.actualUrl}`);
|
|
3374
3433
|
}
|
|
3375
3434
|
|
|
3435
|
+
// #144: --fix 모드에서 tfx-hub URL 불일치를 hub status 기준으로 자동 갱신.
|
|
3436
|
+
// Project MCP (.mcp.json) 와 Codex/Claude/Gemini settings 모두 대상.
|
|
3437
|
+
// Codex review P2: fix 성공 시 issues 집계에서 차감해야 doctor 결과가 ok 로 반영됨.
|
|
3438
|
+
let autoFixedMismatches = 0;
|
|
3439
|
+
if (fix && mismatchRows.some((r) => r.name === "tfx-hub")) {
|
|
3440
|
+
try {
|
|
3441
|
+
const hubUrl = mismatchRows.find(
|
|
3442
|
+
(r) => r.name === "tfx-hub",
|
|
3443
|
+
)?.expectedUrl;
|
|
3444
|
+
if (hubUrl) {
|
|
3445
|
+
const { syncHubMcpSettings, syncProjectMcpJson } = await import(
|
|
3446
|
+
"../scripts/sync-hub-mcp-settings.mjs"
|
|
3447
|
+
);
|
|
3448
|
+
const settingsResult = await syncHubMcpSettings({
|
|
3449
|
+
hubUrl,
|
|
3450
|
+
logger: { log() {}, warn() {}, error() {} },
|
|
3451
|
+
});
|
|
3452
|
+
const projectResult = await syncProjectMcpJson({
|
|
3453
|
+
hubUrl,
|
|
3454
|
+
projectRoot: process.cwd(),
|
|
3455
|
+
logger: { log() {}, warn() {}, error() {} },
|
|
3456
|
+
});
|
|
3457
|
+
const totalUpdated =
|
|
3458
|
+
(settingsResult?.updated?.length || 0) +
|
|
3459
|
+
(projectResult?.updated?.length || 0);
|
|
3460
|
+
if (totalUpdated > 0) {
|
|
3461
|
+
ok(`tfx-hub URL ${totalUpdated}개 파일 자동 갱신 (${hubUrl})`);
|
|
3462
|
+
report.actions.push({
|
|
3463
|
+
name: "sync-hub-url",
|
|
3464
|
+
status: "applied",
|
|
3465
|
+
files: [
|
|
3466
|
+
...(settingsResult?.updated || []),
|
|
3467
|
+
...(projectResult?.updated || []),
|
|
3468
|
+
],
|
|
3469
|
+
});
|
|
3470
|
+
// fix 성공 — mismatchRows 중 tfx-hub 엔트리는 해결된 것으로 집계
|
|
3471
|
+
autoFixedMismatches = mismatchRows.filter(
|
|
3472
|
+
(r) => r.name === "tfx-hub",
|
|
3473
|
+
).length;
|
|
3474
|
+
} else {
|
|
3475
|
+
info("tfx-hub URL 자동 갱신: 대상 파일 없음");
|
|
3476
|
+
}
|
|
3477
|
+
}
|
|
3478
|
+
} catch (e) {
|
|
3479
|
+
warn(
|
|
3480
|
+
`tfx-hub URL 자동 갱신 실패: ${e?.message?.split(/\r?\n/)[0] || e}`,
|
|
3481
|
+
);
|
|
3482
|
+
}
|
|
3483
|
+
}
|
|
3484
|
+
|
|
3376
3485
|
for (const row of missingFileRows) {
|
|
3377
3486
|
info(
|
|
3378
3487
|
`${row.label}: ${row.name} 미배치 (${formatPathForDisplay(row.filePath)})`,
|
|
@@ -3395,7 +3504,7 @@ async function cmdDoctor(options = {}) {
|
|
|
3395
3504
|
}
|
|
3396
3505
|
|
|
3397
3506
|
issues += invalidConfigs.length;
|
|
3398
|
-
issues += mismatchRows.length;
|
|
3507
|
+
issues += Math.max(0, mismatchRows.length - autoFixedMismatches);
|
|
3399
3508
|
issues += stdioRows.length;
|
|
3400
3509
|
}
|
|
3401
3510
|
}
|
|
@@ -5508,7 +5617,8 @@ async function main() {
|
|
|
5508
5617
|
}
|
|
5509
5618
|
const fix = cmdArgs.includes("--fix");
|
|
5510
5619
|
const reset = cmdArgs.includes("--reset");
|
|
5511
|
-
|
|
5620
|
+
const purgeLogs = cmdArgs.includes("--purge-logs");
|
|
5621
|
+
await cmdDoctor({ fix, reset, purgeLogs, json: JSON_OUTPUT });
|
|
5512
5622
|
return;
|
|
5513
5623
|
}
|
|
5514
5624
|
case "mcp":
|
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 ||
|
|
48
|
+
timeoutSec: timeoutSec || 900,
|
|
49
49
|
layout,
|
|
50
50
|
autoAttach: !!autoAttach,
|
|
51
51
|
dashboard: !!dashboard,
|
package/hub/team/headless.mjs
CHANGED
|
@@ -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
|
-
|
|
836
|
-
|
|
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 =
|
|
1012
|
+
timeoutSec = 900,
|
|
949
1013
|
layout = "2x2",
|
|
950
1014
|
onProgress,
|
|
951
1015
|
progressIntervalSec = 0,
|
package/package.json
CHANGED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// scripts/__tests__/setup-cleanup-stale-skills.test.mjs
|
|
2
|
+
// #144: cleanupStaleSkills 가 nested directory 를 가진 stale 스킬도 재귀 삭제하는지 확인.
|
|
3
|
+
//
|
|
4
|
+
// 이전 구현은 top-level 파일만 unlinkSync → 하위 폴더 있는 과거 스킬
|
|
5
|
+
// (tfx-deep-*, tfx-codex-swarm 등) 은 제거 실패 → "triflux update 돌려도 13개 그대로" UX bug.
|
|
6
|
+
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import {
|
|
9
|
+
existsSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
mkdtempSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { after, describe, it } from "node:test";
|
|
18
|
+
|
|
19
|
+
const SETUP_MJS_URL = new URL("../setup.mjs", import.meta.url).href;
|
|
20
|
+
const { cleanupStaleSkills } = await import(SETUP_MJS_URL);
|
|
21
|
+
|
|
22
|
+
describe("#144 cleanupStaleSkills — 재귀 삭제", () => {
|
|
23
|
+
const cleanupDirs = [];
|
|
24
|
+
after(() => {
|
|
25
|
+
for (const d of cleanupDirs) rmSync(d, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function setupFixture() {
|
|
29
|
+
const root = mkdtempSync(path.join(tmpdir(), "tfx-cleanup-"));
|
|
30
|
+
cleanupDirs.push(root);
|
|
31
|
+
const installedDir = path.join(root, "installed");
|
|
32
|
+
const pkgDir = path.join(root, "pkg");
|
|
33
|
+
mkdirSync(installedDir, { recursive: true });
|
|
34
|
+
mkdirSync(pkgDir, { recursive: true });
|
|
35
|
+
// pkg 에는 tfx-auto 만 있음 (나머지는 installed 에서 stale 로 감지)
|
|
36
|
+
mkdirSync(path.join(pkgDir, "tfx-auto"), { recursive: true });
|
|
37
|
+
return { installedDir, pkgDir };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
it("nested directory 가 있는 stale 스킬도 전부 제거된다 (과거 회귀 bug)", () => {
|
|
41
|
+
const { installedDir, pkgDir } = setupFixture();
|
|
42
|
+
// stale 스킬: top-level 파일 + nested 디렉토리
|
|
43
|
+
const staleSkill = path.join(installedDir, "tfx-deep-review");
|
|
44
|
+
mkdirSync(staleSkill, { recursive: true });
|
|
45
|
+
writeFileSync(path.join(staleSkill, "SKILL.md"), "# deprecated");
|
|
46
|
+
const nested = path.join(staleSkill, "snapshot");
|
|
47
|
+
mkdirSync(nested, { recursive: true });
|
|
48
|
+
writeFileSync(path.join(nested, "data.json"), "{}");
|
|
49
|
+
mkdirSync(path.join(nested, "sub"), { recursive: true });
|
|
50
|
+
writeFileSync(path.join(nested, "sub", "more.txt"), "xxx");
|
|
51
|
+
|
|
52
|
+
// 유지해야 할 스킬 (pkg 에 있음)
|
|
53
|
+
mkdirSync(path.join(installedDir, "tfx-auto"), { recursive: true });
|
|
54
|
+
writeFileSync(path.join(installedDir, "tfx-auto", "SKILL.md"), "# ok");
|
|
55
|
+
|
|
56
|
+
const result = cleanupStaleSkills(installedDir, pkgDir);
|
|
57
|
+
assert.equal(result.count, 1);
|
|
58
|
+
assert.deepEqual(result.removed, ["tfx-deep-review"]);
|
|
59
|
+
assert.equal(existsSync(staleSkill), false, "nested dir 포함 전부 삭제");
|
|
60
|
+
assert.equal(
|
|
61
|
+
existsSync(path.join(installedDir, "tfx-auto")),
|
|
62
|
+
true,
|
|
63
|
+
"pkg 에 있는 스킬은 보존",
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("top-level 파일만 있는 stale 스킬도 제거된다 (legacy behavior 회귀 방지)", () => {
|
|
68
|
+
const { installedDir, pkgDir } = setupFixture();
|
|
69
|
+
const staleSkill = path.join(installedDir, "tfx-autoresearch");
|
|
70
|
+
mkdirSync(staleSkill, { recursive: true });
|
|
71
|
+
writeFileSync(path.join(staleSkill, "SKILL.md"), "# deprecated");
|
|
72
|
+
writeFileSync(path.join(staleSkill, "config.json"), "{}");
|
|
73
|
+
|
|
74
|
+
const result = cleanupStaleSkills(installedDir, pkgDir);
|
|
75
|
+
assert.equal(result.count, 1);
|
|
76
|
+
assert.equal(existsSync(staleSkill), false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("SKILL_ALIASES 에 있는 alias 는 유지된다", () => {
|
|
80
|
+
const { installedDir, pkgDir } = setupFixture();
|
|
81
|
+
// alias (tfx-autopilot) 는 SKILL_ALIASES 에 있으므로 pkgNames 에 자동 포함
|
|
82
|
+
mkdirSync(path.join(installedDir, "tfx-autopilot"), { recursive: true });
|
|
83
|
+
writeFileSync(
|
|
84
|
+
path.join(installedDir, "tfx-autopilot", "SKILL.md"),
|
|
85
|
+
"# alias",
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const result = cleanupStaleSkills(installedDir, pkgDir);
|
|
89
|
+
assert.equal(result.count, 0);
|
|
90
|
+
assert.equal(existsSync(path.join(installedDir, "tfx-autopilot")), true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("tfx- 접두사 없는 디렉토리는 건드리지 않음", () => {
|
|
94
|
+
const { installedDir, pkgDir } = setupFixture();
|
|
95
|
+
mkdirSync(path.join(installedDir, "other-skill"), { recursive: true });
|
|
96
|
+
writeFileSync(
|
|
97
|
+
path.join(installedDir, "other-skill", "SKILL.md"),
|
|
98
|
+
"# other",
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const result = cleanupStaleSkills(installedDir, pkgDir);
|
|
102
|
+
assert.equal(result.count, 0);
|
|
103
|
+
assert.equal(existsSync(path.join(installedDir, "other-skill")), true);
|
|
104
|
+
});
|
|
105
|
+
});
|
package/scripts/setup.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
mkdirSync,
|
|
14
14
|
readdirSync,
|
|
15
15
|
readFileSync,
|
|
16
|
+
rmSync,
|
|
16
17
|
unlinkSync,
|
|
17
18
|
writeFileSync,
|
|
18
19
|
} from "fs";
|
|
@@ -56,10 +57,45 @@ const REQUIRED_CODEX_PROFILES = [
|
|
|
56
57
|
name: "codex53_xhigh",
|
|
57
58
|
lines: ['model = "gpt-5.3-codex"', 'model_reasoning_effort = "xhigh"'],
|
|
58
59
|
},
|
|
60
|
+
{
|
|
61
|
+
name: "codex53_med",
|
|
62
|
+
lines: ['model = "gpt-5.3-codex"', 'model_reasoning_effort = "medium"'],
|
|
63
|
+
},
|
|
59
64
|
{
|
|
60
65
|
name: "spark53_low",
|
|
61
66
|
lines: ['model = "gpt-5.3-codex-spark"', 'model_reasoning_effort = "low"'],
|
|
62
67
|
},
|
|
68
|
+
{
|
|
69
|
+
name: "spark53_med",
|
|
70
|
+
lines: [
|
|
71
|
+
'model = "gpt-5.3-codex-spark"',
|
|
72
|
+
'model_reasoning_effort = "medium"',
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: "gpt54_xhigh",
|
|
77
|
+
lines: ['model = "gpt-5.4"', 'model_reasoning_effort = "xhigh"'],
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: "gpt54_high",
|
|
81
|
+
lines: ['model = "gpt-5.4"', 'model_reasoning_effort = "high"'],
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: "gpt54_low",
|
|
85
|
+
lines: ['model = "gpt-5.4"', 'model_reasoning_effort = "low"'],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: "mini54_low",
|
|
89
|
+
lines: ['model = "gpt-5.4-mini"', 'model_reasoning_effort = "low"'],
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: "mini54_med",
|
|
93
|
+
lines: ['model = "gpt-5.4-mini"', 'model_reasoning_effort = "medium"'],
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: "mini54_high",
|
|
97
|
+
lines: ['model = "gpt-5.4-mini"', 'model_reasoning_effort = "high"'],
|
|
98
|
+
},
|
|
63
99
|
];
|
|
64
100
|
|
|
65
101
|
const HUD_SYNC_EXCLUDES = new Set(["omc-hud.mjs", "omc-hud.mjs.bak"]);
|
|
@@ -395,17 +431,15 @@ function cleanupStaleSkills(installedDir, pkgDir) {
|
|
|
395
431
|
if (pkgNames.has(name)) continue;
|
|
396
432
|
|
|
397
433
|
const skillPath = join(installedDir, name);
|
|
434
|
+
// #144: 재귀 삭제 필요 — 과거 구현은 파일만 unlink 하여 nested 디렉토리가 있는 스킬을
|
|
435
|
+
// 온전히 제거하지 못했다. `tfx-deep-*`, `tfx-codex-swarm` 같은 과거 잔재 디렉토리는
|
|
436
|
+
// workspace/snapshot 같은 하위 폴더를 가지므로 rmSync recursive 가 필수.
|
|
398
437
|
try {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
// rmdir only works on empty dirs; ignore errors for nested
|
|
402
|
-
try {
|
|
403
|
-
readdirSync(skillPath).length === 0 && unlinkSync(skillPath);
|
|
404
|
-
} catch {}
|
|
438
|
+
rmSync(skillPath, { recursive: true, force: true });
|
|
439
|
+
removed.push(name);
|
|
405
440
|
} catch {
|
|
406
|
-
/* best effort */
|
|
441
|
+
/* best effort — next setup/update cycle 에서 재시도 */
|
|
407
442
|
}
|
|
408
|
-
removed.push(name);
|
|
409
443
|
}
|
|
410
444
|
return { count: removed.length, removed };
|
|
411
445
|
}
|
|
@@ -556,6 +590,15 @@ function ensureCodexHubServerConfig({
|
|
|
556
590
|
}
|
|
557
591
|
}
|
|
558
592
|
|
|
593
|
+
// Top-level config.toml keys that must exist with these defaults.
|
|
594
|
+
// Only injected when the key is completely absent — existing user values are
|
|
595
|
+
// never overwritten, regardless of what value was set.
|
|
596
|
+
const REQUIRED_TOP_LEVEL_SETTINGS = [
|
|
597
|
+
{ key: "model", value: '"gpt-5.4"' },
|
|
598
|
+
{ key: "model_reasoning_effort", value: '"high"' },
|
|
599
|
+
{ key: "service_tier", value: '"fast"' },
|
|
600
|
+
];
|
|
601
|
+
|
|
559
602
|
function ensureCodexProfiles() {
|
|
560
603
|
try {
|
|
561
604
|
if (!existsSync(CODEX_DIR)) mkdirSync(CODEX_DIR, { recursive: true });
|
|
@@ -564,9 +607,45 @@ function ensureCodexProfiles() {
|
|
|
564
607
|
? readFileSync(CODEX_CONFIG_PATH, "utf8")
|
|
565
608
|
: "";
|
|
566
609
|
|
|
610
|
+
// Safety guard: if the file exists but is suspiciously small (< 100 bytes)
|
|
611
|
+
// skip all writes to avoid perpetuating a corrupted state.
|
|
612
|
+
if (original.length > 0 && original.length < 100) {
|
|
613
|
+
process.stderr.write(
|
|
614
|
+
`[tfx-setup] config.toml 크기 이상 (${original.length} bytes) — 쓰기 스킵. 수동 확인 필요: ${CODEX_CONFIG_PATH}\n`,
|
|
615
|
+
);
|
|
616
|
+
return { ok: false, changed: 0, message: "config too small, skipped" };
|
|
617
|
+
}
|
|
618
|
+
|
|
567
619
|
let updated = original;
|
|
568
620
|
let changed = 0;
|
|
569
621
|
|
|
622
|
+
// ── 1. top-level 필수 설정 주입 (없을 때만, 기존 값 보존) ──
|
|
623
|
+
// 파일 상단 [profiles.*] / [mcp_servers.*] 이전 영역만 검사한다.
|
|
624
|
+
// 프로필 섹션 내부의 동명 키(예: model = "gpt-5.3-codex")는 무시한다.
|
|
625
|
+
// 이미 존재하는 키는 절대 덮어쓰지 않는다.
|
|
626
|
+
for (const { key, value } of REQUIRED_TOP_LEVEL_SETTINGS) {
|
|
627
|
+
// top-level 영역 = 첫 번째 [profiles.*] / [mcp_servers.*] 헤더 이전
|
|
628
|
+
const firstSectionIdx = updated.search(/^\[(?:profiles|mcp_servers)\./m);
|
|
629
|
+
const topLevelRegion =
|
|
630
|
+
firstSectionIdx === -1 ? updated : updated.slice(0, firstSectionIdx);
|
|
631
|
+
const topLevelKeyRe = new RegExp(`^${key}\\s*=`, "m");
|
|
632
|
+
if (!topLevelKeyRe.test(topLevelRegion)) {
|
|
633
|
+
// firstSectionIdx already computed above for this iteration
|
|
634
|
+
const line = `${key} = ${value}\n`;
|
|
635
|
+
if (firstSectionIdx === -1) {
|
|
636
|
+
// 섹션이 없으면 파일 맨 앞에 추가
|
|
637
|
+
updated = line + updated;
|
|
638
|
+
} else {
|
|
639
|
+
updated =
|
|
640
|
+
updated.slice(0, firstSectionIdx) +
|
|
641
|
+
line +
|
|
642
|
+
updated.slice(firstSectionIdx);
|
|
643
|
+
}
|
|
644
|
+
changed++;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// ── 2. 필수 프로필 보장 ──
|
|
570
649
|
for (const profile of REQUIRED_CODEX_PROFILES) {
|
|
571
650
|
const desired = `[profiles.${profile.name}]\n${profile.lines.join("\n")}\n`;
|
|
572
651
|
|
|
@@ -898,6 +977,7 @@ export {
|
|
|
898
977
|
LEGACY_CODEX_MODELS,
|
|
899
978
|
PLUGIN_ROOT,
|
|
900
979
|
REQUIRED_CODEX_PROFILES,
|
|
980
|
+
REQUIRED_TOP_LEVEL_SETTINGS,
|
|
901
981
|
readMarker,
|
|
902
982
|
replaceProfileSection,
|
|
903
983
|
SETUP_MARKER_PATH,
|
package/scripts/tfx-route.sh
CHANGED
|
@@ -1348,6 +1348,32 @@ heartbeat_monitor() {
|
|
|
1348
1348
|
echo "[tfx-heartbeat] pid=$pid elapsed=${elapsed}s output=${current_size}B status=draining(${post_exit_checks}/${max_post_exit_checks})" >&2
|
|
1349
1349
|
fi
|
|
1350
1350
|
elif [[ "$stall_count" -ge "$stall_threshold" ]]; then
|
|
1351
|
+
# STALL kill (#144/#66 regression guard): stall=threshold+grace 이상 지속 시 SIGTERM→SIGKILL.
|
|
1352
|
+
# 기본 활성화. TFX_STALL_KILL=0 으로 opt-out. grace=30s (기본) 은 SSE/MCP 정상 handshake 여유.
|
|
1353
|
+
local kill_on_stall="${TFX_STALL_KILL:-1}"
|
|
1354
|
+
local kill_grace="${TFX_STALL_KILL_GRACE:-30}"
|
|
1355
|
+
if [[ "$kill_on_stall" -eq 1 && "$stall_count" -ge $((stall_threshold + kill_grace)) ]]; then
|
|
1356
|
+
echo "[tfx-heartbeat] pid=$pid elapsed=${elapsed}s output=${current_size}B status=STALL_KILL stall=${stall_count}s — SIGTERM" >&2
|
|
1357
|
+
kill -TERM "$pid" 2>/dev/null || true
|
|
1358
|
+
local _grace_waited=0
|
|
1359
|
+
while kill -0 "$pid" 2>/dev/null && [[ "$_grace_waited" -lt 5 ]]; do
|
|
1360
|
+
sleep 1
|
|
1361
|
+
_grace_waited=$((_grace_waited + 1))
|
|
1362
|
+
done
|
|
1363
|
+
if kill -0 "$pid" 2>/dev/null; then
|
|
1364
|
+
# Windows/MSYS: POSIX SIGKILL 이 Win32 자식 트리까지 닿지 않는다.
|
|
1365
|
+
# cleanup_workers 와 동일하게 taskkill /T /F 로 트리 종료.
|
|
1366
|
+
case "$(uname -s)" in
|
|
1367
|
+
MINGW*|MSYS*)
|
|
1368
|
+
echo "[tfx-heartbeat] pid=$pid SIGTERM 무시 — taskkill /T /F" >&2
|
|
1369
|
+
MSYS_NO_PATHCONV=1 cmd.exe //c "taskkill /T /F /PID $pid" 2>/dev/null || true ;;
|
|
1370
|
+
*)
|
|
1371
|
+
echo "[tfx-heartbeat] pid=$pid SIGTERM 무시 — SIGKILL 강제" >&2
|
|
1372
|
+
kill -KILL "$pid" 2>/dev/null || true ;;
|
|
1373
|
+
esac
|
|
1374
|
+
fi
|
|
1375
|
+
break
|
|
1376
|
+
fi
|
|
1351
1377
|
echo "[tfx-heartbeat] pid=$pid elapsed=${elapsed}s output=${current_size}B status=STALL stall=${stall_count}s" >&2
|
|
1352
1378
|
else
|
|
1353
1379
|
echo "[tfx-heartbeat] pid=$pid elapsed=${elapsed}s output=${current_size}B status=quiet stall=${stall_count}s" >&2
|
|
@@ -1445,15 +1471,63 @@ _codex_config_swap() {
|
|
|
1445
1471
|
return 0
|
|
1446
1472
|
fi
|
|
1447
1473
|
|
|
1448
|
-
#
|
|
1449
|
-
|
|
1450
|
-
|
|
1474
|
+
# Pre-validation: config.toml이 500 bytes 미만이면 이미 손상된 상태일 수 있음 — 스킵
|
|
1475
|
+
local config_size
|
|
1476
|
+
config_size=$(wc -c < "$config" 2>/dev/null | tr -d ' ') || config_size=0
|
|
1477
|
+
if [[ "$config_size" -lt 500 ]]; then
|
|
1478
|
+
echo "[tfx-route] 경고: config.toml 크기 ${config_size} bytes — 손상 의심, swap 스킵 (수동 확인 필요)" >&2
|
|
1451
1479
|
return 0
|
|
1452
1480
|
fi
|
|
1481
|
+
|
|
1482
|
+
# 백업 생성 (이미 있으면 다른 워커가 swap 중 — 단, owner-dead + 백업 안전 복원 시 이어받기)
|
|
1483
|
+
if [[ -f "$backup" ]]; then
|
|
1484
|
+
# Owner PID marker (P1 fix): mtime 만으로 stale 을 판정하면 장시간 정상 실행 워커도 오탐.
|
|
1485
|
+
# $backup.owner 에 생성 워커 PID 기록 → kill -0 로 alive 확인. PID 파일 없거나 죽었으면 stale.
|
|
1486
|
+
# mtime 은 신뢰성 낮아 soft 보조 지표로만 사용 (owner 파일 유실 대비 fallback).
|
|
1487
|
+
local owner_file="${backup}.owner"
|
|
1488
|
+
local owner_alive=false
|
|
1489
|
+
local owner_pid=""
|
|
1490
|
+
if [[ -f "$owner_file" ]]; then
|
|
1491
|
+
owner_pid=$(cat "$owner_file" 2>/dev/null | tr -d '[:space:]')
|
|
1492
|
+
if [[ -n "$owner_pid" ]] && kill -0 "$owner_pid" 2>/dev/null; then
|
|
1493
|
+
owner_alive=true
|
|
1494
|
+
fi
|
|
1495
|
+
fi
|
|
1496
|
+
|
|
1497
|
+
if [[ "$owner_alive" == "true" ]]; then
|
|
1498
|
+
echo "[tfx-route] config.toml swap 스킵: 소유 워커 살아있음 (pid=$owner_pid, $backup)" >&2
|
|
1499
|
+
return 0
|
|
1500
|
+
fi
|
|
1501
|
+
|
|
1502
|
+
# Owner dead or unknown — stale 후보. 다만 backup-loss 방지를 위해 원본 복원 먼저.
|
|
1503
|
+
# P2 fix: `rm -f $backup` 후 현재 config 를 새 backup 으로 cp 하면, 이전 워커가 이미
|
|
1504
|
+
# filter 한 상태에서 crash 했을 때 원본이 영구 소실. 여기서 먼저 restore 를 시도해
|
|
1505
|
+
# backup 이 원본을 담고 있는 한 그것을 살린다.
|
|
1506
|
+
local backup_restore_guard_size
|
|
1507
|
+
backup_restore_guard_size=$(wc -c < "$backup" 2>/dev/null | tr -d ' ') || backup_restore_guard_size=0
|
|
1508
|
+
if [[ "$backup_restore_guard_size" -lt 500 ]]; then
|
|
1509
|
+
# 작은 backup 은 이미 손상된 state. 현재 config 도 필터된 상태일 수 있으므로
|
|
1510
|
+
# 추가 swap 은 상황을 악화시킬 위험. 전체 스킵하고 수동 확인 유도.
|
|
1511
|
+
echo "[tfx-route] stale backup 작음 (size=${backup_restore_guard_size}B, pid=${owner_pid:-?} dead) — swap 스킵, 수동 확인: $backup" >&2
|
|
1512
|
+
return 0
|
|
1513
|
+
fi
|
|
1514
|
+
local stale_tmp="${config}.stale-restore.$$"
|
|
1515
|
+
if cp "$backup" "$stale_tmp" && mv "$stale_tmp" "$config"; then
|
|
1516
|
+
echo "[tfx-route] stale backup 감지 (pid=${owner_pid:-?} dead) — 원본 복원 후 swap 재진행" >&2
|
|
1517
|
+
else
|
|
1518
|
+
echo "[tfx-route] 경고: stale backup 복원 실패, swap 스킵 (수동 확인: $backup)" >&2
|
|
1519
|
+
rm -f "$stale_tmp" 2>/dev/null
|
|
1520
|
+
return 0
|
|
1521
|
+
fi
|
|
1522
|
+
rm -f "$backup" "$owner_file" 2>/dev/null || true
|
|
1523
|
+
fi
|
|
1453
1524
|
cp "$config" "$backup"
|
|
1525
|
+
# Owner marker: 이 워커가 backup 소유자임을 기록. 다음 워커의 stale detection 기준.
|
|
1526
|
+
echo "$$" > "${backup}.owner" 2>/dev/null || true
|
|
1454
1527
|
|
|
1455
1528
|
# awk로 필터링: 비허용 MCP 서버 섹션 제거, 나머지 그대로 유지.
|
|
1456
1529
|
# keep="" 은 진입 가드에서 return 됐지만 defense-in-depth 유지.
|
|
1530
|
+
local tmp_filtered="${config}.filter.$$"
|
|
1457
1531
|
awk -v keep="$allowed_pat" '
|
|
1458
1532
|
BEGIN { skip=0 }
|
|
1459
1533
|
/^\[mcp_servers\./ {
|
|
@@ -1464,7 +1538,26 @@ _codex_config_swap() {
|
|
|
1464
1538
|
}
|
|
1465
1539
|
/^\[/ && !/^\[mcp_servers\./ { skip=0 }
|
|
1466
1540
|
!skip { print }
|
|
1467
|
-
' "$backup" > "$
|
|
1541
|
+
' "$backup" > "$tmp_filtered"
|
|
1542
|
+
|
|
1543
|
+
# Output sanity check: 필터 결과가 비었거나 백업의 30% 미만이면 적용 거부
|
|
1544
|
+
local filtered_size backup_size threshold
|
|
1545
|
+
filtered_size=$(wc -c < "$tmp_filtered" 2>/dev/null | tr -d ' ') || filtered_size=0
|
|
1546
|
+
backup_size=$(wc -c < "$backup" 2>/dev/null | tr -d ' ') || backup_size=1
|
|
1547
|
+
threshold=$(( backup_size * 30 / 100 ))
|
|
1548
|
+
if [[ "$filtered_size" -eq 0 || "$filtered_size" -lt "$threshold" ]]; then
|
|
1549
|
+
echo "[tfx-route] 경고: 필터 결과 크기 ${filtered_size} bytes (백업 ${backup_size} bytes의 30% 미만) — 적용 거부, 백업에서 복원" >&2
|
|
1550
|
+
rm -f "$tmp_filtered" 2>/dev/null
|
|
1551
|
+
rm -f "$backup" 2>/dev/null
|
|
1552
|
+
return 1
|
|
1553
|
+
fi
|
|
1554
|
+
|
|
1555
|
+
# 검증 통과 — atomic rename으로 적용
|
|
1556
|
+
if ! mv "$tmp_filtered" "$config"; then
|
|
1557
|
+
echo "[tfx-route] 경고: 필터 결과 적용 실패 (atomic rename), 백업 보존: $backup" >&2
|
|
1558
|
+
rm -f "$tmp_filtered" 2>/dev/null
|
|
1559
|
+
return 1
|
|
1560
|
+
fi
|
|
1468
1561
|
|
|
1469
1562
|
local kept
|
|
1470
1563
|
kept=$(echo "$allowed_pat" | tr '|' '\n' | wc -l | tr -d ' ')
|
|
@@ -1475,6 +1568,15 @@ _codex_config_swap() {
|
|
|
1475
1568
|
# `cat > $config` 는 cat 실행 전에 dest 가 truncate 되어 mid-stream 실패 시
|
|
1476
1569
|
# 빈/부분 파일이 남는다. 같은 디렉토리 내 mv 는 POSIX 상 atomic 이므로
|
|
1477
1570
|
# 실패해도 기존 config 와 backup 모두 보존된다.
|
|
1571
|
+
|
|
1572
|
+
# Restore sanity check: 백업 자체가 비었거나 500 bytes 미만이면 복원 중단
|
|
1573
|
+
local backup_restore_size
|
|
1574
|
+
backup_restore_size=$(wc -c < "$backup" 2>/dev/null | tr -d ' ') || backup_restore_size=0
|
|
1575
|
+
if [[ "$backup_restore_size" -lt 500 ]]; then
|
|
1576
|
+
echo "[tfx-route] 경고: backup 크기 ${backup_restore_size} bytes — 손상 의심, 복원 중단. 수동 확인 필요: $backup" >&2
|
|
1577
|
+
return 1
|
|
1578
|
+
fi
|
|
1579
|
+
|
|
1478
1580
|
local tmp="${config}.restore.$$"
|
|
1479
1581
|
if ! cp "$backup" "$tmp"; then
|
|
1480
1582
|
echo "[tfx-route] 경고: config.toml 복원 실패 (temp copy). backup 보존: $backup" >&2
|
|
@@ -1489,6 +1591,7 @@ _codex_config_swap() {
|
|
|
1489
1591
|
if ! rm -f "$backup"; then
|
|
1490
1592
|
echo "[tfx-route] 경고: backup 삭제 실패: $backup (수동 정리 필요)" >&2
|
|
1491
1593
|
fi
|
|
1594
|
+
rm -f "${backup}.owner" 2>/dev/null || true
|
|
1492
1595
|
echo "[tfx-route] config.toml 복원 완료" >&2
|
|
1493
1596
|
fi
|
|
1494
1597
|
}
|