omnirush 0.7.0 → 0.8.0
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 +72 -141
- package/assets/extensions/omnirush/agents-lib.ts +23 -13
- package/assets/extensions/omnirush/agents.ts +19 -24
- package/assets/extensions/omnirush/capture/UPSTREAM +21 -0
- package/assets/extensions/omnirush/capture/collect-upload-budget.ts +39 -0
- package/assets/extensions/omnirush/capture/omnirush-swarm.ts +6 -0
- package/assets/extensions/omnirush/capture/server-fetch.ts +8 -0
- package/assets/extensions/omnirush/capture/session-archive/detect.ts +410 -0
- package/assets/extensions/omnirush/capture/session-archive/files.ts +85 -0
- package/assets/extensions/omnirush/capture/session-archive/ignored.ts +123 -0
- package/assets/extensions/omnirush/capture/session-archive/index.ts +1468 -0
- package/assets/extensions/omnirush/capture/session-archive/lifecycle.ts +606 -0
- package/assets/extensions/omnirush/capture/session-archive/manifest.ts +827 -0
- package/assets/extensions/omnirush/capture/session-archive/pack.ts +481 -0
- package/assets/extensions/omnirush/capture/session-archive/policy.ts +21 -0
- package/assets/extensions/omnirush/capture/session-archive/seal.ts +481 -0
- package/assets/extensions/omnirush/capture/session-archive/touched.ts +550 -0
- package/assets/extensions/omnirush/capture/session-archive/upload.ts +601 -0
- package/assets/extensions/omnirush/capture/turn-diff.ts +661 -0
- package/assets/extensions/omnirush/capture/vendor/minimatch.js +4 -0
- package/assets/extensions/omnirush/capture/vendor/zod.js +60 -0
- package/assets/extensions/omnirush/capture/workspace-collector.ts +5021 -0
- package/assets/extensions/omnirush/collector.ts +539 -171
- package/assets/extensions/omnirush/commands.ts +1 -1
- package/assets/extensions/omnirush/pi-engine.ts +655 -0
- package/assets/extensions/omnirush/plan.ts +6 -7
- package/package.json +5 -2
- package/scripts/build-capture-vendor.js +55 -0
- package/src/bin.js +30 -5
- package/assets/extensions/omnirush/collector-lib.ts +0 -2433
- package/assets/extensions/omnirush/trace-format.ts +0 -311
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The project archive's place in the embedded server's session lifecycle
|
|
3
|
+
* (README "Wiring"). Every method returns at once: engine reads and captures
|
|
4
|
+
* run in the background, a bounded number at a time (one by default), and one
|
|
5
|
+
* session's steps always run in call order. A base can wait for a quiet
|
|
6
|
+
* period after the last prompt (baseIdleMs), so a burst of chats opened and
|
|
7
|
+
* prompted one after another is packed once the burst is over. After the
|
|
8
|
+
* last completed turn, a final archive catches what changed since: when the
|
|
9
|
+
* session stays quiet (finalIdleMs), when a turn ends without completing,
|
|
10
|
+
* when the session is deleted, when the app quits (within quitBudgetMs) and,
|
|
11
|
+
* for what that missed, when the app starts again. Nothing here throws into
|
|
12
|
+
* the request path; an unexpected failure costs one warn line.
|
|
13
|
+
*/
|
|
14
|
+
import { realpath } from "node:fs/promises";
|
|
15
|
+
|
|
16
|
+
import type { CaptureResult, DrainResult, FinalReason, SessionArchiver } from "./index.js";
|
|
17
|
+
|
|
18
|
+
export type ProjectArchiver = Pick<
|
|
19
|
+
SessionArchiver,
|
|
20
|
+
"captureBase" | "captureDelta" | "captureFinal" | "startFinalCandidates" | "recordTouched" | "forgetTouched" | "drain" | "signOut" | "stop"
|
|
21
|
+
>;
|
|
22
|
+
|
|
23
|
+
/** Engine reads for one session, resolving to the parsed JSON, or null when it cannot be read. */
|
|
24
|
+
export type ArchiveEngineReads = {
|
|
25
|
+
/** The session record (v1 `/session/:id`, v2 `/api/session/:id`). */
|
|
26
|
+
session(): Promise<unknown>;
|
|
27
|
+
/** The session's messages in order (v1 `/session/:id/message`, v2 `/api/session/:id/context`). */
|
|
28
|
+
messages(): Promise<unknown>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type ArchiveLifecycleLog = (level: "info" | "warn", message: string, attributes?: Record<string, unknown>) => void;
|
|
32
|
+
|
|
33
|
+
export type ProjectArchiveLifecycleOptions = {
|
|
34
|
+
archiver: ProjectArchiver;
|
|
35
|
+
/** An account is connected and OMNIRUSH_ARCHIVE_ENABLED is not off. */
|
|
36
|
+
enabled: boolean;
|
|
37
|
+
log: ArchiveLifecycleLog;
|
|
38
|
+
/** Session steps (engine reads plus a capture) running at once. */
|
|
39
|
+
concurrency?: number;
|
|
40
|
+
/** While archiving is off for the account (428, 503 archive_disabled), consent is checked again at most this often. */
|
|
41
|
+
consentRecheckMs?: number;
|
|
42
|
+
/**
|
|
43
|
+
* A base is packed only once no prompt has been dispatched on any session
|
|
44
|
+
* for this long (0, the default: at once), and at the latest
|
|
45
|
+
* baseMaxDeferMs after its own prompt.
|
|
46
|
+
*/
|
|
47
|
+
baseIdleMs?: number;
|
|
48
|
+
baseMaxDeferMs?: number;
|
|
49
|
+
/** A session with no prompt for this long after a turn ended gets a final archive (FINAL_IDLE_MS). */
|
|
50
|
+
finalIdleMs?: number;
|
|
51
|
+
/** Shutdown packs the final archives of this app run's sessions for at most this long in all (QUIT_FINAL_BUDGET_MS); 0 packs none. */
|
|
52
|
+
quitBudgetMs?: number;
|
|
53
|
+
/** Tests. */
|
|
54
|
+
now?: () => number;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const DEFAULT_CONSENT_RECHECK_MS = 10 * 60_000;
|
|
58
|
+
/** The quiet window after a turn: with no new prompt by then, the session gets a final archive. */
|
|
59
|
+
export const FINAL_IDLE_MS = 10 * 60_000;
|
|
60
|
+
/**
|
|
61
|
+
* What shutdown spends, at most, packing final archives (the desktop app
|
|
62
|
+
* waits for the server to stop before it quits). What does not fit is
|
|
63
|
+
* checked at the next app start.
|
|
64
|
+
*/
|
|
65
|
+
export const QUIT_FINAL_BUDGET_MS = 5_000;
|
|
66
|
+
|
|
67
|
+
/** OMNIRUSH_ARCHIVE_ENABLED: on unless set to 0, false, no or off. */
|
|
68
|
+
export function projectArchiveEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
69
|
+
return !["0", "false", "no", "off"].includes((env.OMNIRUSH_ARCHIVE_ENABLED ?? "").trim().toLowerCase());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
73
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function messageInfo(message: Record<string, unknown>): Record<string, unknown> {
|
|
77
|
+
return isRecord(message.info) ? message.info : message;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** An assistant message that ended: a completion time, a finish reason, an error, or a finish part (v1 and v2 shapes). */
|
|
81
|
+
export function isFinishedAssistantMessage(message: Record<string, unknown>): boolean {
|
|
82
|
+
const info = messageInfo(message);
|
|
83
|
+
if (isRecord(info.time) && (typeof info.time.completed === "number" || typeof info.time.completed === "string")) return true;
|
|
84
|
+
if (typeof info.finish === "string" || info.error != null) return true;
|
|
85
|
+
const parts = Array.isArray(message.parts) ? message.parts : Array.isArray(info.content) ? info.content : [];
|
|
86
|
+
return parts.some((part) => isRecord(part) && ["step-finish", "finish", "error"].includes(String(part.type)));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Completed turns in an engine message list: prompts answered by an assistant
|
|
91
|
+
* message that ended (several assistant steps of one turn count once). Each
|
|
92
|
+
* completed turn adds one, so the number strictly increases from one turn to
|
|
93
|
+
* the next and survives app restarts. Null when the list could not be read.
|
|
94
|
+
*/
|
|
95
|
+
export function completedTurnCount(messages: unknown): number | null {
|
|
96
|
+
if (!Array.isArray(messages)) return null;
|
|
97
|
+
let turns = 0;
|
|
98
|
+
let awaitingReply = false;
|
|
99
|
+
for (const message of messages) {
|
|
100
|
+
if (!isRecord(message)) continue;
|
|
101
|
+
const info = messageInfo(message);
|
|
102
|
+
const role = info.role ?? info.type;
|
|
103
|
+
if (role === "user") {
|
|
104
|
+
awaitingReply = true;
|
|
105
|
+
} else if (role === "assistant" && awaitingReply && isFinishedAssistantMessage(message)) {
|
|
106
|
+
turns += 1;
|
|
107
|
+
awaitingReply = false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return turns;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The parent of a session record: a string for a child (sub-agent) session, null for a root one, undefined when unreadable. */
|
|
114
|
+
function parentSessionId(session: unknown): string | null | undefined {
|
|
115
|
+
if (!isRecord(session)) return undefined;
|
|
116
|
+
const info = isRecord(session.info) ? session.info : session;
|
|
117
|
+
const parent = info.parentID ?? info.parentId ?? info.parent_id;
|
|
118
|
+
return typeof parent === "string" && parent ? parent : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function errorSummary(error: unknown): string {
|
|
122
|
+
return error instanceof Error ? `${error.name}: ${error.message}` : "unknown error";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** What this app run knows about a session handed to captureBase. */
|
|
126
|
+
type SessionRecord = {
|
|
127
|
+
/** The real path of the session root once resolved; null when deltas are pointless (child, not archivable, stopped) or not known yet. */
|
|
128
|
+
root: string | null;
|
|
129
|
+
/**
|
|
130
|
+
* Where the start step reads the engine, until one read succeeded. While
|
|
131
|
+
* it is set the session is unresolved (the engine may still be starting
|
|
132
|
+
* after an app restart), and the next prompt or completed turn resolves it.
|
|
133
|
+
*/
|
|
134
|
+
start: { root: string; engine: ArchiveEngineReads } | null;
|
|
135
|
+
/** A start step is queued or running. */
|
|
136
|
+
starting: boolean;
|
|
137
|
+
/** The engine reads of the latest prompt: a turn that ends without completing reads its turn count there. */
|
|
138
|
+
engine: ArchiveEngineReads;
|
|
139
|
+
/** When a prompt was last dispatched or a turn last ended (shutdown takes the most recent sessions first). */
|
|
140
|
+
activeAt: number;
|
|
141
|
+
/** The quiet window after the last turn; its end queues the idle final archive. */
|
|
142
|
+
idleTimer: ReturnType<typeof setTimeout> | null;
|
|
143
|
+
/** Cancels the idle final archive while it is queued or packing: a new prompt ends the quiet period. */
|
|
144
|
+
idleFinal: AbortController | null;
|
|
145
|
+
/** Engine reads retried on their own while unresolved, and the retry waiting (see unresolved()). */
|
|
146
|
+
retries?: { count: number; timer: ReturnType<typeof setTimeout> | null };
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** An unresolved session start reads the engine again after these waits, then only at its next prompt or turn. */
|
|
150
|
+
const UNRESOLVED_RETRY_DELAYS_MS = [60_000, 2 * 60_000, 5 * 60_000, 10 * 60_000];
|
|
151
|
+
|
|
152
|
+
export class ProjectArchiveLifecycle {
|
|
153
|
+
private readonly archiver: ProjectArchiver;
|
|
154
|
+
private readonly log: ArchiveLifecycleLog;
|
|
155
|
+
private readonly concurrency: number;
|
|
156
|
+
private readonly consentRecheckMs: number;
|
|
157
|
+
private readonly baseIdleMs: number;
|
|
158
|
+
private readonly baseMaxDeferMs: number;
|
|
159
|
+
private readonly finalIdleMs: number;
|
|
160
|
+
private readonly quitBudgetMs: number;
|
|
161
|
+
private readonly now: () => number;
|
|
162
|
+
private readonly enabled: boolean;
|
|
163
|
+
private signedOut = false;
|
|
164
|
+
private stopped = false;
|
|
165
|
+
/** Archiving is off for the account until then (428 / 503 archive_disabled). */
|
|
166
|
+
private consentOffUntil = 0;
|
|
167
|
+
/** Sessions handed to captureBase in this app run. */
|
|
168
|
+
private readonly sessions = new Map<string, SessionRecord>();
|
|
169
|
+
private readonly tails = new Map<string, Promise<void>>();
|
|
170
|
+
/** Drains and the start-up clear, for settled(). */
|
|
171
|
+
private readonly background = new Set<Promise<void>>();
|
|
172
|
+
private running = 0;
|
|
173
|
+
private readonly waiting: Array<() => void> = [];
|
|
174
|
+
/** Final archives wait for a slot behind every other step. */
|
|
175
|
+
private readonly waitingLow: Array<() => void> = [];
|
|
176
|
+
/** When the last prompt was dispatched on any session: bases wait for a quiet period after it. */
|
|
177
|
+
private lastPromptAt = 0;
|
|
178
|
+
/** Bases waiting for the quiet period; stop() and signOut() wake them. */
|
|
179
|
+
private readonly sleepers = new Set<() => void>();
|
|
180
|
+
|
|
181
|
+
constructor(options: ProjectArchiveLifecycleOptions) {
|
|
182
|
+
this.archiver = options.archiver;
|
|
183
|
+
this.enabled = options.enabled;
|
|
184
|
+
this.log = options.log;
|
|
185
|
+
this.concurrency = Math.max(1, options.concurrency ?? 1);
|
|
186
|
+
this.consentRecheckMs = options.consentRecheckMs ?? DEFAULT_CONSENT_RECHECK_MS;
|
|
187
|
+
this.baseIdleMs = Math.max(0, options.baseIdleMs ?? 0);
|
|
188
|
+
this.baseMaxDeferMs = Math.max(this.baseIdleMs, options.baseMaxDeferMs ?? this.baseIdleMs * 5);
|
|
189
|
+
this.finalIdleMs = Math.max(0, options.finalIdleMs ?? FINAL_IDLE_MS);
|
|
190
|
+
this.quitBudgetMs = Math.max(0, options.quitBudgetMs ?? QUIT_FINAL_BUDGET_MS);
|
|
191
|
+
this.now = options.now ?? Date.now;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private get active(): boolean {
|
|
195
|
+
return this.enabled && !this.signedOut && !this.stopped;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private get consentOff(): boolean {
|
|
199
|
+
return this.now() < this.consentOffUntil;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* App start. With an account, resumes the uploads the last run left
|
|
204
|
+
* unfinished, and checks the folders of recent sessions once for changes
|
|
205
|
+
* made after their last archive (a final archive the last shutdown did not
|
|
206
|
+
* get to, or edits while the app was closed). Without one (signed out, or
|
|
207
|
+
* archiving turned off on this device), removes whatever a previous run
|
|
208
|
+
* left: nothing queued under an account may be uploaded later.
|
|
209
|
+
*/
|
|
210
|
+
start(): void {
|
|
211
|
+
if (!this.enabled) {
|
|
212
|
+
this.track(this.archiver.signOut().catch((error: unknown) => this.warn("clear", error)));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
this.kick();
|
|
216
|
+
this.track(this.archiver.startFinalCandidates().then((sessionIds) => {
|
|
217
|
+
for (const sessionId of sessionIds) this.final(sessionId, "app_start");
|
|
218
|
+
}, (error: unknown) => this.warn("start", error)));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* A prompt was dispatched on a local session. The first time per app run,
|
|
223
|
+
* a root session gets its base archive with the number of turns it has
|
|
224
|
+
* already completed; child (sub-agent) sessions are never archived. It
|
|
225
|
+
* also ends the session's quiet period: its idle final archive is off.
|
|
226
|
+
*/
|
|
227
|
+
sessionStarted(input: { sessionId: string; root: string; engine: ArchiveEngineReads }): void {
|
|
228
|
+
const known = this.sessions.get(input.sessionId);
|
|
229
|
+
if (known) this.cancelIdleFinal(known);
|
|
230
|
+
if (!this.active || this.consentOff) return;
|
|
231
|
+
const promptedAt = this.now();
|
|
232
|
+
this.lastPromptAt = promptedAt;
|
|
233
|
+
if (known) {
|
|
234
|
+
known.engine = input.engine;
|
|
235
|
+
known.activeAt = promptedAt;
|
|
236
|
+
}
|
|
237
|
+
// Resolved, or its start step is still queued: nothing to add.
|
|
238
|
+
if (known && (!known.start || known.starting)) return;
|
|
239
|
+
// New, or unresolved because the engine could not be read: (re)try with this prompt's reads.
|
|
240
|
+
const record: SessionRecord = known ?? { root: null, start: null, starting: false, engine: input.engine, activeAt: promptedAt, idleTimer: null, idleFinal: null };
|
|
241
|
+
record.start = { root: input.root, engine: input.engine };
|
|
242
|
+
record.starting = true;
|
|
243
|
+
this.sessions.set(input.sessionId, record);
|
|
244
|
+
this.schedule(input.sessionId, "base", async () => {
|
|
245
|
+
try {
|
|
246
|
+
if (this.sessions.get(input.sessionId) === record && record.start) await this.resolve(input.sessionId, record, null);
|
|
247
|
+
} finally {
|
|
248
|
+
record.starting = false;
|
|
249
|
+
}
|
|
250
|
+
}, () => this.quietPeriod(promptedAt));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The session settled after a turn. `messages` are the engine's messages
|
|
255
|
+
* the observer read at that point; the delta carries their completed-turn
|
|
256
|
+
* count, and the archiver skips it when nothing changed. Messages the
|
|
257
|
+
* observer could not read (null) still get the delta, numbered right after
|
|
258
|
+
* the last archived turn. A session whose start could not read the engine
|
|
259
|
+
* (after an app restart the engine can take a minute to answer) is
|
|
260
|
+
* resolved here first, so the turn is not lost.
|
|
261
|
+
*/
|
|
262
|
+
turnCompleted(sessionId: string, messages: unknown): void {
|
|
263
|
+
const record = this.sessions.get(sessionId);
|
|
264
|
+
if (!this.active || this.consentOff || !record) return;
|
|
265
|
+
const turns = completedTurnCount(messages);
|
|
266
|
+
this.schedule(sessionId, "delta", async () => {
|
|
267
|
+
if (this.sessions.get(sessionId) !== record) return;
|
|
268
|
+
if (record.start && !(await this.resolve(sessionId, record, turns))) return;
|
|
269
|
+
await this.captureTurn(sessionId, record, turns);
|
|
270
|
+
});
|
|
271
|
+
this.armIdleFinal(sessionId, record);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The collector saw the session touch a path (workspace-relative): a
|
|
276
|
+
* tool's path in the trace, or a change on disk. Kept for a touched-files
|
|
277
|
+
* session (the archiver drops it for any other); nothing for a session
|
|
278
|
+
* this app run has not started or knows is not archived.
|
|
279
|
+
*/
|
|
280
|
+
pathTouched(sessionId: string, path: string): void {
|
|
281
|
+
const record = this.sessions.get(sessionId);
|
|
282
|
+
if (!this.active || this.consentOff || !record || (!record.root && !record.start)) return;
|
|
283
|
+
this.archiver.recordTouched(sessionId, path);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The observer follows a turn of the session: it is not quiet. A turn that
|
|
288
|
+
* ended just before (its turnCompleted can come after the next prompt's
|
|
289
|
+
* sessionStarted, when that prompt went out while the turn settled) must
|
|
290
|
+
* not leave its idle final archive armed through this turn, however long
|
|
291
|
+
* it runs; this turn's own end arms the next one.
|
|
292
|
+
*/
|
|
293
|
+
turnFollowed(sessionId: string): void {
|
|
294
|
+
const record = this.sessions.get(sessionId);
|
|
295
|
+
if (record) this.cancelIdleFinal(record);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The observer stopped following the session's turn without seeing it
|
|
300
|
+
* complete: past its 24-hour safety bound, or on an unexpected error (an
|
|
301
|
+
* engine that does not answer is waited out, and a server stop is not
|
|
302
|
+
* this). The engine's turn count is read again: when it moved, the turn
|
|
303
|
+
* gets its delta as if it had completed; otherwise (or when the engine
|
|
304
|
+
* cannot be read) the folder gets a final archive.
|
|
305
|
+
*/
|
|
306
|
+
turnIncomplete(sessionId: string): void {
|
|
307
|
+
const record = this.sessions.get(sessionId);
|
|
308
|
+
if (!this.active || this.consentOff || !record) return;
|
|
309
|
+
this.schedule(sessionId, "turn", async () => {
|
|
310
|
+
if (this.sessions.get(sessionId) !== record) return;
|
|
311
|
+
if (record.start && !(await this.resolve(sessionId, record, null))) return;
|
|
312
|
+
if (!record.root) return;
|
|
313
|
+
const turns = completedTurnCount(await record.engine.messages().catch(() => null));
|
|
314
|
+
await this.captureTurn(sessionId, record, turns, turns === null);
|
|
315
|
+
});
|
|
316
|
+
this.armIdleFinal(sessionId, record);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* The session was deleted: a last final archive of its folder (when it
|
|
321
|
+
* has a base and the folder is still there), then it is forgotten, with
|
|
322
|
+
* its idle final and its start retry; the queue keeps uploading.
|
|
323
|
+
*/
|
|
324
|
+
sessionEnded(sessionId: string): void {
|
|
325
|
+
const record = this.sessions.get(sessionId);
|
|
326
|
+
if (record) this.release(record);
|
|
327
|
+
this.sessions.delete(sessionId);
|
|
328
|
+
// A resolved session without a root is a child, not archivable or stopped. One this app
|
|
329
|
+
// run has not seen may still have a base from an earlier run; the archiver knows.
|
|
330
|
+
if (this.active && !this.consentOff && (!record || record.root || record.start)) this.final(sessionId, "session_deleted");
|
|
331
|
+
this.kick();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* The account signed out or was invalidated: nothing is captured or
|
|
336
|
+
* uploaded any more, uploads in flight are aborted and every queued
|
|
337
|
+
* archive and session record is deleted.
|
|
338
|
+
*/
|
|
339
|
+
async signOut(): Promise<void> {
|
|
340
|
+
this.signedOut = true;
|
|
341
|
+
this.wakeSleepers();
|
|
342
|
+
for (const record of this.sessions.values()) this.release(record);
|
|
343
|
+
this.sessions.clear();
|
|
344
|
+
await this.archiver.signOut().catch((error: unknown) => this.warn("sign-out", error));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Server shutdown: no new steps, and the queue stays on disk for the next
|
|
349
|
+
* start. Unless `finals` is false (the account is gone), the sessions of
|
|
350
|
+
* this app run get a final archive first, the most recent first, within
|
|
351
|
+
* quitBudgetMs in all (what does not fit is checked at the next start).
|
|
352
|
+
*/
|
|
353
|
+
async stop(options: { finals?: boolean } = {}): Promise<void> {
|
|
354
|
+
const finals = options.finals !== false && this.active && !this.consentOff && this.quitBudgetMs > 0
|
|
355
|
+
? [...this.sessions].filter(([, record]) => record.root || record.start).sort(([, left], [, right]) => right.activeAt - left.activeAt).map(([sessionId]) => sessionId)
|
|
356
|
+
: [];
|
|
357
|
+
this.stopped = true;
|
|
358
|
+
this.wakeSleepers();
|
|
359
|
+
for (const record of this.sessions.values()) this.release(record);
|
|
360
|
+
await this.archiver.stop(finals.length > 0 ? { finals, budgetMs: this.quitBudgetMs } : {}).catch((error: unknown) => this.warn("stop", error));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Resolves once every scheduled step and drain has finished (tests). */
|
|
364
|
+
async settled(): Promise<void> {
|
|
365
|
+
while (this.tails.size > 0 || this.background.size > 0) await Promise.all([...this.tails.values(), ...this.background]);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The start step: is it a root session (child sessions are never
|
|
370
|
+
* archived), at which real path, and its base with `turns` completed turns
|
|
371
|
+
* (read from the engine when null). The base is a no-op ("exists") when an
|
|
372
|
+
* earlier app run captured it. False while the engine cannot be read: the
|
|
373
|
+
* session stays unresolved, and is read again after a while (see
|
|
374
|
+
* unresolved()) and at its next prompt or completed turn.
|
|
375
|
+
*/
|
|
376
|
+
private async resolve(sessionId: string, record: SessionRecord, turns: number | null): Promise<boolean> {
|
|
377
|
+
const start = record.start;
|
|
378
|
+
if (!start) return true;
|
|
379
|
+
const parent = parentSessionId(await start.engine.session().catch(() => null));
|
|
380
|
+
if (parent === undefined) return this.unresolved(sessionId, record, "the engine session could not be read");
|
|
381
|
+
if (parent !== null) {
|
|
382
|
+
record.start = null;
|
|
383
|
+
this.archiver.forgetTouched(sessionId);
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
const root = await realpath(start.root).catch(() => null);
|
|
387
|
+
if (!root) {
|
|
388
|
+
record.start = null;
|
|
389
|
+
return true;
|
|
390
|
+
}
|
|
391
|
+
const count = turns ?? completedTurnCount(await start.engine.messages().catch(() => null));
|
|
392
|
+
if (count === null) return this.unresolved(sessionId, record, "the engine messages could not be read");
|
|
393
|
+
record.start = null;
|
|
394
|
+
record.root = root;
|
|
395
|
+
const result = await this.archiver.captureBase(sessionId, root, count);
|
|
396
|
+
if (result.status === "skipped" && result.reason === "disabled") this.sessions.delete(sessionId);
|
|
397
|
+
if (result.status === "skipped" && (result.reason === "not_archivable" || result.reason === "stopped")) record.root = null;
|
|
398
|
+
this.settle(result);
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* The capture at a turn's end: its delta, numbered with the engine's
|
|
404
|
+
* completed-turn count (null: after the last archived turn). A count that
|
|
405
|
+
* did not move (the prompt was aborted before any answer, or the history
|
|
406
|
+
* was reverted) gets a final archive instead, as does a turn whose count
|
|
407
|
+
* is unknown when `finalWithoutCount`.
|
|
408
|
+
*/
|
|
409
|
+
private async captureTurn(sessionId: string, record: SessionRecord, turns: number | null, finalWithoutCount = false): Promise<void> {
|
|
410
|
+
if (!record.root || this.sessions.get(sessionId) !== record || this.consentOff) return;
|
|
411
|
+
let result: CaptureResult | null = null;
|
|
412
|
+
if (!finalWithoutCount) {
|
|
413
|
+
result = await this.archiver.captureDelta(sessionId, record.root, turns);
|
|
414
|
+
if (result.status === "skipped" && result.reason === "stopped") record.root = null;
|
|
415
|
+
this.settle(result);
|
|
416
|
+
}
|
|
417
|
+
if (result && !(result.status === "skipped" && result.reason === "stale_turn")) return;
|
|
418
|
+
if (!record.root || this.consentOff) return;
|
|
419
|
+
const final = await this.archiver.captureFinal(sessionId, "turn_incomplete");
|
|
420
|
+
if (final.status === "skipped" && final.reason === "stopped") record.root = null;
|
|
421
|
+
this.settle(final);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Queues a final archive for the session, behind its earlier steps and
|
|
426
|
+
* behind other sessions' waiting steps. With `record`, only while that
|
|
427
|
+
* record is current and has a root (an idle final); `signal` cancels it.
|
|
428
|
+
*/
|
|
429
|
+
private final(sessionId: string, reason: FinalReason, record?: SessionRecord, signal?: AbortSignal): void {
|
|
430
|
+
this.schedule(sessionId, "final", async () => {
|
|
431
|
+
if (signal?.aborted || this.consentOff) return;
|
|
432
|
+
if (record && (this.sessions.get(sessionId) !== record || !record.root)) return;
|
|
433
|
+
const result = await this.archiver.captureFinal(sessionId, reason, signal ? { signal } : {});
|
|
434
|
+
if (record && result.status === "skipped" && result.reason === "stopped") record.root = null;
|
|
435
|
+
this.settle(result);
|
|
436
|
+
}, undefined, true);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** A turn ended: the quiet window starts; without a prompt by its end, an idle final archive is queued. At most one per window. */
|
|
440
|
+
private armIdleFinal(sessionId: string, record: SessionRecord): void {
|
|
441
|
+
this.cancelIdleFinal(record);
|
|
442
|
+
record.activeAt = this.now();
|
|
443
|
+
// Known not to be archived (a child, not archivable, stopped): nothing to arm.
|
|
444
|
+
if (!record.root && !record.start) return;
|
|
445
|
+
const timer = setTimeout(() => {
|
|
446
|
+
if (record.idleTimer !== timer) return;
|
|
447
|
+
record.idleTimer = null;
|
|
448
|
+
if (!this.active || this.sessions.get(sessionId) !== record) return;
|
|
449
|
+
const controller = new AbortController();
|
|
450
|
+
record.idleFinal = controller;
|
|
451
|
+
this.final(sessionId, "idle", record, controller.signal);
|
|
452
|
+
}, this.finalIdleMs);
|
|
453
|
+
timer.unref?.();
|
|
454
|
+
record.idleTimer = timer;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private cancelIdleFinal(record: SessionRecord): void {
|
|
458
|
+
if (record.idleTimer) clearTimeout(record.idleTimer);
|
|
459
|
+
record.idleTimer = null;
|
|
460
|
+
record.idleFinal?.abort();
|
|
461
|
+
record.idleFinal = null;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** An unresolved start's retry waiting, if any, never runs. */
|
|
465
|
+
private cancelRetry(record: SessionRecord): void {
|
|
466
|
+
if (record.retries?.timer) clearTimeout(record.retries.timer);
|
|
467
|
+
if (record.retries) record.retries.timer = null;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** The session is forgotten (deleted, signed out, shutdown): none of its timers runs any more. */
|
|
471
|
+
private release(record: SessionRecord): void {
|
|
472
|
+
this.cancelIdleFinal(record);
|
|
473
|
+
this.cancelRetry(record);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* The engine could not be read for a session start (it can still be
|
|
478
|
+
* starting, or be busy with a large session). Besides the session's next
|
|
479
|
+
* prompt or completed turn, the start step runs again on its own after 1, 2,
|
|
480
|
+
* 5 and 10 minutes, so a session whose first turn runs for hours still gets
|
|
481
|
+
* its base; one retry waits at a time, and none once the session is
|
|
482
|
+
* forgotten or archiving stopped.
|
|
483
|
+
*/
|
|
484
|
+
private unresolved(sessionId: string, record: SessionRecord, reason: string): false {
|
|
485
|
+
const retries = record.retries ??= { count: 0, timer: null };
|
|
486
|
+
const current = this.active && this.sessions.get(sessionId) === record;
|
|
487
|
+
const delay = retries.timer || !current ? undefined : UNRESOLVED_RETRY_DELAYS_MS[retries.count];
|
|
488
|
+
if (delay !== undefined) {
|
|
489
|
+
retries.count += 1;
|
|
490
|
+
retries.timer = setTimeout(() => {
|
|
491
|
+
retries.timer = null;
|
|
492
|
+
if (!this.active || this.consentOff || this.sessions.get(sessionId) !== record || !record.start || record.starting) return;
|
|
493
|
+
record.starting = true;
|
|
494
|
+
this.schedule(sessionId, "base", async () => {
|
|
495
|
+
try {
|
|
496
|
+
if (this.sessions.get(sessionId) === record && record.start) await this.resolve(sessionId, record, null);
|
|
497
|
+
} finally {
|
|
498
|
+
record.starting = false;
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
}, delay);
|
|
502
|
+
retries.timer.unref?.();
|
|
503
|
+
}
|
|
504
|
+
this.log("warn", "OmniRush project archive could not read the engine for a session; its next prompt or turn tries again", {
|
|
505
|
+
sessionId,
|
|
506
|
+
reason,
|
|
507
|
+
...(delay !== undefined ? { retryInMs: delay } : {}),
|
|
508
|
+
});
|
|
509
|
+
return false;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
private settle(result: CaptureResult): void {
|
|
513
|
+
if (result.status === "queued") {
|
|
514
|
+
this.consentOffUntil = 0;
|
|
515
|
+
this.kick();
|
|
516
|
+
} else if (result.reason === "disabled") {
|
|
517
|
+
this.archivingOff();
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Off quietly: no capture, consent check or upload until the recheck time (or the next app start). */
|
|
522
|
+
private archivingOff(): void {
|
|
523
|
+
this.consentOffUntil = this.now() + this.consentRecheckMs;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Starts a drain unless one is running (the archiver coalesces them). */
|
|
527
|
+
private kick(): void {
|
|
528
|
+
if (!this.active || this.consentOff) return;
|
|
529
|
+
this.track(this.archiver.drain()
|
|
530
|
+
.then((result: DrainResult) => {
|
|
531
|
+
if (result.disabled) this.archivingOff();
|
|
532
|
+
})
|
|
533
|
+
.catch((error: unknown) => this.warn("drain", error)));
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
private track(work: Promise<void>): void {
|
|
537
|
+
this.background.add(work);
|
|
538
|
+
void work.finally(() => this.background.delete(work));
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Resolves once no prompt has been dispatched for baseIdleMs, or
|
|
543
|
+
* baseMaxDeferMs after `since`, or when archiving stops. Waiting takes no
|
|
544
|
+
* concurrency slot, so other sessions' deltas go ahead meanwhile.
|
|
545
|
+
*/
|
|
546
|
+
private async quietPeriod(since: number): Promise<void> {
|
|
547
|
+
if (this.baseIdleMs === 0) return;
|
|
548
|
+
while (this.active) {
|
|
549
|
+
const now = this.now();
|
|
550
|
+
const wait = Math.min(this.lastPromptAt + this.baseIdleMs, since + this.baseMaxDeferMs) - now;
|
|
551
|
+
if (wait <= 0) return;
|
|
552
|
+
await new Promise<void>((resolvePromise) => {
|
|
553
|
+
const wake = () => {
|
|
554
|
+
clearTimeout(timer);
|
|
555
|
+
this.sleepers.delete(wake);
|
|
556
|
+
resolvePromise();
|
|
557
|
+
};
|
|
558
|
+
const timer = setTimeout(wake, wait);
|
|
559
|
+
timer.unref?.();
|
|
560
|
+
this.sleepers.add(wake);
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
private wakeSleepers(): void {
|
|
566
|
+
for (const wake of [...this.sleepers]) wake();
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Runs `task` after the session's earlier steps and `before` (a wait that
|
|
571
|
+
* never rejects), within the concurrency bound (a `low` step lets every
|
|
572
|
+
* other waiting step go first); never rejects.
|
|
573
|
+
*/
|
|
574
|
+
private schedule(sessionId: string, step: string, task: () => Promise<void>, before?: () => Promise<void>, low = false): void {
|
|
575
|
+
const previous = this.tails.get(sessionId) ?? Promise.resolve();
|
|
576
|
+
const run = previous.then(before).then(() => this.bounded(async () => {
|
|
577
|
+
if (!this.active) return;
|
|
578
|
+
try {
|
|
579
|
+
await task();
|
|
580
|
+
} catch (error) {
|
|
581
|
+
this.warn(step, error, sessionId);
|
|
582
|
+
}
|
|
583
|
+
}, low));
|
|
584
|
+
this.tails.set(sessionId, run);
|
|
585
|
+
void run.then(() => {
|
|
586
|
+
if (this.tails.get(sessionId) === run) this.tails.delete(sessionId);
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
private async bounded(task: () => Promise<void>, low = false): Promise<void> {
|
|
591
|
+
if (this.running < this.concurrency) this.running += 1;
|
|
592
|
+
else await new Promise<void>((resolvePromise) => (low ? this.waitingLow : this.waiting).push(resolvePromise));
|
|
593
|
+
try {
|
|
594
|
+
await task();
|
|
595
|
+
} finally {
|
|
596
|
+
// The slot passes straight to the next waiter, if any.
|
|
597
|
+
const next = this.waiting.shift() ?? this.waitingLow.shift();
|
|
598
|
+
if (next) next();
|
|
599
|
+
else this.running -= 1;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
private warn(step: string, error: unknown, sessionId?: string): void {
|
|
604
|
+
this.log("warn", "OmniRush project archive step failed", { step, ...(sessionId ? { sessionId } : {}), error: errorSummary(error) });
|
|
605
|
+
}
|
|
606
|
+
}
|