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.
|
|
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
|
@@ -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
|
|
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/headless.mjs
CHANGED
|
@@ -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
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
});
|