@triflux/remote 10.53.0 → 10.55.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.
Files changed (47) hide show
  1. package/hub/team/backend.mjs +10 -3
  2. package/hub/team/cli/commands/start/index.mjs +0 -2
  3. package/hub/team/handoff.mjs +1 -1
  4. package/hub/team/headless.mjs +41 -57
  5. package/hub/team/intervention.mjs +6 -4
  6. package/hub/team/nativeProxy.mjs +2 -48
  7. package/hub/team/pane.mjs +31 -18
  8. package/hub/team/psmux.mjs +36 -20
  9. package/hub/team/remote-session.mjs +66 -56
  10. package/hub/team/retry-state-machine.mjs +1 -1
  11. package/hub/team/session.mjs +80 -59
  12. package/hub/team/terminal-opener.mjs +5 -19
  13. package/hub/team/tui-viewer.mjs +2 -2
  14. package/hub/team/tui.mjs +0 -6
  15. package/hub/workers/factory.mjs +1 -10
  16. package/hub/workers/interface.mjs +0 -7
  17. package/package.json +2 -6
  18. package/scripts/lib/backup-rotation.mjs +60 -0
  19. package/scripts/lib/codex-profile-config.mjs +20 -7
  20. package/scripts/lib/codex-recovery.sh +2 -3
  21. package/scripts/lib/install-blob-history.json +3196 -0
  22. package/scripts/lib/install-retire.mjs +203 -0
  23. package/scripts/lib/legacy-hook-cleanup.mjs +148 -13
  24. package/scripts/lib/legacy-mcp-cleanup.mjs +405 -104
  25. package/scripts/lib/machine-profile.mjs +2 -2
  26. package/scripts/lib/mcp-filter.mjs +1 -42
  27. package/scripts/lib/mcp-guard-engine.mjs +198 -19
  28. package/scripts/lib/mcp-manifest.mjs +0 -40
  29. package/scripts/lib/process-utils.mjs +19 -0
  30. package/scripts/lib/stable-node.mjs +35 -0
  31. package/scripts/lib/test-env.mjs +20 -0
  32. package/cto/brief.mjs +0 -73
  33. package/cto/collect.mjs +0 -490
  34. package/cto/current.schema.json +0 -148
  35. package/cto/events.mjs +0 -208
  36. package/cto/hygiene.mjs +0 -320
  37. package/cto/lake-root.mjs +0 -127
  38. package/cto/status.mjs +0 -124
  39. package/hub/lib/cto-env.mjs +0 -1
  40. package/hub/team/notify.mjs +0 -385
  41. package/hub/team/process-cleanup.mjs +0 -386
  42. package/hub/team/routing.mjs +0 -249
  43. package/hub/team/staleState.mjs +0 -403
  44. package/hub/team/worker-signal.mjs +0 -263
  45. package/hub/team/worker-signal.types.d.ts +0 -52
  46. package/hub/workers/codex-mcp.mjs +0 -853
  47. package/scripts/lib/remote-spawn-transfer.mjs +0 -226
@@ -1,386 +0,0 @@
1
- // hub/team/process-cleanup.mjs — 고아 node/python 프로세스 감지 및 정리
2
- // Windows: Get-CimInstance Win32_Process로 parent PID + cmdLine 접근
3
- // Unix: ps aux 파싱
4
- import { execFileSync, execFile as nodeExecFile } from "node:child_process";
5
- import { promisify } from "node:util";
6
- import { IS_WINDOWS } from "@triflux/core/hub/platform.mjs";
7
-
8
- const execFileAsync = promisify(nodeExecFile);
9
-
10
- // ── 상수 ────────────────────────────────────────────────────────────────────
11
-
12
- const TARGET_PROCESS_NAMES = ["node", "python", "python3"];
13
- const SIGTERM_GRACE_MS = 5000;
14
-
15
- export function forceKillPid(
16
- pid,
17
- {
18
- isWindows = IS_WINDOWS,
19
- execFileSyncFn = execFileSync,
20
- killFn = process.kill,
21
- } = {},
22
- ) {
23
- if (isWindows) {
24
- try {
25
- execFileSyncFn("taskkill", ["/F", "/T", "/PID", String(pid)], {
26
- stdio: "ignore",
27
- timeout: 5000,
28
- windowsHide: true,
29
- });
30
- return;
31
- } catch (taskkillError) {
32
- try {
33
- killFn(pid);
34
- return;
35
- } catch {
36
- throw taskkillError;
37
- }
38
- }
39
- }
40
-
41
- killFn(pid, "SIGKILL");
42
- }
43
-
44
- // cmdLine 패턴 기반 화이트리스트 (고아 후보에서 제외)
45
- const WHITELIST_CMDLINE = [/oh-my-claudecode/i, /triflux[\\/]hub[\\/]s/i];
46
-
47
- // 프로세스명 기반 화이트리스트
48
- const WHITELIST_NAMES = ["claude", "CCXProcess"];
49
-
50
- // ── PowerShell / ps 파싱 ─────────────────────────────────────────────────────
51
-
52
- /**
53
- * Windows: Get-CimInstance Win32_Process로 프로세스 목록 조회
54
- * @param {Function} execFileFn - DI용 execFile 구현
55
- * @returns {Promise<Array<{pid,name,parentPid,cmdLine,ramMB}>>}
56
- */
57
- async function queryWindowsProcesses(execFileFn) {
58
- const script = [
59
- "Get-CimInstance Win32_Process |",
60
- " Select-Object ProcessId,Name,ParentProcessId,CommandLine,WorkingSetSize |",
61
- " ConvertTo-Json -Compress",
62
- ].join(" ");
63
-
64
- const { stdout } = await execFileFn(
65
- "pwsh",
66
- ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script],
67
- { encoding: "utf8", timeout: 15_000, windowsHide: true },
68
- );
69
-
70
- const raw = JSON.parse(stdout.trim());
71
- const items = Array.isArray(raw) ? raw : [raw];
72
-
73
- return items.filter(Boolean).map((p) => ({
74
- pid: Number(p.ProcessId),
75
- name: String(p.Name || "").replace(/\.exe$/i, ""),
76
- parentPid: Number(p.ParentProcessId) || 0,
77
- cmdLine: String(p.CommandLine || ""),
78
- ramMB: Math.round((Number(p.WorkingSetSize) || 0) / 1024 / 1024),
79
- }));
80
- }
81
-
82
- /**
83
- * Unix: ps aux 파싱
84
- * @param {Function} execFileFn - DI용 execFile 구현
85
- * @returns {Promise<Array<{pid,name,parentPid,cmdLine,ramMB}>>}
86
- */
87
- async function queryUnixProcesses(execFileFn) {
88
- const { stdout } = await execFileFn(
89
- "ps",
90
- ["-eo", "pid=,ppid=,rss=,comm=,args="],
91
- { encoding: "utf8", timeout: 10_000 },
92
- );
93
-
94
- return stdout
95
- .trim()
96
- .split("\n")
97
- .filter(Boolean)
98
- .map((line) => {
99
- const parts = line.trim().split(/\s+/);
100
- const pid = Number(parts[0]);
101
- const parentPid = Number(parts[1]);
102
- const ramMB = Math.round((Number(parts[2]) || 0) / 1024);
103
- const name = String(parts[3] || "");
104
- const cmdLine = parts.slice(4).join(" ");
105
- return { pid, name, parentPid, cmdLine, ramMB };
106
- });
107
- }
108
-
109
- /**
110
- * 프로세스 시작 시각을 Unix 기준 ms로 조회 (Windows 전용, best-effort)
111
- * @param {number} pid
112
- * @param {Function} execFileFn
113
- * @returns {Promise<number>} epoch ms, 실패 시 0
114
- */
115
- async function getWindowsProcessStartMs(pid, execFileFn) {
116
- try {
117
- const script = `(Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}').CreationDate | Get-Date -Format 'o'`;
118
- const { stdout } = await execFileFn(
119
- "pwsh",
120
- ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script],
121
- { encoding: "utf8", timeout: 5_000, windowsHide: true },
122
- );
123
- const ts = Date.parse(stdout.trim());
124
- return Number.isNaN(ts) ? 0 : ts;
125
- } catch {
126
- return 0;
127
- }
128
- }
129
-
130
- // ── 화이트리스트 판정 ──────────────────────────────────────────────────────
131
-
132
- /**
133
- * 프로세스가 화이트리스트에 해당하는지 판정
134
- * @param {{name:string, cmdLine:string}} proc
135
- * @param {Set<number>} parentPids - 화이트리스트 부모 PID 집합
136
- * @returns {boolean}
137
- */
138
- function isWhitelisted(proc, parentPids) {
139
- const nameLower = proc.name.toLowerCase();
140
-
141
- // 프로세스명 기반
142
- if (WHITELIST_NAMES.some((n) => nameLower.includes(n.toLowerCase()))) {
143
- return true;
144
- }
145
-
146
- // cmdLine 패턴 기반
147
- if (WHITELIST_CMDLINE.some((re) => re.test(proc.cmdLine))) {
148
- return true;
149
- }
150
-
151
- // CCXProcess 자식 (Adobe Creative Cloud)
152
- if (parentPids.has(proc.parentPid)) {
153
- return true;
154
- }
155
-
156
- return false;
157
- }
158
-
159
- // ── psmux 교차검증 ──────────────────────────────────────────────────────────
160
-
161
- /**
162
- * psmux list-sessions 결과에서 활성 세션 pane PID 집합을 수집
163
- * @param {Function} execFileFn
164
- * @returns {Promise<Set<number>>}
165
- */
166
- async function getActivePsmuxPids(execFileFn) {
167
- try {
168
- const { stdout: sessOut } = await execFileFn(
169
- "psmux",
170
- ["list-sessions", "-F", "#{session_name}"],
171
- { encoding: "utf8", timeout: 5_000, windowsHide: true },
172
- );
173
-
174
- const sessions = sessOut.trim().split(/\r?\n/).filter(Boolean);
175
- if (sessions.length === 0) return new Set();
176
-
177
- const pids = new Set();
178
-
179
- await Promise.all(
180
- sessions.map(async (session) => {
181
- try {
182
- const { stdout: paneOut } = await execFileFn(
183
- "psmux",
184
- ["list-panes", "-t", session, "-a", "-F", "#{pane_pid}"],
185
- { encoding: "utf8", timeout: 5_000, windowsHide: true },
186
- );
187
- paneOut
188
- .trim()
189
- .split(/\r?\n/)
190
- .filter(Boolean)
191
- .forEach((p) => {
192
- const n = Number(p.trim());
193
- if (n > 0) pids.add(n);
194
- });
195
- } catch {
196
- // 세션 쿼리 실패 시 해당 세션만 건너뜀
197
- }
198
- }),
199
- );
200
-
201
- return pids;
202
- } catch {
203
- // psmux 미설치 또는 실패 시 빈 집합 반환
204
- return new Set();
205
- }
206
- }
207
-
208
- // ── 공개 API ────────────────────────────────────────────────────────────────
209
-
210
- /**
211
- * 고아 프로세스 목록을 반환한다.
212
- *
213
- * @param {object} [opts]
214
- * @param {Function} [opts.execFileFn] - DI용 execFile (기본: node:child_process.execFile의 promisify 버전)
215
- * @param {boolean} [opts.skipPsmuxCheck] - psmux 교차검증 생략 (테스트용)
216
- * @returns {Promise<Array<{pid,name,ramMB,parentPid,cmdLine,age}>>}
217
- */
218
- export async function findOrphanProcesses(opts = {}) {
219
- const execFileFn = opts.execFileFn ?? execFileAsync;
220
-
221
- let allProcs;
222
- try {
223
- allProcs = IS_WINDOWS
224
- ? await queryWindowsProcesses(execFileFn)
225
- : await queryUnixProcesses(execFileFn);
226
- } catch {
227
- return [];
228
- }
229
-
230
- if (allProcs.length === 0) return [];
231
-
232
- const pidSet = new Set(allProcs.map((p) => p.pid));
233
-
234
- // CCXProcess 부모 PID 집합 구성
235
- const ccxParentPids = new Set(
236
- allProcs
237
- .filter((p) => p.name.toLowerCase() === "ccxprocess")
238
- .map((p) => p.pid),
239
- );
240
-
241
- // 활성 psmux PID 교차검증
242
- const activePsmuxPids = opts.skipPsmuxCheck
243
- ? new Set()
244
- : await getActivePsmuxPids(execFileFn);
245
-
246
- const now = Date.now();
247
-
248
- const candidates = allProcs.filter((p) => {
249
- const nameLower = p.name.toLowerCase();
250
-
251
- // 대상 프로세스만
252
- if (
253
- !TARGET_PROCESS_NAMES.some(
254
- (t) => nameLower === t || nameLower === `${t}.exe`,
255
- )
256
- ) {
257
- return false;
258
- }
259
-
260
- // 화이트리스트
261
- if (isWhitelisted(p, ccxParentPids)) return false;
262
-
263
- // 활성 psmux 세션 소속 PID 제외
264
- if (activePsmuxPids.has(p.pid)) return false;
265
-
266
- // 부모가 없거나(0, 1 제외 시 존재하지 않는 PID) 고아
267
- const parentAlive = p.parentPid <= 1 || pidSet.has(p.parentPid);
268
- if (parentAlive) return false;
269
-
270
- return true;
271
- });
272
-
273
- // age 계산 (Windows만 best-effort, Unix는 0)
274
- const results = await Promise.all(
275
- candidates.map(async (p) => {
276
- let startMs = 0;
277
- if (IS_WINDOWS) {
278
- startMs = await getWindowsProcessStartMs(p.pid, execFileFn);
279
- }
280
- return {
281
- pid: p.pid,
282
- name: p.name,
283
- ramMB: p.ramMB,
284
- parentPid: p.parentPid,
285
- cmdLine: p.cmdLine,
286
- age: startMs > 0 ? now - startMs : 0,
287
- };
288
- }),
289
- );
290
-
291
- return results;
292
- }
293
-
294
- /**
295
- * createProcessCleanup — scan/kill/getOrphans 인터페이스를 반환한다.
296
- *
297
- * @param {object} [opts]
298
- * @param {Function} [opts.execFileFn] - DI용 execFile (promisify된 버전)
299
- * @param {boolean} [opts.dryRun] - true이면 kill 없이 목록만 반환
300
- * @param {boolean} [opts.skipPsmuxCheck] - psmux 교차검증 생략 (테스트용)
301
- * @returns {{ scan: Function, kill: Function, getOrphans: Function }}
302
- */
303
- export function createProcessCleanup(opts = {}) {
304
- const execFileFn = opts.execFileFn ?? execFileAsync;
305
- const dryRun = opts.dryRun ?? false;
306
- const isWindows = opts.isWindows ?? IS_WINDOWS;
307
- const execFileSyncFn = opts.execFileSyncFn ?? execFileSync;
308
- const killFn = opts.killFn ?? process.kill;
309
- const sleepFn =
310
- opts.sleepFn ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
311
- const sigtermGraceMs = opts.sigtermGraceMs ?? SIGTERM_GRACE_MS;
312
-
313
- let lastOrphans = [];
314
-
315
- /**
316
- * 고아 프로세스를 스캔하여 내부 상태에 저장하고 목록을 반환한다.
317
- * @returns {Promise<Array<{pid,name,ramMB,parentPid,cmdLine,age}>>}
318
- */
319
- async function scan() {
320
- const found = await findOrphanProcesses({
321
- execFileFn,
322
- skipPsmuxCheck: opts.skipPsmuxCheck,
323
- });
324
- lastOrphans = found;
325
- return found;
326
- }
327
-
328
- /**
329
- * 마지막 scan 결과의 프로세스를 kill한다.
330
- * dryRun=true이면 kill 없이 목록만 반환한다.
331
- * SIGTERM → 5s 대기 → 강제 종료(taskkill/SIGKILL) 순서.
332
- * @returns {Promise<Array<{pid,name,killed,error}>>}
333
- */
334
- async function kill() {
335
- if (dryRun) {
336
- return lastOrphans.map((p) => ({
337
- pid: p.pid,
338
- name: p.name,
339
- killed: false,
340
- dryRun: true,
341
- }));
342
- }
343
-
344
- const results = await Promise.all(
345
- lastOrphans.map(async (p) => {
346
- try {
347
- // SIGTERM
348
- killFn(p.pid, "SIGTERM");
349
-
350
- // 5초 대기 후 살아있으면 강제 종료
351
- await sleepFn(sigtermGraceMs);
352
-
353
- try {
354
- // 프로세스가 아직 살아있는지 확인 (signal 0)
355
- killFn(p.pid, 0);
356
- // 여전히 살아있음 → Windows는 taskkill/process.kill, 그 외는 SIGKILL
357
- forceKillPid(p.pid, { isWindows, execFileSyncFn, killFn });
358
- } catch {
359
- // ESRCH: 이미 종료됨 — 정상
360
- }
361
-
362
- return { pid: p.pid, name: p.name, killed: true };
363
- } catch (err) {
364
- return {
365
- pid: p.pid,
366
- name: p.name,
367
- killed: false,
368
- error: String(err.message || err),
369
- };
370
- }
371
- }),
372
- );
373
-
374
- return results;
375
- }
376
-
377
- /**
378
- * 마지막 scan 결과를 반환한다 (재스캔 없음).
379
- * @returns {Array<{pid,name,ramMB,parentPid,cmdLine,age}>}
380
- */
381
- function getOrphans() {
382
- return lastOrphans;
383
- }
384
-
385
- return { scan, kill, getOrphans };
386
- }
@@ -1,249 +0,0 @@
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
- * dagContext: { dag_width: number, levels: Record<number, string[]>, edges: Array<{from:string, to:string}>, max_complexity: string, taskResults: Record<string, *> }
13
- * }}
14
- */
15
- export function resolveRoutingStrategy({ subtasks, graph_type, thorough }) {
16
- const N = subtasks.length;
17
- if (N === 0) {
18
- const dagContext = {
19
- dag_width: 0,
20
- levels: {},
21
- edges: [],
22
- max_complexity: "S",
23
- taskResults: {},
24
- };
25
- return {
26
- strategy: "quick_single",
27
- reason: "empty_subtasks",
28
- dag_width: 0,
29
- max_complexity: "S",
30
- dagContext,
31
- };
32
- }
33
-
34
- const {
35
- width: dag_width,
36
- levels,
37
- edges,
38
- } = computeDagInfo(subtasks, graph_type);
39
- const max_complexity = getMaxComplexity(subtasks);
40
- const dagContext = {
41
- dag_width,
42
- levels,
43
- edges,
44
- max_complexity,
45
- taskResults: {},
46
- };
47
- const isHighComplexity = ["L", "XL"].includes(max_complexity);
48
- const allSameAgent = new Set(subtasks.map((s) => s.agent)).size === 1;
49
- const allSmall = subtasks.every(
50
- (s) => normalizeComplexity(s.complexity) === "S",
51
- );
52
-
53
- // N==1: 단일 태스크
54
- if (N === 1) {
55
- if (thorough || isHighComplexity) {
56
- return {
57
- strategy: "thorough_single",
58
- reason: "single_high_complexity",
59
- dag_width,
60
- max_complexity,
61
- dagContext,
62
- };
63
- }
64
- return {
65
- strategy: "quick_single",
66
- reason: "single_low_complexity",
67
- dag_width,
68
- max_complexity,
69
- dagContext,
70
- };
71
- }
72
-
73
- // dag_width==1: 사실상 순차 -> single
74
- if (dag_width === 1) {
75
- if (thorough || isHighComplexity) {
76
- return {
77
- strategy: "thorough_single",
78
- reason: "sequential_chain",
79
- dag_width,
80
- max_complexity,
81
- dagContext,
82
- };
83
- }
84
- return {
85
- strategy: "quick_single",
86
- reason: "sequential_chain",
87
- dag_width,
88
- max_complexity,
89
- dagContext,
90
- };
91
- }
92
-
93
- // 동일 에이전트 + 모두 S: 프롬프트 병합 -> batch single
94
- if (allSameAgent && allSmall) {
95
- return {
96
- strategy: "batch_single",
97
- reason: "same_agent_small_batch",
98
- dag_width,
99
- max_complexity,
100
- dagContext,
101
- };
102
- }
103
-
104
- // dag_width >= 2: 팀
105
- if (thorough || isHighComplexity) {
106
- return {
107
- strategy: "thorough_team",
108
- reason: "parallel_high_complexity",
109
- dag_width,
110
- max_complexity,
111
- dagContext,
112
- };
113
- }
114
- return {
115
- strategy: "quick_team",
116
- reason: "parallel_low_complexity",
117
- dag_width,
118
- max_complexity,
119
- dagContext,
120
- };
121
- }
122
-
123
- /**
124
- * DAG 정보 계산 — 레벨별 태스크 배열, 간선, 최대 폭
125
- * @param {Array<{id:string, depends_on?:string[]}>} subtasks
126
- * @param {string} graph_type
127
- * @returns {{ width: number, levels: Record<number, string[]>, edges: Array<{from:string, to:string}> }}
128
- */
129
- function computeDagInfo(subtasks, graph_type) {
130
- if (graph_type === "SEQUENTIAL") {
131
- const levels = {};
132
- const edges = [];
133
- subtasks.forEach((t, i) => {
134
- levels[i] = [t.id];
135
- if (i > 0) edges.push({ from: subtasks[i - 1].id, to: t.id });
136
- });
137
- return { width: 1, levels, edges };
138
- }
139
- if (graph_type === "INDEPENDENT") {
140
- const levels = { 0: subtasks.map((t) => t.id) };
141
- return { width: subtasks.length, levels, edges: [] };
142
- }
143
-
144
- // DAG: 레벨별 계산 (순환 의존 방어)
145
- const taskLevels = {};
146
- const visiting = new Set();
147
-
148
- function getLevel(task) {
149
- if (taskLevels[task.id] !== undefined) return taskLevels[task.id];
150
- if (visiting.has(task.id)) {
151
- taskLevels[task.id] = 0; // 순환 끊기
152
- return 0;
153
- }
154
- if (!task.depends_on || task.depends_on.length === 0) {
155
- taskLevels[task.id] = 0;
156
- return 0;
157
- }
158
- visiting.add(task.id);
159
- const depLevels = task.depends_on.map((depId) => {
160
- const dep = subtasks.find((s) => s.id === depId);
161
- return dep ? getLevel(dep) : 0;
162
- });
163
- visiting.delete(task.id);
164
- taskLevels[task.id] = Math.max(...depLevels) + 1;
165
- return taskLevels[task.id];
166
- }
167
-
168
- subtasks.forEach(getLevel);
169
-
170
- // 레벨별 태스크 그룹핑
171
- const levels = {};
172
- for (const [id, level] of Object.entries(taskLevels)) {
173
- if (!levels[level]) levels[level] = [];
174
- levels[level].push(id);
175
- }
176
-
177
- // 간선 수집
178
- const edges = [];
179
- for (const task of subtasks) {
180
- if (task.depends_on) {
181
- for (const depId of task.depends_on) {
182
- edges.push({ from: depId, to: task.id });
183
- }
184
- }
185
- }
186
-
187
- const width = Math.max(...Object.values(levels).map((arr) => arr.length), 1);
188
- return { width, levels, edges };
189
- }
190
-
191
- /**
192
- * 선행 태스크의 결과를 dagContext.edges에서 조회하여 반환
193
- * @param {string} taskId - 조회 대상 태스크 ID
194
- * @param {{ dagContext?: { edges: Array<{from:string, to:string}>, taskResults: Record<string, *> } }} pipelineState
195
- * @returns {Record<string, *>} 선행 태스크 ID → 결과 매핑
196
- */
197
- export function getUpstreamResults(taskId, pipelineState) {
198
- const ctx = pipelineState?.dagContext;
199
- if (!ctx) return {};
200
- const upstreamIds = ctx.edges
201
- .filter((e) => e.to === taskId)
202
- .map((e) => e.from);
203
- const results = {};
204
- for (const id of upstreamIds) {
205
- if (id in (ctx.taskResults || {})) {
206
- results[id] = ctx.taskResults[id];
207
- }
208
- }
209
- return results;
210
- }
211
-
212
- /**
213
- * 태스크 완료 시 결과를 dagContext에 기록
214
- * @param {string} taskId - 완료된 태스크 ID
215
- * @param {*} result - 태스크 결과
216
- * @param {{ dagContext?: { taskResults: Record<string, *> } }} pipelineState
217
- * @returns {boolean} 기록 성공 여부
218
- */
219
- export function updateTaskResult(taskId, result, pipelineState) {
220
- const ctx = pipelineState?.dagContext;
221
- if (!ctx) return false;
222
- if (!ctx.taskResults) ctx.taskResults = {};
223
- ctx.taskResults[taskId] = result;
224
- return true;
225
- }
226
-
227
- /**
228
- * 최대 복잡도 추출
229
- * @param {Array<{complexity?:string}>} subtasks
230
- * @returns {"S" | "M" | "L" | "XL"}
231
- */
232
- function getMaxComplexity(subtasks) {
233
- const order = { S: 0, M: 1, L: 2, XL: 3 };
234
- let max = "S";
235
- for (const s of subtasks) {
236
- const complexity = normalizeComplexity(s.complexity);
237
- if (order[complexity] > order[max]) max = complexity;
238
- }
239
- return max;
240
- }
241
-
242
- /**
243
- * complexity 기본값 보정
244
- * @param {string | undefined} complexity
245
- * @returns {"S" | "M" | "L" | "XL"}
246
- */
247
- function normalizeComplexity(complexity) {
248
- return ["S", "M", "L", "XL"].includes(complexity) ? complexity : "M";
249
- }