@triflux/core 10.1.0 → 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 +20 -8
- package/hooks/hook-adaptive-collector.mjs +18 -16
- package/hooks/hook-manager.mjs +93 -32
- package/hooks/hook-orchestrator.mjs +267 -35
- package/hooks/hook-registry.json +343 -256
- 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 +254 -33
- 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 +831 -290
- package/hub/cli-adapter-base.mjs +321 -97
- package/hub/codex-adapter.mjs +179 -93
- package/hub/codex-compat.mjs +4 -4
- 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 -18
- package/hub/gemini-adapter.mjs +77 -95
- package/hub/hitl.mjs +202 -143
- package/hub/intent.mjs +167 -38
- 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 +285 -111
- 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 +87 -13
- package/hub/research.mjs +33 -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 +127 -61
- package/hub/state.mjs +86 -44
- package/hub/team/retry-state-machine.mjs +267 -0
- package/hub/token-mode.mjs +72 -71
- 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 +391 -247
- 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 +1281 -115
- 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 +70 -10
- 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 +32 -7
- 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/cli-adapter-base.mjs
CHANGED
|
@@ -1,10 +1,66 @@
|
|
|
1
1
|
// hub/cli-adapter-base.mjs — codex/gemini 공통 CLI adapter 인터페이스
|
|
2
2
|
// Phase 2: codex-adapter.mjs에서 추출한 재사용 가능 유틸리티
|
|
3
3
|
|
|
4
|
-
import { execSync, spawn } from
|
|
5
|
-
import { existsSync, readFileSync } from
|
|
4
|
+
import { execSync, spawn } from "node:child_process";
|
|
5
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { writePromptToTmpFile } from "./lib/prompt-tmp.mjs";
|
|
8
|
+
import { IS_WINDOWS, killProcess } from "./platform.mjs";
|
|
9
|
+
|
|
10
|
+
// ── Quota retry-after 파싱 ──────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
const FALLBACK_COOLDOWN_MS = { codex: 5 * 3600_000, gemini: 86400_000 };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 에러 텍스트에서 retry-after 시간을 동적 파싱.
|
|
16
|
+
* "try again at 2026-04-11T10:00:00Z", "retry after 3600 seconds",
|
|
17
|
+
* "resets in 7 days", "wait 5 hours" 등을 감지.
|
|
18
|
+
*/
|
|
19
|
+
export function parseRetryAfterMs(text, provider) {
|
|
20
|
+
// 1. ISO timestamp: "try again at 2026-04-11T10:00:00"
|
|
21
|
+
const isoMatch = text.match(
|
|
22
|
+
/(?:try again|retry|available|resets?)\s+(?:at|after)\s+(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)/i,
|
|
23
|
+
);
|
|
24
|
+
if (isoMatch) {
|
|
25
|
+
const target = new Date(isoMatch[1]).getTime();
|
|
26
|
+
if (target > Date.now()) return target - Date.now();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 2. Duration: "N seconds/minutes/hours/days"
|
|
30
|
+
const durMatch = text.match(
|
|
31
|
+
/(?:retry|wait|resets?|again)\s+(?:in\s+|after\s+)?(\d+)\s*(second|minute|hour|day|week)/i,
|
|
32
|
+
);
|
|
33
|
+
if (durMatch) {
|
|
34
|
+
const n = Number(durMatch[1]);
|
|
35
|
+
const unit = durMatch[2].toLowerCase();
|
|
36
|
+
const multipliers = {
|
|
37
|
+
second: 1000,
|
|
38
|
+
minute: 60_000,
|
|
39
|
+
hour: 3600_000,
|
|
40
|
+
day: 86400_000,
|
|
41
|
+
week: 604800_000,
|
|
42
|
+
};
|
|
43
|
+
return n * (multipliers[unit] || 3600_000);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 3. "7 days" / "24 hours" 단독 (context 없이)
|
|
47
|
+
const standaloneMatch = text.match(/(\d+)\s*(day|hour|week|minute)/i);
|
|
48
|
+
if (standaloneMatch) {
|
|
49
|
+
const n = Number(standaloneMatch[1]);
|
|
50
|
+
const unit = standaloneMatch[2].toLowerCase();
|
|
51
|
+
const multipliers = {
|
|
52
|
+
minute: 60_000,
|
|
53
|
+
hour: 3600_000,
|
|
54
|
+
day: 86400_000,
|
|
55
|
+
week: 604800_000,
|
|
56
|
+
};
|
|
57
|
+
const parsed = n * (multipliers[unit] || 3600_000);
|
|
58
|
+
if (parsed >= 3600_000) return parsed; // 1시간 이상만 신뢰
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 4. fallback: provider 기본값
|
|
62
|
+
return FALLBACK_COOLDOWN_MS[provider] || 5 * 3600_000;
|
|
63
|
+
}
|
|
8
64
|
|
|
9
65
|
// ── Codex CLI compatibility ─────────────────────────────────────
|
|
10
66
|
|
|
@@ -17,12 +73,23 @@ let _cachedVersion = null;
|
|
|
17
73
|
*/
|
|
18
74
|
export function getCodexVersion() {
|
|
19
75
|
if (_cachedVersion !== null) return _cachedVersion;
|
|
76
|
+
const override = Number(process.env.TFX_CODEX_VERSION_MINOR);
|
|
77
|
+
if (Number.isFinite(override) && override > 0) {
|
|
78
|
+
_cachedVersion = override;
|
|
79
|
+
return _cachedVersion;
|
|
80
|
+
}
|
|
20
81
|
try {
|
|
21
|
-
const out = execSync(
|
|
82
|
+
const out = execSync("codex --version", {
|
|
83
|
+
encoding: "utf8",
|
|
84
|
+
timeout: 5000,
|
|
85
|
+
}).trim();
|
|
22
86
|
const match = out.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
23
87
|
_cachedVersion = match ? Number.parseInt(match[2], 10) : 0;
|
|
24
88
|
} catch {
|
|
25
|
-
|
|
89
|
+
// Command builders should remain stable in CI even when the real Codex
|
|
90
|
+
// CLI is absent. Runtime preflight still reports/install-gates Codex
|
|
91
|
+
// separately; this fallback only selects the modern argv shape.
|
|
92
|
+
_cachedVersion = 117;
|
|
26
93
|
}
|
|
27
94
|
return _cachedVersion;
|
|
28
95
|
}
|
|
@@ -43,19 +110,27 @@ export function gte(minMinor) {
|
|
|
43
110
|
*/
|
|
44
111
|
export const FEATURES = {
|
|
45
112
|
/** exec 서브커맨드 사용 가능 여부 (0.110+ 이전부터 존재) */
|
|
46
|
-
get execSubcommand() {
|
|
113
|
+
get execSubcommand() {
|
|
114
|
+
return gte(110);
|
|
115
|
+
},
|
|
47
116
|
/** --output-last-message 플래그 지원 여부 (0.117+) */
|
|
48
|
-
get outputLastMessage() {
|
|
117
|
+
get outputLastMessage() {
|
|
118
|
+
return gte(117);
|
|
119
|
+
},
|
|
49
120
|
/** --color <COLOR> 플래그 지원 여부 (exec와 동시 도입) */
|
|
50
|
-
get colorNever() {
|
|
121
|
+
get colorNever() {
|
|
122
|
+
return gte(110);
|
|
123
|
+
},
|
|
51
124
|
/** 플러그인 시스템 지원 여부 (향후 확장용) */
|
|
52
|
-
get pluginSystem() {
|
|
125
|
+
get pluginSystem() {
|
|
126
|
+
return gte(120);
|
|
127
|
+
},
|
|
53
128
|
};
|
|
54
129
|
|
|
55
130
|
// ── Shell utilities ─────────────────────────────────────────────
|
|
56
131
|
|
|
57
132
|
export function normalizePathForShell(value) {
|
|
58
|
-
return IS_WINDOWS ? String(value).replace(/\\/g,
|
|
133
|
+
return IS_WINDOWS ? String(value).replace(/\\/g, "/") : String(value);
|
|
59
134
|
}
|
|
60
135
|
|
|
61
136
|
export function shellQuote(value) {
|
|
@@ -71,47 +146,84 @@ export const CODEX_MCP_EXECUTION_EXIT_CODE = 1;
|
|
|
71
146
|
|
|
72
147
|
/**
|
|
73
148
|
* long-form 플래그 기반 명령 빌더.
|
|
149
|
+
*
|
|
150
|
+
* macOS 회귀 fix (codex v0.130.0 oh-my-codex hook lifecycle): 매우 긴
|
|
151
|
+
* argv-inline prompt 가 SessionStart Failed 를 유발하므로 default 가 prompt
|
|
152
|
+
* 를 stdin 으로 전달하는 패턴. 셸별 분기:
|
|
153
|
+
* - Unix (bash/zsh): `codex exec ... < '/tmp/triflux-codex-prompt/prompt-*.txt'`
|
|
154
|
+
* - Windows (pwsh7): `Get-Content -Raw 'path' | codex exec ...`
|
|
155
|
+
* (pwsh7 의 `<` 는 reserved future syntax 라 호환성 보장 안 됨)
|
|
156
|
+
*
|
|
157
|
+
* 환경 변수 `TFX_CODEX_STDIN_PROMPT=0` 이면 legacy argv-inline 으로 회귀.
|
|
158
|
+
* 결정론 보장이 필요한 launcher path 는 호출 시 `stdinPrompt: false` 명시.
|
|
159
|
+
*
|
|
74
160
|
* @param {string} prompt
|
|
75
161
|
* @param {string|null} resultFile — null이면 --output-last-message 생략
|
|
76
|
-
* @param {{ profile?: string, skipGitRepoCheck?: boolean, sandboxBypass?: boolean, cwd?: string, mcpServers?: string[] }} [opts]
|
|
162
|
+
* @param {{ profile?: string, skipGitRepoCheck?: boolean, sandboxBypass?: boolean, cwd?: string, mcpServers?: string[], stdinPrompt?: boolean }} [opts]
|
|
77
163
|
* @returns {string} 실행할 셸 커맨드
|
|
78
164
|
*/
|
|
79
165
|
export function buildExecCommand(prompt, resultFile = null, opts = {}) {
|
|
80
|
-
const {
|
|
166
|
+
const {
|
|
167
|
+
profile,
|
|
168
|
+
skipGitRepoCheck = true,
|
|
169
|
+
sandboxBypass = true,
|
|
170
|
+
mcpServers,
|
|
171
|
+
stdinPrompt,
|
|
172
|
+
} = opts;
|
|
81
173
|
|
|
82
|
-
const parts = [
|
|
83
|
-
if (profile) parts.push(
|
|
174
|
+
const parts = ["codex"];
|
|
175
|
+
if (profile) parts.push("--profile", profile);
|
|
84
176
|
|
|
85
177
|
if (FEATURES.execSubcommand) {
|
|
86
|
-
parts.push(
|
|
87
|
-
if (sandboxBypass) parts.push(
|
|
88
|
-
if (skipGitRepoCheck) parts.push(
|
|
178
|
+
parts.push("exec");
|
|
179
|
+
if (sandboxBypass) parts.push("--dangerously-bypass-approvals-and-sandbox");
|
|
180
|
+
if (skipGitRepoCheck) parts.push("--skip-git-repo-check");
|
|
89
181
|
if (resultFile && FEATURES.outputLastMessage) {
|
|
90
|
-
parts.push(
|
|
182
|
+
parts.push("--output-last-message", resultFile);
|
|
91
183
|
}
|
|
92
|
-
if (FEATURES.colorNever) parts.push(
|
|
93
|
-
|
|
184
|
+
if (FEATURES.colorNever) parts.push("--color", "never");
|
|
185
|
+
// NOTE: `codex exec`는 --cwd 플래그를 지원하지 않는다. Node spawn의 cwd
|
|
186
|
+
// 옵션으로 child process의 working directory를 제어한다 (conductor.mjs 참조).
|
|
187
|
+
// opts.cwd는 기록용으로만 받아두고 CLI command에는 반영하지 않는다.
|
|
188
|
+
// (이전 커밋에서 잘못 추가되어 shard 전체가 exit 2로 크래시한 회귀 #94 후속 이슈)
|
|
94
189
|
if (Array.isArray(mcpServers)) {
|
|
95
190
|
for (const server of mcpServers) {
|
|
96
|
-
parts.push(
|
|
191
|
+
parts.push("-c", `mcp_servers.${server}.enabled=true`);
|
|
97
192
|
}
|
|
98
193
|
}
|
|
99
194
|
} else {
|
|
100
|
-
parts.push(
|
|
101
|
-
if (skipGitRepoCheck) parts.push(
|
|
195
|
+
parts.push("--dangerously-bypass-approvals-and-sandbox");
|
|
196
|
+
if (skipGitRepoCheck) parts.push("--skip-git-repo-check");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const useStdin = resolveStdinPromptMode(stdinPrompt);
|
|
200
|
+
const hasPrompt = typeof prompt === "string" && prompt.length > 0;
|
|
201
|
+
if (useStdin && hasPrompt) {
|
|
202
|
+
const promptFile = writePromptToTmpFile(prompt);
|
|
203
|
+
if (IS_WINDOWS) {
|
|
204
|
+
// pwsh7 호환: `<` redirect 는 reserved future syntax 라 동작 불안정.
|
|
205
|
+
// Get-Content -Raw stdin pipe 로 prompt 를 codex stdin 에 주입.
|
|
206
|
+
return `Get-Content -Raw '${escapePwshSingleQuoted(promptFile)}' | ${parts.join(" ")}`;
|
|
207
|
+
}
|
|
208
|
+
return `${parts.join(" ")} < ${shellQuote(promptFile)}`;
|
|
102
209
|
}
|
|
103
210
|
|
|
104
211
|
parts.push(JSON.stringify(prompt));
|
|
105
|
-
return parts.join(
|
|
212
|
+
return parts.join(" ");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function resolveStdinPromptMode(explicit) {
|
|
216
|
+
if (typeof explicit === "boolean") return explicit;
|
|
217
|
+
// Default ON to fix macOS codex hook regression. Opt out via env=0.
|
|
218
|
+
const env = process.env.TFX_CODEX_STDIN_PROMPT;
|
|
219
|
+
if (env === "0" || env === "false") return false;
|
|
220
|
+
return true;
|
|
106
221
|
}
|
|
107
222
|
|
|
108
223
|
// ── Sleep ───────────────────────────────────────────────────────
|
|
109
224
|
|
|
110
225
|
export function sleep(ms) {
|
|
111
|
-
return new Promise((resolve) =>
|
|
112
|
-
const timer = setTimeout(resolve, ms);
|
|
113
|
-
timer.unref?.();
|
|
114
|
-
});
|
|
226
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
115
227
|
}
|
|
116
228
|
|
|
117
229
|
// ── Result factory ──────────────────────────────────────────────
|
|
@@ -119,80 +231,138 @@ export function sleep(ms) {
|
|
|
119
231
|
export function createResult(ok, extra = {}) {
|
|
120
232
|
return {
|
|
121
233
|
ok,
|
|
122
|
-
output:
|
|
123
|
-
stderr:
|
|
234
|
+
output: "",
|
|
235
|
+
stderr: "",
|
|
124
236
|
exitCode: null,
|
|
125
237
|
duration: 0,
|
|
126
238
|
retried: false,
|
|
127
239
|
fellBack: false,
|
|
128
|
-
failureMode: ok ? null :
|
|
240
|
+
failureMode: ok ? null : "crash",
|
|
129
241
|
...extra,
|
|
130
242
|
};
|
|
131
243
|
}
|
|
132
244
|
|
|
133
245
|
export function appendWarnings(stderr, warnings = []) {
|
|
134
|
-
const text = warnings.map((item) => `[preflight] ${item}`).join(
|
|
135
|
-
return [stderr, text].filter(Boolean).join(
|
|
246
|
+
const text = warnings.map((item) => `[preflight] ${item}`).join("\n");
|
|
247
|
+
return [stderr, text].filter(Boolean).join("\n");
|
|
136
248
|
}
|
|
137
249
|
|
|
138
|
-
// ──
|
|
139
|
-
|
|
140
|
-
export function createCircuitBreaker(opts = {}) {
|
|
141
|
-
const state = {
|
|
142
|
-
failures: [],
|
|
143
|
-
maxFailures: opts.maxFailures ?? 3,
|
|
144
|
-
windowMs: opts.windowMs ?? 10 * 60_000,
|
|
145
|
-
openedAt: 0,
|
|
146
|
-
trialInFlight: false,
|
|
147
|
-
};
|
|
250
|
+
// ── Broker-integrated execution ─────────────────────────────────
|
|
148
251
|
|
|
149
|
-
|
|
150
|
-
|
|
252
|
+
/**
|
|
253
|
+
* Shared execute() logic that uses account-broker for per-account circuit
|
|
254
|
+
* breaking instead of a global breaker.
|
|
255
|
+
*
|
|
256
|
+
* @param {object} params
|
|
257
|
+
* @param {string} params.provider — 'codex' | 'gemini'
|
|
258
|
+
* @param {(prompt: string, workdir: string, preflight: object, attempt: object) => Promise<object>} params.runFn
|
|
259
|
+
* @param {(opts: object) => Promise<object>} params.preflightFn
|
|
260
|
+
* @param {(opts: object, preflight: object) => object[]} params.buildAttemptsFn
|
|
261
|
+
* @param {object} params.opts — caller-supplied execute options
|
|
262
|
+
* @returns {Promise<object>} createResult-shaped result
|
|
263
|
+
*/
|
|
264
|
+
export async function executeWithCircuitBroker({
|
|
265
|
+
provider,
|
|
266
|
+
runFn,
|
|
267
|
+
preflightFn,
|
|
268
|
+
buildAttemptsFn,
|
|
269
|
+
opts = {},
|
|
270
|
+
}) {
|
|
271
|
+
// late-import to avoid circular dependency at module load time
|
|
272
|
+
const brokerMod = await import("./account-broker.mjs");
|
|
273
|
+
const { withRetry } = await import("./workers/worker-utils.mjs");
|
|
274
|
+
|
|
275
|
+
// access broker as live binding property (not destructured) so reloadBroker() propagates
|
|
276
|
+
const hasBroker = brokerMod.broker != null;
|
|
277
|
+
// Empty broker (TFX_DISABLE_ACCOUNT_BROKER=1 or zero accounts) must not
|
|
278
|
+
// gate execution. lease() is null because there are no accounts to lease,
|
|
279
|
+
// not because all accounts are busy/cooldown/circuit. Treat it like
|
|
280
|
+
// hasBroker=false and fall through to the default auth path.
|
|
281
|
+
const brokerDisabled = hasBroker && brokerMod.broker.isDisabled === true;
|
|
282
|
+
const effectiveBroker = hasBroker && !brokerDisabled;
|
|
283
|
+
const lease = effectiveBroker ? brokerMod.broker.lease({ provider }) : null;
|
|
284
|
+
if (effectiveBroker && !lease) {
|
|
285
|
+
return createResult(false, { fellBack: true, failureMode: "circuit_open" });
|
|
151
286
|
}
|
|
152
287
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
288
|
+
const preflight = await preflightFn(opts);
|
|
289
|
+
if (!preflight.ok) {
|
|
290
|
+
if (lease) brokerMod.broker.release(lease.id, { ok: false });
|
|
291
|
+
return createResult(false, {
|
|
292
|
+
stderr: appendWarnings("", preflight.warnings),
|
|
293
|
+
fellBack: opts.fallbackToClaude !== false,
|
|
294
|
+
failureMode: "crash",
|
|
295
|
+
});
|
|
157
296
|
}
|
|
158
297
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
state.trialInFlight = false;
|
|
163
|
-
if (isHalfOpen || state.failures.length >= state.maxFailures) {
|
|
164
|
-
state.openedAt = now;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
298
|
+
const attempts = buildAttemptsFn(opts, preflight);
|
|
299
|
+
let attemptIndex = 0;
|
|
300
|
+
let lastResult = createResult(false);
|
|
167
301
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
302
|
+
try {
|
|
303
|
+
lastResult = await withRetry(
|
|
304
|
+
async () => {
|
|
305
|
+
// PRD A1: lease 메타데이터를 runFn 에 전달해서 adapter 가 실제 spawn 시
|
|
306
|
+
// 해당 account 의 authFile/env/profile 을 적용할 수 있게 한다. lease=null
|
|
307
|
+
// 이면 adapter 는 default 경로 (단일 ~/.codex/auth.json) 로 동작한다.
|
|
308
|
+
const result = await runFn(
|
|
309
|
+
opts.prompt || "",
|
|
310
|
+
opts.workdir || process.cwd(),
|
|
311
|
+
preflight,
|
|
312
|
+
attempts[attemptIndex],
|
|
313
|
+
lease,
|
|
314
|
+
);
|
|
315
|
+
const current = {
|
|
316
|
+
...result,
|
|
317
|
+
stderr: appendWarnings(result.stderr, preflight.warnings),
|
|
318
|
+
retried: attemptIndex > 0,
|
|
319
|
+
};
|
|
320
|
+
const canRetry = !current.ok && attemptIndex < attempts.length - 1;
|
|
321
|
+
attemptIndex += 1;
|
|
322
|
+
if (!canRetry) return current;
|
|
323
|
+
const error = new Error("retry");
|
|
324
|
+
error.retryable = true;
|
|
325
|
+
error.result = current;
|
|
326
|
+
throw error;
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
maxAttempts: attempts.length,
|
|
330
|
+
baseDelayMs: 250,
|
|
331
|
+
maxDelayMs: 750,
|
|
332
|
+
shouldRetry: (error) => error?.retryable === true,
|
|
333
|
+
},
|
|
334
|
+
);
|
|
335
|
+
} catch (error) {
|
|
336
|
+
lastResult =
|
|
337
|
+
error?.result ||
|
|
338
|
+
createResult(false, { stderr: String(error?.message || error) });
|
|
180
339
|
}
|
|
181
340
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if (circuit.state === 'half-open' && state.trialInFlight) return { allowed: false, halfOpen: true };
|
|
186
|
-
const halfOpen = circuit.state === 'half-open';
|
|
187
|
-
if (halfOpen) state.trialInFlight = true;
|
|
188
|
-
return { allowed: true, halfOpen };
|
|
341
|
+
if (lastResult.ok) {
|
|
342
|
+
if (lease) brokerMod.broker.release(lease.id, { ok: true });
|
|
343
|
+
return lastResult;
|
|
189
344
|
}
|
|
190
345
|
|
|
191
|
-
|
|
192
|
-
|
|
346
|
+
if (lease) {
|
|
347
|
+
if (lastResult.failureMode === "rate_limited") {
|
|
348
|
+
const text = `${lastResult.output || ""}\n${lastResult.stderr || ""}`;
|
|
349
|
+
const coolMs = parseRetryAfterMs(text, provider);
|
|
350
|
+
brokerMod.broker.markRateLimited(lease.id, coolMs);
|
|
351
|
+
brokerMod.broker.emit("cooldown", {
|
|
352
|
+
id: lease.id,
|
|
353
|
+
provider,
|
|
354
|
+
coolMs,
|
|
355
|
+
reason: "quota_exhausted",
|
|
356
|
+
});
|
|
357
|
+
} else {
|
|
358
|
+
brokerMod.broker.release(lease.id, { ok: false });
|
|
359
|
+
}
|
|
193
360
|
}
|
|
194
|
-
|
|
195
|
-
|
|
361
|
+
return {
|
|
362
|
+
...lastResult,
|
|
363
|
+
retried: attempts.length > 1,
|
|
364
|
+
fellBack: opts.fallbackToClaude !== false,
|
|
365
|
+
};
|
|
196
366
|
}
|
|
197
367
|
|
|
198
368
|
// ── Process termination ─────────────────────────────────────────
|
|
@@ -200,9 +370,14 @@ export function createCircuitBreaker(opts = {}) {
|
|
|
200
370
|
export async function terminateChild(pid, opts = {}) {
|
|
201
371
|
if (!pid) return;
|
|
202
372
|
const graceMs = opts.graceMs ?? 5000;
|
|
203
|
-
killProcess(pid, { signal:
|
|
373
|
+
killProcess(pid, { signal: "SIGTERM", tree: true, timeout: graceMs });
|
|
204
374
|
await sleep(graceMs);
|
|
205
|
-
killProcess(pid, {
|
|
375
|
+
killProcess(pid, {
|
|
376
|
+
signal: "SIGKILL",
|
|
377
|
+
tree: true,
|
|
378
|
+
force: true,
|
|
379
|
+
timeout: graceMs,
|
|
380
|
+
});
|
|
206
381
|
}
|
|
207
382
|
|
|
208
383
|
// ── Process execution with stall detection ──────────────────────
|
|
@@ -222,29 +397,61 @@ export async function terminateChild(pid, opts = {}) {
|
|
|
222
397
|
*/
|
|
223
398
|
export async function runProcess(command, workdir, timeout, opts = {}) {
|
|
224
399
|
const startedAt = Date.now();
|
|
225
|
-
const inferStallMode = opts.inferStallMode || (() =>
|
|
400
|
+
const inferStallMode = opts.inferStallMode || (() => "timeout");
|
|
226
401
|
const stallCheckIntervalMs = opts.stallCheckIntervalMs ?? 10_000;
|
|
227
402
|
const stallThresholdMs = opts.stallThresholdMs ?? 30_000;
|
|
228
403
|
const resultFile = opts.resultFile || null;
|
|
229
404
|
|
|
230
|
-
let stdout =
|
|
231
|
-
let stderr =
|
|
405
|
+
let stdout = "";
|
|
406
|
+
let stderr = "";
|
|
232
407
|
let exitCode = null;
|
|
233
408
|
let failureMode = null;
|
|
234
409
|
let child;
|
|
235
410
|
|
|
236
411
|
try {
|
|
237
|
-
|
|
412
|
+
// PRD A1: opts.spawnEnv 가 있으면 그 env 로 spawn (lease 의 authFile/env 적용).
|
|
413
|
+
// undefined 면 spawn 의 default 동작 (부모 process env inherit) 유지.
|
|
414
|
+
child = spawn(command, {
|
|
415
|
+
cwd: workdir,
|
|
416
|
+
shell: true,
|
|
417
|
+
windowsHide: true,
|
|
418
|
+
env: opts.spawnEnv,
|
|
419
|
+
});
|
|
238
420
|
} catch (error) {
|
|
239
|
-
return createResult(false, {
|
|
421
|
+
return createResult(false, {
|
|
422
|
+
stderr: String(error?.message || error),
|
|
423
|
+
duration: Date.now() - startedAt,
|
|
424
|
+
});
|
|
240
425
|
}
|
|
241
426
|
|
|
427
|
+
const resultFileSignature = () => {
|
|
428
|
+
if (!resultFile) return "";
|
|
429
|
+
try {
|
|
430
|
+
const info = statSync(resultFile);
|
|
431
|
+
return `${info.size}:${info.mtimeMs}`;
|
|
432
|
+
} catch {
|
|
433
|
+
return "";
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
|
|
242
437
|
let lastBytes = 0;
|
|
438
|
+
let lastResultFileSignature = resultFileSignature();
|
|
243
439
|
let lastChange = Date.now();
|
|
244
|
-
const touch = () => {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
child.on(
|
|
440
|
+
const touch = () => {
|
|
441
|
+
lastChange = Date.now();
|
|
442
|
+
};
|
|
443
|
+
child.stdout?.on("data", (chunk) => {
|
|
444
|
+
stdout += String(chunk);
|
|
445
|
+
touch();
|
|
446
|
+
});
|
|
447
|
+
child.stderr?.on("data", (chunk) => {
|
|
448
|
+
stderr += String(chunk);
|
|
449
|
+
touch();
|
|
450
|
+
});
|
|
451
|
+
child.on("error", (error) => {
|
|
452
|
+
stderr += String(error?.message || error);
|
|
453
|
+
failureMode ||= "crash";
|
|
454
|
+
});
|
|
248
455
|
|
|
249
456
|
const stopFor = async (mode) => {
|
|
250
457
|
if (failureMode) return;
|
|
@@ -252,23 +459,40 @@ export async function runProcess(command, workdir, timeout, opts = {}) {
|
|
|
252
459
|
await terminateChild(child.pid);
|
|
253
460
|
};
|
|
254
461
|
|
|
255
|
-
const timeoutTimer = setTimeout(() => {
|
|
462
|
+
const timeoutTimer = setTimeout(() => {
|
|
463
|
+
void stopFor("timeout");
|
|
464
|
+
}, timeout);
|
|
256
465
|
const stallTimer = setInterval(() => {
|
|
257
466
|
const size = Buffer.byteLength(stdout) + Buffer.byteLength(stderr);
|
|
258
|
-
|
|
467
|
+
const currentResultFileSignature = resultFileSignature();
|
|
468
|
+
if (
|
|
469
|
+
size !== lastBytes ||
|
|
470
|
+
currentResultFileSignature !== lastResultFileSignature
|
|
471
|
+
) {
|
|
259
472
|
lastBytes = size;
|
|
473
|
+
lastResultFileSignature = currentResultFileSignature;
|
|
474
|
+
touch();
|
|
260
475
|
return;
|
|
261
476
|
}
|
|
262
|
-
if (Date.now() - lastChange >= stallThresholdMs)
|
|
477
|
+
if (Date.now() - lastChange >= stallThresholdMs)
|
|
478
|
+
void stopFor(inferStallMode(stdout, stderr));
|
|
263
479
|
}, stallCheckIntervalMs);
|
|
264
480
|
timeoutTimer.unref?.();
|
|
265
481
|
stallTimer.unref?.();
|
|
266
482
|
|
|
267
|
-
await new Promise((resolve) =>
|
|
483
|
+
await new Promise((resolve) =>
|
|
484
|
+
child.on("close", (code) => {
|
|
485
|
+
exitCode = code;
|
|
486
|
+
resolve();
|
|
487
|
+
}),
|
|
488
|
+
);
|
|
268
489
|
clearTimeout(timeoutTimer);
|
|
269
490
|
clearInterval(stallTimer);
|
|
270
491
|
|
|
271
|
-
const fileOutput =
|
|
492
|
+
const fileOutput =
|
|
493
|
+
resultFile && existsSync(resultFile)
|
|
494
|
+
? readFileSync(resultFile, "utf8")
|
|
495
|
+
: "";
|
|
272
496
|
const output = fileOutput || stdout;
|
|
273
497
|
const ok = failureMode == null && exitCode === 0;
|
|
274
498
|
return createResult(ok, {
|
|
@@ -276,6 +500,6 @@ export async function runProcess(command, workdir, timeout, opts = {}) {
|
|
|
276
500
|
stderr,
|
|
277
501
|
exitCode,
|
|
278
502
|
duration: Date.now() - startedAt,
|
|
279
|
-
failureMode: ok ? null :
|
|
503
|
+
failureMode: ok ? null : failureMode || "crash",
|
|
280
504
|
});
|
|
281
505
|
}
|