triflux 4.2.8 → 4.2.10
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/middleware/request-logger.mjs +81 -0
- package/hub/server.mjs +12 -8
- package/hub/team/native.mjs +36 -21
- package/hud/hud-qos-status.mjs +95 -39
- package/package.json +3 -1
- package/scripts/lib/context.mjs +67 -0
- package/scripts/lib/logger.mjs +105 -0
- package/scripts/tfx-route.sh +132 -2
- package/skills/tfx-multi/SKILL.md +1 -1
- package/skills/tfx-multi/references/agent-wrapper-rules.md +18 -5
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub HTTP 서버 요청 로깅 미들웨어.
|
|
3
|
+
*
|
|
4
|
+
* raw http.createServer에 맞춘 래퍼. Express가 아닌 triflux Hub 전용.
|
|
5
|
+
*
|
|
6
|
+
* 사용법 (server.mjs에서):
|
|
7
|
+
* import { wrapRequestHandler } from './middleware/request-logger.mjs';
|
|
8
|
+
* const httpServer = createHttpServer(wrapRequestHandler(originalHandler));
|
|
9
|
+
*
|
|
10
|
+
* 각 요청에 correlationId를 할당하고, 응답 완료 시 구조화 로그를 남긴다.
|
|
11
|
+
* health/status 체크는 로깅을 건너뛴다.
|
|
12
|
+
*/
|
|
13
|
+
import { withRequestContext, getCorrelationId } from '../../scripts/lib/context.mjs';
|
|
14
|
+
import { createModuleLogger } from '../../scripts/lib/logger.mjs';
|
|
15
|
+
|
|
16
|
+
const log = createModuleLogger('hub');
|
|
17
|
+
|
|
18
|
+
const SKIP_PATHS = new Set(['/health', '/healthz', '/status', '/ready']);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 원본 request handler를 래핑하여 로깅 + 컨텍스트 전파를 추가한다.
|
|
22
|
+
*
|
|
23
|
+
* @param {function(import('http').IncomingMessage, import('http').ServerResponse): void} handler
|
|
24
|
+
* @returns {function(import('http').IncomingMessage, import('http').ServerResponse): void}
|
|
25
|
+
*/
|
|
26
|
+
export function wrapRequestHandler(handler) {
|
|
27
|
+
return (req, res) => {
|
|
28
|
+
const path = getRequestPath(req.url);
|
|
29
|
+
|
|
30
|
+
if (SKIP_PATHS.has(path)) {
|
|
31
|
+
return handler(req, res);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const correlationId =
|
|
35
|
+
req.headers['x-correlation-id'] ||
|
|
36
|
+
req.headers['x-request-id'] ||
|
|
37
|
+
undefined; // withRequestContext will generate one
|
|
38
|
+
|
|
39
|
+
withRequestContext(
|
|
40
|
+
{
|
|
41
|
+
correlationId,
|
|
42
|
+
method: req.method,
|
|
43
|
+
path,
|
|
44
|
+
},
|
|
45
|
+
() => {
|
|
46
|
+
const startTime = process.hrtime.bigint();
|
|
47
|
+
|
|
48
|
+
// 응답 헤더에 상관 ID 포함
|
|
49
|
+
const cid = getCorrelationId();
|
|
50
|
+
if (cid) res.setHeader('X-Correlation-ID', cid);
|
|
51
|
+
|
|
52
|
+
// 응답 완료 시 로깅
|
|
53
|
+
res.on('finish', () => {
|
|
54
|
+
const duration = Number(process.hrtime.bigint() - startTime) / 1_000_000;
|
|
55
|
+
const level = res.statusCode >= 500 ? 'error'
|
|
56
|
+
: res.statusCode >= 400 ? 'warn'
|
|
57
|
+
: 'info';
|
|
58
|
+
|
|
59
|
+
log[level](
|
|
60
|
+
{
|
|
61
|
+
status: res.statusCode,
|
|
62
|
+
duration: Math.round(duration * 100) / 100,
|
|
63
|
+
contentLength: res.getHeader('content-length') || 0,
|
|
64
|
+
},
|
|
65
|
+
'http.response',
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
handler(req, res);
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function getRequestPath(url = '/') {
|
|
76
|
+
try {
|
|
77
|
+
return new URL(url, 'http://127.0.0.1').pathname;
|
|
78
|
+
} catch {
|
|
79
|
+
return String(url).replace(/\?.*/, '') || '/';
|
|
80
|
+
}
|
|
81
|
+
}
|
package/hub/server.mjs
CHANGED
|
@@ -19,6 +19,10 @@ import { createTools } from './tools.mjs';
|
|
|
19
19
|
import { ensurePipelineStateDbPath } from './pipeline/state.mjs';
|
|
20
20
|
import { DelegatorService } from './delegator/index.mjs';
|
|
21
21
|
import { createDelegatorMcpWorker } from './workers/delegator-mcp.mjs';
|
|
22
|
+
import { createModuleLogger } from '../scripts/lib/logger.mjs';
|
|
23
|
+
import { wrapRequestHandler } from './middleware/request-logger.mjs';
|
|
24
|
+
|
|
25
|
+
const hubLog = createModuleLogger('hub');
|
|
22
26
|
|
|
23
27
|
const MAX_BODY_SIZE = 1024 * 1024;
|
|
24
28
|
const PUBLIC_PATHS = new Set(['/', '/status', '/health', '/healthz']);
|
|
@@ -230,7 +234,7 @@ function servePublicFile(res, path) {
|
|
|
230
234
|
|
|
231
235
|
mkdirSync(PUBLIC_DIR, { recursive: true });
|
|
232
236
|
if (!existsSync(filePath)) {
|
|
233
|
-
|
|
237
|
+
hubLog.warn({ filePath }, 'static.not_found');
|
|
234
238
|
res.writeHead(404);
|
|
235
239
|
res.end('Not Found (static file missing)');
|
|
236
240
|
return true;
|
|
@@ -326,7 +330,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
326
330
|
return mcp;
|
|
327
331
|
}
|
|
328
332
|
|
|
329
|
-
const httpServer = createHttpServer(async (req, res) => {
|
|
333
|
+
const httpServer = createHttpServer(wrapRequestHandler(async (req, res) => {
|
|
330
334
|
const path = getRequestPath(req.url);
|
|
331
335
|
const corsAllowed = applyCorsHeaders(req, res);
|
|
332
336
|
|
|
@@ -685,7 +689,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
685
689
|
res.end('Method Not Allowed');
|
|
686
690
|
}
|
|
687
691
|
} catch (error) {
|
|
688
|
-
|
|
692
|
+
hubLog.error({ err: error }, 'http.error');
|
|
689
693
|
if (!res.headersSent) {
|
|
690
694
|
const code = error.statusCode === 413 ? 413
|
|
691
695
|
: error instanceof SyntaxError ? 400 : 500;
|
|
@@ -699,7 +703,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
699
703
|
}));
|
|
700
704
|
}
|
|
701
705
|
}
|
|
702
|
-
});
|
|
706
|
+
}));
|
|
703
707
|
|
|
704
708
|
router.startSweeper();
|
|
705
709
|
|
|
@@ -751,8 +755,8 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
751
755
|
started: Date.now(),
|
|
752
756
|
}));
|
|
753
757
|
|
|
754
|
-
|
|
755
|
-
|
|
758
|
+
hubLog.info({ url: info.url, pipePath: pipe.path, assignCallbackPath: assignCallbacks.path, pid: process.pid }, 'hub.started');
|
|
759
|
+
hubLog.debug({ publicDir: PUBLIC_DIR, exists: existsSync(PUBLIC_DIR), hasDashboard: existsSync(resolve(PUBLIC_DIR, 'dashboard.html')) }, 'hub.public_dir');
|
|
756
760
|
|
|
757
761
|
const stopFn = async () => {
|
|
758
762
|
router.stopSweeper();
|
|
@@ -805,14 +809,14 @@ if (selfRun) {
|
|
|
805
809
|
|
|
806
810
|
startHub({ port, dbPath }).then((info) => {
|
|
807
811
|
const shutdown = async (signal) => {
|
|
808
|
-
|
|
812
|
+
hubLog.info({ signal }, 'hub.stopping');
|
|
809
813
|
await info.stop();
|
|
810
814
|
process.exit(0);
|
|
811
815
|
};
|
|
812
816
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
813
817
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
814
818
|
}).catch((error) => {
|
|
815
|
-
|
|
819
|
+
hubLog.fatal({ err: error }, 'hub.start_failed');
|
|
816
820
|
process.exit(1);
|
|
817
821
|
});
|
|
818
822
|
}
|
package/hub/team/native.mjs
CHANGED
|
@@ -126,9 +126,10 @@ function getRouteTimeout(role, _mcpProfile) {
|
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
/**
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
129
|
+
* v3 슬림 래퍼 프롬프트 생성 (async 모드)
|
|
130
|
+
* --async로 즉시 시작 → --job-wait로 내부 폴링 → --job-result로 결과 수집.
|
|
131
|
+
* Claude Code Bash 도구 600초 제한을 우회하여 scientist(24분), scientist-deep(60분) 등
|
|
132
|
+
* 장시간 워커를 안정적으로 실행한다.
|
|
132
133
|
*
|
|
133
134
|
* @param {'codex'|'gemini'} cli — CLI 타입
|
|
134
135
|
* @param {object} opts
|
|
@@ -141,7 +142,7 @@ function getRouteTimeout(role, _mcpProfile) {
|
|
|
141
142
|
* @param {string} [opts.mcp_profile] — MCP 프로필
|
|
142
143
|
* @param {number} [opts.workerIndex] — 검색 힌트 회전에 사용할 워커 인덱스(1-based)
|
|
143
144
|
* @param {string} [opts.searchTool] — 전용 검색 도구 힌트(brave-search|tavily|exa)
|
|
144
|
-
* @param {number} [opts.bashTimeout] —
|
|
145
|
+
* @param {number} [opts.bashTimeout] — (deprecated, async에서는 무시됨)
|
|
145
146
|
* @returns {string} 슬림 래퍼 프롬프트
|
|
146
147
|
*/
|
|
147
148
|
export function buildSlimWrapperPrompt(cli, opts = {}) {
|
|
@@ -156,23 +157,25 @@ export function buildSlimWrapperPrompt(cli, opts = {}) {
|
|
|
156
157
|
workerIndex,
|
|
157
158
|
searchTool = "",
|
|
158
159
|
pipelinePhase = "",
|
|
159
|
-
bashTimeout,
|
|
160
160
|
} = opts;
|
|
161
161
|
|
|
162
|
-
|
|
163
|
-
const bashTimeoutMs = bashTimeout ?? (getRouteTimeout(role, mcp_profile) + 60) * 1000;
|
|
164
|
-
|
|
165
|
-
// 셸 이스케이프
|
|
162
|
+
const routeTimeoutSec = getRouteTimeout(role, mcp_profile);
|
|
166
163
|
const escaped = subtask.replace(/'/g, "'\\''");
|
|
167
164
|
const pipelineHint = pipelinePhase
|
|
168
165
|
? `\n파이프라인 단계: ${pipelinePhase}`
|
|
169
166
|
: '';
|
|
170
167
|
const routeEnvPrefix = buildRouteEnvPrefix(agentName, workerIndex, searchTool);
|
|
171
168
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
3
|
|
169
|
+
// Bash 도구 timeout (모두 600초 이내)
|
|
170
|
+
const launchTimeoutMs = 15000; // Step 1: fork + job_id 반환
|
|
171
|
+
const waitTimeoutMs = 570000; // Step 2: 내부 폴링 (540초 대기 + 여유)
|
|
172
|
+
const resultTimeoutMs = 30000; // Step 3: 결과 읽기
|
|
173
|
+
|
|
174
|
+
return `실행 프로토콜 (subagent_type="${SLIM_WRAPPER_SUBAGENT_TYPE}", async):
|
|
175
|
+
1. --async로 백그라운드 시작 → JOB_ID 수신
|
|
176
|
+
2. --job-wait로 완료 대기 (내부 15초 간격 폴링, 최대 540초)
|
|
177
|
+
3. "still_running"이면 Step 2 반복, "done"이면 --job-result로 결과 수집
|
|
178
|
+
4. TaskUpdate + SendMessage → 종료${pipelineHint}
|
|
176
179
|
|
|
177
180
|
[HARD CONSTRAINT] 허용 도구: Bash, TaskUpdate, TaskGet, TaskList, SendMessage만 사용한다.
|
|
178
181
|
Read, Edit, Write, Grep, Glob, Agent, WebSearch, WebFetch 등 다른 모든 도구 사용을 금지한다.
|
|
@@ -182,21 +185,33 @@ Read, Edit, Write, Grep, Glob, Agent, WebSearch, WebFetch 등 다른 모든 도
|
|
|
182
185
|
gemini/codex를 직접 호출하지 마라. 반드시 tfx-route.sh를 거쳐야 한다.
|
|
183
186
|
프롬프트를 파일로 저장하지 마라. tfx-route.sh가 인자로 받는다.
|
|
184
187
|
|
|
185
|
-
Step 1 —
|
|
186
|
-
Bash(command: 'TFX_TEAM_NAME="${teamName}" TFX_TEAM_TASK_ID="${taskId}" TFX_TEAM_AGENT_NAME="${agentName}" TFX_TEAM_LEAD_NAME="${leadName}"${routeEnvPrefix} bash ${ROUTE_SCRIPT} "${role}" '"'"'${escaped}'"'"' ${mcp_profile}', timeout: ${
|
|
188
|
+
Step 1 — Async 시작 (즉시 리턴, <1초):
|
|
189
|
+
Bash(command: 'TFX_TEAM_NAME="${teamName}" TFX_TEAM_TASK_ID="${taskId}" TFX_TEAM_AGENT_NAME="${agentName}" TFX_TEAM_LEAD_NAME="${leadName}"${routeEnvPrefix} bash ${ROUTE_SCRIPT} --async "${role}" '"'"'${escaped}'"'"' ${mcp_profile} ${routeTimeoutSec}', timeout: ${launchTimeoutMs})
|
|
190
|
+
→ 출력 한 줄이 JOB_ID이다. 반드시 기억하라.
|
|
191
|
+
|
|
192
|
+
Step 2 — 완료 대기 (내부 폴링, 최대 540초):
|
|
193
|
+
Bash(command: 'bash ${ROUTE_SCRIPT} --job-wait JOB_ID 540', timeout: ${waitTimeoutMs})
|
|
194
|
+
→ 주기적 "waiting elapsed=Ns progress=NB" 출력 후 최종 상태:
|
|
195
|
+
"done" → Step 3으로
|
|
196
|
+
"timeout" 또는 "failed ..." → Step 4로 (실패 보고)
|
|
197
|
+
"still_running ..." → Step 2 반복 (같은 명령 재실행)
|
|
198
|
+
|
|
199
|
+
Step 3 — 결과 수집:
|
|
200
|
+
Bash(command: 'bash ${ROUTE_SCRIPT} --job-result JOB_ID', timeout: ${resultTimeoutMs})
|
|
201
|
+
→ 출력이 워커 실행 결과이다.
|
|
187
202
|
|
|
188
|
-
Step
|
|
189
|
-
|
|
203
|
+
Step 4 — Claude Code 태스크 동기화 (반드시 실행):
|
|
204
|
+
"done"이면:
|
|
190
205
|
TaskUpdate(taskId: "${taskId}", status: "completed", metadata: {result: "success"})
|
|
191
206
|
SendMessage(type: "message", recipient: "${leadName}", content: "완료: ${agentName}", summary: "task ${taskId} success")
|
|
192
|
-
|
|
193
|
-
TaskUpdate(taskId: "${taskId}", status: "completed", metadata: {result: "failed", error: "
|
|
194
|
-
SendMessage(type: "message", recipient: "${leadName}", content: "실패: ${agentName} (
|
|
207
|
+
"timeout" 또는 "failed"이면:
|
|
208
|
+
TaskUpdate(taskId: "${taskId}", status: "completed", metadata: {result: "failed", error: "상태 메시지"})
|
|
209
|
+
SendMessage(type: "message", recipient: "${leadName}", content: "실패: ${agentName} (상태)", summary: "task ${taskId} failed")
|
|
195
210
|
TFX_NEEDS_FALLBACK 출력 감지 시:
|
|
196
211
|
TaskUpdate(taskId: "${taskId}", status: "completed", metadata: {result: "fallback", reason: "claude-native"})
|
|
197
212
|
SendMessage(type: "message", recipient: "${leadName}", content: "fallback 필요: ${agentName} — claude-native 역할은 Claude Agent로 위임 필요", summary: "task ${taskId} fallback")
|
|
198
213
|
|
|
199
|
-
Step
|
|
214
|
+
Step 5 — TaskUpdate + SendMessage 후 즉시 종료. 추가 도구 호출 금지.`;
|
|
200
215
|
}
|
|
201
216
|
|
|
202
217
|
/**
|
package/hud/hud-qos-status.mjs
CHANGED
|
@@ -151,12 +151,33 @@ function getGeminiRpmLimit(model) {
|
|
|
151
151
|
return 300; // Flash 기본
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
// Gemini 모델 ID → HUD 표시 라벨
|
|
154
|
+
// Gemini 모델 ID → HUD 표시 라벨 (동적 매핑)
|
|
155
155
|
function getGeminiModelLabel(model) {
|
|
156
156
|
if (!model) return "";
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
return "";
|
|
157
|
+
// 버전 + 티어 추출: gemini-3.1-pro-preview → [3.1Pro], gemini-2.5-flash → [2.5Flash]
|
|
158
|
+
const m = model.match(/gemini-(\d+(?:\.\d+)?)-(\w+)/);
|
|
159
|
+
if (!m) return "";
|
|
160
|
+
const ver = m[1];
|
|
161
|
+
const tier = m[2].charAt(0).toUpperCase() + m[2].slice(1);
|
|
162
|
+
return `[${ver}${tier}]`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Gemini Pro 풀 공유 그룹: 같은 remainingFraction을 공유하는 모델 ID들
|
|
166
|
+
const GEMINI_PRO_POOL = new Set(["gemini-2.5-pro", "gemini-3-pro-preview", "gemini-3.1-pro-preview"]);
|
|
167
|
+
const GEMINI_FLASH_POOL = new Set(["gemini-2.5-flash", "gemini-3-flash-preview"]);
|
|
168
|
+
|
|
169
|
+
// remainingFraction → 사용 퍼센트 변환 (remainingAmount가 있으면 절대값도 제공)
|
|
170
|
+
function deriveGeminiLimits(bucket) {
|
|
171
|
+
if (!bucket || bucket.remainingFraction == null) return null;
|
|
172
|
+
const fraction = bucket.remainingFraction;
|
|
173
|
+
const usedPct = clampPercent(Math.round((1 - fraction) * 100));
|
|
174
|
+
// remainingAmount가 API에서 오면 절대값 역산 (Gemini CLI 방식)
|
|
175
|
+
if (bucket.remainingAmount != null) {
|
|
176
|
+
const remaining = parseInt(bucket.remainingAmount, 10);
|
|
177
|
+
const limit = fraction > 0 ? Math.round(remaining / fraction) : 0;
|
|
178
|
+
return { usedPct, remaining, limit, resetTime: bucket.resetTime, modelId: bucket.modelId };
|
|
179
|
+
}
|
|
180
|
+
return { usedPct, remaining: null, limit: null, resetTime: bucket.resetTime, modelId: bucket.modelId };
|
|
160
181
|
}
|
|
161
182
|
// rows 임계값 상수 (selectTier 에서 tier 결정에 사용)
|
|
162
183
|
const ROWS_BUDGET_FULL = 40;
|
|
@@ -493,10 +514,11 @@ function getMicroLine(stdin, claudeUsage, codexBuckets, geminiSession, geminiBuc
|
|
|
493
514
|
}
|
|
494
515
|
}
|
|
495
516
|
|
|
496
|
-
// Gemini
|
|
517
|
+
// Gemini (일간 쿼터 — P/F/L 3풀)
|
|
497
518
|
let gVal;
|
|
498
519
|
if (geminiBucket) {
|
|
499
|
-
const
|
|
520
|
+
const gl = deriveGeminiLimits(geminiBucket);
|
|
521
|
+
const gU = gl ? gl.usedPct : clampPercent((1 - (geminiBucket.remainingFraction ?? 1)) * 100);
|
|
500
522
|
gVal = colorByProvider(gU, `${gU}`, geminiBlue);
|
|
501
523
|
} else if ((geminiSession?.total || 0) > 0) {
|
|
502
524
|
gVal = geminiBlue("\u221E");
|
|
@@ -536,7 +558,7 @@ function normalizeTimeToken(value) {
|
|
|
536
558
|
}
|
|
537
559
|
const dayHour = text.match(/^(\d+)d(\d+)h$/);
|
|
538
560
|
if (dayHour) {
|
|
539
|
-
return `${Number(dayHour[1])}d${String(Number(dayHour[2])).padStart(2, "0")}h`;
|
|
561
|
+
return `${String(Number(dayHour[1])).padStart(2, "0")}d${String(Number(dayHour[2])).padStart(2, "0")}h`;
|
|
540
562
|
}
|
|
541
563
|
return text;
|
|
542
564
|
}
|
|
@@ -923,7 +945,7 @@ function formatResetRemainingDayHour(isoOrUnix, cycleMs = 0) {
|
|
|
923
945
|
const totalMinutes = Math.floor(diffMs / 60000);
|
|
924
946
|
const days = Math.floor(totalMinutes / (60 * 24));
|
|
925
947
|
const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
|
|
926
|
-
return `${days}d${hours}h`;
|
|
948
|
+
return `${String(days).padStart(2, "0")}d${String(hours).padStart(2, "0")}h`;
|
|
927
949
|
}
|
|
928
950
|
|
|
929
951
|
function calcCooldownLeftSeconds(isoDatetime) {
|
|
@@ -1511,13 +1533,16 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
|
|
|
1511
1533
|
}
|
|
1512
1534
|
}
|
|
1513
1535
|
if (realQuota?.type === "gemini") {
|
|
1514
|
-
const
|
|
1515
|
-
if (
|
|
1516
|
-
const
|
|
1517
|
-
|
|
1536
|
+
const pools = realQuota.pools || {};
|
|
1537
|
+
if (pools.pro || pools.flash) {
|
|
1538
|
+
const pP = pools.pro ? clampPercent(Math.round((1 - (pools.pro.remainingFraction ?? 1)) * 100)) : null;
|
|
1539
|
+
const pF = pools.flash ? clampPercent(Math.round((1 - (pools.flash.remainingFraction ?? 1)) * 100)) : null;
|
|
1540
|
+
const pStr = pP != null ? colorByProvider(pP, `${pP}`, provFn) : dim("--");
|
|
1541
|
+
const fStr = pF != null ? colorByProvider(pF, `${pF}`, provFn) : dim("--");
|
|
1542
|
+
return { prefix: minPrefix, left: `${pStr}${dim("/")}${fStr}`, right: "" };
|
|
1518
1543
|
}
|
|
1519
1544
|
}
|
|
1520
|
-
return { prefix: minPrefix, left: dim("
|
|
1545
|
+
return { prefix: minPrefix, left: dim("--/--"), right: "" };
|
|
1521
1546
|
}
|
|
1522
1547
|
|
|
1523
1548
|
if (CURRENT_TIER === "minimal") {
|
|
@@ -1532,12 +1557,17 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
|
|
|
1532
1557
|
}
|
|
1533
1558
|
}
|
|
1534
1559
|
if (realQuota?.type === "gemini") {
|
|
1535
|
-
const
|
|
1536
|
-
if (
|
|
1537
|
-
const
|
|
1538
|
-
|
|
1560
|
+
const pools = realQuota.pools || {};
|
|
1561
|
+
if (pools.pro || pools.flash) {
|
|
1562
|
+
const slot = (bucket, label) => {
|
|
1563
|
+
if (!bucket) return `${dim(label + ":")}${dim(formatPlaceholderPercentCell())}`;
|
|
1564
|
+
const gl = deriveGeminiLimits(bucket);
|
|
1565
|
+
const usedP = gl ? gl.usedPct : clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
|
|
1566
|
+
return `${dim(label + ":")}${colorByProvider(usedP, formatPercentCell(usedP), provFn)}`;
|
|
1567
|
+
};
|
|
1568
|
+
quotaSection = `${slot(pools.pro, "Pr")} ${slot(pools.flash, "Fl")}`;
|
|
1539
1569
|
} else {
|
|
1540
|
-
quotaSection = `${dim("
|
|
1570
|
+
quotaSection = `${dim("Pr:")}${dim(formatPlaceholderPercentCell())} ${dim("Fl:")}${dim(formatPlaceholderPercentCell())}`;
|
|
1541
1571
|
}
|
|
1542
1572
|
}
|
|
1543
1573
|
if (!quotaSection) {
|
|
@@ -1561,17 +1591,23 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
|
|
|
1561
1591
|
}
|
|
1562
1592
|
}
|
|
1563
1593
|
if (realQuota?.type === "gemini") {
|
|
1564
|
-
const
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
const
|
|
1568
|
-
|
|
1594
|
+
const pools = realQuota.pools || {};
|
|
1595
|
+
const hasAnyPool = pools.pro || pools.flash;
|
|
1596
|
+
if (hasAnyPool) {
|
|
1597
|
+
const slot = (bucket, label) => {
|
|
1598
|
+
if (!bucket) return `${dim(label + ":")}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))}`;
|
|
1599
|
+
const gl = deriveGeminiLimits(bucket);
|
|
1600
|
+
const usedP = gl ? gl.usedPct : clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
|
|
1601
|
+
const rstRemaining = formatResetRemaining(bucket.resetTime, ONE_DAY_MS) || "n/a";
|
|
1602
|
+
return `${dim(label + ":")}${colorByProvider(usedP, formatPercentCell(usedP), provFn)} ${dim(formatTimeCell(rstRemaining))}`;
|
|
1603
|
+
};
|
|
1604
|
+
quotaSection = `${slot(pools.pro, "Pr")} ${slot(pools.flash, "Fl")}`;
|
|
1569
1605
|
} else {
|
|
1570
|
-
quotaSection = `${dim("
|
|
1606
|
+
quotaSection = `${dim("Pr:")}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))} ${dim("Fl:")}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))}`;
|
|
1571
1607
|
}
|
|
1572
1608
|
}
|
|
1573
1609
|
if (!quotaSection) {
|
|
1574
|
-
quotaSection = `${dim("5h:")}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))} ${dim("1w:")}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCellDH("
|
|
1610
|
+
quotaSection = `${dim("5h:")}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))} ${dim("1w:")}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCellDH("--d--h"))}`;
|
|
1575
1611
|
}
|
|
1576
1612
|
const prefix = `${bold(markerColor(`${marker}`))}:`;
|
|
1577
1613
|
const compactRight = [svStr ? `${dim("sv:")}${svStr}` : "", accountLabel ? markerColor(accountLabel) : ""].filter(Boolean).join(" ");
|
|
@@ -1598,21 +1634,31 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
|
|
|
1598
1634
|
}
|
|
1599
1635
|
|
|
1600
1636
|
if (realQuota?.type === "gemini") {
|
|
1601
|
-
const
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1637
|
+
const pools = realQuota.pools || {};
|
|
1638
|
+
const hasAnyPool = pools.pro || pools.flash;
|
|
1639
|
+
|
|
1640
|
+
if (hasAnyPool) {
|
|
1641
|
+
// C/X와 동일한 2슬롯 구조: P:gauge %% (time) F:gauge %% (time)
|
|
1642
|
+
const slot = (bucket, label) => {
|
|
1643
|
+
if (!bucket) {
|
|
1644
|
+
return `${dim(label + ":")}${tierDimBar()}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))}`;
|
|
1645
|
+
}
|
|
1646
|
+
const gl = deriveGeminiLimits(bucket);
|
|
1647
|
+
const usedP = gl ? gl.usedPct : clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
|
|
1648
|
+
const rstRemaining = formatResetRemaining(bucket.resetTime, ONE_DAY_MS) || "n/a";
|
|
1649
|
+
return `${dim(label + ":")}${tierBar(usedP, provAnsi)}${colorByProvider(usedP, formatPercentCell(usedP), provFn)} ${dim(formatTimeCell(rstRemaining))}`;
|
|
1650
|
+
};
|
|
1651
|
+
|
|
1652
|
+
quotaSection = `${slot(pools.pro, "Pr")} ${slot(pools.flash, "Fl")}`;
|
|
1607
1653
|
} else {
|
|
1608
|
-
quotaSection = `${dim("
|
|
1609
|
-
`${dim(
|
|
1654
|
+
quotaSection = `${dim("Pr:")}${tierDimBar()}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))} ` +
|
|
1655
|
+
`${dim("Fl:")}${tierDimBar()}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))}`;
|
|
1610
1656
|
}
|
|
1611
1657
|
}
|
|
1612
1658
|
|
|
1613
1659
|
// 폴백: 쿼터 데이터 없을 때
|
|
1614
1660
|
if (!quotaSection) {
|
|
1615
|
-
quotaSection = `${dim("5h:")}${tierDimBar()}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))} ${dim("1w:")}${tierDimBar()}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCellDH("
|
|
1661
|
+
quotaSection = `${dim("5h:")}${tierDimBar()}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))} ${dim("1w:")}${tierDimBar()}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCellDH("--d--h"))}`;
|
|
1616
1662
|
}
|
|
1617
1663
|
|
|
1618
1664
|
const prefix = `${bold(markerColor(`${marker}`))}:`;
|
|
@@ -1710,11 +1756,15 @@ async function main() {
|
|
|
1710
1756
|
geminiSv = geminiTokens ? geminiTokens / ctxCapacity : null;
|
|
1711
1757
|
}
|
|
1712
1758
|
|
|
1713
|
-
// Gemini:
|
|
1759
|
+
// Gemini: 3풀 버킷 추출 (Pro/Flash/Lite — 각 풀 내 모델들은 쿼터 공유)
|
|
1714
1760
|
const geminiModel = geminiSession?.model || "gemini-3-flash-preview";
|
|
1715
|
-
const
|
|
1716
|
-
|
|
1761
|
+
const geminiBuckets = geminiQuota?.buckets || [];
|
|
1762
|
+
const geminiBucket = geminiBuckets.find((b) => b.modelId === geminiModel)
|
|
1763
|
+
|| geminiBuckets.find((b) => b.modelId === "gemini-3-flash-preview")
|
|
1717
1764
|
|| null;
|
|
1765
|
+
const geminiProBucket = geminiBuckets.find((b) => GEMINI_PRO_POOL.has(b.modelId)) || null;
|
|
1766
|
+
const geminiFlashBucket = geminiBuckets.find((b) => GEMINI_FLASH_POOL.has(b.modelId)) || null;
|
|
1767
|
+
const geminiLiteBucket = geminiBuckets.find((b) => b.modelId?.includes("flash-lite")) || null;
|
|
1718
1768
|
|
|
1719
1769
|
// 합산 절약: Codex+Gemini sv% 합산 (컨텍스트 대비 위임 토큰 비율)
|
|
1720
1770
|
const combinedSvPct = Math.round(((codexSv ?? 0) + (geminiSv ?? 0)) * 100);
|
|
@@ -1731,7 +1781,12 @@ async function main() {
|
|
|
1731
1781
|
}
|
|
1732
1782
|
|
|
1733
1783
|
const codexQuotaData = codexBuckets ? { type: "codex", buckets: codexBuckets } : null;
|
|
1734
|
-
const geminiQuotaData = {
|
|
1784
|
+
const geminiQuotaData = {
|
|
1785
|
+
type: "gemini",
|
|
1786
|
+
quotaBucket: geminiBucket,
|
|
1787
|
+
pools: { pro: geminiProBucket, flash: geminiFlashBucket, lite: geminiLiteBucket },
|
|
1788
|
+
session: geminiSession,
|
|
1789
|
+
};
|
|
1735
1790
|
|
|
1736
1791
|
const rows = [
|
|
1737
1792
|
...getClaudeRows(stdin, claudeUsageSnapshot.data, combinedSvPct),
|
|
@@ -1747,7 +1802,8 @@ async function main() {
|
|
|
1747
1802
|
|
|
1748
1803
|
// 비활성 프로바이더 dim 처리: 데이터 없으면 전체 줄 dim
|
|
1749
1804
|
const codexActive = codexBuckets != null;
|
|
1750
|
-
const geminiActive = (geminiSession?.total || 0) > 0 || geminiBucket != null
|
|
1805
|
+
const geminiActive = (geminiSession?.total || 0) > 0 || geminiBucket != null
|
|
1806
|
+
|| geminiProBucket != null || geminiFlashBucket != null;
|
|
1751
1807
|
|
|
1752
1808
|
let outputLines = renderAlignedRows(rows);
|
|
1753
1809
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "triflux",
|
|
3
|
-
"version": "4.2.
|
|
3
|
+
"version": "4.2.10",
|
|
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": {
|
|
@@ -47,6 +47,8 @@
|
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
49
49
|
"better-sqlite3": "^12.6.2",
|
|
50
|
+
"pino": "^10.3.1",
|
|
51
|
+
"pino-pretty": "^13.1.3",
|
|
50
52
|
"systray2": "^2.1.4"
|
|
51
53
|
},
|
|
52
54
|
"keywords": [
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 요청별 로그 컨텍스트 전파 (AsyncLocalStorage 기반).
|
|
3
|
+
*
|
|
4
|
+
* Hub HTTP 서버의 요청마다 correlationId를 자동 할당하여,
|
|
5
|
+
* 하나의 요청에서 발생한 모든 로그를 추적할 수 있다.
|
|
6
|
+
*
|
|
7
|
+
* 사용법:
|
|
8
|
+
* import { getLogger, getCorrelationId, withRequestContext } from './lib/context.mjs';
|
|
9
|
+
*
|
|
10
|
+
* // 미들웨어에서 컨텍스트 생성
|
|
11
|
+
* withRequestContext({ method: 'POST', path: '/bridge/result' }, () => {
|
|
12
|
+
* const log = getLogger();
|
|
13
|
+
* log.info({ agentId }, 'bridge.result_received');
|
|
14
|
+
* });
|
|
15
|
+
*
|
|
16
|
+
* // 내부 함수에서 자동 상관 ID
|
|
17
|
+
* function processResult() {
|
|
18
|
+
* const log = getLogger();
|
|
19
|
+
* log.info('result.processed'); // correlationId 자동 포함
|
|
20
|
+
* }
|
|
21
|
+
*/
|
|
22
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
23
|
+
import { randomUUID } from 'node:crypto';
|
|
24
|
+
|
|
25
|
+
import { logger } from './logger.mjs';
|
|
26
|
+
|
|
27
|
+
/** @type {AsyncLocalStorage<{logger: import('pino').Logger, correlationId: string}>} */
|
|
28
|
+
export const asyncLocalStorage = new AsyncLocalStorage();
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 현재 요청 컨텍스트의 로거를 반환한다.
|
|
32
|
+
* 요청 컨텍스트 밖에서 호출하면 기본 로거를 반환한다.
|
|
33
|
+
*
|
|
34
|
+
* @returns {import('pino').Logger}
|
|
35
|
+
*/
|
|
36
|
+
export function getLogger() {
|
|
37
|
+
return asyncLocalStorage.getStore()?.logger || logger;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 현재 요청의 상관 ID를 반환한다.
|
|
42
|
+
*
|
|
43
|
+
* @returns {string|undefined}
|
|
44
|
+
*/
|
|
45
|
+
export function getCorrelationId() {
|
|
46
|
+
return asyncLocalStorage.getStore()?.correlationId;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 요청 컨텍스트를 생성하고 콜백을 실행한다.
|
|
51
|
+
*
|
|
52
|
+
* @param {object} context — 컨텍스트 필드 (method, path 등)
|
|
53
|
+
* @param {string} [context.correlationId] — 외부에서 전달된 상관 ID (없으면 자동 생성)
|
|
54
|
+
* @param {function} callback — 컨텍스트 내에서 실행할 함수
|
|
55
|
+
* @returns {*}
|
|
56
|
+
*/
|
|
57
|
+
export function withRequestContext(context, callback) {
|
|
58
|
+
const correlationId = context.correlationId || randomUUID();
|
|
59
|
+
const { correlationId: _, ...rest } = context;
|
|
60
|
+
|
|
61
|
+
const store = {
|
|
62
|
+
correlationId,
|
|
63
|
+
logger: logger.child({ correlationId, ...rest }),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return asyncLocalStorage.run(store, callback);
|
|
67
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logify — triflux 구조화 로깅 설정
|
|
3
|
+
*
|
|
4
|
+
* 사용법:
|
|
5
|
+
* import { logger, createModuleLogger } from './lib/logger.mjs';
|
|
6
|
+
*
|
|
7
|
+
* // 기본 로거
|
|
8
|
+
* logger.info({ taskId: 'abc' }, 'task.started');
|
|
9
|
+
*
|
|
10
|
+
* // 모듈별 로거
|
|
11
|
+
* const log = createModuleLogger('hub');
|
|
12
|
+
* log.info({ port: 27888 }, 'server.started');
|
|
13
|
+
* log.error({ err }, 'server.error');
|
|
14
|
+
*
|
|
15
|
+
* 이벤트 네이밍: {도메인}.{액션} 형식
|
|
16
|
+
* hub.started, hub.stopped, route.started, route.completed,
|
|
17
|
+
* worker.spawned, worker.completed, worker.timeout,
|
|
18
|
+
* mcp.connected, mcp.disconnected, mcp.error,
|
|
19
|
+
* team.created, team.deleted, task.claimed, task.completed,
|
|
20
|
+
* pipe.connected, pipe.message, pipe.error,
|
|
21
|
+
* http.request, http.response, http.error
|
|
22
|
+
*
|
|
23
|
+
* 로그 레벨 가이드:
|
|
24
|
+
* debug — 개발/트러블슈팅용 (변수 값, MCP 메시지, 캐시 키)
|
|
25
|
+
* info — 정상 흐름 상태 변경 (서버 시작, 워커 완료, 팀 생성)
|
|
26
|
+
* warn — 위험 신호 (재시도 발생, 쿼타 임박, 느린 워커)
|
|
27
|
+
* error — 작업 실패 (CLI 실행 실패, MCP 연결 끊김)
|
|
28
|
+
* fatal — 프로세스 위협 (DB 연결 불가, 포트 충돌)
|
|
29
|
+
*/
|
|
30
|
+
import pino from 'pino';
|
|
31
|
+
|
|
32
|
+
const isDev = process.env.NODE_ENV !== 'production';
|
|
33
|
+
|
|
34
|
+
export const logger = pino({
|
|
35
|
+
level: process.env.LOG_LEVEL || (isDev ? 'debug' : 'info'),
|
|
36
|
+
|
|
37
|
+
// 모든 로그에 포함되는 기본 필드
|
|
38
|
+
base: {
|
|
39
|
+
service: process.env.SERVICE_NAME || 'triflux',
|
|
40
|
+
env: process.env.NODE_ENV || 'development',
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
// 레벨을 대문자로 출력 (AI 파싱 용이)
|
|
44
|
+
formatters: {
|
|
45
|
+
level: (label) => ({ level: label.toUpperCase() }),
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
// ISO 8601 타임스탬프
|
|
49
|
+
timestamp: pino.stdTimeFunctions.isoTime,
|
|
50
|
+
|
|
51
|
+
// 민감정보 자동 필터링
|
|
52
|
+
redact: {
|
|
53
|
+
paths: [
|
|
54
|
+
'password',
|
|
55
|
+
'token',
|
|
56
|
+
'apiKey',
|
|
57
|
+
'secret',
|
|
58
|
+
'authorization',
|
|
59
|
+
'*.password',
|
|
60
|
+
'*.token',
|
|
61
|
+
'*.apiKey',
|
|
62
|
+
'*.secret',
|
|
63
|
+
'req.headers.authorization',
|
|
64
|
+
'req.headers.cookie',
|
|
65
|
+
'hubToken',
|
|
66
|
+
],
|
|
67
|
+
remove: true,
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
// 개발 환경: 컬러 콘솔 출력
|
|
71
|
+
transport: isDev
|
|
72
|
+
? {
|
|
73
|
+
target: 'pino-pretty',
|
|
74
|
+
options: {
|
|
75
|
+
colorize: true,
|
|
76
|
+
translateTime: 'yyyy-mm-dd HH:MM:ss',
|
|
77
|
+
ignore: 'pid,hostname',
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
: undefined,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 모듈별 Child Logger 생성.
|
|
85
|
+
* 모듈 이름이 모든 로그에 자동 포함된다.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} module — 모듈 이름 (hub, route, worker, mcp, team 등)
|
|
88
|
+
* @returns {import('pino').Logger}
|
|
89
|
+
*/
|
|
90
|
+
export function createModuleLogger(module) {
|
|
91
|
+
return logger.child({ module });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 정상 종료 시 버퍼 flush 보장
|
|
95
|
+
process.on('uncaughtException', (err) => {
|
|
96
|
+
const finalLogger = pino.final(logger);
|
|
97
|
+
finalLogger.fatal({ err }, 'process.uncaught_exception');
|
|
98
|
+
process.exit(1);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
process.on('unhandledRejection', (reason) => {
|
|
102
|
+
const finalLogger = pino.final(logger);
|
|
103
|
+
finalLogger.fatal({ reason: String(reason) }, 'process.unhandled_rejection');
|
|
104
|
+
process.exit(1);
|
|
105
|
+
});
|
package/scripts/tfx-route.sh
CHANGED
|
@@ -9,17 +9,111 @@
|
|
|
9
9
|
# - Gemini health check 지수 백오프 (30×1s → 5×exp)
|
|
10
10
|
# - 컨텍스트 파일 5번째 인자 지원
|
|
11
11
|
#
|
|
12
|
-
VERSION="2.
|
|
12
|
+
VERSION="2.5"
|
|
13
13
|
#
|
|
14
14
|
# 사용법:
|
|
15
15
|
# tfx-route.sh <agent_type> <prompt> [mcp_profile] [timeout_sec] [context_file]
|
|
16
|
+
# tfx-route.sh --async <agent_type> <prompt> [mcp_profile] [timeout_sec] [context_file]
|
|
17
|
+
# tfx-route.sh --job-status <job_id>
|
|
18
|
+
# tfx-route.sh --job-result <job_id>
|
|
19
|
+
#
|
|
20
|
+
# --async: 백그라운드 실행, 즉시 job_id 반환 (Claude Code Bash 600초 제한 우회)
|
|
21
|
+
# --job-status: running | done | timeout | failed
|
|
22
|
+
# --job-result: 완료된 잡의 전체 출력
|
|
16
23
|
#
|
|
17
24
|
# 예시:
|
|
18
25
|
# tfx-route.sh executor "코드 구현" implement
|
|
19
|
-
# tfx-route.sh
|
|
26
|
+
# tfx-route.sh --async scientist "딥 리서치" auto 1440
|
|
27
|
+
# tfx-route.sh --job-status 1742400000-12345-9876
|
|
28
|
+
# tfx-route.sh --job-result 1742400000-12345-9876
|
|
20
29
|
|
|
21
30
|
set -euo pipefail
|
|
22
31
|
|
|
32
|
+
# ── Async Job 디렉토리 ──
|
|
33
|
+
TFX_JOBS_DIR="${TMPDIR:-/tmp}/tfx-jobs"
|
|
34
|
+
|
|
35
|
+
# ── --job-status / --job-result 핸들러 (인자 파싱 전에 처리) ──
|
|
36
|
+
if [[ "${1:-}" == "--job-status" ]]; then
|
|
37
|
+
job_id="${2:?job_id 필수}"
|
|
38
|
+
job_dir="$TFX_JOBS_DIR/$job_id"
|
|
39
|
+
[[ -d "$job_dir" ]] || { echo "error: job not found"; exit 1; }
|
|
40
|
+
|
|
41
|
+
if [[ -f "$job_dir/done" ]]; then
|
|
42
|
+
exit_code=$(cat "$job_dir/exit_code" 2>/dev/null || echo 1)
|
|
43
|
+
if [[ "$exit_code" -eq 0 ]]; then
|
|
44
|
+
echo "done"
|
|
45
|
+
elif [[ "$exit_code" -eq 124 ]]; then
|
|
46
|
+
echo "timeout"
|
|
47
|
+
else
|
|
48
|
+
echo "failed"
|
|
49
|
+
fi
|
|
50
|
+
elif [[ -f "$job_dir/pid" ]]; then
|
|
51
|
+
pid=$(cat "$job_dir/pid")
|
|
52
|
+
if kill -0 "$pid" 2>/dev/null; then
|
|
53
|
+
# 진행 상황 힌트
|
|
54
|
+
local_bytes=$(wc -c < "$job_dir/stdout.log" 2>/dev/null || echo 0)
|
|
55
|
+
elapsed=$(( $(date +%s) - $(cat "$job_dir/start_time" 2>/dev/null || date +%s) ))
|
|
56
|
+
echo "running elapsed=${elapsed}s output=${local_bytes}B"
|
|
57
|
+
else
|
|
58
|
+
# 프로세스 종료됐는데 done 마커 없음 → 비정상 종료
|
|
59
|
+
echo "failed"
|
|
60
|
+
fi
|
|
61
|
+
else
|
|
62
|
+
echo "error: invalid job state"
|
|
63
|
+
exit 1
|
|
64
|
+
fi
|
|
65
|
+
exit 0
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
if [[ "${1:-}" == "--job-result" ]]; then
|
|
69
|
+
job_id="${2:?job_id 필수}"
|
|
70
|
+
job_dir="$TFX_JOBS_DIR/$job_id"
|
|
71
|
+
[[ -d "$job_dir" ]] || { echo "error: job not found"; exit 1; }
|
|
72
|
+
[[ -f "$job_dir/done" ]] || { echo "error: job still running"; exit 1; }
|
|
73
|
+
|
|
74
|
+
cat "$job_dir/result.log" 2>/dev/null
|
|
75
|
+
exit_code=$(cat "$job_dir/exit_code" 2>/dev/null || echo 1)
|
|
76
|
+
exit "$exit_code"
|
|
77
|
+
fi
|
|
78
|
+
|
|
79
|
+
# ── --job-wait: 내부 폴링으로 완료 대기 (Bash 도구 호출 횟수 최소화) ──
|
|
80
|
+
# 사용법: tfx-route.sh --job-wait <job_id> [max_seconds=540]
|
|
81
|
+
# 출력: 주기적 "waiting elapsed=Ns" + 최종 "done"|"timeout"|"failed"|"still_running"
|
|
82
|
+
if [[ "${1:-}" == "--job-wait" ]]; then
|
|
83
|
+
job_id="${2:?job_id 필수}"
|
|
84
|
+
max_wait="${3:-540}" # 기본 540초 (9분, Bash 도구 600초 제한 이내)
|
|
85
|
+
poll_interval=15
|
|
86
|
+
job_dir="$TFX_JOBS_DIR/$job_id"
|
|
87
|
+
[[ -d "$job_dir" ]] || { echo "error: job not found"; exit 1; }
|
|
88
|
+
|
|
89
|
+
elapsed=0
|
|
90
|
+
while [[ "$elapsed" -lt "$max_wait" ]]; do
|
|
91
|
+
if [[ -f "$job_dir/done" ]]; then
|
|
92
|
+
ec=$(cat "$job_dir/exit_code" 2>/dev/null || echo 1)
|
|
93
|
+
if [[ "$ec" -eq 0 ]]; then echo "done"
|
|
94
|
+
elif [[ "$ec" -eq 124 ]]; then echo "timeout"
|
|
95
|
+
else echo "failed (exit=$ec)"
|
|
96
|
+
fi
|
|
97
|
+
exit 0
|
|
98
|
+
fi
|
|
99
|
+
sleep "$poll_interval"
|
|
100
|
+
elapsed=$((elapsed + poll_interval))
|
|
101
|
+
stderr_bytes=$(wc -c < "$job_dir/stderr.log" 2>/dev/null || echo 0)
|
|
102
|
+
echo "waiting elapsed=${elapsed}s progress=${stderr_bytes}B"
|
|
103
|
+
done
|
|
104
|
+
|
|
105
|
+
# max_wait 도달했지만 아직 실행 중
|
|
106
|
+
echo "still_running elapsed=${elapsed}s"
|
|
107
|
+
exit 0
|
|
108
|
+
fi
|
|
109
|
+
|
|
110
|
+
# ── --async 플래그 감지 ──
|
|
111
|
+
TFX_ASYNC_MODE=0
|
|
112
|
+
if [[ "${1:-}" == "--async" ]]; then
|
|
113
|
+
TFX_ASYNC_MODE=1
|
|
114
|
+
shift
|
|
115
|
+
fi
|
|
116
|
+
|
|
23
117
|
# ── 인자 파싱 ──
|
|
24
118
|
AGENT_TYPE="${1:?에이전트 타입 필수 (executor, debugger, designer 등)}"
|
|
25
119
|
PROMPT="${2:?프롬프트 필수}"
|
|
@@ -1518,4 +1612,40 @@ EOF
|
|
|
1518
1612
|
return "$exit_code"
|
|
1519
1613
|
}
|
|
1520
1614
|
|
|
1615
|
+
# ── Async 모드: 백그라운드 실행 + 즉시 job_id 반환 ──
|
|
1616
|
+
if [[ "$TFX_ASYNC_MODE" -eq 1 ]]; then
|
|
1617
|
+
mkdir -p "$TFX_JOBS_DIR"
|
|
1618
|
+
JOB_ID="$TIMESTAMP-$$-${RANDOM}"
|
|
1619
|
+
JOB_DIR="$TFX_JOBS_DIR/$JOB_ID"
|
|
1620
|
+
mkdir -p "$JOB_DIR"
|
|
1621
|
+
echo "$AGENT_TYPE" > "$JOB_DIR/agent_type"
|
|
1622
|
+
date +%s > "$JOB_DIR/start_time"
|
|
1623
|
+
|
|
1624
|
+
# 백그라운드 서브쉘: main 실행 → 결과 저장
|
|
1625
|
+
(
|
|
1626
|
+
set +e # main 내부 에러가 exit_code 기록 전에 서브쉘을 죽이는 것 방지
|
|
1627
|
+
exec > "$JOB_DIR/result.log" 2>"$JOB_DIR/stderr.log"
|
|
1628
|
+
main
|
|
1629
|
+
echo $? > "$JOB_DIR/exit_code"
|
|
1630
|
+
touch "$JOB_DIR/done"
|
|
1631
|
+
) &
|
|
1632
|
+
bg_pid=$!
|
|
1633
|
+
echo "$bg_pid" > "$JOB_DIR/pid"
|
|
1634
|
+
|
|
1635
|
+
# 종료 감지 데몬 (main이 signal/crash로 죽어도 done 마커 생성)
|
|
1636
|
+
(
|
|
1637
|
+
wait "$bg_pid" 2>/dev/null
|
|
1638
|
+
ec=$?
|
|
1639
|
+
if [[ ! -f "$JOB_DIR/done" ]]; then
|
|
1640
|
+
echo "$ec" > "$JOB_DIR/exit_code"
|
|
1641
|
+
touch "$JOB_DIR/done"
|
|
1642
|
+
fi
|
|
1643
|
+
) &
|
|
1644
|
+
disown
|
|
1645
|
+
|
|
1646
|
+
# 즉시 리턴: 1초 이내에 Claude Code Bash 도구 완료
|
|
1647
|
+
echo "$JOB_ID"
|
|
1648
|
+
exit 0
|
|
1649
|
+
fi
|
|
1650
|
+
|
|
1521
1651
|
main
|
|
@@ -170,7 +170,7 @@ status는 "completed"만 사용. 실패 여부는 `metadata.result`로 구분.
|
|
|
170
170
|
|
|
171
171
|
| 항목 | 설명 |
|
|
172
172
|
|------|------|
|
|
173
|
-
| `scripts/tfx-route.sh` | 팀 통합 라우터 |
|
|
173
|
+
| `scripts/tfx-route.sh` | 팀 통합 라우터 (v2.5: `--async`/`--job-wait`/`--job-status`/`--job-result`) |
|
|
174
174
|
| `hub/team/native.mjs` | Native Teams 래퍼 (프롬프트 템플릿) |
|
|
175
175
|
| `hub/pipeline/` | 파이프라인 상태 기계 (`--thorough` 모드) |
|
|
176
176
|
| `tfx-auto` | one-shot 실행 오케스트레이터 |
|
|
@@ -58,12 +58,25 @@ codex-worker는 반드시 tfx-route.sh를 통해 Codex에 위임하고, gemini-w
|
|
|
58
58
|
|
|
59
59
|
리드는 워커의 Step 2, Step 4 시점에 턴 경계를 인식하고, 방향 전환/추가 지시/재실행 요청을 보낼 수 있다.
|
|
60
60
|
|
|
61
|
-
##
|
|
61
|
+
## Async 실행 프로토콜 (v2.5+)
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
Claude Code Bash 도구는 최대 600초(10분) 하드코딩 제한이 있다.
|
|
64
|
+
scientist(24분), scientist-deep(60분) 등 장시간 워커는 이 제한에 걸린다.
|
|
65
|
+
|
|
66
|
+
**해결: `--async` 3단계 패턴**
|
|
67
|
+
|
|
68
|
+
| 단계 | 명령 | Bash timeout | 소요 |
|
|
69
|
+
|------|------|-------------|------|
|
|
70
|
+
| 시작 | `tfx-route.sh --async {role} '{task}' {profile} {timeout}` | 15초 | <1초 |
|
|
71
|
+
| 대기 | `tfx-route.sh --job-wait {job_id} 540` | 570초 | 최대 540초/회 |
|
|
72
|
+
| 결과 | `tfx-route.sh --job-result {job_id}` | 30초 | <1초 |
|
|
73
|
+
|
|
74
|
+
- `--job-wait`는 내부에서 15초 간격으로 폴링하며 `done`/`timeout`/`failed`/`still_running` 반환
|
|
75
|
+
- `still_running` 시 같은 `--job-wait` 명령을 반복 (무한 반복 가능)
|
|
76
|
+
- 실제 워커 timeout은 tfx-route.sh의 `timeout` 명령으로 관리 (Bash 도구와 무관)
|
|
77
|
+
|
|
78
|
+
**이전 방식 (deprecated):**
|
|
79
|
+
Bash timeout을 role/profile별 timeout + 60초로 설정했으나, 600초 초과 시 Bash 도구가 강제 종료했다.
|
|
67
80
|
|
|
68
81
|
## tfx-route.sh 팀 통합 동작
|
|
69
82
|
|