omnirush 0.5.1 → 0.5.3
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/extensions/omnirush/auth.js +102 -11
- package/assets/extensions/omnirush/collector-lib.ts +110 -54
- package/assets/extensions/omnirush/collector.ts +38 -0
- package/assets/extensions/omnirush/sota-lib.ts +16 -2
- package/assets/extensions/omnirush/sota.ts +36 -1
- package/package.json +1 -1
- package/src/lib.js +82 -5
|
@@ -301,6 +301,66 @@ export async function pollForToken(origin, {
|
|
|
301
301
|
}
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
+
// --- cross-process refresh coordination (issue B) --------------------------
|
|
305
|
+
//
|
|
306
|
+
// Several omnirush processes on one machine share ~/.omnirush/auth.json.
|
|
307
|
+
// When the access token expires, every process sees the same 401 and
|
|
308
|
+
// would refresh with the SAME refresh token; the first winner ROTATES
|
|
309
|
+
// both tokens, the others' attempts then fail with a rejected refresh
|
|
310
|
+
// token and they dead-end telling the (still signed-in!) user to log in
|
|
311
|
+
// again. The refresher therefore: adopts newer on-disk tokens before
|
|
312
|
+
// refreshing, serializes actual refreshes behind an exclusive lockfile,
|
|
313
|
+
// and re-reads once more before declaring the session dead.
|
|
314
|
+
|
|
315
|
+
const REFRESH_LOCK_FILE = ".refresh-lock";
|
|
316
|
+
const REFRESH_LOCK_STALE_MS = 30_000;
|
|
317
|
+
const REFRESH_LOCK_WAIT_MS = 35_000;
|
|
318
|
+
const REFRESH_LOCK_POLL_MS = 200;
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Take the exclusive refresh lock (O_EXCL create). A lock older than
|
|
322
|
+
* REFRESH_LOCK_STALE_MS is crashed-process debris and is taken over.
|
|
323
|
+
* Resolves true when WE hold the lock, false when it could not be
|
|
324
|
+
* acquired within the wait budget (caller proceeds unlocked — an extra
|
|
325
|
+
* rejected refresh is recoverable, a hang is not).
|
|
326
|
+
*/
|
|
327
|
+
async function acquireRefreshLock(dir, sleep) {
|
|
328
|
+
const lockPath = path.join(dir, REFRESH_LOCK_FILE);
|
|
329
|
+
const deadline = Date.now() + REFRESH_LOCK_WAIT_MS;
|
|
330
|
+
for (;;) {
|
|
331
|
+
try {
|
|
332
|
+
const fd = fs.openSync(lockPath, "wx", 0o600);
|
|
333
|
+
fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }));
|
|
334
|
+
fs.closeSync(fd);
|
|
335
|
+
return true;
|
|
336
|
+
} catch (error) {
|
|
337
|
+
if (error?.code !== "EEXIST") return false;
|
|
338
|
+
try {
|
|
339
|
+
const stats = fs.statSync(lockPath);
|
|
340
|
+
if (Date.now() - stats.mtimeMs > REFRESH_LOCK_STALE_MS) {
|
|
341
|
+
fs.rmSync(lockPath, { force: true });
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
} catch {
|
|
345
|
+
continue; // vanished — retry the create
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (Date.now() >= deadline) return false;
|
|
349
|
+
await sleep(REFRESH_LOCK_POLL_MS);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Release the refresh lock, but only if we still own it. */
|
|
354
|
+
function releaseRefreshLock(dir) {
|
|
355
|
+
const lockPath = path.join(dir, REFRESH_LOCK_FILE);
|
|
356
|
+
try {
|
|
357
|
+
const content = fs.readFileSync(lockPath, "utf8");
|
|
358
|
+
if (content.includes(String(process.pid))) fs.rmSync(lockPath, { force: true });
|
|
359
|
+
} catch {
|
|
360
|
+
/* gone already */
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
304
364
|
export class RefreshRejectedError extends Error {
|
|
305
365
|
constructor(detail) {
|
|
306
366
|
super(`refresh rejected: ${detail}`);
|
|
@@ -398,20 +458,51 @@ export function createRefresher({ dir, origin, fetchImpl, now = () => Date.now()
|
|
|
398
458
|
|
|
399
459
|
const performRefresh = async (expectedAccessToken) => {
|
|
400
460
|
if (!auth || auth.accessToken !== expectedAccessToken) return true;
|
|
401
|
-
|
|
461
|
+
// 1. Cross-process adoption: another process may have refreshed
|
|
462
|
+
// already — the on-disk access token would differ from ours. Adopt
|
|
463
|
+
// it and let the caller retry; no refresh call, no rotation race.
|
|
464
|
+
const onDiskBefore = dir ? loadAuth(dir) : null;
|
|
465
|
+
if (onDiskBefore && onDiskBefore.accessToken && onDiskBefore.accessToken !== expectedAccessToken) {
|
|
466
|
+
setAuth(onDiskBefore);
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
// 2. Serialize the actual refresh across processes: re-read under
|
|
470
|
+
// the lock (the winner may have finished while we waited for it).
|
|
471
|
+
const haveLock = dir
|
|
472
|
+
? await acquireRefreshLock(dir, (ms) => new Promise((resolve) => setTimeout(resolve, ms)))
|
|
473
|
+
: false;
|
|
402
474
|
try {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
475
|
+
const underLock = dir ? loadAuth(dir) : null;
|
|
476
|
+
if (underLock && underLock.accessToken && underLock.accessToken !== expectedAccessToken) {
|
|
477
|
+
setAuth(underLock);
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
const attemptRefreshToken = underLock?.refreshToken ?? auth.refreshToken;
|
|
481
|
+
let next;
|
|
482
|
+
try {
|
|
483
|
+
next = await refreshTokens(origin, { refreshToken: attemptRefreshToken, fetchImpl: doFetch });
|
|
484
|
+
} catch (error) {
|
|
485
|
+
if (error instanceof RefreshRejectedError) {
|
|
486
|
+
// 3. Our refresh token was rejected — but the winner may have
|
|
487
|
+
// written its rotated pair after our attempt started. Adopt if
|
|
488
|
+
// the on-disk refresh token differs from the one we tried;
|
|
489
|
+
// otherwise the session is genuinely dead: caller surfaces a
|
|
490
|
+
// re-login hint (the stored pair stays for doctor).
|
|
491
|
+
const latest = dir ? loadAuth(dir) : null;
|
|
492
|
+
if (latest && latest.refreshToken && latest.refreshToken !== attemptRefreshToken) {
|
|
493
|
+
setAuth(latest);
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
throw error;
|
|
409
499
|
}
|
|
410
|
-
|
|
500
|
+
setAuth(next);
|
|
501
|
+
await persist(next);
|
|
502
|
+
return true;
|
|
503
|
+
} finally {
|
|
504
|
+
if (haveLock) releaseRefreshLock(dir);
|
|
411
505
|
}
|
|
412
|
-
setAuth(next);
|
|
413
|
-
await persist(next);
|
|
414
|
-
return true;
|
|
415
506
|
};
|
|
416
507
|
|
|
417
508
|
return {
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
|
|
47
47
|
import { createHash } from "node:crypto";
|
|
48
48
|
import { execFile } from "node:child_process";
|
|
49
|
-
|
|
49
|
+
|
|
50
50
|
import { lstat, mkdir, readFile, readdir, readlink, writeFile } from "node:fs/promises";
|
|
51
51
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
52
52
|
import { promisify } from "node:util";
|
|
@@ -166,6 +166,12 @@ export type SessionLedgerRecord = {
|
|
|
166
166
|
nextSequence: number;
|
|
167
167
|
sentBytes?: number;
|
|
168
168
|
lastMessageId?: string;
|
|
169
|
+
/** True once the session's full-tree start baseline shipped. A resumed
|
|
170
|
+
* session must NOT re-upload it — a restart-heavy session would
|
|
171
|
+
* otherwise re-burn the entire baseline (and starve traces) on every
|
|
172
|
+
* restart. Observed live: 846 start snapshots / multiple GB for one
|
|
173
|
+
* session. */
|
|
174
|
+
baselineUploaded?: boolean;
|
|
169
175
|
lastSeenAt: string;
|
|
170
176
|
/** Per-session-FILE transcript capture state (issue: trace format spec).
|
|
171
177
|
* Keys are absolute .jsonl paths; a size/mtime change re-captures. */
|
|
@@ -190,14 +196,22 @@ type SessionState = {
|
|
|
190
196
|
started: boolean;
|
|
191
197
|
finished: boolean;
|
|
192
198
|
budgetExhausted: boolean;
|
|
199
|
+
baselineUploaded: boolean;
|
|
193
200
|
changeTimer: ReturnType<typeof setTimeout> | null;
|
|
194
201
|
scanTimer: ReturnType<typeof setInterval> | null;
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
|
|
202
|
+
/** Known per-path stat snapshot (mtimeMs + size) for change detection.
|
|
203
|
+
* fs.watch is deliberately NOT used: its recursive handle keeps the
|
|
204
|
+
* event loop alive even after close()/unref() on every runtime, which
|
|
205
|
+
* hung sessions at exit (issue: process never exits after the reply).
|
|
206
|
+
* The scan loop diffs these snapshots instead. */
|
|
207
|
+
knownStats: Map<string, { mtimeMs: number; size: number }>;
|
|
208
|
+
/** True while a change scan is in flight (scans self-serialize). */
|
|
209
|
+
scanning: boolean;
|
|
210
|
+
/** Set once the per-path stat snapshot has been seeded (post-baseline
|
|
211
|
+
* walk) — the scan gate, deliberately independent of the start
|
|
212
|
+
* UPLOAD succeeding so capture keeps working when the backend is
|
|
213
|
+
* down (that is when the journal must absorb changes). */
|
|
214
|
+
baselineSeeded: boolean;
|
|
201
215
|
/** Paths the session knows exist(ed): the baseline walk plus every
|
|
202
216
|
* captured present record. Deletion records require membership —
|
|
203
217
|
* macOS FSEvents leaks parent-directory events (e.g. the previous
|
|
@@ -1013,6 +1027,7 @@ export class WorkspaceCollector {
|
|
|
1013
1027
|
state.sequence = previous?.nextSequence ?? 0;
|
|
1014
1028
|
state.sentBytes = previous?.sentBytes ?? 0;
|
|
1015
1029
|
state.lastMessageId = previous?.lastMessageId;
|
|
1030
|
+
state.baselineUploaded = previous?.baselineUploaded === true;
|
|
1016
1031
|
state.resumed = Boolean(previous);
|
|
1017
1032
|
if (previous?.traces && typeof previous.traces === "object") {
|
|
1018
1033
|
state.transcripts = { ...previous.traces };
|
|
@@ -1029,6 +1044,7 @@ export class WorkspaceCollector {
|
|
|
1029
1044
|
nextSequence: state.sequence,
|
|
1030
1045
|
sentBytes: state.sentBytes,
|
|
1031
1046
|
...(state.lastMessageId ? { lastMessageId: state.lastMessageId } : {}),
|
|
1047
|
+
...(state.baselineUploaded ? { baselineUploaded: true } : {}),
|
|
1032
1048
|
lastSeenAt: new Date().toISOString(),
|
|
1033
1049
|
};
|
|
1034
1050
|
await this.saveLedger();
|
|
@@ -1041,6 +1057,7 @@ export class WorkspaceCollector {
|
|
|
1041
1057
|
nextSequence: state.sequence,
|
|
1042
1058
|
sentBytes: state.sentBytes,
|
|
1043
1059
|
...(state.lastMessageId ? { lastMessageId: state.lastMessageId } : {}),
|
|
1060
|
+
...(state.baselineUploaded ? { baselineUploaded: true } : {}),
|
|
1044
1061
|
...(Object.keys(state.transcripts).length > 0 ? { traces: state.transcripts } : {}),
|
|
1045
1062
|
lastSeenAt: new Date().toISOString(),
|
|
1046
1063
|
};
|
|
@@ -1080,10 +1097,12 @@ export class WorkspaceCollector {
|
|
|
1080
1097
|
started: false,
|
|
1081
1098
|
finished: false,
|
|
1082
1099
|
budgetExhausted: false,
|
|
1100
|
+
baselineUploaded: false,
|
|
1083
1101
|
changeTimer: null,
|
|
1084
1102
|
scanTimer: null,
|
|
1085
|
-
|
|
1086
|
-
|
|
1103
|
+
knownStats: new Map(),
|
|
1104
|
+
scanning: false,
|
|
1105
|
+
baselineSeeded: false,
|
|
1087
1106
|
knownPaths: new Set(),
|
|
1088
1107
|
trace: [],
|
|
1089
1108
|
journalPath: this.stateDir ? join(resolve(this.stateDir), `journal-${sessionId}.ndjson`) : null,
|
|
@@ -1105,32 +1124,82 @@ export class WorkspaceCollector {
|
|
|
1105
1124
|
const baselinePaths = await listWorkspaceFiles(root);
|
|
1106
1125
|
state.knownPaths = new Set(baselinePaths);
|
|
1107
1126
|
state.lastSignature = await workspaceSignature(root);
|
|
1127
|
+
// Seed the per-path stat snapshot so the scan loop only captures
|
|
1128
|
+
// files that change AFTER the start snapshot.
|
|
1129
|
+
for (const relPath of baselinePaths) {
|
|
1130
|
+
try {
|
|
1131
|
+
const stats = await lstat(resolve(root, relPath));
|
|
1132
|
+
state.knownStats.set(relPath, { mtimeMs: stats.mtimeMs, size: stats.size });
|
|
1133
|
+
} catch {
|
|
1134
|
+
/* raced away — the next scan re-checks */
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
state.baselineSeeded = true;
|
|
1138
|
+
if (state.resumed && state.baselineUploaded) {
|
|
1139
|
+
// Resume of a session whose start baseline already shipped: do
|
|
1140
|
+
// NOT re-upload the full tree. A restart-heavy session would
|
|
1141
|
+
// otherwise re-burn the entire baseline (and starve traces) on
|
|
1142
|
+
// every restart — observed live as 846 start snapshots /
|
|
1143
|
+
// multiple GB for one session. Changes + the end snapshot cover
|
|
1144
|
+
// the deltas; a FAILED baseline (flag unset) still retries here.
|
|
1145
|
+
state.started = true;
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1108
1148
|
await this.uploadWorkspace(state, "start");
|
|
1149
|
+
state.baselineUploaded = true;
|
|
1109
1150
|
state.started = true;
|
|
1110
1151
|
});
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
if (filename && isCollectorPathDenied(String(filename))) return;
|
|
1115
|
-
if (filename) this.queueChangedPath(state, String(filename));
|
|
1116
|
-
this.scheduleChange(state);
|
|
1117
|
-
});
|
|
1118
|
-
state.watcher.on("error", () => {
|
|
1119
|
-
state.watcher?.close();
|
|
1120
|
-
state.watcher = null;
|
|
1121
|
-
});
|
|
1122
|
-
} catch {
|
|
1123
|
-
state.watcher = null;
|
|
1124
|
-
}
|
|
1152
|
+
// Change detection: signature scan (unref'd, self-serializing). No
|
|
1153
|
+
// fs.watch anywhere — its recursive handle keeps the event loop
|
|
1154
|
+
// alive even after close()/unref(), hanging every session at exit.
|
|
1125
1155
|
state.scanTimer = setInterval(() => {
|
|
1126
|
-
void
|
|
1127
|
-
if (signature && state.lastSignature && signature !== state.lastSignature) this.scheduleChange(state);
|
|
1128
|
-
state.lastSignature = signature;
|
|
1129
|
-
}).catch(() => undefined);
|
|
1156
|
+
void this.scanForChanges(state).catch(() => undefined);
|
|
1130
1157
|
}, this.fallbackScanMs);
|
|
1131
1158
|
state.scanTimer.unref?.();
|
|
1132
1159
|
}
|
|
1133
1160
|
|
|
1161
|
+
/**
|
|
1162
|
+
* Periodic change detection: cheap signature check first; on a real
|
|
1163
|
+
* change, lstat-diff every path against knownStats and capture exactly
|
|
1164
|
+
* the new/changed/deleted files into the on-disk journal.
|
|
1165
|
+
*/
|
|
1166
|
+
private async scanForChanges(state: SessionState): Promise<void> {
|
|
1167
|
+
if (state.scanning || state.finished || state.budgetExhausted) return;
|
|
1168
|
+
if (!state.baselineSeeded) return;
|
|
1169
|
+
state.scanning = true;
|
|
1170
|
+
try {
|
|
1171
|
+
const signature = await workspaceSignature(state.root);
|
|
1172
|
+
if (!signature || signature === state.lastSignature) return;
|
|
1173
|
+
const paths = (await listWorkspaceFiles(state.root)).sort(comparePaths);
|
|
1174
|
+
const seen = new Set<string>();
|
|
1175
|
+
for (const relPath of paths) {
|
|
1176
|
+
seen.add(relPath);
|
|
1177
|
+
let stats;
|
|
1178
|
+
try {
|
|
1179
|
+
stats = await lstat(resolve(state.root, relPath));
|
|
1180
|
+
} catch {
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
if (isCollectorPathDenied(relPath)) continue;
|
|
1184
|
+
const known = state.knownStats.get(relPath);
|
|
1185
|
+
if (known && known.mtimeMs === stats.mtimeMs && known.size === stats.size) continue;
|
|
1186
|
+
state.knownStats.set(relPath, { mtimeMs: stats.mtimeMs, size: stats.size });
|
|
1187
|
+
await this.captureChangedPath(state, relPath, { force: true });
|
|
1188
|
+
}
|
|
1189
|
+
// Deletions: paths we knew about that vanished.
|
|
1190
|
+
for (const [relPath] of state.knownStats) {
|
|
1191
|
+
if (!seen.has(relPath)) {
|
|
1192
|
+
state.knownStats.delete(relPath);
|
|
1193
|
+
await this.captureChangedPath(state, relPath, { force: true });
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
state.lastSignature = signature;
|
|
1197
|
+
this.scheduleChange(state);
|
|
1198
|
+
} finally {
|
|
1199
|
+
state.scanning = false;
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1134
1203
|
recordTrace(sessionId: string, type: string, data?: unknown): void {
|
|
1135
1204
|
const state = this.sessions.get(sessionId);
|
|
1136
1205
|
if (!state || state.finished || state.budgetExhausted) return;
|
|
@@ -1152,7 +1221,6 @@ export class WorkspaceCollector {
|
|
|
1152
1221
|
state.finished = true;
|
|
1153
1222
|
if (state.changeTimer) clearTimeout(state.changeTimer);
|
|
1154
1223
|
if (state.scanTimer) clearInterval(state.scanTimer);
|
|
1155
|
-
state.watcher?.close();
|
|
1156
1224
|
this.enqueue(state, async () => {
|
|
1157
1225
|
await state.ready;
|
|
1158
1226
|
if (state.trace.length > 0) pendingTrace.push(...state.trace.splice(0));
|
|
@@ -1192,8 +1260,6 @@ export class WorkspaceCollector {
|
|
|
1192
1260
|
clearInterval(state.scanTimer);
|
|
1193
1261
|
state.scanTimer = null;
|
|
1194
1262
|
}
|
|
1195
|
-
state.watcher?.close();
|
|
1196
|
-
state.watcher = null;
|
|
1197
1263
|
}
|
|
1198
1264
|
|
|
1199
1265
|
private exhaustSessionBudget(state: SessionState): void {
|
|
@@ -1233,20 +1299,6 @@ export class WorkspaceCollector {
|
|
|
1233
1299
|
state.changeTimer.unref?.();
|
|
1234
1300
|
}
|
|
1235
1301
|
|
|
1236
|
-
private queueChangedPath(state: SessionState, filename: string): void {
|
|
1237
|
-
if (state.finished || state.budgetExhausted) return;
|
|
1238
|
-
state.changeCaptureTail = state.changeCaptureTail
|
|
1239
|
-
.catch(() => undefined)
|
|
1240
|
-
.then(() => this.captureChangedPath(state, filename))
|
|
1241
|
-
.catch((error: unknown) => {
|
|
1242
|
-
this.log("warn", "OmniRush changed-file capture failed", {
|
|
1243
|
-
sessionId: state.id,
|
|
1244
|
-
path: filename,
|
|
1245
|
-
error: error instanceof Error ? error.message : "unknown",
|
|
1246
|
-
});
|
|
1247
|
-
});
|
|
1248
|
-
}
|
|
1249
|
-
|
|
1250
1302
|
private async captureChangedPath(
|
|
1251
1303
|
state: SessionState,
|
|
1252
1304
|
filename: string,
|
|
@@ -1259,16 +1311,6 @@ export class WorkspaceCollector {
|
|
|
1259
1311
|
let entry: ChangeJournalEntry;
|
|
1260
1312
|
try {
|
|
1261
1313
|
const file = await lstat(absolute);
|
|
1262
|
-
// Pre-baseline activity: the start snapshot already carries this
|
|
1263
|
-
// state, and macOS FSEvents (unlike inotify) delivers events for
|
|
1264
|
-
// files created just before watch() began — often after the start
|
|
1265
|
-
// upload finished. The mtime is compared at millisecond
|
|
1266
|
-
// resolution: sub-ms APFS timestamps make a pre-watch write's
|
|
1267
|
-
// mtime land a few microseconds after Date.now()'s truncated
|
|
1268
|
-
// stamp, which must still count as pre-baseline. Deletions stay
|
|
1269
|
-
// unguarded (replaying a delete of a baseline file the user
|
|
1270
|
-
// removed mid-session is required).
|
|
1271
|
-
if (!options?.force && Math.floor(file.mtimeMs) <= state.watcherStartedAtMs) return;
|
|
1272
1314
|
state.knownPaths.add(path);
|
|
1273
1315
|
if (file.isSymbolicLink()) {
|
|
1274
1316
|
// Symlink record: the target as text content with the symlink
|
|
@@ -1946,6 +1988,15 @@ export class WorkspaceCollector {
|
|
|
1946
1988
|
): Promise<boolean> {
|
|
1947
1989
|
if (!this.collectUrl && !this.uploader) return false;
|
|
1948
1990
|
if (state.budgetExhausted) return false;
|
|
1991
|
+
if (files.length === 0) {
|
|
1992
|
+
// A zero-file part carries no information and would loop forever
|
|
1993
|
+
// if a caller ever fed a packer an empty unit stream.
|
|
1994
|
+
this.log("warn", "OmniRush refused to upload an empty envelope", {
|
|
1995
|
+
sessionId: state.id,
|
|
1996
|
+
snapshotType,
|
|
1997
|
+
});
|
|
1998
|
+
return false;
|
|
1999
|
+
}
|
|
1949
2000
|
const payload = precompressed?.payload ?? buildEnvelopePayload(state, snapshotType, files);
|
|
1950
2001
|
if (state.sentBytes + payload.length > this.sessionBudgetBytes) {
|
|
1951
2002
|
this.exhaustSessionBudget(state);
|
|
@@ -2006,6 +2057,11 @@ export class WorkspaceCollector {
|
|
|
2006
2057
|
},
|
|
2007
2058
|
);
|
|
2008
2059
|
const response = outcome.result?.response;
|
|
2060
|
+
if (response?.ok) {
|
|
2061
|
+
// Drain/cancel the body so keep-alive sockets return to the pool —
|
|
2062
|
+
// an unconsumed body keeps a ref'd socket and hangs process exit.
|
|
2063
|
+
await response.body?.cancel().catch(() => undefined);
|
|
2064
|
+
}
|
|
2009
2065
|
if (!response) {
|
|
2010
2066
|
// Every attempt failed at the network layer.
|
|
2011
2067
|
throw new Error(
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// Silent no-op when there are no device credentials — collection only
|
|
10
10
|
// runs for signed-in omnirush identities.
|
|
11
11
|
|
|
12
|
+
import fs from "node:fs";
|
|
12
13
|
import os from "node:os";
|
|
13
14
|
import path from "node:path";
|
|
14
15
|
|
|
@@ -141,6 +142,43 @@ export default function (pi: any) {
|
|
|
141
142
|
});
|
|
142
143
|
});
|
|
143
144
|
|
|
145
|
+
// Print-mode exit (issue: process hangs after the reply). The agent
|
|
146
|
+
// finishes the turn and prints, but the gateway's keep-alive sockets
|
|
147
|
+
// sometimes keep the runtime's event loop ref'd for minutes, so pi
|
|
148
|
+
// never drains. When the one-shot run settles: finalize the collector
|
|
149
|
+
// (journal + trace + transcripts + end snapshot ship, bounded), then
|
|
150
|
+
// exit explicitly. TUI/RPC sessions never take this path.
|
|
151
|
+
let printRunStarted = false;
|
|
152
|
+
pi.on("agent_start", () => {
|
|
153
|
+
printRunStarted = true;
|
|
154
|
+
});
|
|
155
|
+
pi.on("agent_settled", async (_event: any, ctx: any) => {
|
|
156
|
+
// agent_settled also fires at startup with nothing to do — only the
|
|
157
|
+
// completion of an ACTUAL run finalizes and exits here.
|
|
158
|
+
if (!printRunStarted) return;
|
|
159
|
+
printRunStarted = false;
|
|
160
|
+
if (ctx?.mode !== "print" || !sessionId) return;
|
|
161
|
+
collector.finishSession(sessionId);
|
|
162
|
+
await Promise.race([
|
|
163
|
+
collector.stop(),
|
|
164
|
+
new Promise((resolve) => {
|
|
165
|
+
const timer = setTimeout(resolve, 45_000);
|
|
166
|
+
timer.unref?.();
|
|
167
|
+
}),
|
|
168
|
+
]);
|
|
169
|
+
// Flush buffered stdout (the reply) before the hard exit — bun
|
|
170
|
+
// buffers pipe writes and process.exit would drop them.
|
|
171
|
+
await new Promise((resolve) => {
|
|
172
|
+
const fallback = setTimeout(resolve, 2_000);
|
|
173
|
+
fallback.unref?.();
|
|
174
|
+
process.stdout.write("", () => {
|
|
175
|
+
clearTimeout(fallback);
|
|
176
|
+
resolve();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
process.exit(0);
|
|
180
|
+
});
|
|
181
|
+
|
|
144
182
|
pi.on("session_shutdown", async () => {
|
|
145
183
|
if (!sessionId) return;
|
|
146
184
|
collector.finishSession(sessionId);
|
|
@@ -55,12 +55,26 @@ export function formatSotaWarning(expected: string, served: string): string {
|
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
57
|
* Charter rule 1 check: did the gateway serve something other than the
|
|
58
|
-
* requested model?
|
|
58
|
+
* requested model? Gateway-internal served names count as the requested
|
|
59
|
+
* model when the requested id maps to them (see SERVED_ALIASES).
|
|
59
60
|
*/
|
|
60
61
|
export function isSotaViolation(expected: string, served: string): boolean {
|
|
61
|
-
|
|
62
|
+
if (served === expected) return false;
|
|
63
|
+
const aliases = SERVED_ALIASES[expected];
|
|
64
|
+
return !(aliases && aliases.includes(served));
|
|
62
65
|
}
|
|
63
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Gateway-internal model names per catalog id. The muse lane serves its
|
|
69
|
+
* models under upstream aliases ("spiffy_blimp350" observed 2026-09-25
|
|
70
|
+
* live: every muse request's response.created/response.completed frames
|
|
71
|
+
* carry it), which is the requested model — not a SOTA violation.
|
|
72
|
+
*/
|
|
73
|
+
export const SERVED_ALIASES: Record<string, string[]> = {
|
|
74
|
+
"meta-muse-spark": ["spiffy_blimp350"],
|
|
75
|
+
"muse-spark-1.1": ["spiffy_blimp350"],
|
|
76
|
+
};
|
|
77
|
+
|
|
64
78
|
// --- gateway error sanitization --------------------------------------------
|
|
65
79
|
|
|
66
80
|
/**
|
|
@@ -31,6 +31,7 @@ import { sharedRefresher } from "./refresh";
|
|
|
31
31
|
import {
|
|
32
32
|
createSseDataScanner,
|
|
33
33
|
formatSotaWarning,
|
|
34
|
+
isSotaViolation,
|
|
34
35
|
sanitizeGatewayErrorBody,
|
|
35
36
|
servedModelFromSseData,
|
|
36
37
|
} from "./sota-lib";
|
|
@@ -50,6 +51,9 @@ const STRICT_EXIT_CODE = 3;
|
|
|
50
51
|
// catalog. The `api` field is load-bearing: provider composition routes
|
|
51
52
|
// streams to this base only when the model's api matches one of
|
|
52
53
|
// base.getModels().
|
|
54
|
+
// Mirrors src/lib.js MODELS (the shipped catalog) — provider
|
|
55
|
+
// composition routes streams to this base only when a model's api
|
|
56
|
+
// matches getModels(), so every catalog id MUST appear here.
|
|
53
57
|
const CATALOG_MODELS = [
|
|
54
58
|
{
|
|
55
59
|
id: "gpt-6-astra",
|
|
@@ -71,6 +75,28 @@ const CATALOG_MODELS = [
|
|
|
71
75
|
contextWindow: 400000,
|
|
72
76
|
compat: { supportsMaxOutputTokens: false },
|
|
73
77
|
},
|
|
78
|
+
// Muse (Meta) — mirrors src/lib.js (zeros pricing placeholder until
|
|
79
|
+
// the manager's published per-token rates are public).
|
|
80
|
+
{
|
|
81
|
+
id: "meta-muse-spark",
|
|
82
|
+
name: "Meta Muse Spark",
|
|
83
|
+
api: "openai-responses",
|
|
84
|
+
reasoning: true,
|
|
85
|
+
input: ["text", "image"],
|
|
86
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
87
|
+
contextWindow: 400000,
|
|
88
|
+
compat: { supportsMaxOutputTokens: false },
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
id: "muse-spark-1.1",
|
|
92
|
+
name: "Muse Spark 1.1",
|
|
93
|
+
api: "openai-responses",
|
|
94
|
+
reasoning: true,
|
|
95
|
+
input: ["text", "image"],
|
|
96
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
97
|
+
contextWindow: 400000,
|
|
98
|
+
compat: { supportsMaxOutputTokens: false },
|
|
99
|
+
},
|
|
74
100
|
];
|
|
75
101
|
|
|
76
102
|
function warnStderr(line: string): void {
|
|
@@ -218,6 +244,15 @@ function tapFetch(
|
|
|
218
244
|
/* as above */
|
|
219
245
|
}
|
|
220
246
|
}
|
|
247
|
+
if (response?.status === 401) {
|
|
248
|
+
// The refresh (or the fresh-adopted token) was rejected:
|
|
249
|
+
// the session is dead. Retrying forever would spin pi's
|
|
250
|
+
// auto-retry loop silently — fail fast with the fix.
|
|
251
|
+
warnStderr(
|
|
252
|
+
"Omnirush: this device's session is no longer valid — run `omnirush login` to sign in again.",
|
|
253
|
+
);
|
|
254
|
+
process.exit(2);
|
|
255
|
+
}
|
|
221
256
|
}
|
|
222
257
|
}
|
|
223
258
|
const retryAfterSec = Number(response?.headers?.get?.("retry-after")) || undefined;
|
|
@@ -302,7 +337,7 @@ function tapFetch(
|
|
|
302
337
|
const onData = (data: string) => {
|
|
303
338
|
if (reported) return;
|
|
304
339
|
const served = servedModelFromSseData(data);
|
|
305
|
-
if (served === undefined || served
|
|
340
|
+
if (served === undefined || !isSotaViolation(expectedModelId, served)) return;
|
|
306
341
|
reported = true;
|
|
307
342
|
onViolation(expectedModelId, served);
|
|
308
343
|
};
|
package/package.json
CHANGED
package/src/lib.js
CHANGED
|
@@ -77,11 +77,13 @@ export function resolveModelDefaults(env = process.env) {
|
|
|
77
77
|
};
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
// The product's shipped catalog: gpt-6-astra (default)
|
|
81
|
-
// Live-verified 2026-09-22 against the production
|
|
82
|
-
// lists both ids and POST /v1/responses with
|
|
83
|
-
// as gpt-5.6-sol (x-omnirush-model
|
|
84
|
-
// every SSE frame), so the client
|
|
80
|
+
// The product's shipped catalog: gpt-6-astra (default), gpt-5.6-sol and
|
|
81
|
+
// the Muse models. Live-verified 2026-09-22 against the production
|
|
82
|
+
// gateway: GET /v1/models lists both ids and POST /v1/responses with
|
|
83
|
+
// model "gpt-5.6-sol" is served as gpt-5.6-sol (x-omnirush-model
|
|
84
|
+
// response header + the model named in every SSE frame), so the client
|
|
85
|
+
// may offer the switch honestly. Muse ids (meta-muse-spark,
|
|
86
|
+
// muse-spark-1.1) added 2026-09-25 from the live gateway list.
|
|
85
87
|
export const MODELS = [
|
|
86
88
|
{
|
|
87
89
|
id: "gpt-6-astra",
|
|
@@ -126,6 +128,81 @@ export const MODELS = [
|
|
|
126
128
|
max: "max",
|
|
127
129
|
},
|
|
128
130
|
},
|
|
131
|
+
{
|
|
132
|
+
// Muse (Meta) — added from the live gateway list 2026-09-25.
|
|
133
|
+
// Same backend family shape as sol: native Astra effort levels,
|
|
134
|
+
// no max_output_tokens (codex compat), 400k context.
|
|
135
|
+
id: "meta-muse-spark",
|
|
136
|
+
name: "Meta Muse Spark",
|
|
137
|
+
reasoning: true,
|
|
138
|
+
input: ["text", "image"],
|
|
139
|
+
// Muse is charged at the manager's published per-token rates
|
|
140
|
+
// (PR #410); no public per-Mtok numbers were available when this
|
|
141
|
+
// was added, so cost shows zeros (documented default) until
|
|
142
|
+
// published numbers land.
|
|
143
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
144
|
+
contextWindow: 400000,
|
|
145
|
+
compat: { supportsMaxOutputTokens: false },
|
|
146
|
+
thinkingLevelMap: {
|
|
147
|
+
minimal: "minimal",
|
|
148
|
+
low: "low",
|
|
149
|
+
medium: "medium",
|
|
150
|
+
high: "high",
|
|
151
|
+
xhigh: "xhigh",
|
|
152
|
+
max: "max",
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
id: "muse-spark-1.1",
|
|
157
|
+
name: "Muse Spark 1.1",
|
|
158
|
+
reasoning: true,
|
|
159
|
+
input: ["text", "image"],
|
|
160
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
161
|
+
contextWindow: 400000,
|
|
162
|
+
compat: { supportsMaxOutputTokens: false },
|
|
163
|
+
thinkingLevelMap: {
|
|
164
|
+
minimal: "minimal",
|
|
165
|
+
low: "low",
|
|
166
|
+
medium: "medium",
|
|
167
|
+
high: "high",
|
|
168
|
+
xhigh: "xhigh",
|
|
169
|
+
max: "max",
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: "muse-spark-1.3",
|
|
174
|
+
name: "Muse Spark 1.3",
|
|
175
|
+
reasoning: true,
|
|
176
|
+
input: ["text", "image"],
|
|
177
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
178
|
+
contextWindow: 400000,
|
|
179
|
+
compat: { supportsMaxOutputTokens: false },
|
|
180
|
+
thinkingLevelMap: {
|
|
181
|
+
minimal: "minimal",
|
|
182
|
+
low: "low",
|
|
183
|
+
medium: "medium",
|
|
184
|
+
high: "high",
|
|
185
|
+
xhigh: "xhigh",
|
|
186
|
+
max: "max",
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
id: "muse-spark-1.2-contributor",
|
|
191
|
+
name: "Muse Spark 1.2 Contributor",
|
|
192
|
+
reasoning: true,
|
|
193
|
+
input: ["text", "image"],
|
|
194
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
195
|
+
contextWindow: 400000,
|
|
196
|
+
compat: { supportsMaxOutputTokens: false },
|
|
197
|
+
thinkingLevelMap: {
|
|
198
|
+
minimal: "minimal",
|
|
199
|
+
low: "low",
|
|
200
|
+
medium: "medium",
|
|
201
|
+
high: "high",
|
|
202
|
+
xhigh: "xhigh",
|
|
203
|
+
max: "max",
|
|
204
|
+
},
|
|
205
|
+
},
|
|
129
206
|
];
|
|
130
207
|
|
|
131
208
|
/**
|