@triflux/core 10.0.3 → 10.25.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/hooks/agent-route-guard.mjs +14 -10
- package/hooks/cross-review-tracker.mjs +21 -8
- package/hooks/error-context.mjs +47 -7
- package/hooks/hook-adaptive-collector.mjs +18 -16
- package/hooks/hook-manager.mjs +93 -32
- package/hooks/hook-orchestrator.mjs +279 -33
- package/hooks/hook-registry.json +104 -7
- package/hooks/hooks.json +49 -1
- package/hooks/keyword-rules.json +183 -23
- package/hooks/lib/resolve-root.mjs +1 -1
- package/hooks/mcp-config-watcher.mjs +6 -2
- package/hooks/permission-safe-allow.mjs +233 -0
- package/hooks/pipeline-stop.mjs +369 -22
- package/hooks/post-tool-tips.mjs +306 -0
- package/hooks/pre-compact-snapshot.mjs +181 -0
- package/hooks/safety-guard.mjs +308 -18
- package/hooks/session-end-cleanup.mjs +345 -0
- package/hooks/session-start-fast.mjs +214 -0
- package/hooks/subagent-tracker.mjs +267 -0
- package/hooks/subagent-verifier.mjs +9 -4
- package/hub/account-broker.mjs +1036 -66
- package/hub/adaptive-diagnostic.mjs +75 -48
- package/hub/adaptive-inject.mjs +96 -58
- package/hub/adaptive-memory.mjs +156 -42
- package/hub/adaptive.mjs +60 -31
- package/hub/assign-callbacks.mjs +163 -126
- package/hub/bridge.mjs +832 -291
- package/hub/cli-adapter-base.mjs +321 -97
- package/hub/codex-adapter.mjs +179 -93
- package/hub/codex-compat.mjs +4 -3
- package/hub/codex-preflight.mjs +113 -37
- package/hub/delegator/contracts.mjs +19 -23
- package/hub/delegator/index.mjs +14 -14
- package/hub/delegator/service.mjs +88 -64
- package/hub/delegator/tool-definitions.mjs +35 -35
- package/hub/dynamic-routing-engine.mjs +511 -0
- package/hub/fullcycle.mjs +34 -17
- package/hub/gemini-adapter.mjs +77 -95
- package/hub/hitl.mjs +202 -143
- package/hub/intent.mjs +171 -39
- package/hub/lib/bash-path.mjs +73 -0
- package/hub/lib/cache-guard.mjs +43 -17
- package/hub/lib/env-detect.mjs +145 -0
- package/hub/lib/hosts-compat.mjs +319 -0
- package/hub/lib/mcp-response-cache.mjs +239 -0
- package/hub/lib/memory-store.mjs +303 -107
- package/hub/lib/path-utils.mjs +165 -0
- package/hub/lib/phase-manager.mjs +493 -0
- package/hub/lib/process-utils.mjs +797 -104
- package/hub/lib/prompt-tmp.mjs +33 -0
- package/hub/lib/risk-tier.mjs +53 -0
- package/hub/lib/spawn-trace.mjs +561 -0
- package/hub/lib/ssh-command.mjs +44 -24
- package/hub/lib/ssh-retry.mjs +5 -1
- package/hub/lib/staged-file-detect.mjs +43 -0
- package/hub/lib/state-snapshot.mjs +293 -0
- package/hub/lib/tfx-route-args.mjs +309 -0
- package/hub/lib/timeline-adapter.mjs +190 -0
- package/hub/lib/trace-recorder.mjs +153 -0
- package/hub/lib/uuidv7.mjs +4 -3
- package/hub/memory-doctor.mjs +266 -106
- package/hub/middleware/quota-middleware.mjs +70 -0
- package/hub/middleware/request-logger.mjs +61 -34
- package/hub/paths.mjs +9 -9
- package/hub/pipeline/gates/confidence.mjs +34 -15
- package/hub/pipeline/gates/consensus.mjs +27 -15
- package/hub/pipeline/gates/index.mjs +7 -3
- package/hub/pipeline/gates/selfcheck.mjs +57 -19
- package/hub/pipeline/index.mjs +77 -42
- package/hub/pipeline/state.mjs +10 -10
- package/hub/pipeline/transitions.mjs +40 -23
- package/hub/platform.mjs +65 -48
- package/hub/quality/deslop.mjs +70 -49
- package/hub/reflexion.mjs +116 -58
- package/hub/research.mjs +34 -25
- package/hub/router.mjs +941 -791
- package/hub/routing/complexity.mjs +132 -29
- package/hub/routing/index.mjs +17 -12
- package/hub/routing/q-learning.mjs +76 -28
- package/hub/routing-snapshot.mjs +261 -0
- package/hub/session-fingerprint.mjs +129 -63
- package/hub/state.mjs +86 -44
- package/hub/team/retry-state-machine.mjs +267 -0
- package/hub/token-mode.mjs +76 -72
- package/hub/workers/worker-utils.mjs +22 -16
- package/hud/colors.mjs +27 -9
- package/hud/constants.mjs +184 -27
- package/hud/context-monitor.mjs +133 -44
- package/hud/hud-qos-status.mjs +153 -50
- package/hud/mission-board.mjs +56 -0
- package/hud/providers/claude.mjs +398 -114
- package/hud/providers/codex.mjs +96 -21
- package/hud/providers/gemini.mjs +184 -51
- package/hud/renderers.mjs +413 -123
- package/hud/terminal.mjs +101 -31
- package/hud/utils.mjs +81 -39
- package/mesh/index.mjs +69 -0
- package/mesh/mesh-budget.mjs +137 -0
- package/mesh/mesh-heartbeat.mjs +100 -0
- package/mesh/mesh-protocol.mjs +96 -0
- package/mesh/mesh-queue.mjs +163 -0
- package/mesh/mesh-registry.mjs +78 -0
- package/mesh/mesh-router.mjs +77 -0
- package/package.json +5 -1
- package/scripts/lib/agent-json.mjs +27 -0
- package/scripts/lib/async.mjs +174 -0
- package/scripts/lib/claudemd-scanner.mjs +6 -1
- package/scripts/lib/cli-agy.mjs +62 -0
- package/scripts/lib/cli-claude.mjs +78 -0
- package/scripts/lib/cli-codex.mjs +199 -0
- package/scripts/lib/cli-gemini.mjs +67 -0
- package/scripts/lib/codex-recovery.sh +50 -0
- package/scripts/lib/context.mjs +3 -3
- package/scripts/lib/cross-review-utils.mjs +13 -5
- package/scripts/lib/doctor-env-checks.mjs +121 -0
- package/scripts/lib/dynamic-route-cli.mjs +107 -0
- package/scripts/lib/env-probe.mjs +58 -30
- package/scripts/lib/env.mjs +14 -0
- package/scripts/lib/gemini-profiles.mjs +44 -10
- package/scripts/lib/handoff.mjs +33 -17
- package/scripts/lib/hook-utils.mjs +8 -6
- package/scripts/lib/hub.mjs +48 -0
- package/scripts/lib/keyword-rules.mjs +48 -19
- package/scripts/lib/logger.mjs +24 -24
- package/scripts/lib/mcp-filter.mjs +393 -249
- package/scripts/lib/mcp-gateway-health-check.mjs +159 -0
- package/scripts/lib/mcp-gateway-servers.mjs +70 -0
- package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -0
- package/scripts/lib/mcp-guard-engine.mjs +1282 -116
- package/scripts/lib/mcp-health.mjs +773 -0
- package/scripts/lib/mcp-manifest.mjs +24 -14
- package/scripts/lib/mcp-server-catalog.mjs +355 -118
- package/scripts/lib/pid.mjs +63 -0
- package/scripts/lib/process-utils.mjs +26 -0
- package/scripts/lib/psmux-info.mjs +72 -12
- package/scripts/lib/quota.mjs +47 -0
- package/scripts/lib/remote-spawn-transfer.mjs +44 -14
- package/scripts/lib/skill-state.mjs +220 -0
- package/scripts/lib/skill-template.mjs +36 -11
- package/scripts/lib/team.mjs +75 -0
- package/scripts/lib/timeout.mjs +66 -0
- package/scripts/lib/tmp.mjs +26 -0
- package/scripts/lib/toml.mjs +113 -0
package/hub/state.mjs
CHANGED
|
@@ -1,20 +1,34 @@
|
|
|
1
|
-
import { execSync } from
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
openSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
statSync,
|
|
10
|
+
unlinkSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
const PROJECT_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
18
|
+
const PID_FILE_NAME = "hub.pid";
|
|
19
|
+
const LEGACY_STATE_FILE_NAME = "hub-state.json";
|
|
20
|
+
const LOCK_FILE_NAME = "hub-start.lock";
|
|
11
21
|
|
|
12
22
|
let heldLockPath = null;
|
|
13
23
|
let heldLockFd = null;
|
|
14
24
|
let cachedVersionHash = null;
|
|
15
25
|
|
|
16
26
|
function getStateDir(options = {}) {
|
|
17
|
-
return
|
|
27
|
+
return (
|
|
28
|
+
options.stateDir ||
|
|
29
|
+
process.env.TFX_HUB_STATE_DIR?.trim() ||
|
|
30
|
+
join(homedir(), ".claude", "cache", "tfx-hub")
|
|
31
|
+
);
|
|
18
32
|
}
|
|
19
33
|
|
|
20
34
|
function getStatePath(options = {}) {
|
|
@@ -55,25 +69,32 @@ function safeReplaceFile(tempPath, targetPath) {
|
|
|
55
69
|
try {
|
|
56
70
|
renameSync(tempPath, targetPath);
|
|
57
71
|
} catch (error) {
|
|
58
|
-
if (![
|
|
59
|
-
try {
|
|
72
|
+
if (!["EEXIST", "EPERM", "EACCES"].includes(error?.code)) {
|
|
73
|
+
try {
|
|
74
|
+
unlinkSync(tempPath);
|
|
75
|
+
} catch {}
|
|
60
76
|
throw error;
|
|
61
77
|
}
|
|
62
|
-
try {
|
|
78
|
+
try {
|
|
79
|
+
unlinkSync(targetPath);
|
|
80
|
+
} catch {}
|
|
63
81
|
renameSync(tempPath, targetPath);
|
|
64
82
|
}
|
|
65
83
|
}
|
|
66
84
|
|
|
67
85
|
function writeJsonFile(targetPath, payload) {
|
|
68
86
|
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
|
|
69
|
-
writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, {
|
|
87
|
+
writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, {
|
|
88
|
+
encoding: "utf8",
|
|
89
|
+
mode: 0o600,
|
|
90
|
+
});
|
|
70
91
|
safeReplaceFile(tempPath, targetPath);
|
|
71
92
|
}
|
|
72
93
|
|
|
73
94
|
function readJsonFile(filePath) {
|
|
74
95
|
try {
|
|
75
96
|
if (!existsSync(filePath)) return null;
|
|
76
|
-
return parseJson(readFileSync(filePath,
|
|
97
|
+
return parseJson(readFileSync(filePath, "utf8"), null);
|
|
77
98
|
} catch {
|
|
78
99
|
return null;
|
|
79
100
|
}
|
|
@@ -82,7 +103,7 @@ function readJsonFile(filePath) {
|
|
|
82
103
|
/**
|
|
83
104
|
* 허브의 현재 상태(PID, 포트, 버전 등)를 파일에 기록합니다.
|
|
84
105
|
* 원자적(atomic) 쓰기를 위해 임시 파일을 생성한 후 교체하는 방식을 사용합니다.
|
|
85
|
-
*
|
|
106
|
+
*
|
|
86
107
|
* @param {object} payload - 상태 데이터
|
|
87
108
|
* @param {number} payload.pid - 허브 프로세스 ID
|
|
88
109
|
* @param {number} payload.port - 허브 서버 포트
|
|
@@ -93,31 +114,39 @@ function readJsonFile(filePath) {
|
|
|
93
114
|
* @param {string} [options.stateDir] - 상태 파일이 저장될 디렉토리
|
|
94
115
|
* @returns {object} 기록된 상태 데이터
|
|
95
116
|
*/
|
|
96
|
-
export function writeState(
|
|
117
|
+
export function writeState(
|
|
118
|
+
{ pid, port, version, sessionId, startedAt, ...rest },
|
|
119
|
+
options = {},
|
|
120
|
+
) {
|
|
97
121
|
const stateDir = getStateDir(options);
|
|
98
122
|
const statePath = getStatePath(options);
|
|
99
123
|
const payload = { pid, port, version, sessionId, startedAt, ...rest };
|
|
100
124
|
|
|
101
125
|
mkdirSync(stateDir, { recursive: true });
|
|
102
126
|
writeJsonFile(statePath, payload);
|
|
103
|
-
try {
|
|
127
|
+
try {
|
|
128
|
+
unlinkSync(getLegacyStatePath(options));
|
|
129
|
+
} catch {}
|
|
104
130
|
return payload;
|
|
105
131
|
}
|
|
106
132
|
|
|
107
133
|
/**
|
|
108
134
|
* 파일로부터 허브의 현재 상태를 읽어옵니다.
|
|
109
|
-
*
|
|
135
|
+
*
|
|
110
136
|
* @param {object} [options] - 옵션
|
|
111
137
|
* @param {string} [options.stateDir] - 상태 파일이 저장된 디렉토리
|
|
112
138
|
* @returns {object|null} 읽어온 상태 데이터 또는 실패 시 null
|
|
113
139
|
*/
|
|
114
140
|
export function readState(options = {}) {
|
|
115
|
-
return
|
|
141
|
+
return (
|
|
142
|
+
readJsonFile(getStatePath(options)) ??
|
|
143
|
+
readJsonFile(getLegacyStatePath(options))
|
|
144
|
+
);
|
|
116
145
|
}
|
|
117
146
|
|
|
118
147
|
/**
|
|
119
148
|
* 지정된 포트에서 실행 중인 허브 서버의 헬스 체크를 수행합니다.
|
|
120
|
-
*
|
|
149
|
+
*
|
|
121
150
|
* @param {number|string} port - 서버 포트
|
|
122
151
|
* @param {object} [options] - 옵션
|
|
123
152
|
* @param {number} [options.timeoutMs=1000] - 요청 타임아웃
|
|
@@ -133,7 +162,7 @@ export async function isServerHealthy(port, options = {}) {
|
|
|
133
162
|
|
|
134
163
|
try {
|
|
135
164
|
const response = await fetch(`${baseUrl}/health`, {
|
|
136
|
-
method:
|
|
165
|
+
method: "GET",
|
|
137
166
|
signal: AbortSignal.timeout(timeoutMs),
|
|
138
167
|
});
|
|
139
168
|
if (!response.ok) return false;
|
|
@@ -147,7 +176,7 @@ export async function isServerHealthy(port, options = {}) {
|
|
|
147
176
|
/**
|
|
148
177
|
* 현재 프로젝트의 버전 해시를 생성합니다.
|
|
149
178
|
* package.json의 버전과 Git commit SHA를 조합합니다.
|
|
150
|
-
*
|
|
179
|
+
*
|
|
151
180
|
* @param {object} [options] - 옵션
|
|
152
181
|
* @param {boolean} [options.force=false] - 캐시를 무시하고 새로 생성할지 여부
|
|
153
182
|
* @returns {string} 버전 해시 문자열
|
|
@@ -155,21 +184,21 @@ export async function isServerHealthy(port, options = {}) {
|
|
|
155
184
|
export function getVersionHash(options = {}) {
|
|
156
185
|
if (cachedVersionHash && !options.force) return cachedVersionHash;
|
|
157
186
|
|
|
158
|
-
const packageJsonPath = join(PROJECT_ROOT,
|
|
159
|
-
const pkg = parseJson(readFileSync(packageJsonPath,
|
|
160
|
-
const version = String(pkg?.version ||
|
|
187
|
+
const packageJsonPath = join(PROJECT_ROOT, "package.json");
|
|
188
|
+
const pkg = parseJson(readFileSync(packageJsonPath, "utf8"), {});
|
|
189
|
+
const version = String(pkg?.version || "0.0.0").trim();
|
|
161
190
|
|
|
162
|
-
let sha = String(process.env.TFX_HUB_GIT_SHA ||
|
|
191
|
+
let sha = String(process.env.TFX_HUB_GIT_SHA || "").trim();
|
|
163
192
|
if (!sha) {
|
|
164
193
|
try {
|
|
165
|
-
sha = execSync(
|
|
194
|
+
sha = execSync("git rev-parse --short HEAD", {
|
|
166
195
|
cwd: PROJECT_ROOT,
|
|
167
|
-
encoding:
|
|
168
|
-
stdio: [
|
|
196
|
+
encoding: "utf8",
|
|
197
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
169
198
|
windowsHide: true,
|
|
170
199
|
}).trim();
|
|
171
200
|
} catch {
|
|
172
|
-
sha =
|
|
201
|
+
sha = "";
|
|
173
202
|
}
|
|
174
203
|
}
|
|
175
204
|
|
|
@@ -180,7 +209,7 @@ export function getVersionHash(options = {}) {
|
|
|
180
209
|
/**
|
|
181
210
|
* 허브 시작 시 중복 실행을 방지하기 위한 잠금(lock)을 획득합니다.
|
|
182
211
|
* 이미 실행 중인 다른 프로세스가 있는지 확인하고 유효한 잠금을 획득할 때까지 재시도합니다.
|
|
183
|
-
*
|
|
212
|
+
*
|
|
184
213
|
* @param {object} [options] - 옵션
|
|
185
214
|
* @param {number} [options.timeoutMs=3000] - 최대 대기 시간
|
|
186
215
|
* @param {number} [options.pollMs=50] - 재시도 간격
|
|
@@ -202,27 +231,38 @@ export async function acquireLock(options = {}) {
|
|
|
202
231
|
|
|
203
232
|
while (Date.now() <= deadline) {
|
|
204
233
|
try {
|
|
205
|
-
const fd = openSync(lockPath,
|
|
206
|
-
writeFileSync(
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
234
|
+
const fd = openSync(lockPath, "wx", 0o600);
|
|
235
|
+
writeFileSync(
|
|
236
|
+
fd,
|
|
237
|
+
`${JSON.stringify(
|
|
238
|
+
{
|
|
239
|
+
pid: process.pid,
|
|
240
|
+
createdAt: new Date().toISOString(),
|
|
241
|
+
},
|
|
242
|
+
null,
|
|
243
|
+
2,
|
|
244
|
+
)}\n`,
|
|
245
|
+
"utf8",
|
|
246
|
+
);
|
|
210
247
|
heldLockFd = fd;
|
|
211
248
|
heldLockPath = lockPath;
|
|
212
249
|
return { path: lockPath };
|
|
213
250
|
} catch (error) {
|
|
214
|
-
if (error?.code !==
|
|
251
|
+
if (error?.code !== "EEXIST") {
|
|
215
252
|
throw error;
|
|
216
253
|
}
|
|
217
254
|
|
|
218
255
|
try {
|
|
219
|
-
const raw = readFileSync(lockPath,
|
|
256
|
+
const raw = readFileSync(lockPath, "utf8");
|
|
220
257
|
const data = parseJson(raw, {});
|
|
221
258
|
const stats = statSync(lockPath);
|
|
259
|
+
const STALE_LOCK_AGE_MS = 60_000;
|
|
222
260
|
const staleByPid = !isPidAlive(data?.pid);
|
|
223
|
-
const staleByAge = Date.now() - stats.mtimeMs >
|
|
261
|
+
const staleByAge = Date.now() - stats.mtimeMs > STALE_LOCK_AGE_MS;
|
|
224
262
|
if (staleByPid || staleByAge) {
|
|
225
|
-
try {
|
|
263
|
+
try {
|
|
264
|
+
unlinkSync(lockPath);
|
|
265
|
+
} catch {}
|
|
226
266
|
continue;
|
|
227
267
|
}
|
|
228
268
|
} catch {}
|
|
@@ -236,7 +276,7 @@ export async function acquireLock(options = {}) {
|
|
|
236
276
|
|
|
237
277
|
/**
|
|
238
278
|
* 획득했던 잠금을 해제합니다. 잠금 파일을 삭제하고 관련 리소스를 정리합니다.
|
|
239
|
-
*
|
|
279
|
+
*
|
|
240
280
|
* @param {object} [options] - 옵션
|
|
241
281
|
* @param {string} [options.lockPath] - 명시적인 잠금 파일 경로
|
|
242
282
|
*/
|
|
@@ -244,7 +284,9 @@ export function releaseLock(options = {}) {
|
|
|
244
284
|
const lockPath = options.lockPath || heldLockPath || getLockPath(options);
|
|
245
285
|
|
|
246
286
|
if (heldLockFd !== null) {
|
|
247
|
-
try {
|
|
287
|
+
try {
|
|
288
|
+
closeSync(heldLockFd);
|
|
289
|
+
} catch {}
|
|
248
290
|
heldLockFd = null;
|
|
249
291
|
}
|
|
250
292
|
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// hub/team/retry-state-machine.mjs
|
|
2
|
+
// Phase 3 Step A — true ralph / auto-escalate retry state machine.
|
|
3
|
+
// 설계 문서: .triflux/plans/phase3-lead-codex-ralph-escalate.md
|
|
4
|
+
//
|
|
5
|
+
// Modes:
|
|
6
|
+
// bounded — --retry 1 (기본): maxIterations 도달 시 BUDGET_EXCEEDED.
|
|
7
|
+
// ralph — --retry ralph: maxIterations=0 이면 unlimited. stuck detector 만 종료.
|
|
8
|
+
// auto-escalate — --retry auto-escalate: BUDGET_EXCEEDED 시 다음 CLI 로 전이.
|
|
9
|
+
//
|
|
10
|
+
// 상태 전이:
|
|
11
|
+
// PLANNING → EXECUTING → VERIFYING.success → DONE
|
|
12
|
+
// → VERIFYING.fail → DIAGNOSING → EXECUTING …
|
|
13
|
+
// → stuckCounter≥3 → STUCK
|
|
14
|
+
// → iter≥max → BUDGET_EXCEEDED (escalate 모드는 다음 CLI)
|
|
15
|
+
|
|
16
|
+
import { EventEmitter } from "node:events";
|
|
17
|
+
import {
|
|
18
|
+
appendFileSync,
|
|
19
|
+
existsSync,
|
|
20
|
+
mkdirSync,
|
|
21
|
+
readFileSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
} from "node:fs";
|
|
24
|
+
import { dirname } from "node:path";
|
|
25
|
+
|
|
26
|
+
export const STATES = Object.freeze({
|
|
27
|
+
PLANNING: "PLANNING",
|
|
28
|
+
EXECUTING: "EXECUTING",
|
|
29
|
+
DIAGNOSING: "DIAGNOSING",
|
|
30
|
+
STUCK: "STUCK",
|
|
31
|
+
BUDGET_EXCEEDED: "BUDGET_EXCEEDED",
|
|
32
|
+
DONE: "DONE",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export const MODES = Object.freeze({
|
|
36
|
+
BOUNDED: "bounded",
|
|
37
|
+
RALPH: "ralph",
|
|
38
|
+
ESCALATE: "auto-escalate",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Escalation chain (2026-04-25 정책):
|
|
42
|
+
// 1. gpt-5.4-mini — 비용 최저, fast tier, 단순 task 대부분 해결
|
|
43
|
+
// 2. gpt-5.3-codex — 가성비 중간, code specialized (Plus/free 모두 OK, fast 미지원)
|
|
44
|
+
// 3. gpt-5.5 — top reasoning + 코드 강함, fast tier
|
|
45
|
+
// 4. claude opus-4-7 — 최종 수단
|
|
46
|
+
// sonnet-4-6 단계는 제거: gpt-5.5 가 코드/추론/비용 모두 우위 + 5.3-codex 가성비
|
|
47
|
+
// 단계가 더 적합한 중간 격상.
|
|
48
|
+
const DEFAULT_ESCALATION_CHAIN = Object.freeze([
|
|
49
|
+
Object.freeze({ cli: "codex", model: "gpt-5.4-mini" }),
|
|
50
|
+
Object.freeze({ cli: "codex", model: "gpt-5.3-codex" }),
|
|
51
|
+
Object.freeze({ cli: "codex", model: "gpt-5.5" }),
|
|
52
|
+
Object.freeze({ cli: "claude", model: "opus-4-7" }),
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
const STUCK_THRESHOLD = 3;
|
|
56
|
+
|
|
57
|
+
export function createRetryStateMachine(options = {}) {
|
|
58
|
+
const mode = options.mode || MODES.BOUNDED;
|
|
59
|
+
const cliChain = Array.isArray(options.cliChain)
|
|
60
|
+
? options.cliChain.slice()
|
|
61
|
+
: DEFAULT_ESCALATION_CHAIN.slice();
|
|
62
|
+
const maxIterationsInput =
|
|
63
|
+
typeof options.maxIterations === "number" ? options.maxIterations : null;
|
|
64
|
+
const maxIterations =
|
|
65
|
+
maxIterationsInput !== null
|
|
66
|
+
? maxIterationsInput
|
|
67
|
+
: mode === MODES.BOUNDED
|
|
68
|
+
? 3
|
|
69
|
+
: 0;
|
|
70
|
+
const stateFile = options.stateFile || null;
|
|
71
|
+
const sessionId = options.sessionId || null;
|
|
72
|
+
|
|
73
|
+
const emitter = new EventEmitter();
|
|
74
|
+
const state = {
|
|
75
|
+
current: STATES.PLANNING,
|
|
76
|
+
iterations: 0,
|
|
77
|
+
maxIterations,
|
|
78
|
+
stuckCounter: 0,
|
|
79
|
+
lastFailureReason: null,
|
|
80
|
+
cliIndex: 0,
|
|
81
|
+
cliChain,
|
|
82
|
+
mode,
|
|
83
|
+
sessionId,
|
|
84
|
+
stateFile,
|
|
85
|
+
history: [],
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
function transition(next, meta = {}) {
|
|
89
|
+
const prev = state.current;
|
|
90
|
+
state.current = next;
|
|
91
|
+
const entry = {
|
|
92
|
+
t: Date.now(),
|
|
93
|
+
from: prev,
|
|
94
|
+
to: next,
|
|
95
|
+
iteration: state.iterations,
|
|
96
|
+
cliIndex: state.cliIndex,
|
|
97
|
+
...meta,
|
|
98
|
+
};
|
|
99
|
+
state.history.push(entry);
|
|
100
|
+
if (stateFile) persistTransition(stateFile, entry);
|
|
101
|
+
emitter.emit("transition", entry);
|
|
102
|
+
return entry;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function startIteration() {
|
|
106
|
+
state.iterations += 1;
|
|
107
|
+
return transition(STATES.EXECUTING, { iteration: state.iterations });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function reportVerifySuccess() {
|
|
111
|
+
return transition(STATES.DONE);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function reportVerifyFail(failureReason) {
|
|
115
|
+
const reason = String(failureReason || "unknown");
|
|
116
|
+
if (reason === state.lastFailureReason) {
|
|
117
|
+
state.stuckCounter += 1;
|
|
118
|
+
} else {
|
|
119
|
+
state.stuckCounter = 1;
|
|
120
|
+
state.lastFailureReason = reason;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (state.stuckCounter >= STUCK_THRESHOLD) {
|
|
124
|
+
return transition(STATES.STUCK, {
|
|
125
|
+
reason,
|
|
126
|
+
stuckCounter: state.stuckCounter,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (state.maxIterations > 0 && state.iterations >= state.maxIterations) {
|
|
131
|
+
if (state.mode === MODES.ESCALATE) {
|
|
132
|
+
return escalate();
|
|
133
|
+
}
|
|
134
|
+
return transition(STATES.BUDGET_EXCEEDED, {
|
|
135
|
+
reason,
|
|
136
|
+
iterations: state.iterations,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return transition(STATES.DIAGNOSING, { reason });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function escalate() {
|
|
144
|
+
if (state.cliIndex + 1 >= state.cliChain.length) {
|
|
145
|
+
return transition(STATES.BUDGET_EXCEEDED, {
|
|
146
|
+
reason: "escalation-chain-exhausted",
|
|
147
|
+
chain: state.cliChain,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
state.cliIndex += 1;
|
|
151
|
+
state.iterations = 0;
|
|
152
|
+
state.stuckCounter = 0;
|
|
153
|
+
state.lastFailureReason = null;
|
|
154
|
+
return transition(STATES.EXECUTING, {
|
|
155
|
+
cli: state.cliChain[state.cliIndex],
|
|
156
|
+
escalated: true,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function getCurrent() {
|
|
161
|
+
return {
|
|
162
|
+
current: state.current,
|
|
163
|
+
iterations: state.iterations,
|
|
164
|
+
maxIterations: state.maxIterations,
|
|
165
|
+
stuckCounter: state.stuckCounter,
|
|
166
|
+
lastFailureReason: state.lastFailureReason,
|
|
167
|
+
cliIndex: state.cliIndex,
|
|
168
|
+
cliChain: state.cliChain.slice(),
|
|
169
|
+
mode: state.mode,
|
|
170
|
+
sessionId: state.sessionId,
|
|
171
|
+
history: state.history.slice(),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function on(event, listener) {
|
|
176
|
+
emitter.on(event, listener);
|
|
177
|
+
return () => emitter.off(event, listener);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function serialize() {
|
|
181
|
+
return {
|
|
182
|
+
version: 1,
|
|
183
|
+
current: state.current,
|
|
184
|
+
iterations: state.iterations,
|
|
185
|
+
maxIterations: state.maxIterations,
|
|
186
|
+
stuckCounter: state.stuckCounter,
|
|
187
|
+
lastFailureReason: state.lastFailureReason,
|
|
188
|
+
cliIndex: state.cliIndex,
|
|
189
|
+
cliChain: state.cliChain.slice(),
|
|
190
|
+
mode: state.mode,
|
|
191
|
+
sessionId: state.sessionId,
|
|
192
|
+
history: state.history.slice(),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function applySnapshot(snapshot) {
|
|
197
|
+
if (!snapshot || typeof snapshot !== "object") return;
|
|
198
|
+
state.current = snapshot.current || STATES.PLANNING;
|
|
199
|
+
state.iterations = Number(snapshot.iterations) || 0;
|
|
200
|
+
state.maxIterations =
|
|
201
|
+
snapshot.maxIterations !== undefined
|
|
202
|
+
? Number(snapshot.maxIterations)
|
|
203
|
+
: state.maxIterations;
|
|
204
|
+
state.stuckCounter = Number(snapshot.stuckCounter) || 0;
|
|
205
|
+
state.lastFailureReason = snapshot.lastFailureReason || null;
|
|
206
|
+
state.cliIndex = Number(snapshot.cliIndex) || 0;
|
|
207
|
+
if (Array.isArray(snapshot.cliChain) && snapshot.cliChain.length > 0) {
|
|
208
|
+
state.cliChain = snapshot.cliChain.slice();
|
|
209
|
+
}
|
|
210
|
+
if (snapshot.mode) state.mode = snapshot.mode;
|
|
211
|
+
if (snapshot.sessionId !== undefined) state.sessionId = snapshot.sessionId;
|
|
212
|
+
if (Array.isArray(snapshot.history))
|
|
213
|
+
state.history = snapshot.history.slice();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
STATES,
|
|
218
|
+
MODES,
|
|
219
|
+
getCurrent,
|
|
220
|
+
startIteration,
|
|
221
|
+
reportVerifySuccess,
|
|
222
|
+
reportVerifyFail,
|
|
223
|
+
escalate,
|
|
224
|
+
on,
|
|
225
|
+
serialize,
|
|
226
|
+
applySnapshot,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function persistTransition(stateFile, entry) {
|
|
231
|
+
const dir = dirname(stateFile);
|
|
232
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
233
|
+
appendFileSync(stateFile, `${JSON.stringify(entry)}\n`, "utf8");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function resumeFromStateFile(stateFile) {
|
|
237
|
+
if (!existsSync(stateFile)) return null;
|
|
238
|
+
const raw = readFileSync(stateFile, "utf8").trim();
|
|
239
|
+
if (!raw) return null;
|
|
240
|
+
const lines = raw.split("\n").filter(Boolean);
|
|
241
|
+
if (lines.length === 0) return null;
|
|
242
|
+
return JSON.parse(lines[lines.length - 1]);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Full-snapshot 기반 stateFile I/O — bridge retry-run 에서 multi-process state 복원용.
|
|
246
|
+
// transition event log (resumeFromStateFile) 와 별도 파일로 관리하는 것을 권장.
|
|
247
|
+
export function loadSnapshot(snapshotFile) {
|
|
248
|
+
if (!existsSync(snapshotFile)) return null;
|
|
249
|
+
const raw = readFileSync(snapshotFile, "utf8").trim();
|
|
250
|
+
if (!raw) return null;
|
|
251
|
+
const parsed = JSON.parse(raw);
|
|
252
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
253
|
+
if (parsed.version !== 1) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
`unsupported snapshot version: ${parsed.version} (expected 1)`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
return parsed;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function saveSnapshot(snapshotFile, snapshot) {
|
|
262
|
+
const dir = dirname(snapshotFile);
|
|
263
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
264
|
+
writeFileSync(snapshotFile, JSON.stringify(snapshot), "utf8");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export { DEFAULT_ESCALATION_CHAIN, STUCK_THRESHOLD };
|