omnirush 0.8.4 → 0.8.6
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/assets/collect-once.ts +6 -5
- package/assets/extensions/omnirush/agents-lib.ts +574 -52
- package/assets/extensions/omnirush/agents.ts +384 -84
- package/assets/extensions/omnirush/auth.js +399 -115
- package/assets/extensions/omnirush/collector.ts +90 -23
- package/assets/extensions/omnirush/commands.ts +12 -15
- package/assets/extensions/omnirush/sota-lib.ts +129 -0
- package/assets/extensions/omnirush/sota.ts +69 -51
- package/package.json +1 -1
- package/src/bin.js +48 -21
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
type PiEntry,
|
|
58
58
|
} from "./pi-engine";
|
|
59
59
|
import { sharedRefresher } from "./refresh";
|
|
60
|
+
import { AGENTS_HOOK, type AgentsHook } from "./agents-lib";
|
|
60
61
|
|
|
61
62
|
/** info.agent of the root session's messages (a sub-agent's is its role). */
|
|
62
63
|
export const ROOT_AGENT = "omnirush-cli";
|
|
@@ -254,7 +255,11 @@ export default function (pi: any) {
|
|
|
254
255
|
if ((process.env.OMNIRUSH_PARENT_SESSION || "").trim()) return;
|
|
255
256
|
const origin = resolveOrigin(process.env);
|
|
256
257
|
const gatewayUrl = process.env.OMNIRUSH_GATEWAY_URL || gatewayUrlForOrigin(origin);
|
|
257
|
-
|
|
258
|
+
// The shared auth file is the source of truth (another process may have
|
|
259
|
+
// rotated since launch); the launcher's environment value is a fallback.
|
|
260
|
+
const accessToken = (
|
|
261
|
+
sharedRefresher().accessToken() || process.env.OMNIRUSH_ACCESS_TOKEN || process.env.OMNIRUSH_TOKEN || ""
|
|
262
|
+
).trim();
|
|
258
263
|
const stateDir = omniDir();
|
|
259
264
|
const agentDir = piAgentDir();
|
|
260
265
|
const version = cliVersion();
|
|
@@ -274,19 +279,40 @@ export default function (pi: any) {
|
|
|
274
279
|
// sota guard shares it): a 401 asks it for the token after `current`.
|
|
275
280
|
let current = accessToken;
|
|
276
281
|
const refreshAccessToken = async (): Promise<string | null> => {
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
const next =
|
|
280
|
-
if (!refreshed || !next) return null;
|
|
282
|
+
const outcome = await sharedRefresher().recover(current).catch(() => null);
|
|
283
|
+
if (outcome?.status !== "ok") return null;
|
|
284
|
+
const next = outcome.accessToken;
|
|
281
285
|
current = next;
|
|
282
286
|
archiver.setAccessToken(next);
|
|
283
287
|
return next;
|
|
284
288
|
};
|
|
285
289
|
|
|
290
|
+
// Uploads carry the CURRENT on-disk token, not the one this process
|
|
291
|
+
// last saw: another terminal or a sub-agent may have rotated the pair.
|
|
292
|
+
// Only requests that already carry a bearer (collect, archive API) are
|
|
293
|
+
// touched — presigned S3 part PUTs pass through unchanged.
|
|
294
|
+
const withCurrentBearer = (input: string, init?: RequestInit): Promise<Response> => {
|
|
295
|
+
const headers = new Headers(init?.headers ?? {});
|
|
296
|
+
if (/^Bearer /i.test(headers.get("authorization") ?? "")) {
|
|
297
|
+
let latest = "";
|
|
298
|
+
try {
|
|
299
|
+
latest = sharedRefresher().accessToken();
|
|
300
|
+
} catch {
|
|
301
|
+
/* keep the caller's bearer */
|
|
302
|
+
}
|
|
303
|
+
if (latest) {
|
|
304
|
+
headers.set("Authorization", `Bearer ${latest}`);
|
|
305
|
+
return globalThis.fetch(input, { ...init, headers });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return globalThis.fetch(input, init);
|
|
309
|
+
};
|
|
310
|
+
|
|
286
311
|
const collector = new WorkspaceCollector({
|
|
287
312
|
gatewayUrl,
|
|
288
313
|
accessToken,
|
|
289
314
|
refreshAccessToken,
|
|
315
|
+
fetch: withCurrentBearer,
|
|
290
316
|
stateDir,
|
|
291
317
|
log,
|
|
292
318
|
// environment.app_version tells the CLI apart from the desktop app (whose
|
|
@@ -308,6 +334,7 @@ export default function (pi: any) {
|
|
|
308
334
|
gatewayUrl,
|
|
309
335
|
accessToken,
|
|
310
336
|
refreshAccessToken,
|
|
337
|
+
fetch: withCurrentBearer,
|
|
311
338
|
// The CLI's own state (auth, spool, archive queue) and the agent's
|
|
312
339
|
// session files are never archived when they sit inside a project.
|
|
313
340
|
excludedDirs: [stateDir, agentDir],
|
|
@@ -403,6 +430,32 @@ export default function (pi: any) {
|
|
|
403
430
|
return session;
|
|
404
431
|
};
|
|
405
432
|
|
|
433
|
+
/**
|
|
434
|
+
* The sub-agent tree's messages since their checkpoints. Background
|
|
435
|
+
* sub-agents keep running past the turn that started them: each later
|
|
436
|
+
* settle (and the session's end) records what they did since.
|
|
437
|
+
*/
|
|
438
|
+
const captureChildren = async (state: NonNullable<typeof session>, room: (bytes: number) => void): Promise<void> => {
|
|
439
|
+
const { id } = state;
|
|
440
|
+
try {
|
|
441
|
+
await captureChildSessions({
|
|
442
|
+
collector,
|
|
443
|
+
rootSessionId: id,
|
|
444
|
+
parentSessionId: id,
|
|
445
|
+
parentEntries: state.ctx?.sessionManager?.getBranch?.() ?? [],
|
|
446
|
+
depth: 1,
|
|
447
|
+
sessionDirs: sessionDirs(state.ctx),
|
|
448
|
+
checkpoints: await collector.childCheckpoints(id),
|
|
449
|
+
known: new Set(await collector.childSessionIds(id)),
|
|
450
|
+
visited: new Set([id]),
|
|
451
|
+
seenFiles: state.seenFiles,
|
|
452
|
+
beforeRecord: room,
|
|
453
|
+
});
|
|
454
|
+
} catch (error) {
|
|
455
|
+
collector.recordTrace(id, "session.children_failed", { error: error instanceof Error ? error.message : "unknown" });
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
|
|
406
459
|
/**
|
|
407
460
|
* Everything a settled run records, in the desktop observer's order:
|
|
408
461
|
* messages, sub-agents, the idle event, the snapshot, the trace and the
|
|
@@ -444,23 +497,7 @@ export default function (pi: any) {
|
|
|
444
497
|
}
|
|
445
498
|
pending += bytes;
|
|
446
499
|
};
|
|
447
|
-
|
|
448
|
-
await captureChildSessions({
|
|
449
|
-
collector,
|
|
450
|
-
rootSessionId: id,
|
|
451
|
-
parentSessionId: id,
|
|
452
|
-
parentEntries: state.ctx?.sessionManager?.getBranch?.() ?? [],
|
|
453
|
-
depth: 1,
|
|
454
|
-
sessionDirs: sessionDirs(state.ctx),
|
|
455
|
-
checkpoints: await collector.childCheckpoints(id),
|
|
456
|
-
known: new Set(await collector.childSessionIds(id)),
|
|
457
|
-
visited: new Set([id]),
|
|
458
|
-
seenFiles: state.seenFiles,
|
|
459
|
-
beforeRecord: room,
|
|
460
|
-
});
|
|
461
|
-
} catch (error) {
|
|
462
|
-
collector.recordTrace(id, "session.children_failed", { error: error instanceof Error ? error.message : "unknown" });
|
|
463
|
-
}
|
|
500
|
+
await captureChildren(state, room);
|
|
464
501
|
room(newest.bytes);
|
|
465
502
|
collector.recordTrace(id, "session.idle", { status });
|
|
466
503
|
// The turn snapshot runs first so the artifacts it discovers are part of
|
|
@@ -485,11 +522,34 @@ export default function (pi: any) {
|
|
|
485
522
|
* settled too, so they reach the trace.
|
|
486
523
|
*/
|
|
487
524
|
const settleRemaining = async (): Promise<void> => {
|
|
525
|
+
// Background sub-agents still running are stopped first (their session
|
|
526
|
+
// files are complete once they exited) and marked interrupted.
|
|
527
|
+
const hook = (globalThis as any)[AGENTS_HOOK] as AgentsHook | undefined;
|
|
528
|
+
const followed = session;
|
|
529
|
+
const interrupted = followed?.started && hook ? await hook.interruptAll(followed.id).catch(() => []) : [];
|
|
488
530
|
await settleRunning("interrupted");
|
|
489
531
|
const state = session;
|
|
490
532
|
if (!state?.started) return;
|
|
533
|
+
for (const child of interrupted) {
|
|
534
|
+
collector.recordTrace(state.id, "session.child_interrupted", { child_session_id: child.sessionId, agent: child.role });
|
|
535
|
+
}
|
|
491
536
|
const lastId = messagesOf(state).at(-1)?.info.id;
|
|
492
|
-
if (typeof lastId !== "string" || lastId === state.lastMessageId)
|
|
537
|
+
if (typeof lastId !== "string" || lastId === state.lastMessageId) {
|
|
538
|
+
// No new message of its own: what its sub-agents did since the last settle.
|
|
539
|
+
settling = settling.then(async () => {
|
|
540
|
+
let pending = 0;
|
|
541
|
+
await captureChildren(state, (bytes) => {
|
|
542
|
+
if (pending > 0 && pending + bytes > MAX_TURN_MESSAGE_BYTES) {
|
|
543
|
+
collector.flushTrace(state.id);
|
|
544
|
+
pending = 0;
|
|
545
|
+
}
|
|
546
|
+
pending += bytes;
|
|
547
|
+
});
|
|
548
|
+
collector.flushTrace(state.id);
|
|
549
|
+
}).catch(() => undefined);
|
|
550
|
+
await settling;
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
493
553
|
settling = settling.then(() => settle(state, "idle")).catch(() => undefined);
|
|
494
554
|
await settling;
|
|
495
555
|
};
|
|
@@ -591,6 +651,13 @@ export default function (pi: any) {
|
|
|
591
651
|
archive.turnFollowed(state.id);
|
|
592
652
|
});
|
|
593
653
|
|
|
654
|
+
// A run that no prompt started (a background sub-agent's results
|
|
655
|
+
// delivered as a message start one): it settles like any other.
|
|
656
|
+
pi.on("agent_start", (_event: any, ctx: any) => {
|
|
657
|
+
const state = follow(ctx, false);
|
|
658
|
+
if (state?.started) state.running = true;
|
|
659
|
+
});
|
|
660
|
+
|
|
594
661
|
pi.on("tool_execution_start", (event: any) => {
|
|
595
662
|
if (session && typeof event?.toolCallId === "string") session.toolStarts.set(event.toolCallId, Date.now());
|
|
596
663
|
});
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { readSessionLedger, SESSION_LEDGER_FILE } from "./capture/workspace-collector";
|
|
21
|
-
import { deviceMe, omniDir, resolveOrigin } from "./auth";
|
|
21
|
+
import { deviceMe, omniDir, resolveOrigin, sessionEndedMessage } from "./auth";
|
|
22
22
|
import { sharedRefresher } from "./refresh";
|
|
23
23
|
import { formatCollectorLine, formatStatusLines } from "./status-lib";
|
|
24
24
|
import {
|
|
@@ -42,27 +42,24 @@ interface MeResult {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
|
-
* Fetch the signed-in identity + server-side usage with the
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
45
|
+
* Fetch the signed-in identity + server-side usage with the current
|
|
46
|
+
* on-disk token; a 401 adopts a newer on-disk pair or refreshes under the
|
|
47
|
+
* cross-process lock and retries (auth.js). Never throws; errors come back
|
|
48
|
+
* as `error` strings safe to display.
|
|
49
49
|
*/
|
|
50
50
|
async function fetchMe(): Promise<MeResult> {
|
|
51
51
|
const origin = resolveOrigin(process.env);
|
|
52
52
|
const refresher = sharedRefresher();
|
|
53
|
-
|
|
54
|
-
// in-flight 401 recovery — and fall back to the launcher-provided one.
|
|
55
|
-
const token = refresher.auth?.accessToken || (process.env.OMNIRUSH_TOKEN || "").trim();
|
|
53
|
+
let token = refresher.accessToken() || (process.env.OMNIRUSH_TOKEN || "").trim();
|
|
56
54
|
if (!token) return { me: null, error: "not signed in — run `omnirush login` first" };
|
|
57
55
|
try {
|
|
58
|
-
let
|
|
59
|
-
|
|
60
|
-
// 401: single-flight refresh, retry once with the rotated token.
|
|
61
|
-
const refreshed = await refresher.refresh(token).catch(() => false);
|
|
62
|
-
const next = refresher.auth?.accessToken;
|
|
63
|
-
if (refreshed && next && next !== token) {
|
|
64
|
-
me = await deviceMe(origin, { accessToken: next, fetchImpl: globalThis.fetch });
|
|
56
|
+
for (let round = 0; round < 3; round++) {
|
|
57
|
+
const me = await deviceMe(origin, { accessToken: token, fetchImpl: globalThis.fetch });
|
|
65
58
|
if (me !== null) return { me };
|
|
59
|
+
const outcome = await refresher.recover(token);
|
|
60
|
+
if (outcome.status === "transient") return { me: null, error: `manager unreachable (${outcome.error})` };
|
|
61
|
+
if (outcome.status !== "ok") return { me: null, error: sessionEndedMessage(outcome).replace(/^Omnirush: /, "") };
|
|
62
|
+
token = outcome.accessToken;
|
|
66
63
|
}
|
|
67
64
|
return { me: null, error: "credentials rejected — run `omnirush login` again" };
|
|
68
65
|
} catch (error) {
|
|
@@ -138,3 +138,132 @@ export function sanitizeGatewayErrorBody({
|
|
|
138
138
|
const message = friendlyGatewayMessage(status, ref, attempts, waitedMs);
|
|
139
139
|
return { bodyText: message, sanitized: true };
|
|
140
140
|
}
|
|
141
|
+
|
|
142
|
+
// --- stream stall watchdog -------------------------------------------------
|
|
143
|
+
//
|
|
144
|
+
// The agent core runs on Bun, and there its undici transport never
|
|
145
|
+
// settles a pending body read when the connection dies mid-stream: the
|
|
146
|
+
// HTTP idle timeout (or the gateway/edge dropping the socket) closes the
|
|
147
|
+
// socket, but `reader.read()` stays pending forever. The turn then sits
|
|
148
|
+
// on "Working…" until the user presses Esc — no error, no auto-retry
|
|
149
|
+
// (field report: session 01a0dd1a, a 13-minute hang after a tool call).
|
|
150
|
+
// On Node the same close surfaces as a `terminated` error that pi retries.
|
|
151
|
+
// The watchdog gives Bun that behavior: it re-exposes the body through a
|
|
152
|
+
// stream of our own and errors it when the upstream read has been
|
|
153
|
+
// pending for `idleMs`, so pi's retry takes over.
|
|
154
|
+
|
|
155
|
+
/** setTimeout's ceiling; pi passes it (2^31-1) for a disabled timeout. */
|
|
156
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
157
|
+
export const DEFAULT_STREAM_IDLE_MS = 300_000;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The idle limit for a model stream: OMNIRUSH_STREAM_IDLE_MS wins, then
|
|
161
|
+
* the request timeout pi hands the provider (its httpIdleTimeoutMs /
|
|
162
|
+
* retry.provider.timeoutMs), then 5 minutes. 0 (or a value at the timer
|
|
163
|
+
* ceiling, pi's "disabled") turns the watchdog off.
|
|
164
|
+
*/
|
|
165
|
+
export function streamIdleMs(envValue: string | undefined, requestTimeoutMs: unknown): number {
|
|
166
|
+
const pick = (value: unknown): number | undefined => {
|
|
167
|
+
if (value === undefined || value === null || value === "") return undefined;
|
|
168
|
+
const n = Number(value);
|
|
169
|
+
if (!Number.isFinite(n) || n < 0) return undefined;
|
|
170
|
+
return n >= MAX_TIMER_MS ? 0 : Math.floor(n);
|
|
171
|
+
};
|
|
172
|
+
return pick(envValue) ?? pick(requestTimeoutMs) ?? DEFAULT_STREAM_IDLE_MS;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The error a stalled stream fails with ("timed out" keeps it retryable for pi). */
|
|
176
|
+
export function streamStallError(idleMs: number): Error {
|
|
177
|
+
const seconds = Math.max(1, Math.round(idleMs / 1000));
|
|
178
|
+
const error = new Error(
|
|
179
|
+
`Omnirush model stream timed out: no data from the gateway for ${seconds}s — retrying`,
|
|
180
|
+
);
|
|
181
|
+
error.name = "OmnirushStreamStall";
|
|
182
|
+
return error;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Re-expose `source` as a new ReadableStream that errors with
|
|
187
|
+
* `streamStallError` when one upstream read stays pending for `idleMs`
|
|
188
|
+
* (0 = never), and with the abort reason when `signal` aborts. Every
|
|
189
|
+
* chunk is handed to `onChunk` before it is enqueued. The upstream
|
|
190
|
+
* reader is cancelled (best effort, never awaited — it may be the hung
|
|
191
|
+
* one) whenever the wrapper errors or is cancelled.
|
|
192
|
+
*/
|
|
193
|
+
export function withStallWatchdog(
|
|
194
|
+
source: ReadableStream<any>,
|
|
195
|
+
{
|
|
196
|
+
idleMs,
|
|
197
|
+
signal,
|
|
198
|
+
onChunk,
|
|
199
|
+
}: { idleMs: number; signal?: AbortSignal | null; onChunk?: (chunk: any) => void },
|
|
200
|
+
): ReadableStream<any> {
|
|
201
|
+
const reader = source.getReader();
|
|
202
|
+
let controller: ReadableStreamDefaultController<any>;
|
|
203
|
+
let finished = false;
|
|
204
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
205
|
+
|
|
206
|
+
const clearTimer = () => {
|
|
207
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
208
|
+
timer = undefined;
|
|
209
|
+
};
|
|
210
|
+
const finish = () => {
|
|
211
|
+
finished = true;
|
|
212
|
+
clearTimer();
|
|
213
|
+
signal?.removeEventListener?.("abort", onAbort);
|
|
214
|
+
};
|
|
215
|
+
const fail = (error: unknown) => {
|
|
216
|
+
if (finished) return;
|
|
217
|
+
finish();
|
|
218
|
+
try {
|
|
219
|
+
controller.error(error);
|
|
220
|
+
} catch {
|
|
221
|
+
/* already closed */
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
reader.cancel(error).catch(() => undefined);
|
|
225
|
+
} catch {
|
|
226
|
+
/* already released */
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
function onAbort() {
|
|
230
|
+
const reason = (signal as any)?.reason;
|
|
231
|
+
fail(reason ?? Object.assign(new Error("This operation was aborted"), { name: "AbortError" }));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return new ReadableStream<any>({
|
|
235
|
+
start(c) {
|
|
236
|
+
controller = c;
|
|
237
|
+
if (signal?.aborted) onAbort();
|
|
238
|
+
else signal?.addEventListener?.("abort", onAbort, { once: true });
|
|
239
|
+
},
|
|
240
|
+
async pull(c) {
|
|
241
|
+
if (finished) return;
|
|
242
|
+
if (idleMs > 0) timer = setTimeout(() => fail(streamStallError(idleMs)), Math.min(idleMs, MAX_TIMER_MS - 1));
|
|
243
|
+
let result: ReadableStreamReadResult<any>;
|
|
244
|
+
try {
|
|
245
|
+
result = await reader.read();
|
|
246
|
+
} catch (error) {
|
|
247
|
+
fail(error);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
clearTimer();
|
|
251
|
+
if (finished) return;
|
|
252
|
+
if (result.done) {
|
|
253
|
+
finish();
|
|
254
|
+
c.close();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
onChunk?.(result.value);
|
|
259
|
+
} catch {
|
|
260
|
+
/* observers never break the stream */
|
|
261
|
+
}
|
|
262
|
+
c.enqueue(result.value);
|
|
263
|
+
},
|
|
264
|
+
cancel(reason) {
|
|
265
|
+
finish();
|
|
266
|
+
return reader.cancel(reason).catch(() => undefined);
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
}
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
// - on 429 / 5xx / network failures, retries with exponential backoff
|
|
15
15
|
// + full jitter (shared retry layer, "attempt 2/5" progress on
|
|
16
16
|
// stderr) so users ride out backend deploys.
|
|
17
|
+
// - fails a model stream that stops delivering data (stall watchdog)
|
|
18
|
+
// with a retryable error, so a dropped connection never leaves the
|
|
19
|
+
// turn hanging on Bun (pi retries it like on Node).
|
|
17
20
|
// - SANITIZES every non-OK response body: raw upstream provider text
|
|
18
21
|
// ("help.openai.com… request ID…") never reaches the user — the
|
|
19
22
|
// agent layer gets an omnirush message with a local correlation id
|
|
@@ -28,12 +31,15 @@ import fs from "node:fs";
|
|
|
28
31
|
import { openAIResponsesApi } from "@earendil-works/pi-ai";
|
|
29
32
|
import { recordGatewayUsage } from "./usage";
|
|
30
33
|
import { sharedRefresher } from "./refresh";
|
|
34
|
+
import { sendWithDeviceAuth, sessionEndedMessage } from "./auth";
|
|
31
35
|
import {
|
|
32
36
|
createSseDataScanner,
|
|
33
37
|
formatSotaWarning,
|
|
34
38
|
isSotaViolation,
|
|
35
39
|
sanitizeGatewayErrorBody,
|
|
36
40
|
servedModelFromSseData,
|
|
41
|
+
streamIdleMs,
|
|
42
|
+
withStallWatchdog,
|
|
37
43
|
} from "./sota-lib";
|
|
38
44
|
import {
|
|
39
45
|
formatUnavailableSummary,
|
|
@@ -197,8 +203,9 @@ async function readErrorBody(response: any): Promise<string> {
|
|
|
197
203
|
|
|
198
204
|
/**
|
|
199
205
|
* Wrap a fetch implementation so that:
|
|
200
|
-
* -
|
|
201
|
-
*
|
|
206
|
+
* - every attempt carries the current on-disk device token, and a 401
|
|
207
|
+
* adopts a newer on-disk pair or refreshes under the cross-process
|
|
208
|
+
* lock, then resends (auth.js sendWithDeviceAuth),
|
|
202
209
|
* - 429 / 5xx / network failures retry with exponential backoff + full
|
|
203
210
|
* jitter (progress lines on stderr), and
|
|
204
211
|
* - SSE response bodies stream through a pass-through tap that observes
|
|
@@ -209,12 +216,15 @@ async function readErrorBody(response: any): Promise<string> {
|
|
|
209
216
|
* Retries only cover the request up to (and including) the response
|
|
210
217
|
* headers — once an OK response body starts streaming it is forwarded
|
|
211
218
|
* untouched; a mid-stream failure surfaces as the provider's own error.
|
|
219
|
+
* A body that stops delivering data for `idleMs` fails with a retryable
|
|
220
|
+
* "timed out" error (stall watchdog, see sota-lib) instead of hanging.
|
|
212
221
|
* Client aborts (Esc) are never retried.
|
|
213
222
|
*/
|
|
214
223
|
function tapFetch(
|
|
215
224
|
baseFetch: any,
|
|
216
225
|
expectedModelId: string,
|
|
217
226
|
onViolation: (expected: string, served: string) => void,
|
|
227
|
+
idleMs: number,
|
|
218
228
|
): any {
|
|
219
229
|
return async (input: any, init: any) => {
|
|
220
230
|
const requestUrl = typeof input === "string" ? input : input?.url;
|
|
@@ -223,47 +233,46 @@ function tapFetch(
|
|
|
223
233
|
|
|
224
234
|
const outcome = await withRetries(
|
|
225
235
|
async (attempt, ref) => {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
/* usage capture must never break the request */
|
|
236
|
-
}
|
|
237
|
-
if (response?.status === 401) {
|
|
238
|
-
const tokenUsed = bearerFromInit(init) || bearerFromInit(input);
|
|
239
|
-
debug(`401 seen; bearer present: ${Boolean(tokenUsed)}`);
|
|
240
|
-
if (tokenUsed) {
|
|
241
|
-
const refresher = sharedRefresher();
|
|
242
|
-
const refreshed = await refresher.refresh(tokenUsed).catch((error: any) => {
|
|
243
|
-
debug(`refresh threw: ${error?.message ?? error}`);
|
|
244
|
-
return false;
|
|
245
|
-
});
|
|
246
|
-
const next = refresher.auth?.accessToken;
|
|
247
|
-
debug(`refresh ok: ${refreshed}, next token present: ${Boolean(next)}`);
|
|
248
|
-
if (refreshed && next) {
|
|
249
|
-
response = await doFetch(next);
|
|
250
|
-
debug(`retry-after-refresh status: ${response?.status}`);
|
|
251
|
-
try {
|
|
252
|
-
recordGatewayUsage(response?.headers);
|
|
253
|
-
} catch {
|
|
254
|
-
/* as above */
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
if (response?.status === 401) {
|
|
258
|
-
// The refresh (or the fresh-adopted token) was rejected:
|
|
259
|
-
// the session is dead. Retrying forever would spin pi's
|
|
260
|
-
// auto-retry loop silently — fail fast with the fix.
|
|
261
|
-
warnStderr(
|
|
262
|
-
"Omnirush: this device's session is no longer valid — run `omnirush login` to sign in again.",
|
|
263
|
-
);
|
|
264
|
-
process.exit(2);
|
|
265
|
-
}
|
|
236
|
+
// Every attempt carries the CURRENT on-disk token (re-read per
|
|
237
|
+
// request: another terminal or a sub-agent may have rotated the
|
|
238
|
+
// pair since this process started). A 401 adopts a newer on-disk
|
|
239
|
+
// pair or refreshes under the cross-process lock, then resends.
|
|
240
|
+
const recordUsage = (res: any) => {
|
|
241
|
+
try {
|
|
242
|
+
recordGatewayUsage(res?.headers);
|
|
243
|
+
} catch {
|
|
244
|
+
/* usage capture must never break the request */
|
|
266
245
|
}
|
|
246
|
+
};
|
|
247
|
+
const { response: answered, outcome: authOutcome } = await sendWithDeviceAuth(
|
|
248
|
+
(token: string) => doFetch(token || undefined),
|
|
249
|
+
sharedRefresher(),
|
|
250
|
+
{
|
|
251
|
+
fallbackToken: bearerFromInit(init) || bearerFromInit(input),
|
|
252
|
+
onResponse: (res: any) => {
|
|
253
|
+
debug(`provider fetch ${requestUrl} -> ${res?.status} (attempt ${attempt})`);
|
|
254
|
+
recordUsage(res);
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
);
|
|
258
|
+
let response = answered;
|
|
259
|
+
if (authOutcome?.status === "dead") {
|
|
260
|
+
// The manager refused the refresh token that is on disk right
|
|
261
|
+
// now (re-read under the refresh lock): the device session is
|
|
262
|
+
// gone (revoked, expired, signed out). Retrying would spin pi's
|
|
263
|
+
// auto-retry loop silently — fail fast with the fix.
|
|
264
|
+
debug(`device session ended: ${authOutcome.reason} ${authOutcome.detail ?? ""}`);
|
|
265
|
+
warnStderr(sessionEndedMessage(authOutcome));
|
|
266
|
+
process.exit(2);
|
|
267
|
+
}
|
|
268
|
+
if (authOutcome?.status === "transient") {
|
|
269
|
+
// Could not reach the manager to refresh: not a sign-out. Retry
|
|
270
|
+
// like any other brief outage.
|
|
271
|
+
debug(`refresh unavailable: ${authOutcome.error}`);
|
|
272
|
+
response = new Response(
|
|
273
|
+
JSON.stringify({ error: { message: "Omnirush sign-in service unavailable", type: "omnirush_error", code: "auth_refresh_unavailable" } }),
|
|
274
|
+
{ status: 503, headers: { "content-type": "application/json" } },
|
|
275
|
+
);
|
|
267
276
|
}
|
|
268
277
|
const retryAfterSec = Number(response?.headers?.get?.("retry-after")) || undefined;
|
|
269
278
|
return { response, ref, retryAfterSec };
|
|
@@ -353,13 +362,11 @@ function tapFetch(
|
|
|
353
362
|
};
|
|
354
363
|
const scanner = createSseDataScanner(onData);
|
|
355
364
|
const decoder = new TextDecoder();
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
},
|
|
365
|
+
const body = withStallWatchdog(response.body, {
|
|
366
|
+
idleMs,
|
|
367
|
+
signal: init?.signal,
|
|
368
|
+
onChunk: (chunk: any) => scanner(decoder.decode(chunk, { stream: true })),
|
|
361
369
|
});
|
|
362
|
-
const body = response.body.pipeThrough(passthrough);
|
|
363
370
|
return new Response(body, {
|
|
364
371
|
status: response.status,
|
|
365
372
|
statusText: response.statusText,
|
|
@@ -389,8 +396,11 @@ export default function (pi: any) {
|
|
|
389
396
|
const baseFetch = options?.fetch ?? globalThis.fetch;
|
|
390
397
|
const tapped = {
|
|
391
398
|
...(options ?? {}),
|
|
392
|
-
fetch: tapFetch(
|
|
393
|
-
|
|
399
|
+
fetch: tapFetch(
|
|
400
|
+
baseFetch,
|
|
401
|
+
model.id,
|
|
402
|
+
(expected, served) => reportViolation(strictSota(), expected, served),
|
|
403
|
+
streamIdleMs(process.env.OMNIRUSH_STREAM_IDLE_MS, options?.timeoutMs),
|
|
394
404
|
),
|
|
395
405
|
};
|
|
396
406
|
return simple
|
|
@@ -406,7 +416,15 @@ export default function (pi: any) {
|
|
|
406
416
|
apiKey: {
|
|
407
417
|
name: "Omnirush token",
|
|
408
418
|
async resolve() {
|
|
409
|
-
|
|
419
|
+
// The shared auth file first (always current); the launcher's
|
|
420
|
+
// environment value only as a bootstrap.
|
|
421
|
+
let key = "";
|
|
422
|
+
try {
|
|
423
|
+
key = sharedRefresher().accessToken();
|
|
424
|
+
} catch {
|
|
425
|
+
/* fall back to the environment */
|
|
426
|
+
}
|
|
427
|
+
key ||= (process.env.OMNIRUSH_TOKEN || "").trim();
|
|
410
428
|
if (!key) return undefined;
|
|
411
429
|
return { auth: { apiKey: key }, source: "environment" };
|
|
412
430
|
},
|