triflux 10.9.15 → 10.9.17
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/.claude-plugin/marketplace.json +2 -2
- package/hub/lib/spawn-trace.mjs +70 -9
- package/hub/server.mjs +55 -0
- package/hub/team/backend.mjs +1 -1
- package/hub/team/conductor.mjs +48 -6
- package/hub/team/headless.mjs +104 -8
- package/hub/team/synapse-http.mjs +59 -0
- package/hub/team/synapse-registry.mjs +7 -3
- package/package.json +1 -1
- package/scripts/__tests__/spawn-trace.test.mjs +175 -22
- package/scripts/tfx-route.sh +6 -0
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
{
|
|
10
10
|
"name": "triflux",
|
|
11
11
|
"description": "Tri-CLI orchestrator for Claude Code. Routes tasks across Claude + Codex + Gemini with consensus intelligence, natural language routing, 42 skills, and cross-model review.",
|
|
12
|
-
"version": "10.9.
|
|
12
|
+
"version": "10.9.17",
|
|
13
13
|
"author": {
|
|
14
14
|
"name": "tellang"
|
|
15
15
|
},
|
|
@@ -30,5 +30,5 @@
|
|
|
30
30
|
]
|
|
31
31
|
}
|
|
32
32
|
],
|
|
33
|
-
"version": "10.9.
|
|
33
|
+
"version": "10.9.17"
|
|
34
34
|
}
|
package/hub/lib/spawn-trace.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { join } from "node:path";
|
|
|
7
7
|
const LOG_DIR = join(homedir(), ".triflux", "logs");
|
|
8
8
|
const DEDUPE_WINDOW_MS = 5_000;
|
|
9
9
|
const RATE_WINDOW_MS = 1_000;
|
|
10
|
-
export
|
|
10
|
+
export let MAX_SPAWN_PER_SEC = resolvePositiveInteger(
|
|
11
11
|
process.env.TRIFLUX_MAX_SPAWN_RATE,
|
|
12
12
|
30,
|
|
13
13
|
);
|
|
@@ -149,6 +149,18 @@ function createPolicyError(reasonCode, message, meta = {}) {
|
|
|
149
149
|
return error;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
export function getMaxSpawnPerSec() {
|
|
153
|
+
return MAX_SPAWN_PER_SEC;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function reload() {
|
|
157
|
+
MAX_SPAWN_PER_SEC = resolvePositiveInteger(
|
|
158
|
+
process.env.TRIFLUX_MAX_SPAWN_RATE,
|
|
159
|
+
30,
|
|
160
|
+
);
|
|
161
|
+
return getMaxSpawnPerSec();
|
|
162
|
+
}
|
|
163
|
+
|
|
152
164
|
function logBlocked(traceId, command, args, options, error, extra = {}) {
|
|
153
165
|
appendTrace({
|
|
154
166
|
event: "blocked",
|
|
@@ -167,6 +179,7 @@ function enforceGuards(command, args, options) {
|
|
|
167
179
|
const now = Date.now();
|
|
168
180
|
trimRecentSpawnTimes(now);
|
|
169
181
|
trimDedupeEntries(now);
|
|
182
|
+
const maxSpawnPerSec = getMaxSpawnPerSec();
|
|
170
183
|
|
|
171
184
|
const dedupeKey = getDedupeKey(options);
|
|
172
185
|
if (dedupeKey) {
|
|
@@ -180,11 +193,11 @@ function enforceGuards(command, args, options) {
|
|
|
180
193
|
}
|
|
181
194
|
}
|
|
182
195
|
|
|
183
|
-
if (recentSpawnTimes.length >=
|
|
196
|
+
if (recentSpawnTimes.length >= maxSpawnPerSec) {
|
|
184
197
|
return createPolicyError(
|
|
185
198
|
"rate_limit",
|
|
186
|
-
`spawn-trace rate limit exceeded (${
|
|
187
|
-
{ maxPerSec:
|
|
199
|
+
`spawn-trace rate limit exceeded (${maxSpawnPerSec}/sec)`,
|
|
200
|
+
{ maxPerSec: maxSpawnPerSec },
|
|
188
201
|
);
|
|
189
202
|
}
|
|
190
203
|
|
|
@@ -318,6 +331,10 @@ function normalizeExecFileArgs(args, options, callback) {
|
|
|
318
331
|
};
|
|
319
332
|
}
|
|
320
333
|
|
|
334
|
+
function wait(ms) {
|
|
335
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
336
|
+
}
|
|
337
|
+
|
|
321
338
|
export function spawn(command, args, options) {
|
|
322
339
|
const { argsList, options: normalizedOptions } = normalizeSpawnArgs(
|
|
323
340
|
args,
|
|
@@ -356,6 +373,32 @@ export function spawn(command, args, options) {
|
|
|
356
373
|
});
|
|
357
374
|
}
|
|
358
375
|
|
|
376
|
+
export async function spawnWithBackoff(command, args, options, maxRetries = 1) {
|
|
377
|
+
const retryLimit =
|
|
378
|
+
Number.isInteger(maxRetries) && maxRetries >= 0 ? maxRetries : 1;
|
|
379
|
+
let originalRateLimitError = null;
|
|
380
|
+
|
|
381
|
+
for (let attempt = 0; attempt <= retryLimit; attempt += 1) {
|
|
382
|
+
try {
|
|
383
|
+
return spawn(command, args, options);
|
|
384
|
+
} catch (error) {
|
|
385
|
+
if (error?.reasonCode !== "rate_limit") {
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
originalRateLimitError ??= error;
|
|
390
|
+
|
|
391
|
+
if (attempt >= retryLimit) {
|
|
392
|
+
throw originalRateLimitError;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
await wait(RATE_WINDOW_MS);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
throw originalRateLimitError;
|
|
400
|
+
}
|
|
401
|
+
|
|
359
402
|
export function execFile(file, args, options, callback) {
|
|
360
403
|
const normalized = normalizeExecFileArgs(args, options, callback);
|
|
361
404
|
const traceId = nextTraceId();
|
|
@@ -365,7 +408,13 @@ export function execFile(file, args, options, callback) {
|
|
|
365
408
|
normalized.options,
|
|
366
409
|
);
|
|
367
410
|
if (blockedError) {
|
|
368
|
-
logBlocked(
|
|
411
|
+
logBlocked(
|
|
412
|
+
traceId,
|
|
413
|
+
file,
|
|
414
|
+
normalized.argsList,
|
|
415
|
+
normalized.options,
|
|
416
|
+
blockedError,
|
|
417
|
+
);
|
|
369
418
|
if (typeof normalized.callback === "function") {
|
|
370
419
|
queueMicrotask(() => normalized.callback(blockedError, "", ""));
|
|
371
420
|
return createRejectedChild(file, normalized.argsList, blockedError);
|
|
@@ -417,9 +466,16 @@ export function execFileSync(file, args, options) {
|
|
|
417
466
|
normalized.options,
|
|
418
467
|
);
|
|
419
468
|
if (blockedError) {
|
|
420
|
-
logBlocked(
|
|
421
|
-
|
|
422
|
-
|
|
469
|
+
logBlocked(
|
|
470
|
+
traceId,
|
|
471
|
+
file,
|
|
472
|
+
normalized.argsList,
|
|
473
|
+
normalized.options,
|
|
474
|
+
blockedError,
|
|
475
|
+
{
|
|
476
|
+
sync: true,
|
|
477
|
+
},
|
|
478
|
+
);
|
|
423
479
|
throw blockedError;
|
|
424
480
|
}
|
|
425
481
|
|
|
@@ -489,8 +545,13 @@ export const spawnSync = childProcess.spawnSync;
|
|
|
489
545
|
export default {
|
|
490
546
|
...childProcess,
|
|
491
547
|
spawn,
|
|
548
|
+
spawnWithBackoff,
|
|
492
549
|
execFile,
|
|
493
550
|
execFileSync,
|
|
494
|
-
MAX_SPAWN_PER_SEC
|
|
551
|
+
get MAX_SPAWN_PER_SEC() {
|
|
552
|
+
return MAX_SPAWN_PER_SEC;
|
|
553
|
+
},
|
|
495
554
|
MAX_TOTAL_DESCENDANTS,
|
|
555
|
+
getMaxSpawnPerSec,
|
|
556
|
+
reload,
|
|
496
557
|
};
|
package/hub/server.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import { createAssignCallbackServer } from "./assign-callbacks.mjs";
|
|
|
28
28
|
import { DelegatorService } from "./delegator/index.mjs";
|
|
29
29
|
import { createHitlManager } from "./hitl.mjs";
|
|
30
30
|
import { cleanupOrphanNodeProcesses } from "./lib/process-utils.mjs";
|
|
31
|
+
import * as spawnTrace from "./lib/spawn-trace.mjs";
|
|
31
32
|
import { wrapRequestHandler } from "./middleware/request-logger.mjs";
|
|
32
33
|
import { createPipeServer } from "./pipe.mjs";
|
|
33
34
|
import { createRouter } from "./router.mjs";
|
|
@@ -718,6 +719,11 @@ export async function startHub({
|
|
|
718
719
|
pipe: pipe.getStatus(),
|
|
719
720
|
assign_callback_pipe_path: assignCallbacks.path,
|
|
720
721
|
assign_callback_pipe: assignCallbacks.getStatus(),
|
|
722
|
+
spawn_trace: {
|
|
723
|
+
max_per_sec: spawnTrace.getMaxSpawnPerSec(),
|
|
724
|
+
max_total_descendants: spawnTrace.MAX_TOTAL_DESCENDANTS,
|
|
725
|
+
},
|
|
726
|
+
version,
|
|
721
727
|
});
|
|
722
728
|
}
|
|
723
729
|
|
|
@@ -774,11 +780,60 @@ export async function startHub({
|
|
|
774
780
|
return writeJson(res, 200, { ok: true, accounts });
|
|
775
781
|
}
|
|
776
782
|
|
|
783
|
+
if (path === "/spawn-trace/reload" && req.method === "POST") {
|
|
784
|
+
return writeJson(res, 200, {
|
|
785
|
+
ok: true,
|
|
786
|
+
max_spawn_per_sec: spawnTrace.reload(),
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
777
790
|
// ── Synapse Layer 5: session registry + locks + preflight routes ──
|
|
778
791
|
if (path === "/synapse/sessions" && req.method === "GET") {
|
|
779
792
|
return writeJson(res, 200, { ok: true, ...synapseRegistry.snapshot(), ts: Date.now() });
|
|
780
793
|
}
|
|
781
794
|
|
|
795
|
+
if (path === "/synapse/register" && req.method === "POST") {
|
|
796
|
+
try {
|
|
797
|
+
const body = await parseBody(req);
|
|
798
|
+
const { sessionId } = body || {};
|
|
799
|
+
const result = synapseRegistry.register(sessionId, body);
|
|
800
|
+
if (!result?.ok) {
|
|
801
|
+
throw new Error(result?.reason || "register failed");
|
|
802
|
+
}
|
|
803
|
+
return writeJson(res, 200, { ok: true, sessionId: result.sessionId || sessionId });
|
|
804
|
+
} catch (err) {
|
|
805
|
+
return writeJson(res, 400, { ok: false, error: String(err?.message || err) });
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if (path === "/synapse/heartbeat" && req.method === "POST") {
|
|
810
|
+
try {
|
|
811
|
+
const body = await parseBody(req);
|
|
812
|
+
const { sessionId, partial } = body || {};
|
|
813
|
+
const ok = synapseRegistry.heartbeat(sessionId, partial);
|
|
814
|
+
if (!ok) {
|
|
815
|
+
throw new Error("heartbeat failed");
|
|
816
|
+
}
|
|
817
|
+
return writeJson(res, 200, { ok: true });
|
|
818
|
+
} catch (err) {
|
|
819
|
+
return writeJson(res, 400, { ok: false, error: String(err?.message || err) });
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if (path === "/synapse/unregister" && req.method === "POST") {
|
|
824
|
+
try {
|
|
825
|
+
const body = await parseBody(req);
|
|
826
|
+
const { sessionId } = body || {};
|
|
827
|
+
const ok = synapseRegistry.unregister(sessionId);
|
|
828
|
+
if (!ok) {
|
|
829
|
+
throw new Error("unregister failed");
|
|
830
|
+
}
|
|
831
|
+
return writeJson(res, 200, { ok: true });
|
|
832
|
+
} catch (err) {
|
|
833
|
+
return writeJson(res, 400, { ok: false, error: String(err?.message || err) });
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
782
837
|
if (path === "/synapse/locks" && req.method === "GET") {
|
|
783
838
|
return writeJson(res, 200, { ok: true, locks: swarmLocks.snapshot(), ts: Date.now() });
|
|
784
839
|
}
|
package/hub/team/backend.mjs
CHANGED
|
@@ -41,7 +41,7 @@ export class GeminiBackend {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
buildArgs(prompt, resultFile, opts = {}) {
|
|
44
|
-
return
|
|
44
|
+
return `$null | gemini --prompt ${prompt} --output-format text > '${resultFile}' 2>'${resultFile}.err'`;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
env() {
|
package/hub/team/conductor.mjs
CHANGED
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
// 3. Auto-restart (maxRestarts=3)
|
|
9
9
|
// 4. JSONL event log (블랙박스 리코더)
|
|
10
10
|
|
|
11
|
-
import { execFile, spawn } from "../lib/spawn-trace.mjs";
|
|
12
11
|
import { EventEmitter } from "node:events";
|
|
13
12
|
import {
|
|
14
13
|
copyFileSync,
|
|
@@ -20,6 +19,7 @@ import { homedir } from "node:os";
|
|
|
20
19
|
import { dirname, join } from "node:path";
|
|
21
20
|
import { createRegistry } from "../../mesh/mesh-registry.mjs";
|
|
22
21
|
import { broker } from "../account-broker.mjs";
|
|
22
|
+
import { execFile, spawn } from "../lib/spawn-trace.mjs";
|
|
23
23
|
import { killProcess } from "../platform.mjs";
|
|
24
24
|
import { createConductorMeshBridge } from "./conductor-mesh-bridge.mjs";
|
|
25
25
|
import {
|
|
@@ -28,8 +28,13 @@ import {
|
|
|
28
28
|
} from "./conductor-registry.mjs";
|
|
29
29
|
import { createEventLog } from "./event-log.mjs";
|
|
30
30
|
import { createHealthProbe } from "./health-probe.mjs";
|
|
31
|
-
import { buildLauncher
|
|
32
|
-
import {
|
|
31
|
+
import { buildLauncher } from "./launcher-template.mjs";
|
|
32
|
+
import {
|
|
33
|
+
buildSynapseTaskSummary,
|
|
34
|
+
heartbeatSynapseSession,
|
|
35
|
+
registerSynapseSession,
|
|
36
|
+
unregisterSynapseSession,
|
|
37
|
+
} from "./synapse-http.mjs";
|
|
33
38
|
|
|
34
39
|
/** 세션 상태 */
|
|
35
40
|
export const STATES = Object.freeze({
|
|
@@ -126,6 +131,25 @@ export function createConductor(opts = {}) {
|
|
|
126
131
|
const sessions = new Map();
|
|
127
132
|
let shuttingDown = false;
|
|
128
133
|
const publicApi = null;
|
|
134
|
+
const synapseOpts = {
|
|
135
|
+
baseUrl: opts.synapseBaseUrl,
|
|
136
|
+
fetchImpl: opts.synapseFetch,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
function buildSynapseMeta(session, state = session.state, reason = "") {
|
|
140
|
+
return {
|
|
141
|
+
sessionId: session.id,
|
|
142
|
+
host:
|
|
143
|
+
typeof session.config.host === "string" &&
|
|
144
|
+
session.config.host.length > 0
|
|
145
|
+
? session.config.host
|
|
146
|
+
: "local",
|
|
147
|
+
taskSummary: buildSynapseTaskSummary(session.config.prompt),
|
|
148
|
+
status: state,
|
|
149
|
+
reason,
|
|
150
|
+
isRemote: Boolean(session.config.remote),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
129
153
|
|
|
130
154
|
// 공유 event log (모든 세션 이벤트를 하나의 JSONL에)
|
|
131
155
|
const eventLog = createEventLog(join(logsDir, "conductor-events.jsonl"));
|
|
@@ -166,6 +190,21 @@ export function createConductor(opts = {}) {
|
|
|
166
190
|
reason,
|
|
167
191
|
});
|
|
168
192
|
|
|
193
|
+
if (nextState === STATES.HEALTHY) {
|
|
194
|
+
registerSynapseSession(
|
|
195
|
+
buildSynapseMeta(session, nextState, reason),
|
|
196
|
+
synapseOpts,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
heartbeatSynapseSession(
|
|
200
|
+
session.id,
|
|
201
|
+
buildSynapseMeta(session, nextState, reason),
|
|
202
|
+
synapseOpts,
|
|
203
|
+
);
|
|
204
|
+
if (nextState === STATES.COMPLETED || nextState === STATES.DEAD) {
|
|
205
|
+
unregisterSynapseSession(session.id, synapseOpts);
|
|
206
|
+
}
|
|
207
|
+
|
|
169
208
|
// Terminal state cleanup
|
|
170
209
|
if (TERMINAL_STATES.has(nextState)) {
|
|
171
210
|
session.probe?.stop();
|
|
@@ -572,13 +611,16 @@ export function createConductor(opts = {}) {
|
|
|
572
611
|
} else if (agent === "gemini") {
|
|
573
612
|
remoteBin = "gemini -y";
|
|
574
613
|
} else {
|
|
575
|
-
remoteBin =
|
|
614
|
+
remoteBin =
|
|
615
|
+
"codex exec -s danger-full-access --dangerously-bypass-approvals-and-sandbox";
|
|
576
616
|
}
|
|
577
617
|
|
|
578
618
|
// prompt는 stdin으로 전달 — 셸 이스케이프 문제 완전 회피
|
|
579
619
|
const sshArgs = [
|
|
580
|
-
"-o",
|
|
581
|
-
"
|
|
620
|
+
"-o",
|
|
621
|
+
"ConnectTimeout=30",
|
|
622
|
+
"-o",
|
|
623
|
+
"BatchMode=yes",
|
|
582
624
|
host,
|
|
583
625
|
`${cdPrefix}${remoteBin}`,
|
|
584
626
|
];
|
package/hub/team/headless.mjs
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
// 의존성: psmux.mjs (Node.js 내장 모듈만 사용)
|
|
6
6
|
|
|
7
7
|
import { execSync } from "node:child_process";
|
|
8
|
-
import { spawn } from "../lib/spawn-trace.mjs";
|
|
9
8
|
import { randomUUID } from "node:crypto";
|
|
10
9
|
import {
|
|
11
10
|
existsSync,
|
|
@@ -19,6 +18,7 @@ import { createRequire } from "node:module";
|
|
|
19
18
|
import { tmpdir } from "node:os";
|
|
20
19
|
import { join } from "node:path";
|
|
21
20
|
import { requestJson } from "../bridge.mjs";
|
|
21
|
+
import { getMaxSpawnPerSec } from "../lib/spawn-trace.mjs";
|
|
22
22
|
import { escapePwshSingleQuoted } from "../cli-adapter-base.mjs";
|
|
23
23
|
import { getBackend } from "./backend.mjs";
|
|
24
24
|
import { resolveDashboardLayout } from "./dashboard-layout.mjs";
|
|
@@ -33,6 +33,11 @@ import {
|
|
|
33
33
|
startCapture,
|
|
34
34
|
waitForCompletion,
|
|
35
35
|
} from "./psmux.mjs";
|
|
36
|
+
import {
|
|
37
|
+
buildSynapseTaskSummary,
|
|
38
|
+
registerSynapseSession,
|
|
39
|
+
unregisterSynapseSession,
|
|
40
|
+
} from "./synapse-http.mjs";
|
|
36
41
|
import { createLogDashboard } from "./tui.mjs";
|
|
37
42
|
import { createWtManager } from "./wt-manager.mjs";
|
|
38
43
|
|
|
@@ -119,6 +124,18 @@ export async function deregisterHeadlessWorkers(
|
|
|
119
124
|
);
|
|
120
125
|
}
|
|
121
126
|
|
|
127
|
+
function registerHeadlessSynapseWorker(workerId, prompt) {
|
|
128
|
+
registerSynapseSession({
|
|
129
|
+
sessionId: workerId,
|
|
130
|
+
host: "local",
|
|
131
|
+
taskSummary: buildSynapseTaskSummary(prompt),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function unregisterHeadlessSynapseWorker(workerId) {
|
|
136
|
+
unregisterSynapseSession(workerId);
|
|
137
|
+
}
|
|
138
|
+
|
|
122
139
|
/** MCP 프로필별 프롬프트 힌트 (tfx-route.sh resolve_mcp_policy의 경량 미러) */
|
|
123
140
|
const MCP_PROFILE_HINTS = {
|
|
124
141
|
implement:
|
|
@@ -554,6 +571,7 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
|
|
|
554
571
|
// pane 간 pipe-pane EBUSY 방지 — 이벤트 루프 해방하며 순차 대기
|
|
555
572
|
if (i > 0) await new Promise((r) => setTimeout(r, 300));
|
|
556
573
|
const dispatch = dispatchCommand(sessionName, newPaneId, cmd);
|
|
574
|
+
registerHeadlessSynapseWorker(workerId, assignment.prompt);
|
|
557
575
|
|
|
558
576
|
if (safeProgress)
|
|
559
577
|
safeProgress({ type: "dispatched", paneName, cli: assignment.cli });
|
|
@@ -626,6 +644,7 @@ async function dispatchBatch(sessionName, assignments, opts = {}) {
|
|
|
626
644
|
scriptDir,
|
|
627
645
|
scriptName: paneName,
|
|
628
646
|
});
|
|
647
|
+
registerHeadlessSynapseWorker(workerId, assignment.prompt);
|
|
629
648
|
|
|
630
649
|
// P1 fix: 비-progressive에서는 pane 리네임 금지 — 캡처 로그 경로가 타이틀 기반이므로
|
|
631
650
|
// 리네임하면 waitForCompletion이 "codex (role).log"를 찾지만 실제는 "worker-N.log"로 불일치
|
|
@@ -754,6 +773,7 @@ async function awaitAll(
|
|
|
754
773
|
const output = completion.matched
|
|
755
774
|
? readResult(d.resultFile, d.paneId)
|
|
756
775
|
: "";
|
|
776
|
+
unregisterHeadlessSynapseWorker(d.workerId);
|
|
757
777
|
|
|
758
778
|
if (safeProgress) {
|
|
759
779
|
safeProgress({
|
|
@@ -861,6 +881,35 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
861
881
|
|
|
862
882
|
mkdirSync(RESULT_DIR, { recursive: true });
|
|
863
883
|
|
|
884
|
+
// Hub version skew pre-flight (fail-open, best-effort)
|
|
885
|
+
requestJson("/status", { method: "GET", timeoutMs: 500 })
|
|
886
|
+
.then((status) => {
|
|
887
|
+
const hubRate = status?.spawn_trace?.max_per_sec;
|
|
888
|
+
const localRate = getMaxSpawnPerSec();
|
|
889
|
+
if (typeof hubRate === "number" && hubRate !== localRate) {
|
|
890
|
+
console.warn(
|
|
891
|
+
`[headless] Hub version skew detected: hub spawn rate=${hubRate}/s, local=${localRate}/s. Restart hub to sync.`,
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
})
|
|
895
|
+
.catch(() => {});
|
|
896
|
+
|
|
897
|
+
// Synapse: 세션 registration (fire-and-forget, hub 미응답 시 무시)
|
|
898
|
+
const synapseIds = assignments.map((_, i) => `${sessionName}-worker-${i + 1}`);
|
|
899
|
+
for (let i = 0; i < assignments.length; i++) {
|
|
900
|
+
const a = assignments[i];
|
|
901
|
+
requestJson("/synapse/register", {
|
|
902
|
+
method: "POST",
|
|
903
|
+
body: {
|
|
904
|
+
sessionId: synapseIds[i],
|
|
905
|
+
host: "local",
|
|
906
|
+
taskSummary: String(a.prompt || "").slice(0, 100),
|
|
907
|
+
isRemote: false,
|
|
908
|
+
},
|
|
909
|
+
timeoutMs: 1000,
|
|
910
|
+
}).catch(() => {});
|
|
911
|
+
}
|
|
912
|
+
|
|
864
913
|
// in-process TUI: dashboard=true이고 stdout이 TTY일 때 직접 구동
|
|
865
914
|
let tui = null;
|
|
866
915
|
const resolvedLayout = resolveDashboardLayout(
|
|
@@ -918,9 +967,25 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
918
967
|
}
|
|
919
968
|
}
|
|
920
969
|
|
|
970
|
+
// Synapse heartbeat: progress 이벤트마다 해당 워커의 세션 갱신
|
|
971
|
+
const feedSynapse = (event) => {
|
|
972
|
+
if (!event?.paneName) return;
|
|
973
|
+
const match = event.paneName.match(/worker-(\d+)/);
|
|
974
|
+
if (!match) return;
|
|
975
|
+
const idx = parseInt(match[1], 10) - 1;
|
|
976
|
+
const sid = synapseIds[idx];
|
|
977
|
+
if (!sid) return;
|
|
978
|
+
requestJson("/synapse/heartbeat", {
|
|
979
|
+
method: "POST",
|
|
980
|
+
body: { sessionId: sid, partial: { taskSummary: (event.snapshot || "").slice(0, 100) } },
|
|
981
|
+
timeoutMs: 500,
|
|
982
|
+
}).catch(() => {});
|
|
983
|
+
};
|
|
984
|
+
|
|
921
985
|
// onProgress 예외를 삼켜 실행 흐름 보호 (onPoll과 동일 패턴)
|
|
922
986
|
const combinedProgress = (event) => {
|
|
923
987
|
feedTui(event);
|
|
988
|
+
feedSynapse(event);
|
|
924
989
|
if (onProgress) {
|
|
925
990
|
try {
|
|
926
991
|
onProgress(event);
|
|
@@ -981,6 +1046,15 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
981
1046
|
tui.close();
|
|
982
1047
|
}
|
|
983
1048
|
|
|
1049
|
+
// Synapse: 세션 unregister (fire-and-forget)
|
|
1050
|
+
for (const sid of synapseIds) {
|
|
1051
|
+
requestJson("/synapse/unregister", {
|
|
1052
|
+
method: "POST",
|
|
1053
|
+
body: { sessionId: sid },
|
|
1054
|
+
timeoutMs: 1000,
|
|
1055
|
+
}).catch(() => {});
|
|
1056
|
+
}
|
|
1057
|
+
|
|
984
1058
|
return { sessionName, results: collected };
|
|
985
1059
|
}
|
|
986
1060
|
|
|
@@ -999,6 +1073,11 @@ export async function runHeadlessWithCleanup(assignments, opts = {}) {
|
|
|
999
1073
|
try {
|
|
1000
1074
|
return await runHeadless(sessionName, assignments, runOpts);
|
|
1001
1075
|
} finally {
|
|
1076
|
+
for (let index = 0; index < assignments.length; index++) {
|
|
1077
|
+
unregisterHeadlessSynapseWorker(
|
|
1078
|
+
getHeadlessWorkerAgentId(sessionName, index),
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1002
1081
|
await deregisterHeadlessWorkers(sessionName, assignments.length);
|
|
1003
1082
|
try {
|
|
1004
1083
|
killPsmuxSession(sessionName);
|
|
@@ -1056,7 +1135,7 @@ export function applyTrifluxTheme(sessionName) {
|
|
|
1056
1135
|
* WT 기본 프로필의 폰트 크기를 읽는다.
|
|
1057
1136
|
* @returns {number} 기본 폰트 크기 (못 읽으면 12)
|
|
1058
1137
|
*/
|
|
1059
|
-
function
|
|
1138
|
+
function _getWtDefaultFontSize() {
|
|
1060
1139
|
const settingsPaths = [
|
|
1061
1140
|
join(
|
|
1062
1141
|
process.env.LOCALAPPDATA || "",
|
|
@@ -1096,7 +1175,7 @@ function getWtDefaultFontSize() {
|
|
|
1096
1175
|
* @param {string} filePath — 대상 파일 경로
|
|
1097
1176
|
* @param {string} data — 쓸 내용
|
|
1098
1177
|
*/
|
|
1099
|
-
function
|
|
1178
|
+
function _atomicWriteSync(filePath, data) {
|
|
1100
1179
|
const tmpPath = `${filePath}.${process.pid}.tmp`;
|
|
1101
1180
|
try {
|
|
1102
1181
|
writeFileSync(tmpPath, data, "utf8");
|
|
@@ -1131,7 +1210,11 @@ function buildAttachTitle(sessionName, suffix = "") {
|
|
|
1131
1210
|
* @param {number} [workerCount=2]
|
|
1132
1211
|
* @returns {Promise<boolean>} 성공 여부
|
|
1133
1212
|
*/
|
|
1134
|
-
export async function autoAttachTerminal(
|
|
1213
|
+
export async function autoAttachTerminal(
|
|
1214
|
+
sessionName,
|
|
1215
|
+
opts = {},
|
|
1216
|
+
workerCount = 2,
|
|
1217
|
+
) {
|
|
1135
1218
|
if (!process.env.WT_SESSION) return false;
|
|
1136
1219
|
try {
|
|
1137
1220
|
execSync("where wt.exe", { stdio: "ignore" });
|
|
@@ -1145,8 +1228,14 @@ export async function autoAttachTerminal(sessionName, opts = {}, workerCount = 2
|
|
|
1145
1228
|
try {
|
|
1146
1229
|
const safeSession = sanitizeSessionName(sessionName);
|
|
1147
1230
|
if (workerCount >= 5) {
|
|
1148
|
-
const resolvedLayout = resolveDashboardLayout(
|
|
1149
|
-
|
|
1231
|
+
const resolvedLayout = resolveDashboardLayout(
|
|
1232
|
+
opts.dashboardLayout || "single",
|
|
1233
|
+
workerCount,
|
|
1234
|
+
);
|
|
1235
|
+
const viewerPath = join(import.meta.dirname, "tui-viewer.mjs").replace(
|
|
1236
|
+
/\\/g,
|
|
1237
|
+
"/",
|
|
1238
|
+
);
|
|
1150
1239
|
await wt.createTab({
|
|
1151
1240
|
title: buildAttachTitle(safeSession, "dashboard"),
|
|
1152
1241
|
profile: "triflux",
|
|
@@ -1220,7 +1309,10 @@ export async function attachDashboardTab(
|
|
|
1220
1309
|
try {
|
|
1221
1310
|
const safeSession = sanitizeSessionName(sessionName);
|
|
1222
1311
|
const resolvedLayout = resolveDashboardLayout(dashboardLayout, workerCount);
|
|
1223
|
-
const viewerPath = join(import.meta.dirname, "tui-viewer.mjs").replace(
|
|
1312
|
+
const viewerPath = join(import.meta.dirname, "tui-viewer.mjs").replace(
|
|
1313
|
+
/\\/g,
|
|
1314
|
+
"/",
|
|
1315
|
+
);
|
|
1224
1316
|
|
|
1225
1317
|
await wt.createTab({
|
|
1226
1318
|
title: buildAttachTitle(safeSession, "dashboard"),
|
|
@@ -1233,7 +1325,6 @@ export async function attachDashboardTab(
|
|
|
1233
1325
|
}
|
|
1234
1326
|
}
|
|
1235
1327
|
|
|
1236
|
-
|
|
1237
1328
|
/**
|
|
1238
1329
|
* 모든 워커 pane의 현재 스냅샷을 수집한다.
|
|
1239
1330
|
*
|
|
@@ -1415,6 +1506,11 @@ export async function runHeadlessInteractive(
|
|
|
1415
1506
|
kill() {
|
|
1416
1507
|
if (this._killed) return;
|
|
1417
1508
|
this._killed = true;
|
|
1509
|
+
for (let index = 0; index < assignments.length; index++) {
|
|
1510
|
+
unregisterHeadlessSynapseWorker(
|
|
1511
|
+
getHeadlessWorkerAgentId(sessionName, index),
|
|
1512
|
+
);
|
|
1513
|
+
}
|
|
1418
1514
|
void deregisterHeadlessWorkers(sessionName, assignments.length);
|
|
1419
1515
|
try {
|
|
1420
1516
|
killPsmuxSession(sessionName);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const DEFAULT_SYNAPSE_BASE_URL = "http://127.0.0.1:27888";
|
|
2
|
+
|
|
3
|
+
function resolveSynapseFetch(fetchImpl) {
|
|
4
|
+
if (typeof fetchImpl === "function") return fetchImpl;
|
|
5
|
+
if (typeof globalThis.fetch === "function") {
|
|
6
|
+
return globalThis.fetch.bind(globalThis);
|
|
7
|
+
}
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function buildSynapseTaskSummary(prompt, maxLength = 100) {
|
|
12
|
+
if (maxLength <= 0) return "";
|
|
13
|
+
return String(prompt ?? "").slice(0, maxLength);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function fireAndForgetSynapse(path, payload, opts = {}) {
|
|
17
|
+
const fetchImpl = resolveSynapseFetch(opts.fetchImpl);
|
|
18
|
+
if (!fetchImpl) return false;
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const url = new URL(
|
|
22
|
+
path,
|
|
23
|
+
opts.baseUrl || DEFAULT_SYNAPSE_BASE_URL,
|
|
24
|
+
).toString();
|
|
25
|
+
Promise.resolve(
|
|
26
|
+
fetchImpl(url, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: { "content-type": "application/json" },
|
|
29
|
+
body: JSON.stringify(payload),
|
|
30
|
+
}),
|
|
31
|
+
).catch(() => {});
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function registerSynapseSession(meta, opts = {}) {
|
|
39
|
+
return fireAndForgetSynapse("/synapse/register", meta, opts);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function heartbeatSynapseSession(
|
|
43
|
+
sessionId,
|
|
44
|
+
partialMeta = {},
|
|
45
|
+
opts = {},
|
|
46
|
+
) {
|
|
47
|
+
return fireAndForgetSynapse(
|
|
48
|
+
"/synapse/heartbeat",
|
|
49
|
+
{
|
|
50
|
+
sessionId,
|
|
51
|
+
...(partialMeta && typeof partialMeta === "object" ? partialMeta : {}),
|
|
52
|
+
},
|
|
53
|
+
opts,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function unregisterSynapseSession(sessionId, opts = {}) {
|
|
58
|
+
return fireAndForgetSynapse("/synapse/unregister", { sessionId }, opts);
|
|
59
|
+
}
|
|
@@ -165,8 +165,12 @@ export function createSynapseRegistry(opts = {}) {
|
|
|
165
165
|
startMonitor(sessionId);
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
-
function register(meta) {
|
|
169
|
-
const
|
|
168
|
+
function register(sessionIdOrMeta, meta = null) {
|
|
169
|
+
const normalizedMeta =
|
|
170
|
+
meta && typeof meta === "object"
|
|
171
|
+
? { ...meta, sessionId: sessionIdOrMeta }
|
|
172
|
+
: sessionIdOrMeta;
|
|
173
|
+
const sessionId = normalizeSessionId(normalizedMeta?.sessionId);
|
|
170
174
|
if (!sessionId) {
|
|
171
175
|
return { ok: false, sessionId, reason: "invalid_id" };
|
|
172
176
|
}
|
|
@@ -178,7 +182,7 @@ export function createSynapseRegistry(opts = {}) {
|
|
|
178
182
|
|
|
179
183
|
const session = sanitizeSession(
|
|
180
184
|
{
|
|
181
|
-
...
|
|
185
|
+
...normalizedMeta,
|
|
182
186
|
sessionId,
|
|
183
187
|
status: "active",
|
|
184
188
|
lastHeartbeat: now(),
|
package/package.json
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import {
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { mkdirSync, rmSync, readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
5
3
|
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { performance } from "node:perf_hooks";
|
|
6
|
+
import { after, before, describe, it } from "node:test";
|
|
6
7
|
|
|
7
8
|
const TEST_LOG_DIR = join(tmpdir(), `spawn-trace-test-${Date.now()}`);
|
|
9
|
+
let importSequence = 0;
|
|
10
|
+
|
|
11
|
+
async function loadSpawnTraceModule() {
|
|
12
|
+
importSequence += 1;
|
|
13
|
+
return import(`../../hub/lib/spawn-trace.mjs?test=${importSequence}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function waitForClose(child) {
|
|
17
|
+
return new Promise((resolve) => child.once("close", resolve));
|
|
18
|
+
}
|
|
8
19
|
|
|
9
20
|
describe("spawn-trace", () => {
|
|
10
21
|
before(() => {
|
|
@@ -12,12 +23,17 @@ describe("spawn-trace", () => {
|
|
|
12
23
|
});
|
|
13
24
|
|
|
14
25
|
after(() => {
|
|
15
|
-
try {
|
|
26
|
+
try {
|
|
27
|
+
rmSync(TEST_LOG_DIR, { recursive: true, force: true });
|
|
28
|
+
} catch {
|
|
29
|
+
/* ignore */
|
|
30
|
+
}
|
|
16
31
|
});
|
|
17
32
|
|
|
18
33
|
it("exports child_process-compatible API surface", async () => {
|
|
19
|
-
const mod = await
|
|
34
|
+
const mod = await loadSpawnTraceModule();
|
|
20
35
|
assert.equal(typeof mod.spawn, "function");
|
|
36
|
+
assert.equal(typeof mod.spawnWithBackoff, "function");
|
|
21
37
|
assert.equal(typeof mod.execFile, "function");
|
|
22
38
|
assert.equal(typeof mod.execFileSync, "function");
|
|
23
39
|
assert.equal(typeof mod.exec, "function");
|
|
@@ -27,13 +43,35 @@ describe("spawn-trace", () => {
|
|
|
27
43
|
});
|
|
28
44
|
|
|
29
45
|
it("exports guard constants", async () => {
|
|
30
|
-
const mod = await
|
|
46
|
+
const mod = await loadSpawnTraceModule();
|
|
31
47
|
assert.equal(typeof mod.MAX_SPAWN_PER_SEC, "number");
|
|
32
48
|
assert.equal(typeof mod.MAX_TOTAL_DESCENDANTS, "number");
|
|
49
|
+
assert.equal(typeof mod.getMaxSpawnPerSec, "function");
|
|
50
|
+
assert.equal(typeof mod.reload, "function");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("reload re-evaluates TRIFLUX_MAX_SPAWN_RATE", async () => {
|
|
54
|
+
const mod = await loadSpawnTraceModule();
|
|
55
|
+
const original = process.env.TRIFLUX_MAX_SPAWN_RATE;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
process.env.TRIFLUX_MAX_SPAWN_RATE = "7";
|
|
59
|
+
assert.equal(mod.reload(), 7);
|
|
60
|
+
assert.equal(mod.getMaxSpawnPerSec(), 7);
|
|
61
|
+
assert.equal(mod.MAX_SPAWN_PER_SEC, 7);
|
|
62
|
+
assert.equal(mod.default.MAX_SPAWN_PER_SEC, 7);
|
|
63
|
+
} finally {
|
|
64
|
+
if (original == null) {
|
|
65
|
+
delete process.env.TRIFLUX_MAX_SPAWN_RATE;
|
|
66
|
+
} else {
|
|
67
|
+
process.env.TRIFLUX_MAX_SPAWN_RATE = original;
|
|
68
|
+
}
|
|
69
|
+
mod.reload();
|
|
70
|
+
}
|
|
33
71
|
});
|
|
34
72
|
|
|
35
73
|
it("spawn returns a ChildProcess-like object", async () => {
|
|
36
|
-
const mod = await
|
|
74
|
+
const mod = await loadSpawnTraceModule();
|
|
37
75
|
const child = mod.spawn("node", ["-e", "process.exit(0)"], {
|
|
38
76
|
windowsHide: true,
|
|
39
77
|
});
|
|
@@ -45,16 +83,20 @@ describe("spawn-trace", () => {
|
|
|
45
83
|
});
|
|
46
84
|
|
|
47
85
|
it("execFileSync returns stdout buffer", async () => {
|
|
48
|
-
const mod = await
|
|
49
|
-
const result = mod.execFileSync(
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
86
|
+
const mod = await loadSpawnTraceModule();
|
|
87
|
+
const result = mod.execFileSync(
|
|
88
|
+
"node",
|
|
89
|
+
["-e", 'process.stdout.write("hello")'],
|
|
90
|
+
{
|
|
91
|
+
encoding: "utf8",
|
|
92
|
+
windowsHide: true,
|
|
93
|
+
},
|
|
94
|
+
);
|
|
53
95
|
assert.equal(result.trim(), "hello");
|
|
54
96
|
});
|
|
55
97
|
|
|
56
98
|
it("execFileSync throws on non-zero exit", async () => {
|
|
57
|
-
const mod = await
|
|
99
|
+
const mod = await loadSpawnTraceModule();
|
|
58
100
|
assert.throws(() => {
|
|
59
101
|
mod.execFileSync("node", ["-e", "process.exit(1)"], {
|
|
60
102
|
windowsHide: true,
|
|
@@ -63,7 +105,7 @@ describe("spawn-trace", () => {
|
|
|
63
105
|
});
|
|
64
106
|
|
|
65
107
|
it("execFile with callback receives stdout", async () => {
|
|
66
|
-
const mod = await
|
|
108
|
+
const mod = await loadSpawnTraceModule();
|
|
67
109
|
const result = await new Promise((resolve, reject) => {
|
|
68
110
|
mod.execFile(
|
|
69
111
|
"node",
|
|
@@ -79,22 +121,133 @@ describe("spawn-trace", () => {
|
|
|
79
121
|
});
|
|
80
122
|
|
|
81
123
|
it("strips trace-specific options before passing to child_process", async () => {
|
|
82
|
-
const mod = await
|
|
124
|
+
const mod = await loadSpawnTraceModule();
|
|
83
125
|
// reason and dedupe should not cause child_process to error
|
|
84
|
-
const result = mod.execFileSync(
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
126
|
+
const result = mod.execFileSync(
|
|
127
|
+
"node",
|
|
128
|
+
["-e", 'process.stdout.write("ok")'],
|
|
129
|
+
{
|
|
130
|
+
encoding: "utf8",
|
|
131
|
+
windowsHide: true,
|
|
132
|
+
reason: "test:strip-options",
|
|
133
|
+
dedupe: "test-key",
|
|
134
|
+
},
|
|
135
|
+
);
|
|
90
136
|
assert.equal(result.trim(), "ok");
|
|
91
137
|
});
|
|
92
138
|
|
|
93
139
|
it("default export includes spawn/execFile/execFileSync", async () => {
|
|
94
|
-
const mod = await
|
|
140
|
+
const mod = await loadSpawnTraceModule();
|
|
95
141
|
assert.equal(typeof mod.default.spawn, "function");
|
|
142
|
+
assert.equal(typeof mod.default.spawnWithBackoff, "function");
|
|
96
143
|
assert.equal(typeof mod.default.execFile, "function");
|
|
97
144
|
assert.equal(typeof mod.default.execFileSync, "function");
|
|
98
145
|
assert.equal(typeof mod.default.MAX_SPAWN_PER_SEC, "number");
|
|
146
|
+
assert.equal(typeof mod.default.getMaxSpawnPerSec, "function");
|
|
147
|
+
assert.equal(typeof mod.default.reload, "function");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("waits for RATE_WINDOW_MS and retries once after a rate limit error", async () => {
|
|
151
|
+
const original = process.env.TRIFLUX_MAX_SPAWN_RATE;
|
|
152
|
+
process.env.TRIFLUX_MAX_SPAWN_RATE = "1";
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
const mod = await loadSpawnTraceModule();
|
|
156
|
+
const blocker = mod.spawn(
|
|
157
|
+
"node",
|
|
158
|
+
["-e", "setTimeout(() => process.exit(0), 1500)"],
|
|
159
|
+
{ windowsHide: true },
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const startedAt = performance.now();
|
|
163
|
+
const child = await mod.spawnWithBackoff(
|
|
164
|
+
"node",
|
|
165
|
+
["-e", "process.exit(0)"],
|
|
166
|
+
{ windowsHide: true },
|
|
167
|
+
);
|
|
168
|
+
const elapsedMs = performance.now() - startedAt;
|
|
169
|
+
|
|
170
|
+
assert.ok(elapsedMs >= 900, `expected retry delay, got ${elapsedMs}ms`);
|
|
171
|
+
assert.equal(typeof child.pid, "number");
|
|
172
|
+
|
|
173
|
+
await waitForClose(child);
|
|
174
|
+
await waitForClose(blocker);
|
|
175
|
+
} finally {
|
|
176
|
+
if (original == null) {
|
|
177
|
+
delete process.env.TRIFLUX_MAX_SPAWN_RATE;
|
|
178
|
+
} else {
|
|
179
|
+
process.env.TRIFLUX_MAX_SPAWN_RATE = original;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("rethrows the original rate limit error when the retry also hits the limit", async () => {
|
|
185
|
+
const originalEnv = process.env.TRIFLUX_MAX_SPAWN_RATE;
|
|
186
|
+
const originalDateNow = Date.now;
|
|
187
|
+
process.env.TRIFLUX_MAX_SPAWN_RATE = "1";
|
|
188
|
+
Date.now = () => 1_000;
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const mod = await loadSpawnTraceModule();
|
|
192
|
+
const blocker = mod.spawn(
|
|
193
|
+
"node",
|
|
194
|
+
["-e", "setTimeout(() => process.exit(0), 1500)"],
|
|
195
|
+
{ windowsHide: true },
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const startedAt = performance.now();
|
|
199
|
+
await assert.rejects(
|
|
200
|
+
() =>
|
|
201
|
+
mod.spawnWithBackoff("node", ["-e", "process.exit(0)"], {
|
|
202
|
+
windowsHide: true,
|
|
203
|
+
}),
|
|
204
|
+
(error) => {
|
|
205
|
+
assert.equal(error?.reasonCode, "rate_limit");
|
|
206
|
+
assert.equal(error?.maxPerSec, 1);
|
|
207
|
+
return true;
|
|
208
|
+
},
|
|
209
|
+
);
|
|
210
|
+
const elapsedMs = performance.now() - startedAt;
|
|
211
|
+
assert.ok(elapsedMs >= 900, `expected retry delay, got ${elapsedMs}ms`);
|
|
212
|
+
|
|
213
|
+
blocker.kill();
|
|
214
|
+
await waitForClose(blocker);
|
|
215
|
+
} finally {
|
|
216
|
+
Date.now = originalDateNow;
|
|
217
|
+
if (originalEnv == null) {
|
|
218
|
+
delete process.env.TRIFLUX_MAX_SPAWN_RATE;
|
|
219
|
+
} else {
|
|
220
|
+
process.env.TRIFLUX_MAX_SPAWN_RATE = originalEnv;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("throws non-rate-limit guard errors immediately", async () => {
|
|
226
|
+
const mod = await loadSpawnTraceModule();
|
|
227
|
+
const blocker = mod.spawn(
|
|
228
|
+
"node",
|
|
229
|
+
["-e", "setTimeout(() => process.exit(0), 250)"],
|
|
230
|
+
{ dedupe: "same-key", windowsHide: true },
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const startedAt = performance.now();
|
|
234
|
+
await assert.rejects(
|
|
235
|
+
() =>
|
|
236
|
+
mod.spawnWithBackoff("node", ["-e", "process.exit(0)"], {
|
|
237
|
+
dedupe: "same-key",
|
|
238
|
+
windowsHide: true,
|
|
239
|
+
}),
|
|
240
|
+
(error) => {
|
|
241
|
+
assert.equal(error?.reasonCode, "dedupe");
|
|
242
|
+
return true;
|
|
243
|
+
},
|
|
244
|
+
);
|
|
245
|
+
const elapsedMs = performance.now() - startedAt;
|
|
246
|
+
assert.ok(
|
|
247
|
+
elapsedMs < 500,
|
|
248
|
+
`expected immediate failure, got ${elapsedMs}ms`,
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
await waitForClose(blocker);
|
|
99
252
|
});
|
|
100
253
|
});
|
package/scripts/tfx-route.sh
CHANGED
|
@@ -1795,6 +1795,12 @@ EOF
|
|
|
1795
1795
|
team_complete_task "success" "$output_preview"
|
|
1796
1796
|
elif [[ "$exit_code" -eq 124 ]]; then
|
|
1797
1797
|
team_complete_task "timeout" "타임아웃 (${TIMEOUT_SEC}초)"
|
|
1798
|
+
elif [[ "$exit_code" -eq 143 ]]; then
|
|
1799
|
+
team_complete_task "timeout" "외부 시그널로 종료 (SIGTERM, ${TIMEOUT_SEC}초)"
|
|
1800
|
+
elif [[ "$exit_code" -eq 137 ]]; then
|
|
1801
|
+
team_complete_task "timeout" "외부 시그널로 종료 (SIGKILL, ${TIMEOUT_SEC}초)"
|
|
1802
|
+
elif [[ "$exit_code" -eq 130 ]]; then
|
|
1803
|
+
team_complete_task "failed" "사용자 인터럽트 (SIGINT)"
|
|
1798
1804
|
else
|
|
1799
1805
|
local err_preview
|
|
1800
1806
|
err_preview=$(tail -c 1024 "$STDERR_LOG" 2>/dev/null || echo "에러 정보 없음")
|