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.
@@ -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 const MAX_SPAWN_PER_SEC = resolvePositiveInteger(
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 >= MAX_SPAWN_PER_SEC) {
196
+ if (recentSpawnTimes.length >= maxSpawnPerSec) {
184
197
  return createPolicyError(
185
198
  "rate_limit",
186
- `spawn-trace rate limit exceeded (${MAX_SPAWN_PER_SEC}/sec)`,
187
- { maxPerSec: MAX_SPAWN_PER_SEC },
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(traceId, file, normalized.argsList, normalized.options, blockedError);
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(traceId, file, normalized.argsList, normalized.options, blockedError, {
421
- sync: true,
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
  }
@@ -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, getAdapter } from "./launcher-template.mjs";
32
- import { createRemoteProbe } from "./remote-probe.mjs";
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 = "codex exec -s danger-full-access --dangerously-bypass-approvals-and-sandbox";
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", "ConnectTimeout=30",
581
- "-o", "BatchMode=yes",
620
+ "-o",
621
+ "ConnectTimeout=30",
622
+ "-o",
623
+ "BatchMode=yes",
582
624
  host,
583
625
  `${cdPrefix}${remoteBin}`,
584
626
  ];
@@ -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 getWtDefaultFontSize() {
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 atomicWriteSync(filePath, data) {
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(sessionName, opts = {}, workerCount = 2) {
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(opts.dashboardLayout || "single", workerCount);
1149
- const viewerPath = join(import.meta.dirname, "tui-viewer.mjs").replace(/\\/g, "/");
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(/\\/g, "/");
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 sessionId = normalizeSessionId(meta?.sessionId);
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
- ...meta,
185
+ ...normalizedMeta,
182
186
  sessionId,
183
187
  status: "active",
184
188
  lastHeartbeat: now(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.9.15",
3
+ "version": "10.9.16",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,8 +1,8 @@
1
1
  import assert from "node:assert/strict";
2
- import { after, before, describe, it, mock } from "node:test";
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 { rmSync(TEST_LOG_DIR, { recursive: true, force: true }); } catch { /* ignore */ }
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("node", ["-e", 'process.stdout.write("hello")'], {
50
- encoding: "utf8",
51
- windowsHide: true,
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("node", ["-e", 'process.stdout.write("ok")'], {
85
- encoding: "utf8",
86
- windowsHide: true,
87
- reason: "test:strip-options",
88
- dedupe: "test-key",
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
  });
@@ -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 "에러 정보 없음")