triflux 10.9.15 → 10.9.16
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/lib/spawn-trace.mjs +39 -9
- package/hub/server.mjs +50 -0
- package/hub/team/conductor.mjs +48 -6
- package/hub/team/headless.mjs +90 -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 +50 -14
- package/scripts/tfx-route.sh +6 -0
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
|
|
|
@@ -365,7 +378,13 @@ export function execFile(file, args, options, callback) {
|
|
|
365
378
|
normalized.options,
|
|
366
379
|
);
|
|
367
380
|
if (blockedError) {
|
|
368
|
-
logBlocked(
|
|
381
|
+
logBlocked(
|
|
382
|
+
traceId,
|
|
383
|
+
file,
|
|
384
|
+
normalized.argsList,
|
|
385
|
+
normalized.options,
|
|
386
|
+
blockedError,
|
|
387
|
+
);
|
|
369
388
|
if (typeof normalized.callback === "function") {
|
|
370
389
|
queueMicrotask(() => normalized.callback(blockedError, "", ""));
|
|
371
390
|
return createRejectedChild(file, normalized.argsList, blockedError);
|
|
@@ -417,9 +436,16 @@ export function execFileSync(file, args, options) {
|
|
|
417
436
|
normalized.options,
|
|
418
437
|
);
|
|
419
438
|
if (blockedError) {
|
|
420
|
-
logBlocked(
|
|
421
|
-
|
|
422
|
-
|
|
439
|
+
logBlocked(
|
|
440
|
+
traceId,
|
|
441
|
+
file,
|
|
442
|
+
normalized.argsList,
|
|
443
|
+
normalized.options,
|
|
444
|
+
blockedError,
|
|
445
|
+
{
|
|
446
|
+
sync: true,
|
|
447
|
+
},
|
|
448
|
+
);
|
|
423
449
|
throw blockedError;
|
|
424
450
|
}
|
|
425
451
|
|
|
@@ -491,6 +517,10 @@ export default {
|
|
|
491
517
|
spawn,
|
|
492
518
|
execFile,
|
|
493
519
|
execFileSync,
|
|
494
|
-
MAX_SPAWN_PER_SEC
|
|
520
|
+
get MAX_SPAWN_PER_SEC() {
|
|
521
|
+
return MAX_SPAWN_PER_SEC;
|
|
522
|
+
},
|
|
495
523
|
MAX_TOTAL_DESCENDANTS,
|
|
524
|
+
getMaxSpawnPerSec,
|
|
525
|
+
reload,
|
|
496
526
|
};
|
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";
|
|
@@ -774,11 +775,60 @@ export async function startHub({
|
|
|
774
775
|
return writeJson(res, 200, { ok: true, accounts });
|
|
775
776
|
}
|
|
776
777
|
|
|
778
|
+
if (path === "/spawn-trace/reload" && req.method === "POST") {
|
|
779
|
+
return writeJson(res, 200, {
|
|
780
|
+
ok: true,
|
|
781
|
+
max_spawn_per_sec: spawnTrace.reload(),
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
|
|
777
785
|
// ── Synapse Layer 5: session registry + locks + preflight routes ──
|
|
778
786
|
if (path === "/synapse/sessions" && req.method === "GET") {
|
|
779
787
|
return writeJson(res, 200, { ok: true, ...synapseRegistry.snapshot(), ts: Date.now() });
|
|
780
788
|
}
|
|
781
789
|
|
|
790
|
+
if (path === "/synapse/register" && req.method === "POST") {
|
|
791
|
+
try {
|
|
792
|
+
const body = await parseBody(req);
|
|
793
|
+
const { sessionId } = body || {};
|
|
794
|
+
const result = synapseRegistry.register(sessionId, body);
|
|
795
|
+
if (!result?.ok) {
|
|
796
|
+
throw new Error(result?.reason || "register failed");
|
|
797
|
+
}
|
|
798
|
+
return writeJson(res, 200, { ok: true, sessionId: result.sessionId || sessionId });
|
|
799
|
+
} catch (err) {
|
|
800
|
+
return writeJson(res, 400, { ok: false, error: String(err?.message || err) });
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
if (path === "/synapse/heartbeat" && req.method === "POST") {
|
|
805
|
+
try {
|
|
806
|
+
const body = await parseBody(req);
|
|
807
|
+
const { sessionId, partial } = body || {};
|
|
808
|
+
const ok = synapseRegistry.heartbeat(sessionId, partial);
|
|
809
|
+
if (!ok) {
|
|
810
|
+
throw new Error("heartbeat failed");
|
|
811
|
+
}
|
|
812
|
+
return writeJson(res, 200, { ok: true });
|
|
813
|
+
} catch (err) {
|
|
814
|
+
return writeJson(res, 400, { ok: false, error: String(err?.message || err) });
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
if (path === "/synapse/unregister" && req.method === "POST") {
|
|
819
|
+
try {
|
|
820
|
+
const body = await parseBody(req);
|
|
821
|
+
const { sessionId } = body || {};
|
|
822
|
+
const ok = synapseRegistry.unregister(sessionId);
|
|
823
|
+
if (!ok) {
|
|
824
|
+
throw new Error("unregister failed");
|
|
825
|
+
}
|
|
826
|
+
return writeJson(res, 200, { ok: true });
|
|
827
|
+
} catch (err) {
|
|
828
|
+
return writeJson(res, 400, { ok: false, error: String(err?.message || err) });
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
782
832
|
if (path === "/synapse/locks" && req.method === "GET") {
|
|
783
833
|
return writeJson(res, 200, { ok: true, locks: swarmLocks.snapshot(), ts: Date.now() });
|
|
784
834
|
}
|
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,
|
|
@@ -33,6 +32,11 @@ import {
|
|
|
33
32
|
startCapture,
|
|
34
33
|
waitForCompletion,
|
|
35
34
|
} from "./psmux.mjs";
|
|
35
|
+
import {
|
|
36
|
+
buildSynapseTaskSummary,
|
|
37
|
+
registerSynapseSession,
|
|
38
|
+
unregisterSynapseSession,
|
|
39
|
+
} from "./synapse-http.mjs";
|
|
36
40
|
import { createLogDashboard } from "./tui.mjs";
|
|
37
41
|
import { createWtManager } from "./wt-manager.mjs";
|
|
38
42
|
|
|
@@ -119,6 +123,18 @@ export async function deregisterHeadlessWorkers(
|
|
|
119
123
|
);
|
|
120
124
|
}
|
|
121
125
|
|
|
126
|
+
function registerHeadlessSynapseWorker(workerId, prompt) {
|
|
127
|
+
registerSynapseSession({
|
|
128
|
+
sessionId: workerId,
|
|
129
|
+
host: "local",
|
|
130
|
+
taskSummary: buildSynapseTaskSummary(prompt),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function unregisterHeadlessSynapseWorker(workerId) {
|
|
135
|
+
unregisterSynapseSession(workerId);
|
|
136
|
+
}
|
|
137
|
+
|
|
122
138
|
/** MCP 프로필별 프롬프트 힌트 (tfx-route.sh resolve_mcp_policy의 경량 미러) */
|
|
123
139
|
const MCP_PROFILE_HINTS = {
|
|
124
140
|
implement:
|
|
@@ -554,6 +570,7 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
|
|
|
554
570
|
// pane 간 pipe-pane EBUSY 방지 — 이벤트 루프 해방하며 순차 대기
|
|
555
571
|
if (i > 0) await new Promise((r) => setTimeout(r, 300));
|
|
556
572
|
const dispatch = dispatchCommand(sessionName, newPaneId, cmd);
|
|
573
|
+
registerHeadlessSynapseWorker(workerId, assignment.prompt);
|
|
557
574
|
|
|
558
575
|
if (safeProgress)
|
|
559
576
|
safeProgress({ type: "dispatched", paneName, cli: assignment.cli });
|
|
@@ -626,6 +643,7 @@ async function dispatchBatch(sessionName, assignments, opts = {}) {
|
|
|
626
643
|
scriptDir,
|
|
627
644
|
scriptName: paneName,
|
|
628
645
|
});
|
|
646
|
+
registerHeadlessSynapseWorker(workerId, assignment.prompt);
|
|
629
647
|
|
|
630
648
|
// P1 fix: 비-progressive에서는 pane 리네임 금지 — 캡처 로그 경로가 타이틀 기반이므로
|
|
631
649
|
// 리네임하면 waitForCompletion이 "codex (role).log"를 찾지만 실제는 "worker-N.log"로 불일치
|
|
@@ -754,6 +772,7 @@ async function awaitAll(
|
|
|
754
772
|
const output = completion.matched
|
|
755
773
|
? readResult(d.resultFile, d.paneId)
|
|
756
774
|
: "";
|
|
775
|
+
unregisterHeadlessSynapseWorker(d.workerId);
|
|
757
776
|
|
|
758
777
|
if (safeProgress) {
|
|
759
778
|
safeProgress({
|
|
@@ -861,6 +880,22 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
861
880
|
|
|
862
881
|
mkdirSync(RESULT_DIR, { recursive: true });
|
|
863
882
|
|
|
883
|
+
// Synapse: 세션 registration (fire-and-forget, hub 미응답 시 무시)
|
|
884
|
+
const synapseIds = assignments.map((_, i) => `${sessionName}-worker-${i + 1}`);
|
|
885
|
+
for (let i = 0; i < assignments.length; i++) {
|
|
886
|
+
const a = assignments[i];
|
|
887
|
+
requestJson("/synapse/register", {
|
|
888
|
+
method: "POST",
|
|
889
|
+
body: {
|
|
890
|
+
sessionId: synapseIds[i],
|
|
891
|
+
host: "local",
|
|
892
|
+
taskSummary: String(a.prompt || "").slice(0, 100),
|
|
893
|
+
isRemote: false,
|
|
894
|
+
},
|
|
895
|
+
timeoutMs: 1000,
|
|
896
|
+
}).catch(() => {});
|
|
897
|
+
}
|
|
898
|
+
|
|
864
899
|
// in-process TUI: dashboard=true이고 stdout이 TTY일 때 직접 구동
|
|
865
900
|
let tui = null;
|
|
866
901
|
const resolvedLayout = resolveDashboardLayout(
|
|
@@ -918,9 +953,25 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
918
953
|
}
|
|
919
954
|
}
|
|
920
955
|
|
|
956
|
+
// Synapse heartbeat: progress 이벤트마다 해당 워커의 세션 갱신
|
|
957
|
+
const feedSynapse = (event) => {
|
|
958
|
+
if (!event?.paneName) return;
|
|
959
|
+
const match = event.paneName.match(/worker-(\d+)/);
|
|
960
|
+
if (!match) return;
|
|
961
|
+
const idx = parseInt(match[1], 10) - 1;
|
|
962
|
+
const sid = synapseIds[idx];
|
|
963
|
+
if (!sid) return;
|
|
964
|
+
requestJson("/synapse/heartbeat", {
|
|
965
|
+
method: "POST",
|
|
966
|
+
body: { sessionId: sid, partial: { taskSummary: (event.snapshot || "").slice(0, 100) } },
|
|
967
|
+
timeoutMs: 500,
|
|
968
|
+
}).catch(() => {});
|
|
969
|
+
};
|
|
970
|
+
|
|
921
971
|
// onProgress 예외를 삼켜 실행 흐름 보호 (onPoll과 동일 패턴)
|
|
922
972
|
const combinedProgress = (event) => {
|
|
923
973
|
feedTui(event);
|
|
974
|
+
feedSynapse(event);
|
|
924
975
|
if (onProgress) {
|
|
925
976
|
try {
|
|
926
977
|
onProgress(event);
|
|
@@ -981,6 +1032,15 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
981
1032
|
tui.close();
|
|
982
1033
|
}
|
|
983
1034
|
|
|
1035
|
+
// Synapse: 세션 unregister (fire-and-forget)
|
|
1036
|
+
for (const sid of synapseIds) {
|
|
1037
|
+
requestJson("/synapse/unregister", {
|
|
1038
|
+
method: "POST",
|
|
1039
|
+
body: { sessionId: sid },
|
|
1040
|
+
timeoutMs: 1000,
|
|
1041
|
+
}).catch(() => {});
|
|
1042
|
+
}
|
|
1043
|
+
|
|
984
1044
|
return { sessionName, results: collected };
|
|
985
1045
|
}
|
|
986
1046
|
|
|
@@ -999,6 +1059,11 @@ export async function runHeadlessWithCleanup(assignments, opts = {}) {
|
|
|
999
1059
|
try {
|
|
1000
1060
|
return await runHeadless(sessionName, assignments, runOpts);
|
|
1001
1061
|
} finally {
|
|
1062
|
+
for (let index = 0; index < assignments.length; index++) {
|
|
1063
|
+
unregisterHeadlessSynapseWorker(
|
|
1064
|
+
getHeadlessWorkerAgentId(sessionName, index),
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
1002
1067
|
await deregisterHeadlessWorkers(sessionName, assignments.length);
|
|
1003
1068
|
try {
|
|
1004
1069
|
killPsmuxSession(sessionName);
|
|
@@ -1056,7 +1121,7 @@ export function applyTrifluxTheme(sessionName) {
|
|
|
1056
1121
|
* WT 기본 프로필의 폰트 크기를 읽는다.
|
|
1057
1122
|
* @returns {number} 기본 폰트 크기 (못 읽으면 12)
|
|
1058
1123
|
*/
|
|
1059
|
-
function
|
|
1124
|
+
function _getWtDefaultFontSize() {
|
|
1060
1125
|
const settingsPaths = [
|
|
1061
1126
|
join(
|
|
1062
1127
|
process.env.LOCALAPPDATA || "",
|
|
@@ -1096,7 +1161,7 @@ function getWtDefaultFontSize() {
|
|
|
1096
1161
|
* @param {string} filePath — 대상 파일 경로
|
|
1097
1162
|
* @param {string} data — 쓸 내용
|
|
1098
1163
|
*/
|
|
1099
|
-
function
|
|
1164
|
+
function _atomicWriteSync(filePath, data) {
|
|
1100
1165
|
const tmpPath = `${filePath}.${process.pid}.tmp`;
|
|
1101
1166
|
try {
|
|
1102
1167
|
writeFileSync(tmpPath, data, "utf8");
|
|
@@ -1131,7 +1196,11 @@ function buildAttachTitle(sessionName, suffix = "") {
|
|
|
1131
1196
|
* @param {number} [workerCount=2]
|
|
1132
1197
|
* @returns {Promise<boolean>} 성공 여부
|
|
1133
1198
|
*/
|
|
1134
|
-
export async function autoAttachTerminal(
|
|
1199
|
+
export async function autoAttachTerminal(
|
|
1200
|
+
sessionName,
|
|
1201
|
+
opts = {},
|
|
1202
|
+
workerCount = 2,
|
|
1203
|
+
) {
|
|
1135
1204
|
if (!process.env.WT_SESSION) return false;
|
|
1136
1205
|
try {
|
|
1137
1206
|
execSync("where wt.exe", { stdio: "ignore" });
|
|
@@ -1145,8 +1214,14 @@ export async function autoAttachTerminal(sessionName, opts = {}, workerCount = 2
|
|
|
1145
1214
|
try {
|
|
1146
1215
|
const safeSession = sanitizeSessionName(sessionName);
|
|
1147
1216
|
if (workerCount >= 5) {
|
|
1148
|
-
const resolvedLayout = resolveDashboardLayout(
|
|
1149
|
-
|
|
1217
|
+
const resolvedLayout = resolveDashboardLayout(
|
|
1218
|
+
opts.dashboardLayout || "single",
|
|
1219
|
+
workerCount,
|
|
1220
|
+
);
|
|
1221
|
+
const viewerPath = join(import.meta.dirname, "tui-viewer.mjs").replace(
|
|
1222
|
+
/\\/g,
|
|
1223
|
+
"/",
|
|
1224
|
+
);
|
|
1150
1225
|
await wt.createTab({
|
|
1151
1226
|
title: buildAttachTitle(safeSession, "dashboard"),
|
|
1152
1227
|
profile: "triflux",
|
|
@@ -1220,7 +1295,10 @@ export async function attachDashboardTab(
|
|
|
1220
1295
|
try {
|
|
1221
1296
|
const safeSession = sanitizeSessionName(sessionName);
|
|
1222
1297
|
const resolvedLayout = resolveDashboardLayout(dashboardLayout, workerCount);
|
|
1223
|
-
const viewerPath = join(import.meta.dirname, "tui-viewer.mjs").replace(
|
|
1298
|
+
const viewerPath = join(import.meta.dirname, "tui-viewer.mjs").replace(
|
|
1299
|
+
/\\/g,
|
|
1300
|
+
"/",
|
|
1301
|
+
);
|
|
1224
1302
|
|
|
1225
1303
|
await wt.createTab({
|
|
1226
1304
|
title: buildAttachTitle(safeSession, "dashboard"),
|
|
@@ -1233,7 +1311,6 @@ export async function attachDashboardTab(
|
|
|
1233
1311
|
}
|
|
1234
1312
|
}
|
|
1235
1313
|
|
|
1236
|
-
|
|
1237
1314
|
/**
|
|
1238
1315
|
* 모든 워커 pane의 현재 스냅샷을 수집한다.
|
|
1239
1316
|
*
|
|
@@ -1415,6 +1492,11 @@ export async function runHeadlessInteractive(
|
|
|
1415
1492
|
kill() {
|
|
1416
1493
|
if (this._killed) return;
|
|
1417
1494
|
this._killed = true;
|
|
1495
|
+
for (let index = 0; index < assignments.length; index++) {
|
|
1496
|
+
unregisterHeadlessSynapseWorker(
|
|
1497
|
+
getHeadlessWorkerAgentId(sessionName, index),
|
|
1498
|
+
);
|
|
1499
|
+
}
|
|
1418
1500
|
void deregisterHeadlessWorkers(sessionName, assignments.length);
|
|
1419
1501
|
try {
|
|
1420
1502
|
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,8 +1,8 @@
|
|
|
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 { after, before, describe, it } from "node:test";
|
|
6
6
|
|
|
7
7
|
const TEST_LOG_DIR = join(tmpdir(), `spawn-trace-test-${Date.now()}`);
|
|
8
8
|
|
|
@@ -12,7 +12,11 @@ describe("spawn-trace", () => {
|
|
|
12
12
|
});
|
|
13
13
|
|
|
14
14
|
after(() => {
|
|
15
|
-
try {
|
|
15
|
+
try {
|
|
16
|
+
rmSync(TEST_LOG_DIR, { recursive: true, force: true });
|
|
17
|
+
} catch {
|
|
18
|
+
/* ignore */
|
|
19
|
+
}
|
|
16
20
|
});
|
|
17
21
|
|
|
18
22
|
it("exports child_process-compatible API surface", async () => {
|
|
@@ -30,6 +34,28 @@ describe("spawn-trace", () => {
|
|
|
30
34
|
const mod = await import("../../hub/lib/spawn-trace.mjs");
|
|
31
35
|
assert.equal(typeof mod.MAX_SPAWN_PER_SEC, "number");
|
|
32
36
|
assert.equal(typeof mod.MAX_TOTAL_DESCENDANTS, "number");
|
|
37
|
+
assert.equal(typeof mod.getMaxSpawnPerSec, "function");
|
|
38
|
+
assert.equal(typeof mod.reload, "function");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("reload re-evaluates TRIFLUX_MAX_SPAWN_RATE", async () => {
|
|
42
|
+
const mod = await import("../../hub/lib/spawn-trace.mjs");
|
|
43
|
+
const original = process.env.TRIFLUX_MAX_SPAWN_RATE;
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
process.env.TRIFLUX_MAX_SPAWN_RATE = "7";
|
|
47
|
+
assert.equal(mod.reload(), 7);
|
|
48
|
+
assert.equal(mod.getMaxSpawnPerSec(), 7);
|
|
49
|
+
assert.equal(mod.MAX_SPAWN_PER_SEC, 7);
|
|
50
|
+
assert.equal(mod.default.MAX_SPAWN_PER_SEC, 7);
|
|
51
|
+
} finally {
|
|
52
|
+
if (original == null) {
|
|
53
|
+
delete process.env.TRIFLUX_MAX_SPAWN_RATE;
|
|
54
|
+
} else {
|
|
55
|
+
process.env.TRIFLUX_MAX_SPAWN_RATE = original;
|
|
56
|
+
}
|
|
57
|
+
mod.reload();
|
|
58
|
+
}
|
|
33
59
|
});
|
|
34
60
|
|
|
35
61
|
it("spawn returns a ChildProcess-like object", async () => {
|
|
@@ -46,10 +72,14 @@ describe("spawn-trace", () => {
|
|
|
46
72
|
|
|
47
73
|
it("execFileSync returns stdout buffer", async () => {
|
|
48
74
|
const mod = await import("../../hub/lib/spawn-trace.mjs");
|
|
49
|
-
const result = mod.execFileSync(
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
75
|
+
const result = mod.execFileSync(
|
|
76
|
+
"node",
|
|
77
|
+
["-e", 'process.stdout.write("hello")'],
|
|
78
|
+
{
|
|
79
|
+
encoding: "utf8",
|
|
80
|
+
windowsHide: true,
|
|
81
|
+
},
|
|
82
|
+
);
|
|
53
83
|
assert.equal(result.trim(), "hello");
|
|
54
84
|
});
|
|
55
85
|
|
|
@@ -81,12 +111,16 @@ describe("spawn-trace", () => {
|
|
|
81
111
|
it("strips trace-specific options before passing to child_process", async () => {
|
|
82
112
|
const mod = await import("../../hub/lib/spawn-trace.mjs");
|
|
83
113
|
// reason and dedupe should not cause child_process to error
|
|
84
|
-
const result = mod.execFileSync(
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
114
|
+
const result = mod.execFileSync(
|
|
115
|
+
"node",
|
|
116
|
+
["-e", 'process.stdout.write("ok")'],
|
|
117
|
+
{
|
|
118
|
+
encoding: "utf8",
|
|
119
|
+
windowsHide: true,
|
|
120
|
+
reason: "test:strip-options",
|
|
121
|
+
dedupe: "test-key",
|
|
122
|
+
},
|
|
123
|
+
);
|
|
90
124
|
assert.equal(result.trim(), "ok");
|
|
91
125
|
});
|
|
92
126
|
|
|
@@ -96,5 +130,7 @@ describe("spawn-trace", () => {
|
|
|
96
130
|
assert.equal(typeof mod.default.execFile, "function");
|
|
97
131
|
assert.equal(typeof mod.default.execFileSync, "function");
|
|
98
132
|
assert.equal(typeof mod.default.MAX_SPAWN_PER_SEC, "number");
|
|
133
|
+
assert.equal(typeof mod.default.getMaxSpawnPerSec, "function");
|
|
134
|
+
assert.equal(typeof mod.default.reload, "function");
|
|
99
135
|
});
|
|
100
136
|
});
|
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 "에러 정보 없음")
|