@centerforagenticai/pi-multi-account 0.1.1
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/LICENSE +21 -0
- package/NOTICE +29 -0
- package/README.md +999 -0
- package/config/models/pi-multi-account.v1.json +32 -0
- package/config/subscription-plans.v1.json +122 -0
- package/package.json +76 -0
- package/packages/pi-anthropic-oauth/LICENSE +21 -0
- package/packages/pi-anthropic-oauth/package.json +54 -0
- package/packages/pi-anthropic-oauth/src/auth.ts +396 -0
- package/packages/pi-anthropic-oauth/src/context.ts +116 -0
- package/packages/pi-anthropic-oauth/src/convert.ts +303 -0
- package/packages/pi-anthropic-oauth/src/index.ts +37 -0
- package/packages/pi-anthropic-oauth/src/prompt.ts +137 -0
- package/packages/pi-anthropic-oauth/src/stream.ts +476 -0
- package/packages/pi-antigravity/LICENSE +21 -0
- package/packages/pi-antigravity/package.json +77 -0
- package/packages/pi-antigravity/src/auth/index.ts +14 -0
- package/packages/pi-antigravity/src/auth/oauth.ts +442 -0
- package/packages/pi-antigravity/src/client/client.ts +561 -0
- package/packages/pi-antigravity/src/client/index.ts +1 -0
- package/packages/pi-antigravity/src/context.ts +110 -0
- package/packages/pi-antigravity/src/diagnostics/diagnostics.ts +96 -0
- package/packages/pi-antigravity/src/diagnostics/index.ts +1 -0
- package/packages/pi-antigravity/src/image/image.ts +336 -0
- package/packages/pi-antigravity/src/image/index.ts +1 -0
- package/packages/pi-antigravity/src/index.ts +280 -0
- package/packages/pi-antigravity/src/models/discovery.ts +154 -0
- package/packages/pi-antigravity/src/models/grouping.ts +424 -0
- package/packages/pi-antigravity/src/models/index.ts +3 -0
- package/packages/pi-antigravity/src/models/models.ts +500 -0
- package/packages/pi-antigravity/src/stream/index.ts +1 -0
- package/packages/pi-antigravity/src/stream/stream.ts +1478 -0
- package/packages/pi-antigravity/src/types/enums.ts +42 -0
- package/packages/pi-antigravity/src/types/index.ts +2 -0
- package/packages/pi-antigravity/src/types/types.ts +292 -0
- package/packages/pi-antigravity/src/usage/index.ts +1 -0
- package/packages/pi-antigravity/src/usage/usage.ts +416 -0
- package/packages/pi-antigravity/src/utils/http.ts +91 -0
- package/packages/pi-antigravity/src/utils/index.ts +3 -0
- package/packages/pi-antigravity/src/utils/security.ts +73 -0
- package/packages/pi-antigravity/src/utils/util.ts +132 -0
- package/scripts/multi-account.mjs +44 -0
- package/src/account-labels.ts +223 -0
- package/src/account-plan-assignment.ts +340 -0
- package/src/account-rate-history.ts +372 -0
- package/src/anthropic-adaptive-stream.ts +531 -0
- package/src/anthropic-alias-stream.ts +140 -0
- package/src/anthropic-context-compat.ts +80 -0
- package/src/api-pricing.ts +579 -0
- package/src/bounded-file-lines.ts +97 -0
- package/src/catalog-rebinding.ts +177 -0
- package/src/catalog-registration-probe.ts +111 -0
- package/src/codex-adapter.ts +345 -0
- package/src/codex-model-defaults.ts +785 -0
- package/src/command-completions.ts +404 -0
- package/src/commands.ts +2000 -0
- package/src/compaction.ts +14 -0
- package/src/config.ts +1317 -0
- package/src/continuation.ts +569 -0
- package/src/cooldowns.ts +110 -0
- package/src/cost-digest-store.ts +332 -0
- package/src/cost-digest.ts +1044 -0
- package/src/cost-history.ts +251 -0
- package/src/cost-period-closer.ts +160 -0
- package/src/cost-report-json.ts +318 -0
- package/src/cost-report-reader.ts +368 -0
- package/src/cost-report-render.ts +207 -0
- package/src/cost-report.ts +1104 -0
- package/src/coverage-attestation.ts +397 -0
- package/src/credential-lifecycle.ts +169 -0
- package/src/credential-refresh.ts +248 -0
- package/src/declaration-notice-marker.ts +238 -0
- package/src/diagnostic-store.ts +276 -0
- package/src/diagnostics.ts +309 -0
- package/src/discovery.ts +471 -0
- package/src/duration.ts +13 -0
- package/src/error-classification.ts +256 -0
- package/src/fuzzy.ts +15 -0
- package/src/group-policy.ts +81 -0
- package/src/history-store.ts +897 -0
- package/src/index.ts +5572 -0
- package/src/lifecycle.ts +378 -0
- package/src/logical-dispatch.ts +279 -0
- package/src/logical-model-selector.ts +254 -0
- package/src/logical-model-switcher.ts +430 -0
- package/src/logical-provider-attribution.ts +544 -0
- package/src/logical-provider.ts +1237 -0
- package/src/logical-route-indicator.ts +215 -0
- package/src/machine-lease.ts +445 -0
- package/src/model-support.ts +66 -0
- package/src/models-declaration.ts +1091 -0
- package/src/openai-adapter.ts +117 -0
- package/src/openrouter-budget.ts +304 -0
- package/src/openrouter-fallback.ts +146 -0
- package/src/period-boundaries.ts +376 -0
- package/src/pi-anthropic-oauth.d.ts +6 -0
- package/src/preflight.ts +253 -0
- package/src/pricing-cache.ts +235 -0
- package/src/project-identity.ts +100 -0
- package/src/provider-registration.ts +942 -0
- package/src/rate-formula.ts +163 -0
- package/src/recovery-engine.ts +853 -0
- package/src/recovery-output.ts +837 -0
- package/src/recovery-plan.ts +239 -0
- package/src/report-range.ts +203 -0
- package/src/route-resolver.ts +789 -0
- package/src/routing-config-transaction.ts +232 -0
- package/src/routing.ts +1163 -0
- package/src/runtime-state.ts +630 -0
- package/src/session-account-groups.ts +284 -0
- package/src/session-restore.ts +287 -0
- package/src/shared-usage.ts +1392 -0
- package/src/standalone-cli.ts +720 -0
- package/src/status-view.ts +578 -0
- package/src/subscription-plan-catalog.ts +346 -0
- package/src/tier-model-resolver.ts +46 -0
- package/src/upstream-anthropic.ts +315 -0
- package/src/upstream-antigravity.ts +327 -0
- package/src/usage-fetch.ts +1634 -0
- package/src/usage.ts +1026 -0
- package/src/vendor.ts +87 -0
- package/src/warmer.ts +231 -0
- package/src/watchdog.ts +219 -0
- package/src/window-history.ts +270 -0
package/src/vendor.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two derived axes over a managed provider-family token: the vendor that
|
|
3
|
+
* authors the model, and the provider type that reaches it. Both are pure
|
|
4
|
+
* functions of the existing family token plus the per-slot credential type; no
|
|
5
|
+
* new token is introduced and nothing here performs I/O.
|
|
6
|
+
*
|
|
7
|
+
* This module is the single home for the vendor mapping that `src/api-pricing.ts`
|
|
8
|
+
* previously duplicated for pricing attribution.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ManagedFamily } from "./config.js";
|
|
12
|
+
import type { CredentialType } from "./discovery.js";
|
|
13
|
+
|
|
14
|
+
/** Exhaustive managed-family ownership map and the sole source of vendor identity. */
|
|
15
|
+
const VENDOR_BY_FAMILY = {
|
|
16
|
+
anthropic: "anthropic",
|
|
17
|
+
"openai-codex": "openai",
|
|
18
|
+
"google-antigravity": "google",
|
|
19
|
+
openai: "openai",
|
|
20
|
+
} as const satisfies Readonly<Record<ManagedFamily, string>>;
|
|
21
|
+
|
|
22
|
+
/** Who authors the model, independent of how an account reaches it. */
|
|
23
|
+
export type Vendor = (typeof VENDOR_BY_FAMILY)[ManagedFamily];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* How a managed account reaches a model:
|
|
27
|
+
* - `subscription`: an OAuth subscription (Claude Pro/Max, ChatGPT/Codex).
|
|
28
|
+
* - `owning-vendor-api`: the vendor's own pay-per-token platform API key.
|
|
29
|
+
* - `openrouter`: the metered OpenRouter last resort.
|
|
30
|
+
*/
|
|
31
|
+
export type ProviderType = "subscription" | "owning-vendor-api" | "openrouter";
|
|
32
|
+
|
|
33
|
+
/** The vendor that authors the models a managed family serves. */
|
|
34
|
+
export function vendorForFamily<Family extends ManagedFamily>(
|
|
35
|
+
family: Family,
|
|
36
|
+
): (typeof VENDOR_BY_FAMILY)[Family] {
|
|
37
|
+
return VENDOR_BY_FAMILY[family];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The provider type for a managed family and its discovered credential type.
|
|
42
|
+
*
|
|
43
|
+
* Anthropic's OAuth subscription and API key both reach the one `anthropic`
|
|
44
|
+
* messages provider, so the family alone cannot decide: only a proven `api_key`
|
|
45
|
+
* credential is the owning-vendor-API tier, and every other credential state
|
|
46
|
+
* (including `unknown`) defaults to the established subscription. `openai-codex`
|
|
47
|
+
* and `google-antigravity` are always subscriptions; the distinct `openai`
|
|
48
|
+
* platform family is always owning-vendor-api regardless of credential.
|
|
49
|
+
*/
|
|
50
|
+
export function providerTypeFor(
|
|
51
|
+
family: ManagedFamily,
|
|
52
|
+
credentialType: CredentialType,
|
|
53
|
+
): Exclude<ProviderType, "openrouter"> {
|
|
54
|
+
switch (family) {
|
|
55
|
+
case "anthropic":
|
|
56
|
+
return credentialType === "api_key"
|
|
57
|
+
? "owning-vendor-api"
|
|
58
|
+
: "subscription";
|
|
59
|
+
case "openai":
|
|
60
|
+
return "owning-vendor-api";
|
|
61
|
+
case "openai-codex":
|
|
62
|
+
case "google-antigravity":
|
|
63
|
+
return "subscription";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The paid-progression order a turn descends: subscription first, then the
|
|
69
|
+
* owning-vendor-API tier, then OpenRouter. Lower rank is preferred. This orders
|
|
70
|
+
* the tiers only; Child 1 uses it for presentation, and the physical descent
|
|
71
|
+
* that consumes it lands in Child 2.
|
|
72
|
+
*/
|
|
73
|
+
export function tierRank(providerType: ProviderType): number {
|
|
74
|
+
switch (providerType) {
|
|
75
|
+
case "subscription":
|
|
76
|
+
return 0;
|
|
77
|
+
case "owning-vendor-api":
|
|
78
|
+
return 1;
|
|
79
|
+
case "openrouter":
|
|
80
|
+
return 2;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Whether two managed families are authored by the same vendor. */
|
|
85
|
+
export function sameVendor(a: ManagedFamily, b: ManagedFamily): boolean {
|
|
86
|
+
return vendorForFamily(a) === vendorForFamily(b);
|
|
87
|
+
}
|
package/src/warmer.ts
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import {
|
|
2
|
+
acquireMachineLease,
|
|
3
|
+
type MachineLeaseHandle,
|
|
4
|
+
} from "./machine-lease.js";
|
|
5
|
+
import {
|
|
6
|
+
credentialWarmingThresholdMs,
|
|
7
|
+
type AllowedFamily,
|
|
8
|
+
type MultiAccountConfig,
|
|
9
|
+
} from "./config.js";
|
|
10
|
+
import type { DiagnosticLog } from "./diagnostics.js";
|
|
11
|
+
import type { CredentialType } from "./discovery.js";
|
|
12
|
+
|
|
13
|
+
export interface WarmCandidate {
|
|
14
|
+
readonly providerId: string;
|
|
15
|
+
readonly family: AllowedFamily;
|
|
16
|
+
/**
|
|
17
|
+
* Discovered credential type, carried so the usage fetcher can branch an
|
|
18
|
+
* `api_key` account to its unmeasured outcome before any OAuth call. Optional
|
|
19
|
+
* so existing construction sites and fixtures stay valid.
|
|
20
|
+
*/
|
|
21
|
+
readonly credentialType?: CredentialType;
|
|
22
|
+
readonly expiresAtMs?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type CredentialWarmResolver = (providerId: string) => Promise<boolean>;
|
|
26
|
+
|
|
27
|
+
export interface WarmCycleResult {
|
|
28
|
+
readonly holder: boolean;
|
|
29
|
+
readonly attempted: readonly string[];
|
|
30
|
+
readonly warmed: readonly string[];
|
|
31
|
+
readonly unavailable: readonly string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CredentialWarmerOptions {
|
|
35
|
+
readonly lockPath: string;
|
|
36
|
+
readonly leaseTtlMs?: number;
|
|
37
|
+
readonly now?: () => number;
|
|
38
|
+
readonly random?: () => number;
|
|
39
|
+
readonly baseIntervalMs?: number;
|
|
40
|
+
readonly jitterFraction?: number;
|
|
41
|
+
/** Test harnesses can disable recursive timers while still exercising a cycle. */
|
|
42
|
+
readonly scheduleBackground?: boolean;
|
|
43
|
+
readonly resolveCredential: CredentialWarmResolver;
|
|
44
|
+
readonly onUnavailable?: (candidate: WarmCandidate) => void;
|
|
45
|
+
readonly onWarmed?: (candidate: WarmCandidate) => void;
|
|
46
|
+
/** Runs while this cycle owns the machine lease; it must remain bounded. */
|
|
47
|
+
readonly compactUnderLease?: (lease: MachineLeaseHandle) => void;
|
|
48
|
+
readonly diagnostics?: DiagnosticLog;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const DEFAULT_INTERVAL_MS = 60_000;
|
|
52
|
+
const DEFAULT_JITTER_FRACTION = 0.2;
|
|
53
|
+
|
|
54
|
+
function boundedRandom(random: () => number): number {
|
|
55
|
+
try {
|
|
56
|
+
const value = random();
|
|
57
|
+
return Number.isFinite(value) && value >= 0 && value <= 1 ? value : 0.5;
|
|
58
|
+
} catch {
|
|
59
|
+
return 0.5;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Runs Pi's public credential resolver under the machine-global lease. The
|
|
65
|
+
* resolver returns only a boolean, so an access token cannot be retained,
|
|
66
|
+
* logged, or returned by this module.
|
|
67
|
+
*/
|
|
68
|
+
export class CredentialWarmer {
|
|
69
|
+
readonly #options: CredentialWarmerOptions;
|
|
70
|
+
readonly #now: () => number;
|
|
71
|
+
readonly #random: () => number;
|
|
72
|
+
readonly #baseIntervalMs: number;
|
|
73
|
+
readonly #jitterFraction: number;
|
|
74
|
+
readonly #scheduleBackground: boolean;
|
|
75
|
+
readonly #unavailable = new Set<string>();
|
|
76
|
+
#timer: ReturnType<typeof setTimeout> | undefined;
|
|
77
|
+
#stopped = false;
|
|
78
|
+
|
|
79
|
+
constructor(options: CredentialWarmerOptions) {
|
|
80
|
+
this.#options = options;
|
|
81
|
+
this.#now = options.now ?? Date.now;
|
|
82
|
+
this.#random = options.random ?? Math.random;
|
|
83
|
+
this.#baseIntervalMs = options.baseIntervalMs ?? DEFAULT_INTERVAL_MS;
|
|
84
|
+
this.#jitterFraction = options.jitterFraction ?? DEFAULT_JITTER_FRACTION;
|
|
85
|
+
this.#scheduleBackground = options.scheduleBackground ?? true;
|
|
86
|
+
if (
|
|
87
|
+
!Number.isFinite(this.#baseIntervalMs) ||
|
|
88
|
+
this.#baseIntervalMs < 1_000
|
|
89
|
+
) {
|
|
90
|
+
throw new RangeError("warmer baseIntervalMs must be at least 1000 ms.");
|
|
91
|
+
}
|
|
92
|
+
if (
|
|
93
|
+
!Number.isFinite(this.#jitterFraction) ||
|
|
94
|
+
this.#jitterFraction < 0 ||
|
|
95
|
+
this.#jitterFraction > 1
|
|
96
|
+
) {
|
|
97
|
+
throw new RangeError("warmer jitterFraction must be between 0 and 1.");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async runCycle(
|
|
102
|
+
candidates: readonly WarmCandidate[],
|
|
103
|
+
config: MultiAccountConfig,
|
|
104
|
+
): Promise<WarmCycleResult> {
|
|
105
|
+
const empty: WarmCycleResult = {
|
|
106
|
+
holder: false,
|
|
107
|
+
attempted: [],
|
|
108
|
+
warmed: [],
|
|
109
|
+
unavailable: [],
|
|
110
|
+
};
|
|
111
|
+
let lease: MachineLeaseHandle | undefined;
|
|
112
|
+
try {
|
|
113
|
+
lease = acquireMachineLease({
|
|
114
|
+
lockPath: this.#options.lockPath,
|
|
115
|
+
...(this.#options.leaseTtlMs === undefined
|
|
116
|
+
? {}
|
|
117
|
+
: { ttlMs: this.#options.leaseTtlMs }),
|
|
118
|
+
now: this.#now,
|
|
119
|
+
// A contentless warming.lock left by a crash would otherwise disable
|
|
120
|
+
// credential warming permanently and silently.
|
|
121
|
+
reclaimMalformed: true,
|
|
122
|
+
});
|
|
123
|
+
} catch (error) {
|
|
124
|
+
this.#options.diagnostics?.recordError("credential.warmer.lease", error);
|
|
125
|
+
return empty;
|
|
126
|
+
}
|
|
127
|
+
if (!lease) return empty;
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
this.#options.compactUnderLease?.(lease);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
this.#options.diagnostics?.recordError(
|
|
133
|
+
"credential.warmer.compact",
|
|
134
|
+
error,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
const attempted: string[] = [];
|
|
138
|
+
const warmed: string[] = [];
|
|
139
|
+
const unavailable: string[] = [];
|
|
140
|
+
try {
|
|
141
|
+
const nowMs = this.#now();
|
|
142
|
+
const thresholdMs = credentialWarmingThresholdMs(config);
|
|
143
|
+
for (const candidate of candidates) {
|
|
144
|
+
if (
|
|
145
|
+
this.#unavailable.has(candidate.providerId) ||
|
|
146
|
+
candidate.expiresAtMs === undefined ||
|
|
147
|
+
!Number.isFinite(candidate.expiresAtMs) ||
|
|
148
|
+
candidate.expiresAtMs - nowMs > thresholdMs
|
|
149
|
+
)
|
|
150
|
+
continue;
|
|
151
|
+
if (!lease.renew()) break;
|
|
152
|
+
attempted.push(candidate.providerId);
|
|
153
|
+
let resolved = false;
|
|
154
|
+
try {
|
|
155
|
+
resolved =
|
|
156
|
+
(await this.#options.resolveCredential(candidate.providerId)) ===
|
|
157
|
+
true;
|
|
158
|
+
} catch (error) {
|
|
159
|
+
this.#options.diagnostics?.recordError(
|
|
160
|
+
"credential.warmer.resolve",
|
|
161
|
+
error,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (resolved) {
|
|
165
|
+
warmed.push(candidate.providerId);
|
|
166
|
+
this.#unavailable.delete(candidate.providerId);
|
|
167
|
+
this.#options.onWarmed?.(candidate);
|
|
168
|
+
} else {
|
|
169
|
+
unavailable.push(candidate.providerId);
|
|
170
|
+
this.#unavailable.add(candidate.providerId);
|
|
171
|
+
this.#options.onUnavailable?.(candidate);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} catch (error) {
|
|
175
|
+
this.#options.diagnostics?.recordError("credential.warmer.cycle", error);
|
|
176
|
+
} finally {
|
|
177
|
+
lease.release();
|
|
178
|
+
}
|
|
179
|
+
return { holder: true, attempted, warmed, unavailable };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Forget terminal failures after rediscovery observes a new account state. */
|
|
183
|
+
resetUnavailable(providerId?: string): void {
|
|
184
|
+
if (providerId === undefined) this.#unavailable.clear();
|
|
185
|
+
else this.#unavailable.delete(providerId);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Starts one leased cycle at a time; only the holder touches credentials. */
|
|
189
|
+
async start(
|
|
190
|
+
getCandidates: () => readonly WarmCandidate[],
|
|
191
|
+
config: MultiAccountConfig,
|
|
192
|
+
): Promise<WarmCycleResult> {
|
|
193
|
+
this.#stopped = false;
|
|
194
|
+
const result = await this.runCycle(getCandidates(), config);
|
|
195
|
+
if (this.#scheduleBackground) this.#schedule(getCandidates, config);
|
|
196
|
+
return result;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
stop(): void {
|
|
200
|
+
this.#stopped = true;
|
|
201
|
+
if (this.#timer !== undefined) clearTimeout(this.#timer);
|
|
202
|
+
this.#timer = undefined;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
#schedule(
|
|
206
|
+
getCandidates: () => readonly WarmCandidate[],
|
|
207
|
+
config: MultiAccountConfig,
|
|
208
|
+
): void {
|
|
209
|
+
if (this.#stopped || this.#timer !== undefined) return;
|
|
210
|
+
const jitter = (boundedRandom(this.#random) * 2 - 1) * this.#jitterFraction;
|
|
211
|
+
const delay = Math.max(
|
|
212
|
+
1_000,
|
|
213
|
+
Math.round(this.#baseIntervalMs * (1 + jitter)),
|
|
214
|
+
);
|
|
215
|
+
this.#timer = setTimeout(async () => {
|
|
216
|
+
this.#timer = undefined;
|
|
217
|
+
try {
|
|
218
|
+
await this.runCycle(getCandidates(), config);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
this.#options.diagnostics?.recordError(
|
|
221
|
+
"credential.warmer.schedule",
|
|
222
|
+
error,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
this.#schedule(getCandidates, config);
|
|
226
|
+
}, delay);
|
|
227
|
+
this.#timer.unref?.();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export { credentialWarmingThresholdMs as warmerThresholdMs } from "./config.js";
|
package/src/watchdog.ts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { DiagnosticLog } from "./diagnostics.js";
|
|
2
|
+
import type { RuntimeReference } from "./runtime-state.js";
|
|
3
|
+
import { RuntimeState } from "./runtime-state.js";
|
|
4
|
+
|
|
5
|
+
export const WATCHDOG_ADVISORY =
|
|
6
|
+
"The continuation after the routing switch made no progress. Check account limits or switch manually.";
|
|
7
|
+
|
|
8
|
+
/** Suppresses identical operator notices for 15 minutes across continuation refs. */
|
|
9
|
+
export const WATCHDOG_NOTIFICATION_COOLDOWN_MS = 15 * 60 * 1_000;
|
|
10
|
+
|
|
11
|
+
export interface WatchdogScheduler {
|
|
12
|
+
set(delayMs: number, callback: () => void): unknown;
|
|
13
|
+
clear(handle: unknown): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface WatchdogRegistration {
|
|
17
|
+
readonly continuationTurnRef: RuntimeReference;
|
|
18
|
+
readonly intervalMs: number;
|
|
19
|
+
readonly cancelContinuation: () => void | Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ActiveWatchdog {
|
|
23
|
+
readonly continuationTurnRef: RuntimeReference;
|
|
24
|
+
readonly intervalMs: number;
|
|
25
|
+
readonly cancelContinuation: () => void | Promise<void>;
|
|
26
|
+
readonly dispatchedAtMs: number;
|
|
27
|
+
lastProgressAtMs: number;
|
|
28
|
+
toolRunning: boolean;
|
|
29
|
+
timer?: unknown;
|
|
30
|
+
fired: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const defaultScheduler: WatchdogScheduler = {
|
|
34
|
+
set(delayMs, callback) {
|
|
35
|
+
return setTimeout(callback, delayMs);
|
|
36
|
+
},
|
|
37
|
+
clear(handle) {
|
|
38
|
+
clearTimeout(handle as ReturnType<typeof setTimeout>);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export class ContinuationWatchdog {
|
|
43
|
+
readonly #state: RuntimeState;
|
|
44
|
+
readonly #diagnostics: DiagnosticLog;
|
|
45
|
+
readonly #scheduler: WatchdogScheduler;
|
|
46
|
+
readonly #now: () => number;
|
|
47
|
+
readonly #notify: (message: string) => void | Promise<void>;
|
|
48
|
+
readonly #notificationCooldownMs: number;
|
|
49
|
+
readonly #active = new Map<RuntimeReference, ActiveWatchdog>();
|
|
50
|
+
#lastNotificationAtMs: number | undefined;
|
|
51
|
+
|
|
52
|
+
constructor(options: {
|
|
53
|
+
state: RuntimeState;
|
|
54
|
+
diagnostics: DiagnosticLog;
|
|
55
|
+
notify: (message: string) => void | Promise<void>;
|
|
56
|
+
scheduler?: WatchdogScheduler;
|
|
57
|
+
now?: () => number;
|
|
58
|
+
notificationCooldownMs?: number;
|
|
59
|
+
}) {
|
|
60
|
+
const notificationCooldownMs =
|
|
61
|
+
options.notificationCooldownMs ?? WATCHDOG_NOTIFICATION_COOLDOWN_MS;
|
|
62
|
+
if (
|
|
63
|
+
!Number.isSafeInteger(notificationCooldownMs) ||
|
|
64
|
+
notificationCooldownMs < 1
|
|
65
|
+
) {
|
|
66
|
+
throw new RangeError(
|
|
67
|
+
"watchdog notification cooldown must be a positive safe integer.",
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
this.#state = options.state;
|
|
71
|
+
this.#diagnostics = options.diagnostics;
|
|
72
|
+
this.#notify = options.notify;
|
|
73
|
+
this.#scheduler = options.scheduler ?? defaultScheduler;
|
|
74
|
+
this.#now = options.now ?? Date.now;
|
|
75
|
+
this.#notificationCooldownMs = notificationCooldownMs;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
start(registration: WatchdogRegistration): void {
|
|
79
|
+
if (!Number.isSafeInteger(registration.intervalMs) || registration.intervalMs < 1) {
|
|
80
|
+
throw new RangeError("watchdog interval must be a positive safe integer.");
|
|
81
|
+
}
|
|
82
|
+
this.cancel(registration.continuationTurnRef);
|
|
83
|
+
const now = this.#now();
|
|
84
|
+
const active: ActiveWatchdog = {
|
|
85
|
+
...registration,
|
|
86
|
+
dispatchedAtMs: now,
|
|
87
|
+
lastProgressAtMs: now,
|
|
88
|
+
toolRunning: false,
|
|
89
|
+
fired: false,
|
|
90
|
+
};
|
|
91
|
+
this.#syncState(active);
|
|
92
|
+
this.#active.set(registration.continuationTurnRef, active);
|
|
93
|
+
try {
|
|
94
|
+
this.#arm(active);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
this.#active.delete(registration.continuationTurnRef);
|
|
97
|
+
try {
|
|
98
|
+
this.#state.clearWatchdog(registration.continuationTurnRef);
|
|
99
|
+
} catch (cleanupError) {
|
|
100
|
+
this.#diagnostics.recordError("watchdog.start-cleanup", cleanupError);
|
|
101
|
+
}
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
progress(continuationTurnRef: RuntimeReference): void {
|
|
107
|
+
const active = this.#active.get(continuationTurnRef);
|
|
108
|
+
if (!active || active.fired) return;
|
|
109
|
+
active.lastProgressAtMs = this.#now();
|
|
110
|
+
this.#syncState(active);
|
|
111
|
+
if (!active.toolRunning) this.#arm(active);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
setToolRunning(continuationTurnRef: RuntimeReference, running: boolean): void {
|
|
115
|
+
const active = this.#active.get(continuationTurnRef);
|
|
116
|
+
if (!active || active.fired) return;
|
|
117
|
+
active.toolRunning = running;
|
|
118
|
+
active.lastProgressAtMs = this.#now();
|
|
119
|
+
if (active.timer !== undefined) {
|
|
120
|
+
this.#scheduler.clear(active.timer);
|
|
121
|
+
active.timer = undefined;
|
|
122
|
+
}
|
|
123
|
+
this.#syncState(active);
|
|
124
|
+
// A running tool has no polling loop. Tool completion starts one fresh interval.
|
|
125
|
+
if (!running) this.#arm(active);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
progressAll(): void {
|
|
129
|
+
for (const reference of this.#active.keys()) this.progress(reference);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
setAllToolsRunning(running: boolean): void {
|
|
133
|
+
for (const reference of this.#active.keys()) this.setToolRunning(reference, running);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#syncState(active: ActiveWatchdog): void {
|
|
137
|
+
this.#state.setWatchdog({
|
|
138
|
+
continuationTurnRef: active.continuationTurnRef,
|
|
139
|
+
dispatchedAtMs: active.dispatchedAtMs,
|
|
140
|
+
lastProgressAtMs: active.lastProgressAtMs,
|
|
141
|
+
toolRunning: active.toolRunning,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
#arm(active: ActiveWatchdog): void {
|
|
146
|
+
if (active.timer !== undefined) this.#scheduler.clear(active.timer);
|
|
147
|
+
active.timer = this.#scheduler.set(active.intervalMs, () => {
|
|
148
|
+
active.timer = undefined;
|
|
149
|
+
if (!active.fired && !active.toolRunning) void this.#timeout(active);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async #timeout(active: ActiveWatchdog): Promise<void> {
|
|
154
|
+
if (active.fired || !this.#active.has(active.continuationTurnRef)) return;
|
|
155
|
+
active.fired = true;
|
|
156
|
+
this.#active.delete(active.continuationTurnRef);
|
|
157
|
+
try {
|
|
158
|
+
this.#state.clearWatchdog(active.continuationTurnRef);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
this.#diagnostics.recordError("watchdog.state", error);
|
|
161
|
+
}
|
|
162
|
+
this.#diagnostics.record("warning", "watchdog.timeout", WATCHDOG_ADVISORY);
|
|
163
|
+
try {
|
|
164
|
+
await active.cancelContinuation();
|
|
165
|
+
} catch (error) {
|
|
166
|
+
this.#diagnostics.recordError("watchdog.cancel", error);
|
|
167
|
+
}
|
|
168
|
+
const now = this.#now();
|
|
169
|
+
if (
|
|
170
|
+
this.#lastNotificationAtMs !== undefined &&
|
|
171
|
+
now - this.#lastNotificationAtMs < this.#notificationCooldownMs
|
|
172
|
+
) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// Reserve the window before awaiting the sink so concurrent timeouts cannot
|
|
176
|
+
// race identical notices through. A failed notice attempt is still bounded.
|
|
177
|
+
this.#lastNotificationAtMs = now;
|
|
178
|
+
try {
|
|
179
|
+
await this.#notify(this.#diagnostics.sanitizeOutput(WATCHDOG_ADVISORY));
|
|
180
|
+
} catch (error) {
|
|
181
|
+
this.#diagnostics.recordError("watchdog.notify", error);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
cancel(continuationTurnRef: RuntimeReference): boolean {
|
|
186
|
+
const active = this.#active.get(continuationTurnRef);
|
|
187
|
+
if (!active) return false;
|
|
188
|
+
if (active.timer !== undefined) this.#scheduler.clear(active.timer);
|
|
189
|
+
this.#active.delete(continuationTurnRef);
|
|
190
|
+
try {
|
|
191
|
+
this.#state.clearWatchdog(continuationTurnRef);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
this.#diagnostics.recordError("watchdog.cancel", error);
|
|
194
|
+
}
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
cancelAll(): void {
|
|
199
|
+
for (const active of this.#active.values()) {
|
|
200
|
+
if (active.timer !== undefined) {
|
|
201
|
+
try {
|
|
202
|
+
this.#scheduler.clear(active.timer);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
this.#diagnostics.recordError("watchdog.cancel-timer", error);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
this.#state.clearWatchdog(active.continuationTurnRef);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
this.#diagnostics.recordError("watchdog.cancel-state", error);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
this.#active.clear();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
activeCount(): number {
|
|
217
|
+
return this.#active.size;
|
|
218
|
+
}
|
|
219
|
+
}
|