pi-fluency 0.1.2 → 0.2.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/README.md +30 -5
- package/extensions/pi-fluency/analytics.ts +158 -4
- package/extensions/pi-fluency/analyzer.ts +29 -3
- package/extensions/pi-fluency/coaching-overlay.ts +259 -0
- package/extensions/pi-fluency/coaching.ts +158 -0
- package/extensions/pi-fluency/collector.ts +3 -2
- package/extensions/pi-fluency/index.ts +538 -27
- package/extensions/pi-fluency/overlay.ts +296 -40
- package/extensions/pi-fluency/practice-settings.ts +201 -0
- package/extensions/pi-fluency/setup.ts +1 -1
- package/extensions/pi-fluency/status.ts +3 -0
- package/extensions/pi-fluency/store.ts +294 -13
- package/extensions/pi-fluency/types.ts +50 -0
- package/extensions/pi-fluency/worker.ts +408 -27
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AnalyzerConfigurationError, type Analyzer } from "./analyzer.js";
|
|
2
|
-
import type {
|
|
2
|
+
import type { AnalysisCommitFence } from "./store.js";
|
|
3
|
+
import type { AnalysisResult, CollectedPrompt, MistakePattern, PracticeTarget } from "./types.js";
|
|
3
4
|
|
|
4
5
|
export interface WorkerSnapshot {
|
|
5
6
|
queued: number;
|
|
@@ -8,24 +9,376 @@ export interface WorkerSnapshot {
|
|
|
8
9
|
shuttingDown: boolean;
|
|
9
10
|
}
|
|
10
11
|
|
|
12
|
+
export interface AnalyzerCoordinatorOwner {
|
|
13
|
+
readonly token: symbol;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface OwnerState {
|
|
17
|
+
revoked: boolean;
|
|
18
|
+
revocationListeners: Set<() => void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface OwnerRevocationSubscription {
|
|
22
|
+
promise: Promise<"revoked">;
|
|
23
|
+
unsubscribe: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface SettledAnalysis<T> {
|
|
27
|
+
ok: boolean;
|
|
28
|
+
value?: T;
|
|
29
|
+
error?: Error;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ActiveAnalysis<T = unknown> {
|
|
33
|
+
requestToken: symbol;
|
|
34
|
+
ownerToken: symbol;
|
|
35
|
+
controller: AbortController;
|
|
36
|
+
settlement: Promise<SettledAnalysis<T>>;
|
|
37
|
+
settled: boolean;
|
|
38
|
+
invalidated: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type ForegroundAnalysisOutcome =
|
|
42
|
+
| { kind: "success"; result: AnalysisResult }
|
|
43
|
+
| { kind: "busy" | "timeout" | "cancelled" | "quarantined" | "shutdown" }
|
|
44
|
+
| { kind: "error"; error: Error };
|
|
45
|
+
|
|
46
|
+
export interface ForegroundAnalysisOptions {
|
|
47
|
+
owner: AnalyzerCoordinatorOwner;
|
|
48
|
+
analyzer: Analyzer;
|
|
49
|
+
prompt: CollectedPrompt;
|
|
50
|
+
patterns: MistakePattern[];
|
|
51
|
+
selectedTargets?: readonly PracticeTarget[];
|
|
52
|
+
/** Absolute epoch deadline. */
|
|
53
|
+
deadline: number;
|
|
54
|
+
signal?: AbortSignal;
|
|
55
|
+
abortGraceMs?: number;
|
|
56
|
+
/** Runs after coordinator wait, immediately before provider call. */
|
|
57
|
+
authorize?: () => Promise<boolean>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class AnalyzerCoordinatorUnavailableError extends Error {
|
|
61
|
+
override readonly name = "AnalyzerCoordinatorUnavailableError";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeError(error: unknown): Error {
|
|
65
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Process-local serializer. State contains no extension callbacks, stores, or UI contexts. */
|
|
69
|
+
export class AnalyzerCoordinator {
|
|
70
|
+
private readonly owners = new Map<symbol, OwnerState>();
|
|
71
|
+
private active: ActiveAnalysis | undefined;
|
|
72
|
+
private foregroundPending = 0;
|
|
73
|
+
private quarantined = false;
|
|
74
|
+
private readonly waiters = new Set<() => void>();
|
|
75
|
+
|
|
76
|
+
attachOwner(): AnalyzerCoordinatorOwner {
|
|
77
|
+
const token = Symbol("pi-fluency-analyzer-owner");
|
|
78
|
+
this.owners.set(token, { revoked: false, revocationListeners: new Set() });
|
|
79
|
+
return { token };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
isQuarantined(): boolean {
|
|
83
|
+
return this.quarantined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
canAcceptBackground(owner: AnalyzerCoordinatorOwner): boolean {
|
|
87
|
+
return this.isCurrentOwner(owner) && !this.quarantined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private isCurrentOwner(owner: AnalyzerCoordinatorOwner): boolean {
|
|
91
|
+
return this.owners.get(owner.token)?.revoked === false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private changed(): void {
|
|
95
|
+
for (const resolve of this.waiters) resolve();
|
|
96
|
+
this.waiters.clear();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private waitForChange(maximumMs?: number): Promise<void> {
|
|
100
|
+
return new Promise((resolve) => {
|
|
101
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
102
|
+
const finish = () => {
|
|
103
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
104
|
+
this.waiters.delete(finish);
|
|
105
|
+
resolve();
|
|
106
|
+
};
|
|
107
|
+
this.waiters.add(finish);
|
|
108
|
+
if (maximumMs !== undefined) timer = setTimeout(finish, Math.max(0, maximumMs));
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private start<T>(owner: AnalyzerCoordinatorOwner, task: (signal: AbortSignal) => Promise<T>): ActiveAnalysis<T> {
|
|
113
|
+
if (this.active) throw new Error("Analyzer coordinator overlap");
|
|
114
|
+
const controller = new AbortController();
|
|
115
|
+
const requestToken = Symbol("pi-fluency-analyzer-request");
|
|
116
|
+
const active: ActiveAnalysis<T> = {
|
|
117
|
+
requestToken,
|
|
118
|
+
ownerToken: owner.token,
|
|
119
|
+
controller,
|
|
120
|
+
settled: false,
|
|
121
|
+
invalidated: false,
|
|
122
|
+
settlement: undefined as unknown as Promise<SettledAnalysis<T>>,
|
|
123
|
+
};
|
|
124
|
+
let taskPromise: Promise<T>;
|
|
125
|
+
try {
|
|
126
|
+
taskPromise = task(controller.signal);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
taskPromise = Promise.reject(error);
|
|
129
|
+
}
|
|
130
|
+
active.settlement = taskPromise.then(
|
|
131
|
+
(value): SettledAnalysis<T> => ({ ok: true, value }),
|
|
132
|
+
(error): SettledAnalysis<T> => ({ ok: false, error: normalizeError(error) }),
|
|
133
|
+
)
|
|
134
|
+
.finally(() => {
|
|
135
|
+
active.settled = true;
|
|
136
|
+
if (this.active?.requestToken === requestToken) this.active = undefined;
|
|
137
|
+
this.quarantined = false;
|
|
138
|
+
this.changed();
|
|
139
|
+
});
|
|
140
|
+
this.active = active as ActiveAnalysis;
|
|
141
|
+
this.changed();
|
|
142
|
+
return active;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private subscribeOwnerRevocation(owner: AnalyzerCoordinatorOwner): OwnerRevocationSubscription {
|
|
146
|
+
const state = this.owners.get(owner.token);
|
|
147
|
+
if (!state || state.revoked) {
|
|
148
|
+
return { promise: Promise.resolve("revoked"), unsubscribe: () => undefined };
|
|
149
|
+
}
|
|
150
|
+
let listener!: () => void;
|
|
151
|
+
const promise = new Promise<"revoked">((resolve) => {
|
|
152
|
+
listener = () => {
|
|
153
|
+
state.revocationListeners.delete(listener);
|
|
154
|
+
resolve("revoked");
|
|
155
|
+
};
|
|
156
|
+
state.revocationListeners.add(listener);
|
|
157
|
+
});
|
|
158
|
+
return {
|
|
159
|
+
promise,
|
|
160
|
+
unsubscribe: () => state.revocationListeners.delete(listener),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private async raceOwnerRevocation<T>(owner: AnalyzerCoordinatorOwner, operation: Promise<T>): Promise<T | "revoked"> {
|
|
165
|
+
const subscription = this.subscribeOwnerRevocation(owner);
|
|
166
|
+
try {
|
|
167
|
+
return await Promise.race([operation, subscription.promise]);
|
|
168
|
+
} finally {
|
|
169
|
+
subscription.unsubscribe();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async runBackground<T>(
|
|
174
|
+
owner: AnalyzerCoordinatorOwner,
|
|
175
|
+
task: (signal: AbortSignal) => Promise<T>,
|
|
176
|
+
): Promise<T> {
|
|
177
|
+
while (true) {
|
|
178
|
+
if (!this.isCurrentOwner(owner)) throw new DOMException("Aborted", "AbortError");
|
|
179
|
+
if (this.quarantined) throw new AnalyzerCoordinatorUnavailableError("Analyzer coordinator quarantined");
|
|
180
|
+
if (this.active || this.foregroundPending > 0) {
|
|
181
|
+
await this.raceOwnerRevocation(owner, this.waitForChange());
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const active = this.start(owner, task);
|
|
185
|
+
const settled = await this.raceOwnerRevocation(owner, active.settlement);
|
|
186
|
+
if (settled === "revoked") throw new DOMException("Aborted", "AbortError");
|
|
187
|
+
if (!this.isCurrentOwner(owner) || active.invalidated) throw new DOMException("Aborted", "AbortError");
|
|
188
|
+
if (settled.ok) return settled.value as T;
|
|
189
|
+
throw settled.error ?? new Error("Analysis failed");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private async abortWithGrace(active: ActiveAnalysis, graceMs: number): Promise<boolean> {
|
|
194
|
+
active.invalidated = true;
|
|
195
|
+
active.controller.abort(new DOMException("Aborted", "AbortError"));
|
|
196
|
+
if (active.settled) return true;
|
|
197
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
198
|
+
try {
|
|
199
|
+
await Promise.race([
|
|
200
|
+
active.settlement.then(() => undefined),
|
|
201
|
+
new Promise<void>((resolve) => { timer = setTimeout(resolve, Math.max(0, graceMs)); }),
|
|
202
|
+
]);
|
|
203
|
+
} finally {
|
|
204
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
205
|
+
}
|
|
206
|
+
if (!active.settled) {
|
|
207
|
+
this.quarantined = true;
|
|
208
|
+
this.changed();
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async analyzeForeground(options: ForegroundAnalysisOptions): Promise<ForegroundAnalysisOutcome> {
|
|
215
|
+
const graceMs = Math.max(0, Math.min(100, options.abortGraceMs ?? 100));
|
|
216
|
+
if (!Number.isFinite(options.deadline)) return { kind: "error", error: new Error("Invalid analysis deadline") };
|
|
217
|
+
if (!this.isCurrentOwner(options.owner)) return { kind: "shutdown" };
|
|
218
|
+
if (this.quarantined) return { kind: "quarantined" };
|
|
219
|
+
this.foregroundPending += 1;
|
|
220
|
+
this.changed();
|
|
221
|
+
try {
|
|
222
|
+
while (this.active) {
|
|
223
|
+
if (options.signal?.aborted) {
|
|
224
|
+
const active = this.active;
|
|
225
|
+
if (active) await this.abortWithGrace(active, graceMs);
|
|
226
|
+
return { kind: "cancelled" };
|
|
227
|
+
}
|
|
228
|
+
const remaining = options.deadline - Date.now();
|
|
229
|
+
if (remaining <= 0) {
|
|
230
|
+
const active = this.active;
|
|
231
|
+
if (active) await this.abortWithGrace(active, graceMs);
|
|
232
|
+
return { kind: "busy" };
|
|
233
|
+
}
|
|
234
|
+
let onAbort: (() => void) | undefined;
|
|
235
|
+
try {
|
|
236
|
+
const cancellation = options.signal === undefined
|
|
237
|
+
? new Promise<never>(() => undefined)
|
|
238
|
+
: new Promise<void>((resolve) => {
|
|
239
|
+
onAbort = resolve;
|
|
240
|
+
options.signal!.addEventListener("abort", onAbort, { once: true });
|
|
241
|
+
if (options.signal!.aborted) resolve();
|
|
242
|
+
});
|
|
243
|
+
await Promise.race([this.waitForChange(remaining), cancellation]);
|
|
244
|
+
} finally {
|
|
245
|
+
if (onAbort !== undefined) options.signal!.removeEventListener("abort", onAbort);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (!this.isCurrentOwner(options.owner)) return { kind: "shutdown" };
|
|
249
|
+
if (this.quarantined) return { kind: "quarantined" };
|
|
250
|
+
if (options.signal?.aborted) return { kind: "cancelled" };
|
|
251
|
+
if (Date.now() >= options.deadline) return { kind: "timeout" };
|
|
252
|
+
if (options.authorize !== undefined) {
|
|
253
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
254
|
+
let onAbort: (() => void) | undefined;
|
|
255
|
+
try {
|
|
256
|
+
const remaining = Math.max(0, options.deadline - Date.now());
|
|
257
|
+
const deadline = new Promise<"deadline">((resolve) => { timer = setTimeout(() => resolve("deadline"), remaining); });
|
|
258
|
+
const cancelled = options.signal === undefined
|
|
259
|
+
? new Promise<never>(() => undefined)
|
|
260
|
+
: new Promise<"cancel">((resolve) => {
|
|
261
|
+
onAbort = () => resolve("cancel");
|
|
262
|
+
options.signal!.addEventListener("abort", onAbort, { once: true });
|
|
263
|
+
if (options.signal!.aborted) onAbort();
|
|
264
|
+
});
|
|
265
|
+
const authorized = await Promise.race([options.authorize(), deadline, cancelled]);
|
|
266
|
+
if (authorized === "deadline") return { kind: "timeout" };
|
|
267
|
+
if (authorized === "cancel") return { kind: "cancelled" };
|
|
268
|
+
if (!authorized) return { kind: "cancelled" };
|
|
269
|
+
} catch (error) {
|
|
270
|
+
return { kind: "error", error: normalizeError(error) };
|
|
271
|
+
} finally {
|
|
272
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
273
|
+
if (onAbort !== undefined) options.signal!.removeEventListener("abort", onAbort);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (!this.isCurrentOwner(options.owner)) return { kind: "shutdown" };
|
|
277
|
+
if (Date.now() >= options.deadline) return { kind: "timeout" };
|
|
278
|
+
|
|
279
|
+
const active = this.start(options.owner, (signal) => options.analyzer.analyze(
|
|
280
|
+
options.prompt,
|
|
281
|
+
options.patterns,
|
|
282
|
+
signal,
|
|
283
|
+
options.selectedTargets,
|
|
284
|
+
));
|
|
285
|
+
const remaining = Math.max(0, options.deadline - Date.now());
|
|
286
|
+
let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
|
|
287
|
+
let onAbort: (() => void) | undefined;
|
|
288
|
+
let outcome: SettledAnalysis<AnalysisResult> | "deadline" | "cancel" | "revoked";
|
|
289
|
+
try {
|
|
290
|
+
const deadlineRace = new Promise<"deadline">((resolve) => {
|
|
291
|
+
deadlineTimer = setTimeout(() => resolve("deadline"), remaining);
|
|
292
|
+
});
|
|
293
|
+
const cancelRace = options.signal === undefined
|
|
294
|
+
? new Promise<never>(() => undefined)
|
|
295
|
+
: new Promise<"cancel">((resolve) => {
|
|
296
|
+
onAbort = () => resolve("cancel");
|
|
297
|
+
options.signal!.addEventListener("abort", onAbort, { once: true });
|
|
298
|
+
if (options.signal!.aborted) onAbort();
|
|
299
|
+
});
|
|
300
|
+
outcome = await this.raceOwnerRevocation(options.owner, Promise.race([
|
|
301
|
+
active.settlement,
|
|
302
|
+
deadlineRace,
|
|
303
|
+
cancelRace,
|
|
304
|
+
]));
|
|
305
|
+
} finally {
|
|
306
|
+
if (deadlineTimer !== undefined) clearTimeout(deadlineTimer);
|
|
307
|
+
if (onAbort !== undefined) options.signal!.removeEventListener("abort", onAbort);
|
|
308
|
+
}
|
|
309
|
+
if (outcome === "deadline" || outcome === "cancel" || outcome === "revoked") {
|
|
310
|
+
await this.abortWithGrace(active, graceMs);
|
|
311
|
+
if (outcome === "cancel") return { kind: "cancelled" };
|
|
312
|
+
if (outcome === "revoked") return { kind: "shutdown" };
|
|
313
|
+
return { kind: "timeout" };
|
|
314
|
+
}
|
|
315
|
+
if (!this.isCurrentOwner(options.owner)) return { kind: "shutdown" };
|
|
316
|
+
if (active.invalidated) return { kind: "cancelled" };
|
|
317
|
+
if (outcome.ok) return { kind: "success", result: outcome.value as AnalysisResult };
|
|
318
|
+
return { kind: "error", error: outcome.error ?? new Error("Analysis failed") };
|
|
319
|
+
} finally {
|
|
320
|
+
this.foregroundPending -= 1;
|
|
321
|
+
this.changed();
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async shutdownOwner(owner: AnalyzerCoordinatorOwner, abortGraceMs = 100): Promise<void> {
|
|
326
|
+
const state = this.owners.get(owner.token);
|
|
327
|
+
if (!state || state.revoked) return;
|
|
328
|
+
state.revoked = true;
|
|
329
|
+
for (const listener of state.revocationListeners) listener();
|
|
330
|
+
state.revocationListeners.clear();
|
|
331
|
+
this.owners.delete(owner.token);
|
|
332
|
+
this.changed();
|
|
333
|
+
const active = this.active;
|
|
334
|
+
if (active?.ownerToken === owner.token) {
|
|
335
|
+
await this.abortWithGrace(active, Math.max(0, Math.min(100, abortGraceMs)));
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const COORDINATOR_SYMBOL = Symbol.for("pi-fluency.analyzer-coordinator.v1");
|
|
341
|
+
interface GlobalCoordinatorSlot { version: 1; coordinator: AnalyzerCoordinator }
|
|
342
|
+
|
|
343
|
+
export function getProcessAnalyzerCoordinator(): AnalyzerCoordinator {
|
|
344
|
+
const globals = globalThis as typeof globalThis & { [COORDINATOR_SYMBOL]?: GlobalCoordinatorSlot };
|
|
345
|
+
const current = globals[COORDINATOR_SYMBOL];
|
|
346
|
+
if (current?.version === 1
|
|
347
|
+
&& typeof current.coordinator?.attachOwner === "function"
|
|
348
|
+
&& typeof current.coordinator?.analyzeForeground === "function"
|
|
349
|
+
&& typeof current.coordinator?.runBackground === "function") return current.coordinator;
|
|
350
|
+
const coordinator = new AnalyzerCoordinator();
|
|
351
|
+
globals[COORDINATOR_SYMBOL] = { version: 1, coordinator };
|
|
352
|
+
return coordinator;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export interface WorkerAnalyzerConfiguration {
|
|
356
|
+
fingerprint: string;
|
|
357
|
+
analyzer: Analyzer;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export interface BackgroundAnalysisJob {
|
|
361
|
+
prompt: CollectedPrompt;
|
|
362
|
+
fence?: AnalysisCommitFence;
|
|
363
|
+
}
|
|
364
|
+
|
|
11
365
|
export interface WorkerOptions {
|
|
12
366
|
analyzer: Analyzer;
|
|
367
|
+
/** Fresh request-scoped authorization/configuration resolver. Undefined discards stale job. */
|
|
368
|
+
getAnalyzerConfiguration?: (job: BackgroundAnalysisJob) => WorkerAnalyzerConfiguration | undefined | Promise<WorkerAnalyzerConfiguration | undefined>;
|
|
13
369
|
isIdle: () => boolean;
|
|
14
370
|
getPatterns: () => MistakePattern[];
|
|
15
|
-
onResult: (prompt: CollectedPrompt, result: AnalysisResult) => Promise<void>;
|
|
371
|
+
onResult: (prompt: CollectedPrompt, result: AnalysisResult, fence?: AnalysisCommitFence) => Promise<void>;
|
|
16
372
|
onError: (error: Error) => void;
|
|
17
373
|
onOverflow: (dropped: number) => void;
|
|
18
374
|
maxQueue?: number;
|
|
375
|
+
coordinator?: AnalyzerCoordinator;
|
|
19
376
|
}
|
|
20
377
|
|
|
21
378
|
const DEFAULT_MAX_QUEUE = 10;
|
|
22
379
|
const ANALYSIS_TIMEOUT_MS = 30_000;
|
|
23
380
|
const RETRY_DELAY_MS = 500;
|
|
24
381
|
|
|
25
|
-
function normalizeError(error: unknown): Error {
|
|
26
|
-
return error instanceof Error ? error : new Error(String(error));
|
|
27
|
-
}
|
|
28
|
-
|
|
29
382
|
function abortableDelay(delayMs: number, signal: AbortSignal): Promise<void> {
|
|
30
383
|
return new Promise((resolve, reject) => {
|
|
31
384
|
if (signal.aborted) {
|
|
@@ -41,22 +394,28 @@ function abortableDelay(delayMs: number, signal: AbortSignal): Promise<void> {
|
|
|
41
394
|
}
|
|
42
395
|
|
|
43
396
|
export class FluencyWorker {
|
|
44
|
-
private readonly queue:
|
|
397
|
+
private readonly queue: BackgroundAnalysisJob[] = [];
|
|
45
398
|
private readonly maxQueue: number;
|
|
399
|
+
private readonly coordinator: AnalyzerCoordinator;
|
|
400
|
+
private readonly owner: AnalyzerCoordinatorOwner;
|
|
46
401
|
private controller: AbortController | undefined;
|
|
47
402
|
private active: Promise<void> | undefined;
|
|
48
403
|
private dropped = 0;
|
|
49
404
|
private shuttingDown = false;
|
|
405
|
+
private analyzerConfiguration: WorkerAnalyzerConfiguration;
|
|
50
406
|
|
|
51
407
|
constructor(private readonly options: WorkerOptions) {
|
|
52
408
|
this.maxQueue = Number.isSafeInteger(options.maxQueue) && (options.maxQueue ?? -1) >= 0
|
|
53
409
|
? options.maxQueue as number
|
|
54
410
|
: DEFAULT_MAX_QUEUE;
|
|
411
|
+
this.coordinator = options.coordinator ?? getProcessAnalyzerCoordinator();
|
|
412
|
+
this.owner = this.coordinator.attachOwner();
|
|
413
|
+
this.analyzerConfiguration = { fingerprint: "legacy-static-analyzer", analyzer: options.analyzer };
|
|
55
414
|
}
|
|
56
415
|
|
|
57
|
-
enqueue(prompt: CollectedPrompt): void {
|
|
58
|
-
if (this.shuttingDown) return;
|
|
59
|
-
this.queue.push(prompt);
|
|
416
|
+
enqueue(prompt: CollectedPrompt, fence?: AnalysisCommitFence): void {
|
|
417
|
+
if (this.shuttingDown || !this.coordinator.canAcceptBackground(this.owner)) return;
|
|
418
|
+
this.queue.push({ prompt, ...(fence === undefined ? {} : { fence: { ...fence } }) });
|
|
60
419
|
while (this.queue.length > this.maxQueue) {
|
|
61
420
|
this.queue.shift();
|
|
62
421
|
this.dropped += 1;
|
|
@@ -64,6 +423,10 @@ export class FluencyWorker {
|
|
|
64
423
|
}
|
|
65
424
|
}
|
|
66
425
|
|
|
426
|
+
analyzeForeground(options: Omit<ForegroundAnalysisOptions, "owner">): Promise<ForegroundAnalysisOutcome> {
|
|
427
|
+
return this.coordinator.analyzeForeground({ ...options, owner: this.owner });
|
|
428
|
+
}
|
|
429
|
+
|
|
67
430
|
async drain(): Promise<void> {
|
|
68
431
|
if (this.active || this.shuttingDown || !this.options.isIdle()) return this.active;
|
|
69
432
|
this.active = this.run().finally(() => {
|
|
@@ -76,6 +439,7 @@ export class FluencyWorker {
|
|
|
76
439
|
this.shuttingDown = true;
|
|
77
440
|
this.queue.length = 0;
|
|
78
441
|
this.controller?.abort();
|
|
442
|
+
await this.coordinator.shutdownOwner(this.owner);
|
|
79
443
|
await this.active?.catch(() => undefined);
|
|
80
444
|
}
|
|
81
445
|
|
|
@@ -90,16 +454,21 @@ export class FluencyWorker {
|
|
|
90
454
|
|
|
91
455
|
private async run(): Promise<void> {
|
|
92
456
|
while (!this.shuttingDown && this.options.isIdle()) {
|
|
93
|
-
const
|
|
94
|
-
if (!
|
|
457
|
+
const job = this.queue.shift();
|
|
458
|
+
if (!job) return;
|
|
95
459
|
try {
|
|
96
|
-
const result = await this.analyzeWithRetry(
|
|
97
|
-
await this.options.onResult(prompt, result);
|
|
460
|
+
const result = await this.analyzeWithRetry(job);
|
|
461
|
+
if (result !== undefined && !this.shuttingDown) await this.options.onResult(job.prompt, result, job.fence);
|
|
98
462
|
} catch (error) {
|
|
99
463
|
if (!this.shuttingDown) {
|
|
100
464
|
const normalized = normalizeError(error);
|
|
465
|
+
if (normalized instanceof AnalyzerCoordinatorUnavailableError) {
|
|
466
|
+
this.queue.length = 0;
|
|
467
|
+
this.options.onError(normalized);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
101
470
|
if (normalized instanceof AnalyzerConfigurationError) {
|
|
102
|
-
this.queue.unshift(
|
|
471
|
+
this.queue.unshift(job);
|
|
103
472
|
this.options.onError(normalized);
|
|
104
473
|
return;
|
|
105
474
|
}
|
|
@@ -108,33 +477,45 @@ export class FluencyWorker {
|
|
|
108
477
|
} finally {
|
|
109
478
|
this.controller = undefined;
|
|
110
479
|
}
|
|
480
|
+
await Promise.resolve();
|
|
111
481
|
}
|
|
112
482
|
}
|
|
113
483
|
|
|
114
|
-
private async analyzeWithRetry(
|
|
484
|
+
private async analyzeWithRetry(job: BackgroundAnalysisJob): Promise<AnalysisResult | undefined> {
|
|
115
485
|
let lastError: Error | undefined;
|
|
116
486
|
for (const delayMs of [0, RETRY_DELAY_MS]) {
|
|
117
487
|
if (this.shuttingDown) throw new DOMException("Aborted", "AbortError");
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (!signal) throw new Error("Analysis retry lost abort controller");
|
|
121
|
-
await abortableDelay(delayMs, signal);
|
|
122
|
-
}
|
|
488
|
+
this.controller = new AbortController();
|
|
489
|
+
if (delayMs > 0) await abortableDelay(delayMs, this.controller.signal);
|
|
123
490
|
if (this.shuttingDown) throw new DOMException("Aborted", "AbortError");
|
|
124
491
|
|
|
125
|
-
this.controller = new AbortController();
|
|
126
|
-
const signal = AbortSignal.any([
|
|
127
|
-
this.controller.signal,
|
|
128
|
-
AbortSignal.timeout(ANALYSIS_TIMEOUT_MS),
|
|
129
|
-
]);
|
|
130
492
|
try {
|
|
131
|
-
return await this.
|
|
493
|
+
return await this.coordinator.runBackground(this.owner, async (coordinatorSignal) => {
|
|
494
|
+
// Resolve durable authorization/configuration only after coordinator waits. Keep it
|
|
495
|
+
// adjacent to each provider attempt so queued jobs cannot use pre-wait consent.
|
|
496
|
+
const configured = this.options.getAnalyzerConfiguration?.(job);
|
|
497
|
+
let fresh: WorkerAnalyzerConfiguration | undefined;
|
|
498
|
+
if (configured !== undefined && typeof (configured as Promise<WorkerAnalyzerConfiguration | undefined>).then === "function") {
|
|
499
|
+
fresh = await (configured as Promise<WorkerAnalyzerConfiguration | undefined>);
|
|
500
|
+
} else {
|
|
501
|
+
fresh = configured as WorkerAnalyzerConfiguration | undefined;
|
|
502
|
+
}
|
|
503
|
+
if (this.options.getAnalyzerConfiguration !== undefined && fresh === undefined) return undefined;
|
|
504
|
+
if (fresh !== undefined) this.analyzerConfiguration = fresh;
|
|
505
|
+
const timeoutSignal = AbortSignal.timeout(ANALYSIS_TIMEOUT_MS);
|
|
506
|
+
return this.analyzerConfiguration.analyzer.analyze(
|
|
507
|
+
job.prompt,
|
|
508
|
+
this.options.getPatterns(),
|
|
509
|
+
AbortSignal.any([this.controller!.signal, timeoutSignal, coordinatorSignal]),
|
|
510
|
+
);
|
|
511
|
+
});
|
|
132
512
|
} catch (error) {
|
|
133
513
|
const normalized = normalizeError(error);
|
|
134
514
|
if (
|
|
135
515
|
normalized.name === "AbortError"
|
|
136
516
|
|| this.shuttingDown
|
|
137
517
|
|| normalized instanceof AnalyzerConfigurationError
|
|
518
|
+
|| normalized instanceof AnalyzerCoordinatorUnavailableError
|
|
138
519
|
) throw normalized;
|
|
139
520
|
lastError = normalized;
|
|
140
521
|
}
|