triflux 4.2.9 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/tfx-doctor.mjs +1 -1
- package/bin/tfx-setup.mjs +1 -1
- package/bin/triflux.mjs +1 -1
- package/hub/middleware/request-logger.mjs +81 -0
- package/hub/pipeline/index.mjs +19 -0
- package/hub/server.mjs +12 -8
- package/hub/team/native.mjs +82 -32
- package/hub/team/routing.mjs +154 -0
- package/hub/tools.mjs +66 -1
- package/hud/hud-qos-status.mjs +103 -47
- package/package.json +3 -1
- package/scripts/lib/context.mjs +67 -0
- package/scripts/lib/logger.mjs +105 -0
- package/scripts/lib/mcp-filter.mjs +45 -3
- package/skills/tfx-auto/SKILL.md +72 -14
- package/skills/tfx-multi/SKILL.md +12 -6
- package/skills/tfx-multi/references/thorough-pipeline.md +57 -18
package/bin/tfx-doctor.mjs
CHANGED
package/bin/tfx-setup.mjs
CHANGED
package/bin/triflux.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
// triflux CLI — setup, doctor, version
|
|
3
3
|
import { copyFileSync, existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, readdirSync, unlinkSync, rmSync, statSync, openSync, closeSync } from "fs";
|
|
4
4
|
import { join, dirname } from "path";
|
|
@@ -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/pipeline/index.mjs
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
//
|
|
3
3
|
// 상태(state.mjs) + 전이(transitions.mjs) 통합 인터페이스
|
|
4
4
|
|
|
5
|
+
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
6
|
+
import { join, resolve } from 'node:path';
|
|
7
|
+
|
|
5
8
|
import { canTransition, transitionPhase, ralphRestart, TERMINAL } from './transitions.mjs';
|
|
6
9
|
import {
|
|
7
10
|
ensurePipelineTable,
|
|
@@ -92,6 +95,22 @@ export function createPipeline(db, teamName, opts = {}) {
|
|
|
92
95
|
state = updatePipelineState(db, teamName, { artifacts });
|
|
93
96
|
},
|
|
94
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Plan 파일을 .tfx/plans/{teamName}-plan.md 에 기록하고
|
|
100
|
+
* artifact('plan_path')에 절대 경로를 저장한다.
|
|
101
|
+
* @param {string} content - Plan markdown 내용
|
|
102
|
+
* @returns {string} 절대 경로
|
|
103
|
+
*/
|
|
104
|
+
writePlanFile(content) {
|
|
105
|
+
const safeName = teamName.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
|
|
106
|
+
const planDir = resolve(process.cwd(), '.tfx', 'plans');
|
|
107
|
+
mkdirSync(planDir, { recursive: true });
|
|
108
|
+
const planPath = join(planDir, `${safeName}-plan.md`);
|
|
109
|
+
writeFileSync(planPath, content, 'utf8');
|
|
110
|
+
this.setArtifact('plan_path', planPath);
|
|
111
|
+
return planPath;
|
|
112
|
+
},
|
|
113
|
+
|
|
95
114
|
/**
|
|
96
115
|
* 터미널 상태 여부
|
|
97
116
|
*/
|
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
|
@@ -10,9 +10,17 @@ import * as fs from "node:fs/promises";
|
|
|
10
10
|
import os from "node:os";
|
|
11
11
|
import path from "node:path";
|
|
12
12
|
|
|
13
|
-
const ROUTE_SCRIPT = "~/.claude/scripts/tfx-route.sh";
|
|
14
|
-
export const SLIM_WRAPPER_SUBAGENT_TYPE = "slim-wrapper";
|
|
15
|
-
|
|
13
|
+
const ROUTE_SCRIPT = "~/.claude/scripts/tfx-route.sh";
|
|
14
|
+
export const SLIM_WRAPPER_SUBAGENT_TYPE = "slim-wrapper";
|
|
15
|
+
/** scout 역할 기본 설정 — read-only 탐색 전용 */
|
|
16
|
+
export const SCOUT_ROLE_CONFIG = {
|
|
17
|
+
cli: "codex",
|
|
18
|
+
role: "scientist",
|
|
19
|
+
mcp_profile: "analyze",
|
|
20
|
+
maxIterations: 2,
|
|
21
|
+
readOnly: true,
|
|
22
|
+
};
|
|
23
|
+
const ROUTE_LOG_RE = /\[tfx-route\]/i;
|
|
16
24
|
const ROUTE_COMMAND_RE = /(?:^|[\s"'`])(?:bash\s+)?(?:[^"'`\s]*\/)?tfx-route\.sh\b/i;
|
|
17
25
|
const ROUTE_PROMPT_RE = /tfx-route\.sh/i;
|
|
18
26
|
const DIRECT_TOOL_BYPASS_RE = /\b(?:Read|Edit|Write)\s*\(/;
|
|
@@ -143,9 +151,10 @@ function getRouteTimeout(role, _mcpProfile) {
|
|
|
143
151
|
* @param {number} [opts.workerIndex] — 검색 힌트 회전에 사용할 워커 인덱스(1-based)
|
|
144
152
|
* @param {string} [opts.searchTool] — 전용 검색 도구 힌트(brave-search|tavily|exa)
|
|
145
153
|
* @param {number} [opts.bashTimeout] — (deprecated, async에서는 무시됨)
|
|
154
|
+
* @param {number} [opts.maxIterations=3] — 피드백 루프 최대 반복 횟수
|
|
146
155
|
* @returns {string} 슬림 래퍼 프롬프트
|
|
147
156
|
*/
|
|
148
|
-
export function buildSlimWrapperPrompt(cli, opts = {}) {
|
|
157
|
+
export function buildSlimWrapperPrompt(cli, opts = {}) {
|
|
149
158
|
const {
|
|
150
159
|
subtask,
|
|
151
160
|
role = "executor",
|
|
@@ -157,30 +166,36 @@ export function buildSlimWrapperPrompt(cli, opts = {}) {
|
|
|
157
166
|
workerIndex,
|
|
158
167
|
searchTool = "",
|
|
159
168
|
pipelinePhase = "",
|
|
169
|
+
maxIterations = 3,
|
|
160
170
|
} = opts;
|
|
161
171
|
|
|
162
172
|
const routeTimeoutSec = getRouteTimeout(role, mcp_profile);
|
|
163
173
|
const escaped = subtask.replace(/'/g, "'\\''");
|
|
164
|
-
const pipelineHint = pipelinePhase
|
|
165
|
-
? `\n파이프라인 단계: ${pipelinePhase}`
|
|
166
|
-
: '';
|
|
167
|
-
const routeEnvPrefix = buildRouteEnvPrefix(agentName, workerIndex, searchTool);
|
|
174
|
+
const pipelineHint = pipelinePhase
|
|
175
|
+
? `\n파이프라인 단계: ${pipelinePhase}`
|
|
176
|
+
: '';
|
|
177
|
+
const routeEnvPrefix = buildRouteEnvPrefix(agentName, workerIndex, searchTool);
|
|
178
|
+
const scoutConstraint = (role === "scout" || role === "scientist")
|
|
179
|
+
? "\n이 워커는 scout(탐색 전용)이다. 코드를 수정하거나 파일을 생성하지 마라. 기존 코드를 읽고 분석하여 보고만 하라."
|
|
180
|
+
: "";
|
|
168
181
|
|
|
169
182
|
// Bash 도구 timeout (모두 600초 이내)
|
|
170
183
|
const launchTimeoutMs = 15000; // Step 1: fork + job_id 반환
|
|
171
184
|
const waitTimeoutMs = 570000; // Step 2: 내부 폴링 (540초 대기 + 여유)
|
|
172
185
|
const resultTimeoutMs = 30000; // Step 3: 결과 읽기
|
|
173
186
|
|
|
174
|
-
return `실행 프로토콜 (subagent_type="${SLIM_WRAPPER_SUBAGENT_TYPE}", async):
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
3. "still_running"이면 Step 2 반복, "done"이면 --job-result로 결과 수집
|
|
178
|
-
4. TaskUpdate + SendMessage → 종료${pipelineHint}
|
|
187
|
+
return `실행 프로토콜 (subagent_type="${SLIM_WRAPPER_SUBAGENT_TYPE}", async + feedback):
|
|
188
|
+
MAX_ITERATIONS = ${maxIterations}
|
|
189
|
+
ITERATION = 0${pipelineHint}
|
|
179
190
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
191
|
+
Step 0 — 시작 보고 (턴 경계 생성):
|
|
192
|
+
TaskUpdate(taskId: "${taskId}", status: "in_progress")
|
|
193
|
+
SendMessage(type: "message", recipient: "${leadName}", content: "작업 시작: ${agentName}", summary: "task ${taskId} started")
|
|
194
|
+
|
|
195
|
+
[HARD CONSTRAINT] 허용 도구: Bash, TaskUpdate, TaskGet, TaskList, SendMessage만 사용한다.
|
|
196
|
+
Read, Edit, Write, Grep, Glob, Agent, WebSearch, WebFetch 등 다른 모든 도구 사용을 금지한다.
|
|
197
|
+
코드를 직접 읽거나 수정하면 안 된다. 반드시 아래 Bash 명령(tfx-route.sh)을 통해 Codex/Gemini에 위임하라.
|
|
198
|
+
이 규칙을 위반하면 작업 실패로 간주한다.${scoutConstraint}
|
|
184
199
|
|
|
185
200
|
gemini/codex를 직접 호출하지 마라. 반드시 tfx-route.sh를 거쳐야 한다.
|
|
186
201
|
프롬프트를 파일로 저장하지 마라. tfx-route.sh가 인자로 받는다.
|
|
@@ -193,29 +208,64 @@ Step 2 — 완료 대기 (내부 폴링, 최대 540초):
|
|
|
193
208
|
Bash(command: 'bash ${ROUTE_SCRIPT} --job-wait JOB_ID 540', timeout: ${waitTimeoutMs})
|
|
194
209
|
→ 주기적 "waiting elapsed=Ns progress=NB" 출력 후 최종 상태:
|
|
195
210
|
"done" → Step 3으로
|
|
196
|
-
"timeout" 또는 "failed ..." → Step 4로 (실패
|
|
211
|
+
"timeout" 또는 "failed ..." → Step 4로 (실패 상태로)
|
|
197
212
|
"still_running ..." → Step 2 반복 (같은 명령 재실행)
|
|
198
213
|
|
|
199
214
|
Step 3 — 결과 수집:
|
|
200
215
|
Bash(command: 'bash ${ROUTE_SCRIPT} --job-result JOB_ID', timeout: ${resultTimeoutMs})
|
|
201
|
-
→
|
|
216
|
+
→ RESULT에 저장.
|
|
202
217
|
|
|
203
|
-
Step 4 —
|
|
218
|
+
Step 4 — 결과 보고 (턴 경계 생성, TaskUpdate 하지 않음):
|
|
204
219
|
"done"이면:
|
|
205
|
-
|
|
206
|
-
SendMessage(type: "message", recipient: "${leadName}", content: "완료: ${agentName}", summary: "task ${taskId} success")
|
|
220
|
+
SendMessage(type: "message", recipient: "${leadName}", content: "결과 (iteration ITERATION): ${agentName} 성공\\n{결과 요약}", summary: "task ${taskId} iteration ITERATION done")
|
|
207
221
|
"timeout" 또는 "failed"이면:
|
|
208
|
-
|
|
209
|
-
SendMessage(type: "message", recipient: "${leadName}", content: "실패: ${agentName} (상태)", summary: "task ${taskId} failed")
|
|
222
|
+
SendMessage(type: "message", recipient: "${leadName}", content: "결과 (iteration ITERATION): ${agentName} 실패\\n{에러 요약}", summary: "task ${taskId} iteration ITERATION failed")
|
|
210
223
|
TFX_NEEDS_FALLBACK 출력 감지 시:
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
224
|
+
→ Step 6으로 즉시 이동 (fallback은 재실행 불가)
|
|
225
|
+
|
|
226
|
+
Step 5 — 피드백 대기:
|
|
227
|
+
SendMessage 후 너는 IDLE 상태가 된다. 리드의 응답을 기다려라.
|
|
228
|
+
수신 메시지에 따라:
|
|
229
|
+
- "재실행:" 포함 → ITERATION++ → ITERATION < MAX_ITERATIONS이면 메시지의 지시를 반영하여 Step 1로. ITERATION >= MAX_ITERATIONS이면 Step 6으로 (반복 한도 초과)
|
|
230
|
+
- "승인" 또는 기타 → Step 6으로
|
|
231
|
+
- 메시지 없이 팀이 삭제되면 자동 종료 (처리 불필요)
|
|
232
|
+
|
|
233
|
+
Step 6 — 최종 종료 (반드시 실행):
|
|
234
|
+
TaskUpdate(taskId: "${taskId}", status: "completed", metadata: {result: "success"|"failed"|"fallback", iterations: ITERATION})
|
|
235
|
+
SendMessage(type: "message", recipient: "${leadName}", content: "최종 완료: ${agentName} (ITERATION회 실행)", summary: "task ${taskId} final")
|
|
236
|
+
→ 종료. 이후 추가 도구 호출 금지.`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* scout 파견용 프롬프트 생성
|
|
241
|
+
* @param {object} opts
|
|
242
|
+
* @param {string} opts.question — 탐색 질문
|
|
243
|
+
* @param {string} [opts.scope] — 탐색 범위 힌트 (파일 패턴)
|
|
244
|
+
* @param {string} [opts.teamName] — 팀 이름
|
|
245
|
+
* @param {string} [opts.taskId] — 태스크 ID
|
|
246
|
+
* @param {string} [opts.agentName] — 에이전트 이름
|
|
247
|
+
* @param {string} [opts.leadName] — 리드 이름
|
|
248
|
+
* @returns {string} slim wrapper 프롬프트
|
|
249
|
+
*/
|
|
250
|
+
export function buildScoutDispatchPrompt(opts = {}) {
|
|
251
|
+
const { question, scope = "", teamName, taskId, agentName, leadName } = opts;
|
|
252
|
+
const subtask = scope
|
|
253
|
+
? `${question} 탐색 범위: ${scope}`
|
|
254
|
+
: question;
|
|
255
|
+
return buildSlimWrapperPrompt("codex", {
|
|
256
|
+
subtask,
|
|
257
|
+
role: "scientist",
|
|
258
|
+
teamName,
|
|
259
|
+
taskId,
|
|
260
|
+
agentName,
|
|
261
|
+
leadName,
|
|
262
|
+
mcp_profile: "analyze",
|
|
263
|
+
maxIterations: SCOUT_ROLE_CONFIG.maxIterations,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* v3 하이브리드 래퍼 프롬프트 생성
|
|
219
269
|
* psmux pane 기반 비동기 실행 + polling 패턴.
|
|
220
270
|
* Agent가 idle 상태를 유지하여 인터럽트 수신이 가능하다.
|
|
221
271
|
*
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 라우팅 결정 함수
|
|
3
|
+
* @param {object} opts
|
|
4
|
+
* @param {Array<{id:string, description?:string, agent?:string, depends_on?:string[], complexity?:string}>} opts.subtasks
|
|
5
|
+
* @param {string} opts.graph_type - "INDEPENDENT" | "SEQUENTIAL" | "DAG"
|
|
6
|
+
* @param {boolean} opts.thorough - thorough 모드 여부
|
|
7
|
+
* @returns {{
|
|
8
|
+
* strategy: "quick_single" | "thorough_single" | "quick_team" | "thorough_team" | "batch_single",
|
|
9
|
+
* reason: string,
|
|
10
|
+
* dag_width: number,
|
|
11
|
+
* max_complexity: string
|
|
12
|
+
* }}
|
|
13
|
+
*/
|
|
14
|
+
export function resolveRoutingStrategy({ subtasks, graph_type, thorough }) {
|
|
15
|
+
const N = subtasks.length;
|
|
16
|
+
if (N === 0) {
|
|
17
|
+
return { strategy: 'quick_single', reason: 'empty_subtasks', dag_width: 0, max_complexity: 'S' };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const dag_width = computeDagWidth(subtasks, graph_type);
|
|
21
|
+
const max_complexity = getMaxComplexity(subtasks);
|
|
22
|
+
const isHighComplexity = ['L', 'XL'].includes(max_complexity);
|
|
23
|
+
const allSameAgent = new Set(subtasks.map((s) => s.agent)).size === 1;
|
|
24
|
+
const allSmall = subtasks.every((s) => normalizeComplexity(s.complexity) === 'S');
|
|
25
|
+
|
|
26
|
+
// N==1: 단일 태스크
|
|
27
|
+
if (N === 1) {
|
|
28
|
+
if (thorough || isHighComplexity) {
|
|
29
|
+
return {
|
|
30
|
+
strategy: 'thorough_single',
|
|
31
|
+
reason: 'single_high_complexity',
|
|
32
|
+
dag_width,
|
|
33
|
+
max_complexity,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
strategy: 'quick_single',
|
|
38
|
+
reason: 'single_low_complexity',
|
|
39
|
+
dag_width,
|
|
40
|
+
max_complexity,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// dag_width==1: 사실상 순차 -> single
|
|
45
|
+
if (dag_width === 1) {
|
|
46
|
+
if (thorough || isHighComplexity) {
|
|
47
|
+
return {
|
|
48
|
+
strategy: 'thorough_single',
|
|
49
|
+
reason: 'sequential_chain',
|
|
50
|
+
dag_width,
|
|
51
|
+
max_complexity,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
strategy: 'quick_single',
|
|
56
|
+
reason: 'sequential_chain',
|
|
57
|
+
dag_width,
|
|
58
|
+
max_complexity,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 동일 에이전트 + 모두 S: 프롬프트 병합 -> batch single
|
|
63
|
+
if (allSameAgent && allSmall) {
|
|
64
|
+
return {
|
|
65
|
+
strategy: 'batch_single',
|
|
66
|
+
reason: 'same_agent_small_batch',
|
|
67
|
+
dag_width,
|
|
68
|
+
max_complexity,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// dag_width >= 2: 팀
|
|
73
|
+
if (thorough || isHighComplexity) {
|
|
74
|
+
return {
|
|
75
|
+
strategy: 'thorough_team',
|
|
76
|
+
reason: 'parallel_high_complexity',
|
|
77
|
+
dag_width,
|
|
78
|
+
max_complexity,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
strategy: 'quick_team',
|
|
83
|
+
reason: 'parallel_low_complexity',
|
|
84
|
+
dag_width,
|
|
85
|
+
max_complexity,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* DAG 폭 계산 - 레벨별 최대 병렬 태스크 수
|
|
91
|
+
* @param {Array<{id:string, depends_on?:string[]}>} subtasks
|
|
92
|
+
* @param {string} graph_type
|
|
93
|
+
* @returns {number}
|
|
94
|
+
*/
|
|
95
|
+
function computeDagWidth(subtasks, graph_type) {
|
|
96
|
+
if (graph_type === 'SEQUENTIAL') return 1;
|
|
97
|
+
if (graph_type === 'INDEPENDENT') return subtasks.length;
|
|
98
|
+
|
|
99
|
+
// DAG: 레벨별 계산 (순환 의존 방어)
|
|
100
|
+
const levels = {};
|
|
101
|
+
const visiting = new Set();
|
|
102
|
+
|
|
103
|
+
function getLevel(task) {
|
|
104
|
+
if (levels[task.id] !== undefined) return levels[task.id];
|
|
105
|
+
if (visiting.has(task.id)) {
|
|
106
|
+
levels[task.id] = 0; // 순환 끊기
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
if (!task.depends_on || task.depends_on.length === 0) {
|
|
110
|
+
levels[task.id] = 0;
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
visiting.add(task.id);
|
|
114
|
+
const depLevels = task.depends_on.map((depId) => {
|
|
115
|
+
const dep = subtasks.find((s) => s.id === depId);
|
|
116
|
+
return dep ? getLevel(dep) : 0;
|
|
117
|
+
});
|
|
118
|
+
visiting.delete(task.id);
|
|
119
|
+
levels[task.id] = Math.max(...depLevels) + 1;
|
|
120
|
+
return levels[task.id];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
subtasks.forEach(getLevel);
|
|
124
|
+
|
|
125
|
+
const levelCounts = {};
|
|
126
|
+
for (const level of Object.values(levels)) {
|
|
127
|
+
levelCounts[level] = (levelCounts[level] || 0) + 1;
|
|
128
|
+
}
|
|
129
|
+
return Math.max(...Object.values(levelCounts), 1);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 최대 복잡도 추출
|
|
134
|
+
* @param {Array<{complexity?:string}>} subtasks
|
|
135
|
+
* @returns {"S" | "M" | "L" | "XL"}
|
|
136
|
+
*/
|
|
137
|
+
function getMaxComplexity(subtasks) {
|
|
138
|
+
const order = { S: 0, M: 1, L: 2, XL: 3 };
|
|
139
|
+
let max = 'S';
|
|
140
|
+
for (const s of subtasks) {
|
|
141
|
+
const complexity = normalizeComplexity(s.complexity);
|
|
142
|
+
if (order[complexity] > order[max]) max = complexity;
|
|
143
|
+
}
|
|
144
|
+
return max;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* complexity 기본값 보정
|
|
149
|
+
* @param {string | undefined} complexity
|
|
150
|
+
* @returns {"S" | "M" | "L" | "XL"}
|
|
151
|
+
*/
|
|
152
|
+
function normalizeComplexity(complexity) {
|
|
153
|
+
return ['S', 'M', 'L', 'XL'].includes(complexity) ? complexity : 'M';
|
|
154
|
+
}
|
package/hub/tools.mjs
CHANGED
|
@@ -472,7 +472,72 @@ export function createTools(store, router, hitl, pipe = null) {
|
|
|
472
472
|
}),
|
|
473
473
|
},
|
|
474
474
|
|
|
475
|
-
// ── 19.
|
|
475
|
+
// ── 19. pipeline_advance_gated (HITL 승인 게이트) ──
|
|
476
|
+
{
|
|
477
|
+
name: 'pipeline_advance_gated',
|
|
478
|
+
description: 'HITL 승인 게이트가 포함된 파이프라인 전이. 지정 단계로의 전이 전 사용자 승인을 요청하고, 승인 후 전이를 실행합니다. deadline 초과 시 default_action에 따라 자동 처리됩니다.',
|
|
479
|
+
inputSchema: {
|
|
480
|
+
type: 'object',
|
|
481
|
+
required: ['team_name', 'phase'],
|
|
482
|
+
properties: {
|
|
483
|
+
team_name: { type: 'string', pattern: '^[a-z0-9][a-z0-9-]*$' },
|
|
484
|
+
phase: { type: 'string', enum: ['plan', 'prd', 'exec', 'verify', 'fix', 'complete', 'failed'] },
|
|
485
|
+
prompt: { type: 'string', description: '사용자에게 표시할 승인 요청 메시지' },
|
|
486
|
+
deadline_ms: { type: 'integer', minimum: 5000, maximum: 600000, default: 120000 },
|
|
487
|
+
default_action: { type: 'string', enum: ['timeout_continue', 'timeout_abort'], default: 'timeout_continue' },
|
|
488
|
+
requester_agent: { type: 'string', description: '요청자 에이전트 이름' },
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
handler: wrap('PIPELINE_ADVANCE_GATED_FAILED', (args) => {
|
|
492
|
+
ensurePipelineTable(store.db);
|
|
493
|
+
const pipeline = createPipeline(store.db, args.team_name);
|
|
494
|
+
|
|
495
|
+
// 전이 가능 여부 사전 확인
|
|
496
|
+
if (!pipeline.canAdvance(args.phase)) {
|
|
497
|
+
const current = pipeline.getState();
|
|
498
|
+
return {
|
|
499
|
+
ok: false,
|
|
500
|
+
error: {
|
|
501
|
+
code: 'TRANSITION_BLOCKED',
|
|
502
|
+
message: `전이 불가: ${current.phase} → ${args.phase}`,
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// HITL 승인 요청 생성
|
|
508
|
+
const approvalPrompt = args.prompt || `파이프라인 ${args.team_name}: ${pipeline.getState().phase} → ${args.phase} 전이를 승인하시겠습니까?`;
|
|
509
|
+
const deadlineMs = args.deadline_ms || 120000;
|
|
510
|
+
const now = Date.now();
|
|
511
|
+
|
|
512
|
+
const hitlResult = hitl.requestHumanInput({
|
|
513
|
+
requester_agent: args.requester_agent || `pipeline:${args.team_name}`,
|
|
514
|
+
kind: 'approval',
|
|
515
|
+
prompt: approvalPrompt,
|
|
516
|
+
deadline_ms: now + deadlineMs,
|
|
517
|
+
default_action: args.default_action || 'timeout_continue',
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
if (!hitlResult.ok) {
|
|
521
|
+
return hitlResult;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return {
|
|
525
|
+
ok: true,
|
|
526
|
+
data: {
|
|
527
|
+
pending: true,
|
|
528
|
+
request_id: hitlResult.data.request_id,
|
|
529
|
+
team_name: args.team_name,
|
|
530
|
+
target_phase: args.phase,
|
|
531
|
+
current_phase: pipeline.getState().phase,
|
|
532
|
+
deadline_ms: now + deadlineMs,
|
|
533
|
+
default_action: args.default_action || 'timeout_continue',
|
|
534
|
+
message: `승인 대기 중. ID: ${hitlResult.data.request_id}. ${Math.round(deadlineMs / 1000)}초 후 ${args.default_action || 'timeout_continue'} 자동 실행.`,
|
|
535
|
+
},
|
|
536
|
+
};
|
|
537
|
+
}),
|
|
538
|
+
},
|
|
539
|
+
|
|
540
|
+
// ── 20. pipeline_list ──
|
|
476
541
|
{
|
|
477
542
|
name: 'pipeline_list',
|
|
478
543
|
description: '활성 파이프라인 목록을 조회합니다',
|