triflux 10.9.16 → 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.
@@ -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.6",
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.14"
33
+ "version": "10.9.17"
34
34
  }
@@ -331,6 +331,10 @@ function normalizeExecFileArgs(args, options, callback) {
331
331
  };
332
332
  }
333
333
 
334
+ function wait(ms) {
335
+ return new Promise((resolve) => setTimeout(resolve, ms));
336
+ }
337
+
334
338
  export function spawn(command, args, options) {
335
339
  const { argsList, options: normalizedOptions } = normalizeSpawnArgs(
336
340
  args,
@@ -369,6 +373,32 @@ export function spawn(command, args, options) {
369
373
  });
370
374
  }
371
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
+
372
402
  export function execFile(file, args, options, callback) {
373
403
  const normalized = normalizeExecFileArgs(args, options, callback);
374
404
  const traceId = nextTraceId();
@@ -515,6 +545,7 @@ export const spawnSync = childProcess.spawnSync;
515
545
  export default {
516
546
  ...childProcess,
517
547
  spawn,
548
+ spawnWithBackoff,
518
549
  execFile,
519
550
  execFileSync,
520
551
  get MAX_SPAWN_PER_SEC() {
package/hub/server.mjs CHANGED
@@ -719,6 +719,11 @@ export async function startHub({
719
719
  pipe: pipe.getStatus(),
720
720
  assign_callback_pipe_path: assignCallbacks.path,
721
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,
722
727
  });
723
728
  }
724
729
 
@@ -41,7 +41,7 @@ export class GeminiBackend {
41
41
  }
42
42
 
43
43
  buildArgs(prompt, resultFile, opts = {}) {
44
- return `gemini --prompt ${prompt} --output-format text > '${resultFile}' 2>'${resultFile}.err'`;
44
+ return `$null | gemini --prompt ${prompt} --output-format text > '${resultFile}' 2>'${resultFile}.err'`;
45
45
  }
46
46
 
47
47
  env() {
@@ -18,6 +18,7 @@ import { createRequire } from "node:module";
18
18
  import { tmpdir } from "node:os";
19
19
  import { join } from "node:path";
20
20
  import { requestJson } from "../bridge.mjs";
21
+ import { getMaxSpawnPerSec } from "../lib/spawn-trace.mjs";
21
22
  import { escapePwshSingleQuoted } from "../cli-adapter-base.mjs";
22
23
  import { getBackend } from "./backend.mjs";
23
24
  import { resolveDashboardLayout } from "./dashboard-layout.mjs";
@@ -880,6 +881,19 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
880
881
 
881
882
  mkdirSync(RESULT_DIR, { recursive: true });
882
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
+
883
897
  // Synapse: 세션 registration (fire-and-forget, hub 미응답 시 무시)
884
898
  const synapseIds = assignments.map((_, i) => `${sessionName}-worker-${i + 1}`);
885
899
  for (let i = 0; i < assignments.length; i++) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.9.16",
3
+ "version": "10.9.17",
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": {
@@ -2,9 +2,20 @@ import assert from "node:assert/strict";
2
2
  import { mkdirSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { performance } from "node:perf_hooks";
5
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(() => {
@@ -20,8 +31,9 @@ describe("spawn-trace", () => {
20
31
  });
21
32
 
22
33
  it("exports child_process-compatible API surface", async () => {
23
- const mod = await import("../../hub/lib/spawn-trace.mjs");
34
+ const mod = await loadSpawnTraceModule();
24
35
  assert.equal(typeof mod.spawn, "function");
36
+ assert.equal(typeof mod.spawnWithBackoff, "function");
25
37
  assert.equal(typeof mod.execFile, "function");
26
38
  assert.equal(typeof mod.execFileSync, "function");
27
39
  assert.equal(typeof mod.exec, "function");
@@ -31,7 +43,7 @@ describe("spawn-trace", () => {
31
43
  });
32
44
 
33
45
  it("exports guard constants", async () => {
34
- const mod = await import("../../hub/lib/spawn-trace.mjs");
46
+ const mod = await loadSpawnTraceModule();
35
47
  assert.equal(typeof mod.MAX_SPAWN_PER_SEC, "number");
36
48
  assert.equal(typeof mod.MAX_TOTAL_DESCENDANTS, "number");
37
49
  assert.equal(typeof mod.getMaxSpawnPerSec, "function");
@@ -39,7 +51,7 @@ describe("spawn-trace", () => {
39
51
  });
40
52
 
41
53
  it("reload re-evaluates TRIFLUX_MAX_SPAWN_RATE", async () => {
42
- const mod = await import("../../hub/lib/spawn-trace.mjs");
54
+ const mod = await loadSpawnTraceModule();
43
55
  const original = process.env.TRIFLUX_MAX_SPAWN_RATE;
44
56
 
45
57
  try {
@@ -59,7 +71,7 @@ describe("spawn-trace", () => {
59
71
  });
60
72
 
61
73
  it("spawn returns a ChildProcess-like object", async () => {
62
- const mod = await import("../../hub/lib/spawn-trace.mjs");
74
+ const mod = await loadSpawnTraceModule();
63
75
  const child = mod.spawn("node", ["-e", "process.exit(0)"], {
64
76
  windowsHide: true,
65
77
  });
@@ -71,7 +83,7 @@ describe("spawn-trace", () => {
71
83
  });
72
84
 
73
85
  it("execFileSync returns stdout buffer", async () => {
74
- const mod = await import("../../hub/lib/spawn-trace.mjs");
86
+ const mod = await loadSpawnTraceModule();
75
87
  const result = mod.execFileSync(
76
88
  "node",
77
89
  ["-e", 'process.stdout.write("hello")'],
@@ -84,7 +96,7 @@ describe("spawn-trace", () => {
84
96
  });
85
97
 
86
98
  it("execFileSync throws on non-zero exit", async () => {
87
- const mod = await import("../../hub/lib/spawn-trace.mjs");
99
+ const mod = await loadSpawnTraceModule();
88
100
  assert.throws(() => {
89
101
  mod.execFileSync("node", ["-e", "process.exit(1)"], {
90
102
  windowsHide: true,
@@ -93,7 +105,7 @@ describe("spawn-trace", () => {
93
105
  });
94
106
 
95
107
  it("execFile with callback receives stdout", async () => {
96
- const mod = await import("../../hub/lib/spawn-trace.mjs");
108
+ const mod = await loadSpawnTraceModule();
97
109
  const result = await new Promise((resolve, reject) => {
98
110
  mod.execFile(
99
111
  "node",
@@ -109,7 +121,7 @@ describe("spawn-trace", () => {
109
121
  });
110
122
 
111
123
  it("strips trace-specific options before passing to child_process", async () => {
112
- const mod = await import("../../hub/lib/spawn-trace.mjs");
124
+ const mod = await loadSpawnTraceModule();
113
125
  // reason and dedupe should not cause child_process to error
114
126
  const result = mod.execFileSync(
115
127
  "node",
@@ -125,12 +137,117 @@ describe("spawn-trace", () => {
125
137
  });
126
138
 
127
139
  it("default export includes spawn/execFile/execFileSync", async () => {
128
- const mod = await import("../../hub/lib/spawn-trace.mjs");
140
+ const mod = await loadSpawnTraceModule();
129
141
  assert.equal(typeof mod.default.spawn, "function");
142
+ assert.equal(typeof mod.default.spawnWithBackoff, "function");
130
143
  assert.equal(typeof mod.default.execFile, "function");
131
144
  assert.equal(typeof mod.default.execFileSync, "function");
132
145
  assert.equal(typeof mod.default.MAX_SPAWN_PER_SEC, "number");
133
146
  assert.equal(typeof mod.default.getMaxSpawnPerSec, "function");
134
147
  assert.equal(typeof mod.default.reload, "function");
135
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);
252
+ });
136
253
  });