@teamvibe/poller 0.8.3 → 0.8.4-rc.2
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 +13 -0
- package/dist/brain-directory.d.ts +14 -0
- package/dist/brain-directory.js +16 -0
- package/dist/brain-manager.d.ts +2 -2
- package/dist/brain-manager.js +32 -19
- package/dist/brain-path.d.ts +9 -0
- package/dist/brain-path.js +65 -0
- package/dist/claude-spawner.d.ts +3 -2
- package/dist/claude-spawner.js +195 -74
- package/dist/config.d.ts +4 -0
- package/dist/config.js +1 -0
- package/dist/event-queue.js +14 -1
- package/dist/inbox-manager.d.ts +11 -0
- package/dist/inbox-manager.js +101 -6
- package/dist/inbox-watcher.d.ts +9 -4
- package/dist/inbox-watcher.js +219 -49
- package/dist/maintenance-cron.d.ts +4 -2
- package/dist/maintenance-cron.js +56 -14
- package/dist/metrics/scheduler.d.ts +4 -1
- package/dist/metrics/scheduler.js +8 -6
- package/dist/poller.d.ts +2 -1
- package/dist/poller.js +50 -24
- package/dist/pool-logging.js +2 -2
- package/dist/pool-telemetry.d.ts +21 -5
- package/dist/pool-telemetry.js +28 -11
- package/dist/session-store.d.ts +1 -1
- package/dist/session-store.js +6 -2
- package/dist/startup-auth.js +4 -1
- package/dist/subscription-pool-client.js +7 -0
- package/dist/subscription-pool.d.ts +1 -0
- package/dist/subscription-pool.js +1 -1
- package/dist/subscription-runtime.d.ts +21 -3
- package/dist/subscription-runtime.js +17 -9
- package/dist/subscription-selection.d.ts +20 -3
- package/dist/subscription-selection.js +66 -4
- package/dist/subscription-usage-store.d.ts +2 -0
- package/dist/subscription-usage-store.js +41 -20
- package/dist/thread-routing.d.ts +23 -0
- package/dist/thread-routing.js +81 -0
- package/dist/types.d.ts +4 -10
- package/dist/usage-limit-messages.d.ts +1 -1
- package/dist/usage-limit-messages.js +1 -1
- package/dist/usage-probe-contract.d.ts +15 -0
- package/dist/usage-probe-contract.js +14 -0
- package/dist/usage-probe-job.js +20 -7
- package/dist/worktree-manager.d.ts +2 -1
- package/dist/worktree-manager.js +38 -10
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
|
@@ -2,13 +2,13 @@ import { type PoolInfoLogger } from './pool-logging.js';
|
|
|
2
2
|
import { type SubscriptionAccount } from './subscription-pool-client.js';
|
|
3
3
|
import { type ResolvedPoolModel } from './subscription-pool.js';
|
|
4
4
|
import { FileSubscriptionUsageStore, type SubscriptionUsageStore } from './subscription-usage-store.js';
|
|
5
|
-
import { type PoolFetchResult } from './subscription-selection.js';
|
|
5
|
+
import { type PoolFetchResult, type HeadroomScore } from './subscription-selection.js';
|
|
6
6
|
import type { SubscriptionPin, TeamVibeQueueMessage, SessionMetrics } from './types.js';
|
|
7
7
|
import type { PoolUsageLimitNotice } from './usage-limit-messages.js';
|
|
8
8
|
import type { ClaudeRateLimitInfo } from './claude-rate-limit.js';
|
|
9
9
|
import type { ClaudeQuotaFailure } from './claude-quota.js';
|
|
10
10
|
import type { KilledByPoller } from './classify-failed-iteration.js';
|
|
11
|
-
export interface PoolAttempt {
|
|
11
|
+
export interface PoolAttempt extends Partial<HeadroomScore> {
|
|
12
12
|
workspaceId: string;
|
|
13
13
|
accountId: string;
|
|
14
14
|
accountLabel?: string;
|
|
@@ -46,12 +46,30 @@ export declare function preparePool(msg: TeamVibeQueueMessage, resolved: Resolve
|
|
|
46
46
|
account: SubscriptionAccount;
|
|
47
47
|
store: FileSubscriptionUsageStore;
|
|
48
48
|
pool: PoolFetchResult<SubscriptionAccount>;
|
|
49
|
+
selection: ({
|
|
50
|
+
kind: "selected";
|
|
51
|
+
account: SubscriptionAccount;
|
|
52
|
+
provenance: "known";
|
|
53
|
+
source: "passive" | "probe";
|
|
54
|
+
} & HeadroomScore & {
|
|
55
|
+
model: string | undefined;
|
|
56
|
+
modelSource: import("./claude-spawner.js").ModelSource;
|
|
57
|
+
}) | ({
|
|
58
|
+
kind: "selected";
|
|
59
|
+
account: SubscriptionAccount;
|
|
60
|
+
provenance: "unknown";
|
|
61
|
+
uncertainApplicability: boolean;
|
|
62
|
+
} & {
|
|
63
|
+
model: string | undefined;
|
|
64
|
+
modelSource: import("./claude-spawner.js").ModelSource;
|
|
65
|
+
});
|
|
49
66
|
} | {
|
|
50
67
|
account: undefined;
|
|
51
68
|
store: FileSubscriptionUsageStore;
|
|
52
69
|
pool: PoolFetchResult<SubscriptionAccount>;
|
|
70
|
+
selection: undefined;
|
|
53
71
|
}>;
|
|
54
72
|
export declare function accountPin(account: SubscriptionAccount, resolved: ResolvedPoolModel): SubscriptionPin;
|
|
55
73
|
export declare function validateAccount(account: SubscriptionAccount, resolved: ResolvedPoolModel): Promise<void>;
|
|
56
|
-
/** A snapshot TTL/recheck is not a provider reset.
|
|
74
|
+
/** A snapshot TTL/recheck is not a provider reset. Explicit rejections also establish resets. */
|
|
57
75
|
export declare function poolDeferredError(pool: PoolFetchResult<SubscriptionAccount>, resolved: ResolvedPoolModel, store: SubscriptionUsageStore, eligibleAt?: number, log?: PoolInfoLogger): Promise<PoolDeferredError>;
|
|
@@ -6,7 +6,7 @@ import { getPollerId } from './auth-provider.js';
|
|
|
6
6
|
import { fetchSubscriptionPool } from './subscription-pool-client.js';
|
|
7
7
|
import { selectPoolForResolvedModel } from './subscription-pool.js';
|
|
8
8
|
import { FileSubscriptionUsageStore, usageStateForSelection } from './subscription-usage-store.js';
|
|
9
|
-
import { PASSIVE_USAGE_TTL_MS, PROBE_USAGE_TTL_MS, reactiveEligibleAt, USAGE_THRESHOLD_PERCENT, POOL_RECHECK_MS } from './subscription-selection.js';
|
|
9
|
+
import { normalizePassiveUsage, selectSubscriptionAccount, PASSIVE_USAGE_TTL_MS, PROBE_USAGE_TTL_MS, reactiveEligibleAt, USAGE_THRESHOLD_PERCENT, POOL_RECHECK_MS } from './subscription-selection.js';
|
|
10
10
|
export class PoolDeferredError extends Error {
|
|
11
11
|
eligibleAt;
|
|
12
12
|
resetAt;
|
|
@@ -39,20 +39,20 @@ export async function preparePool(msg, resolved, pin, fetched, log) {
|
|
|
39
39
|
if (other.accountId !== account.accountId)
|
|
40
40
|
poolInfo(log, `skipped account=${poolLabel(other)} reason=session pinned elsewhere`);
|
|
41
41
|
}
|
|
42
|
-
return { account, store, pool };
|
|
42
|
+
return { account, store, pool, selection: selected };
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
|
-
const selected = await selectPoolForResolvedModel(resolved, { workspaceId: msg.teamvibe.workspaceId, pool, store, now: Date.now(), log });
|
|
46
|
+
const selected = await selectPoolForResolvedModel(resolved, { workspaceId: msg.teamvibe.workspaceId, pool, store, now: Date.now(), log, currentAccountId: pin?.workspaceId === msg.teamvibe.workspaceId ? pin.accountId : undefined });
|
|
47
47
|
if (selected.kind === 'selected') {
|
|
48
48
|
if (log && pool.kind === 'ready' && pin?.workspaceId === msg.teamvibe.workspaceId && pin.accountId !== selected.account.accountId) {
|
|
49
49
|
const previous = pool.accounts.find(a => a.accountId === pin.accountId);
|
|
50
50
|
poolInfo(log, `switch from=${poolLabel(previous ?? {})} to=${poolLabel(selected.account)} reason=pinned account unavailable`);
|
|
51
51
|
}
|
|
52
|
-
return { account: selected.account, store, pool };
|
|
52
|
+
return { account: selected.account, store, pool, selection: selected };
|
|
53
53
|
}
|
|
54
54
|
if (selected.kind === 'no_accounts_configured')
|
|
55
|
-
return { account: undefined, store, pool };
|
|
55
|
+
return { account: undefined, store, pool, selection: undefined };
|
|
56
56
|
throw await poolDeferredError(pool, resolved, store, selected.kind === 'deferred' ? selected.eligibleAt : undefined);
|
|
57
57
|
}
|
|
58
58
|
export function accountPin(account, resolved) {
|
|
@@ -64,7 +64,7 @@ export async function validateAccount(account, resolved) {
|
|
|
64
64
|
throw new PoolDeferredError(selected.kind === 'deferred' ? selected.eligibleAt : undefined);
|
|
65
65
|
}
|
|
66
66
|
/** Message-only evidence; never changes selection, eligibility or durable pause state. */
|
|
67
|
-
async function poolUsageNotice(pool, store) {
|
|
67
|
+
async function poolUsageNotice(pool, resolved, store) {
|
|
68
68
|
if (pool.kind !== 'ready')
|
|
69
69
|
return {};
|
|
70
70
|
const now = Date.now();
|
|
@@ -75,7 +75,12 @@ async function poolUsageNotice(pool, store) {
|
|
|
75
75
|
const ttl = usage?.source === 'passive' ? PASSIVE_USAGE_TTL_MS : PROBE_USAGE_TTL_MS;
|
|
76
76
|
const windows = usage && usage.observedAt <= now && now < usage.observedAt + ttl
|
|
77
77
|
? usage.windows.filter(w => w.resetAt === undefined || w.resetAt > now) : [];
|
|
78
|
-
const
|
|
78
|
+
const rejection = selectSubscriptionAccount({
|
|
79
|
+
pool: { kind: 'ready', accounts: [account] },
|
|
80
|
+
states: { [account.accountId]: { usage: usage ? { ...usage, windows: usage.windows.filter(w => w.status === 'rejected') } : undefined } },
|
|
81
|
+
model: resolved.model, now,
|
|
82
|
+
});
|
|
83
|
+
const rejected = rejection.kind === 'deferred' || (state.reactive?.some(r => r.observedAt <= now && reactiveEligibleAt(r) > now) ?? false);
|
|
79
84
|
const blockers = windows.filter(w => w.percent !== undefined && w.percent >= (account.useFullQuota ? 100 : USAGE_THRESHOLD_PERCENT));
|
|
80
85
|
const resets = blockers.map(w => w.resetAt);
|
|
81
86
|
return {
|
|
@@ -94,9 +99,9 @@ async function poolUsageNotice(pool, store) {
|
|
|
94
99
|
return {};
|
|
95
100
|
}
|
|
96
101
|
}
|
|
97
|
-
/** A snapshot TTL/recheck is not a provider reset.
|
|
102
|
+
/** A snapshot TTL/recheck is not a provider reset. Explicit rejections also establish resets. */
|
|
98
103
|
export async function poolDeferredError(pool, resolved, store, eligibleAt, log) {
|
|
99
|
-
const notice = await poolUsageNotice(pool, store);
|
|
104
|
+
const notice = await poolUsageNotice(pool, resolved, store);
|
|
100
105
|
let usageDeferred = eligibleAt !== undefined;
|
|
101
106
|
const deferredError = (resetAt) => {
|
|
102
107
|
const error = new PoolDeferredError(eligibleAt, resetAt);
|
|
@@ -115,6 +120,9 @@ export async function poolDeferredError(pool, resolved, store, eligibleAt, log)
|
|
|
115
120
|
if (selected.kind !== 'deferred' || selected.eligibleAt !== eligibleAt)
|
|
116
121
|
continue;
|
|
117
122
|
const state = await store.read(account);
|
|
123
|
+
const usage = state.passive ? normalizePassiveUsage(state.passive.rateLimitInfo, state.passive.observedAt) : undefined;
|
|
124
|
+
if (usage?.windows.some(w => w.status === 'rejected' && w.resetAt === eligibleAt))
|
|
125
|
+
return deferredError(new Date(eligibleAt).toISOString());
|
|
118
126
|
const live = state.reactive.filter(r => !r.quota.resetAt || Date.parse(r.quota.resetAt) > Date.now());
|
|
119
127
|
if (live.length && live.every(r => r.quota.resetAt && Number.isFinite(Date.parse(r.quota.resetAt))) && live.some(r => Date.parse(r.quota.resetAt) === eligibleAt))
|
|
120
128
|
return deferredError(new Date(eligibleAt).toISOString());
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import type { AccountProbeFailureReason } from './usage-probe-contract.js';
|
|
1
2
|
import type { ClaudeQuotaFailure } from './claude-quota.js';
|
|
2
3
|
export declare const USAGE_THRESHOLD_PERCENT = 90;
|
|
3
4
|
/** Passive snapshots are useful for five minutes, then become unknown. */
|
|
4
5
|
export declare const PASSIVE_USAGE_TTL_MS: number;
|
|
5
6
|
export declare const PROBE_USAGE_TTL_MS = 60000;
|
|
6
7
|
export declare const POOL_RECHECK_MS: number;
|
|
8
|
+
/** Safety hold from the rejection observation when the provider gives no reset. */
|
|
9
|
+
export declare const REJECTED_BLOCK_MS: number;
|
|
7
10
|
export interface AccountIdentity {
|
|
8
11
|
workspaceId: string;
|
|
9
12
|
accountId: string;
|
|
@@ -14,6 +17,9 @@ export interface AccountIdentity {
|
|
|
14
17
|
export type PoolFetchResult<A extends AccountIdentity = AccountIdentity> = {
|
|
15
18
|
kind: 'ready';
|
|
16
19
|
accounts: readonly A[];
|
|
20
|
+
} | {
|
|
21
|
+
kind: 'probe_account_unavailable';
|
|
22
|
+
reason: AccountProbeFailureReason;
|
|
17
23
|
} | {
|
|
18
24
|
kind: 'no_accounts_configured';
|
|
19
25
|
} | {
|
|
@@ -30,6 +36,9 @@ export interface UsageWindow {
|
|
|
30
36
|
window: 'five_hour' | 'seven_day';
|
|
31
37
|
scope: 'global' | 'sonnet' | 'opus' | 'haiku' | 'fable' | 'uncertain';
|
|
32
38
|
status?: 'allowed' | 'allowed_warning' | 'rejected';
|
|
39
|
+
/** Retained rejections keep their own clock when merged into newer measurements. */
|
|
40
|
+
observedAt?: number;
|
|
41
|
+
source?: 'passive' | 'probe';
|
|
33
42
|
percent?: number;
|
|
34
43
|
resetAt?: number;
|
|
35
44
|
}
|
|
@@ -38,6 +47,8 @@ export interface UsageObservation {
|
|
|
38
47
|
observedAt: number;
|
|
39
48
|
windows: readonly UsageWindow[];
|
|
40
49
|
}
|
|
50
|
+
/** Latest measurements plus durable rejections; omission or success never clears a hold. */
|
|
51
|
+
export declare function mergeUsageObservations(observations: readonly (UsageObservation | undefined)[], now: number): UsageObservation | undefined;
|
|
41
52
|
export interface ReactiveExhaustion {
|
|
42
53
|
observedAt: number;
|
|
43
54
|
quota: ClaudeQuotaFailure;
|
|
@@ -46,13 +57,17 @@ export interface AccountUsageState {
|
|
|
46
57
|
usage?: UsageObservation;
|
|
47
58
|
reactive?: readonly ReactiveExhaustion[];
|
|
48
59
|
}
|
|
49
|
-
export
|
|
60
|
+
export interface HeadroomScore {
|
|
61
|
+
score: number;
|
|
62
|
+
rateWindow: UsageWindow;
|
|
63
|
+
reason: 'headroom_per_hour';
|
|
64
|
+
}
|
|
65
|
+
export type SelectionOutcome<A extends AccountIdentity = AccountIdentity> = ({
|
|
50
66
|
kind: 'selected';
|
|
51
67
|
account: A;
|
|
52
68
|
provenance: 'known';
|
|
53
|
-
score: number;
|
|
54
69
|
source: 'passive' | 'probe';
|
|
55
|
-
} | {
|
|
70
|
+
} & HeadroomScore) | {
|
|
56
71
|
kind: 'selected';
|
|
57
72
|
account: A;
|
|
58
73
|
provenance: 'unknown';
|
|
@@ -73,5 +88,7 @@ export declare function selectSubscriptionAccount<A extends AccountIdentity>(inp
|
|
|
73
88
|
/** Stable account ID within the pool's single workspace. */
|
|
74
89
|
states: Readonly<Record<string, AccountUsageState | undefined>>;
|
|
75
90
|
model: string | undefined;
|
|
91
|
+
/** Tie preference only; session pins are enforced separately by the runtime. */
|
|
92
|
+
currentAccountId?: string;
|
|
76
93
|
now: number;
|
|
77
94
|
}): SelectionOutcome<A>;
|
|
@@ -3,6 +3,40 @@ export const USAGE_THRESHOLD_PERCENT = 90;
|
|
|
3
3
|
export const PASSIVE_USAGE_TTL_MS = 5 * 60_000;
|
|
4
4
|
export const PROBE_USAGE_TTL_MS = 60_000;
|
|
5
5
|
export const POOL_RECHECK_MS = 15 * 60_000;
|
|
6
|
+
/** Safety hold from the rejection observation when the provider gives no reset. */
|
|
7
|
+
export const REJECTED_BLOCK_MS = 15 * 60_000;
|
|
8
|
+
/** Latest measurements plus durable rejections; omission or success never clears a hold. */
|
|
9
|
+
export function mergeUsageObservations(observations, now) {
|
|
10
|
+
let latest;
|
|
11
|
+
const rejected = new Map();
|
|
12
|
+
for (const usage of observations) {
|
|
13
|
+
if (!usage || usage.observedAt > now)
|
|
14
|
+
continue;
|
|
15
|
+
if (!latest || usage.observedAt >= latest.observedAt)
|
|
16
|
+
latest = usage;
|
|
17
|
+
for (const w of usage.windows) {
|
|
18
|
+
const observedAt = w.observedAt ?? usage.observedAt;
|
|
19
|
+
if (w.status !== 'rejected' || observedAt > now)
|
|
20
|
+
continue;
|
|
21
|
+
const key = `${w.window}:${w.scope}`;
|
|
22
|
+
const previous = rejected.get(key);
|
|
23
|
+
if (!previous || observedAt >= previous.observedAt)
|
|
24
|
+
rejected.set(key, { ...w, observedAt, source: w.source ?? usage.source });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (!latest)
|
|
28
|
+
return undefined;
|
|
29
|
+
const active = [...rejected.values()].filter(w => now < (w.resetAt ?? w.observedAt + REJECTED_BLOCK_MS));
|
|
30
|
+
return {
|
|
31
|
+
...latest,
|
|
32
|
+
windows: [
|
|
33
|
+
...latest.windows.filter(w => w.status !== 'rejected' &&
|
|
34
|
+
(w.observedAt ?? latest.observedAt) === latest.observedAt &&
|
|
35
|
+
(w.source ?? latest.source) === latest.source).slice(0, 133 - active.length),
|
|
36
|
+
...active,
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
6
40
|
export function record(value) {
|
|
7
41
|
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
8
42
|
}
|
|
@@ -92,7 +126,7 @@ export function normalizeProbeUsage(value, observedAt) {
|
|
|
92
126
|
return { source: 'probe', observedAt, windows };
|
|
93
127
|
}
|
|
94
128
|
function family(model) {
|
|
95
|
-
return model?.toLowerCase().match(/^(?:claude-)?(sonnet|opus|haiku)(?:-\d[\w.-]*)?(?:\[1m\])?$/)?.[1];
|
|
129
|
+
return model?.toLowerCase().match(/^(?:claude-)?(sonnet|opus|haiku|fable)(?:-\d[\w.-]*)?(?:\[1m\])?$/)?.[1];
|
|
96
130
|
}
|
|
97
131
|
function applicable(scope, model) {
|
|
98
132
|
return scope === 'global' || scope === 'uncertain' || family(model) === undefined || scope === family(model);
|
|
@@ -101,6 +135,21 @@ export function reactiveEligibleAt(reactive) {
|
|
|
101
135
|
const parsed = reactive.quota.resetAt ? Date.parse(reactive.quota.resetAt) : NaN;
|
|
102
136
|
return Number.isFinite(parsed) && parsed > reactive.observedAt ? parsed : reactive.observedAt + POOL_RECHECK_MS;
|
|
103
137
|
}
|
|
138
|
+
/** Called only for nonempty, applicable known windows after all blockers are ruled out.
|
|
139
|
+
* The minimum rate is the account's binding window; retain its numbers for explanation.
|
|
140
|
+
*/
|
|
141
|
+
function scoreHeadroom(windows, threshold, now) {
|
|
142
|
+
let score = Infinity;
|
|
143
|
+
let rateWindow = windows[0];
|
|
144
|
+
for (const window of windows) {
|
|
145
|
+
const rate = (threshold - window.percent) / Math.max((window.resetAt - now) / 3600_000, 0.25);
|
|
146
|
+
if (rate < score) {
|
|
147
|
+
score = rate;
|
|
148
|
+
rateWindow = window;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { score, rateWindow, reason: 'headroom_per_hour' };
|
|
152
|
+
}
|
|
104
153
|
/** Pure policy. Unknown is eligible after every known/reactive blocker is ruled out. */
|
|
105
154
|
export function selectSubscriptionAccount(input) {
|
|
106
155
|
if (input.pool.kind !== 'ready')
|
|
@@ -128,9 +177,22 @@ export function selectSubscriptionAccount(input) {
|
|
|
128
177
|
blockers.push(until);
|
|
129
178
|
}
|
|
130
179
|
const usage = state?.usage;
|
|
180
|
+
// A provider rejection outlives snapshot freshness. Never score it as headroom.
|
|
181
|
+
if (usage && usage.observedAt <= input.now) {
|
|
182
|
+
for (const w of usage.windows) {
|
|
183
|
+
if (w.status !== 'rejected' || !applicable(w.scope, input.model))
|
|
184
|
+
continue;
|
|
185
|
+
const observedAt = w.observedAt ?? usage.observedAt;
|
|
186
|
+
if (observedAt > input.now)
|
|
187
|
+
continue;
|
|
188
|
+
const until = w.resetAt ?? observedAt + REJECTED_BLOCK_MS;
|
|
189
|
+
if (input.now < until)
|
|
190
|
+
blockers.push(until);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
131
193
|
const expiry = usage ? usage.observedAt + (usage.source === 'passive' ? PASSIVE_USAGE_TTL_MS : PROBE_USAGE_TTL_MS) : 0;
|
|
132
194
|
const fresh = usage && usage.observedAt <= input.now && input.now < expiry;
|
|
133
|
-
const windows = fresh ? usage.windows.filter(w => applicable(w.scope, input.model) && (w.resetAt === undefined || w.resetAt > input.now)) : [];
|
|
195
|
+
const windows = fresh ? usage.windows.filter(w => w.status !== 'rejected' && (w.observedAt ?? usage.observedAt) === usage.observedAt && (w.source ?? usage.source) === usage.source && applicable(w.scope, input.model) && (w.resetAt === undefined || w.resetAt > input.now)) : [];
|
|
134
196
|
const uncertain = windows.some(w => w.scope === 'uncertain' || (w.scope !== 'global' && !family(input.model)));
|
|
135
197
|
for (const w of windows) {
|
|
136
198
|
if (w.percent !== undefined && w.percent >= (account.useFullQuota ? 100 : USAGE_THRESHOLD_PERCENT))
|
|
@@ -140,14 +202,14 @@ export function selectSubscriptionAccount(input) {
|
|
|
140
202
|
deadlines.push(Math.max(...blockers));
|
|
141
203
|
}
|
|
142
204
|
else if (windows.length && !uncertain && windows.every(w => w.percent !== undefined && w.resetAt !== undefined)) {
|
|
143
|
-
known.push({ account,
|
|
205
|
+
known.push({ account, ...scoreHeadroom(windows, account.useFullQuota ? 100 : USAGE_THRESHOLD_PERCENT, input.now), source: usage.source });
|
|
144
206
|
}
|
|
145
207
|
else {
|
|
146
208
|
unknown.push({ account, uncertainApplicability: uncertain });
|
|
147
209
|
}
|
|
148
210
|
}
|
|
149
211
|
const compare = (a, b) => a.accountId < b.accountId ? -1 : a.accountId > b.accountId ? 1 : 0;
|
|
150
|
-
known.sort((a, b) =>
|
|
212
|
+
known.sort((a, b) => b.score - a.score || Number(b.account.accountId === input.currentAccountId) - Number(a.account.accountId === input.currentAccountId) || compare(a.account, b.account));
|
|
151
213
|
unknown.sort((a, b) => compare(a.account, b.account));
|
|
152
214
|
if (known[0])
|
|
153
215
|
return { kind: 'selected', provenance: 'known', ...known[0] };
|
|
@@ -9,6 +9,8 @@ export interface ProbeObservation {
|
|
|
9
9
|
usage?: UsageObservation;
|
|
10
10
|
}
|
|
11
11
|
export interface StoredAccountUsage {
|
|
12
|
+
/** Latest measured snapshot plus active rejected windows, persisted across observations. */
|
|
13
|
+
usage?: UsageObservation;
|
|
12
14
|
passive?: {
|
|
13
15
|
rateLimitInfo: ClaudeRateLimitInfo;
|
|
14
16
|
observedAt: number;
|
|
@@ -2,12 +2,16 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { join, resolve } from 'node:path';
|
|
4
4
|
import { parseClaudeRateLimitInfo } from './claude-rate-limit.js';
|
|
5
|
-
import { normalizePassiveUsage, record, reactiveEligibleAt
|
|
5
|
+
import { normalizePassiveUsage, mergeUsageObservations, record, reactiveEligibleAt } from './subscription-selection.js';
|
|
6
6
|
export function usageStateForSelection(state, now) {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
return {
|
|
8
|
+
usage: mergeUsageObservations([
|
|
9
|
+
state.usage,
|
|
10
|
+
state.passive ? normalizePassiveUsage(state.passive.rateLimitInfo, state.passive.observedAt) : undefined,
|
|
11
|
+
state.probe?.status === 'ok' ? state.probe.usage : undefined,
|
|
12
|
+
], now),
|
|
13
|
+
reactive: state.reactive,
|
|
14
|
+
};
|
|
11
15
|
}
|
|
12
16
|
export class SubscriptionUsageStoreError extends Error {
|
|
13
17
|
constructor() { super('Subscription usage store unavailable'); this.name = 'SubscriptionUsageStoreError'; }
|
|
@@ -51,6 +55,28 @@ export function safeQuota(quota) {
|
|
|
51
55
|
...(Number.isFinite(reset) && reset > 0 ? { resetAt: new Date(reset).toISOString() } : {}),
|
|
52
56
|
};
|
|
53
57
|
}
|
|
58
|
+
function safeUsage(value, source) {
|
|
59
|
+
const usage = record(value);
|
|
60
|
+
if (typeof usage['observedAt'] !== 'number' || !Array.isArray(usage['windows']))
|
|
61
|
+
throw new SubscriptionUsageStoreError();
|
|
62
|
+
validTime(usage['observedAt']);
|
|
63
|
+
const windows = usage['windows'].slice(0, 133).map(raw => {
|
|
64
|
+
const w = record(raw);
|
|
65
|
+
const window = w['window'] === 'five_hour' ? 'five_hour' : 'seven_day';
|
|
66
|
+
const scope = typeof w['scope'] === 'string' && ['global', 'sonnet', 'opus', 'haiku', 'fable'].includes(w['scope']) ? w['scope'] : 'uncertain';
|
|
67
|
+
const percent = typeof w['percent'] === 'number' && Number.isFinite(w['percent']) && w['percent'] >= 0 && w['percent'] <= 100 ? w['percent'] : undefined;
|
|
68
|
+
const resetAt = typeof w['resetAt'] === 'number' && Number.isFinite(w['resetAt']) && w['resetAt'] > 0 && w['resetAt'] <= 253402300799000 ? w['resetAt'] : undefined;
|
|
69
|
+
const status = w['status'] === 'allowed' || w['status'] === 'allowed_warning' || w['status'] === 'rejected' ? w['status'] : undefined;
|
|
70
|
+
const observedAt = typeof w['observedAt'] === 'number' ? w['observedAt'] : undefined;
|
|
71
|
+
if (observedAt !== undefined)
|
|
72
|
+
validTime(observedAt);
|
|
73
|
+
const source = w['source'] === 'passive' || w['source'] === 'probe' ? w['source'] : undefined;
|
|
74
|
+
return { window, scope, percent, resetAt, ...(status ? { status } : {}), ...(observedAt ? { observedAt } : {}), ...(source ? { source } : {}) };
|
|
75
|
+
});
|
|
76
|
+
if (usage['windows'].length > 133)
|
|
77
|
+
windows.push({ window: 'seven_day', scope: 'uncertain' });
|
|
78
|
+
return { source, observedAt: usage['observedAt'], windows };
|
|
79
|
+
}
|
|
54
80
|
function safeProbe(value) {
|
|
55
81
|
const obj = record(value);
|
|
56
82
|
const status = obj['status'];
|
|
@@ -62,21 +88,7 @@ function safeProbe(value) {
|
|
|
62
88
|
validTime(observedAt);
|
|
63
89
|
const result = { status: status, observedAt };
|
|
64
90
|
if (status === 'ok' && obj['usage']) {
|
|
65
|
-
|
|
66
|
-
if (typeof usage['observedAt'] !== 'number' || !Array.isArray(usage['windows']))
|
|
67
|
-
throw new SubscriptionUsageStoreError();
|
|
68
|
-
validTime(usage['observedAt']);
|
|
69
|
-
const windows = usage['windows'].slice(0, 133).map(raw => {
|
|
70
|
-
const w = record(raw);
|
|
71
|
-
const window = w['window'] === 'five_hour' ? 'five_hour' : 'seven_day';
|
|
72
|
-
const scope = typeof w['scope'] === 'string' && ['global', 'sonnet', 'opus', 'haiku', 'fable'].includes(w['scope']) ? w['scope'] : 'uncertain';
|
|
73
|
-
const percent = typeof w['percent'] === 'number' && Number.isFinite(w['percent']) && w['percent'] >= 0 && w['percent'] <= 100 ? w['percent'] : undefined;
|
|
74
|
-
const resetAt = typeof w['resetAt'] === 'number' && Number.isFinite(w['resetAt']) && w['resetAt'] > 0 && w['resetAt'] <= 253402300799000 ? w['resetAt'] : undefined;
|
|
75
|
-
return { window, scope, percent, resetAt };
|
|
76
|
-
});
|
|
77
|
-
if (usage['windows'].length > 133)
|
|
78
|
-
windows.push({ window: 'seven_day', scope: 'uncertain' });
|
|
79
|
-
result.usage = { source: 'probe', observedAt: usage['observedAt'], windows };
|
|
91
|
+
result.usage = safeUsage(obj['usage'], 'probe');
|
|
80
92
|
}
|
|
81
93
|
return result;
|
|
82
94
|
}
|
|
@@ -88,6 +100,9 @@ function safeVersion(value) {
|
|
|
88
100
|
}
|
|
89
101
|
if (value.probe)
|
|
90
102
|
state.probe = safeProbe(value.probe);
|
|
103
|
+
if (value.usage) {
|
|
104
|
+
state.usage = safeUsage(value.usage, value.usage.source === 'passive' ? 'passive' : 'probe');
|
|
105
|
+
}
|
|
91
106
|
return state;
|
|
92
107
|
}
|
|
93
108
|
function safeReactive(value) {
|
|
@@ -158,7 +173,9 @@ export class FileSubscriptionUsageStore {
|
|
|
158
173
|
const state = safeVersion(await this.#load(path, {}));
|
|
159
174
|
if (state.passive && state.passive.observedAt > observedAt && !options?.expireFutureObservations)
|
|
160
175
|
return;
|
|
176
|
+
const previous = usageStateForSelection({ ...state, reactive: [] }, observedAt).usage;
|
|
161
177
|
state.passive = { rateLimitInfo: safeRateLimitInfo(rateLimitInfo), observedAt };
|
|
178
|
+
state.usage = mergeUsageObservations([previous, normalizePassiveUsage(state.passive.rateLimitInfo, observedAt)], observedAt);
|
|
162
179
|
await this.#save(path, state);
|
|
163
180
|
});
|
|
164
181
|
}
|
|
@@ -216,7 +233,11 @@ export class FileSubscriptionUsageStore {
|
|
|
216
233
|
const path = this.#path(key, true);
|
|
217
234
|
await serial(path, async () => {
|
|
218
235
|
const state = safeVersion(await this.#load(path, {}));
|
|
236
|
+
// Network completion order must not discard a newer stored snapshot.
|
|
237
|
+
const mergeAt = Math.max(observation.observedAt, state.usage?.observedAt ?? 0, state.passive?.observedAt ?? 0);
|
|
238
|
+
const previous = usageStateForSelection({ ...state, reactive: [] }, mergeAt).usage;
|
|
219
239
|
state.probe = safeProbe(observation);
|
|
240
|
+
state.usage = mergeUsageObservations([previous, state.probe.usage], mergeAt);
|
|
220
241
|
await this.#save(path, state);
|
|
221
242
|
});
|
|
222
243
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface ThreadRouting {
|
|
2
|
+
workspaceId: string;
|
|
3
|
+
channelId: string;
|
|
4
|
+
brainId?: string;
|
|
5
|
+
/** Historical evidence only. Recovery always derives the current directory. */
|
|
6
|
+
directory: string;
|
|
7
|
+
threadId?: string;
|
|
8
|
+
processedDropIds?: string[];
|
|
9
|
+
}
|
|
10
|
+
/** Poller state survives inbox pruning and process restarts; contains no credentials. */
|
|
11
|
+
export declare function saveThreadRouting(threadId: string, routing: ThreadRouting): void;
|
|
12
|
+
export declare function readThreadRouting(threadId: string): ThreadRouting | undefined;
|
|
13
|
+
export declare function inboxDropId(threadId: string, filename: string, content: string | Uint8Array): string;
|
|
14
|
+
export declare function recordProcessedDrop(threadId: string, filename: string, content: string | Uint8Array): void;
|
|
15
|
+
export interface StoredThreadRouting {
|
|
16
|
+
key: string;
|
|
17
|
+
threadId?: string;
|
|
18
|
+
modifiedAt: number;
|
|
19
|
+
}
|
|
20
|
+
/** Older records have no threadId; discovery can match their hashed file name. */
|
|
21
|
+
export declare function listThreadRoutings(): StoredThreadRouting[];
|
|
22
|
+
export declare function threadRoutingKey(threadId: string): string;
|
|
23
|
+
export declare function removeThreadRouting(key: string): void;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { config } from './config.js';
|
|
5
|
+
function routingPath(threadId) {
|
|
6
|
+
return join(config.TEAMVIBE_DATA_DIR, 'thread-routing', threadRoutingKey(threadId));
|
|
7
|
+
}
|
|
8
|
+
/** Poller state survives inbox pruning and process restarts; contains no credentials. */
|
|
9
|
+
export function saveThreadRouting(threadId, routing) {
|
|
10
|
+
mkdirSync(join(config.TEAMVIBE_DATA_DIR, 'thread-routing'), { recursive: true, mode: 0o700 });
|
|
11
|
+
const path = routingPath(threadId);
|
|
12
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
13
|
+
try {
|
|
14
|
+
writeFileSync(temporary, JSON.stringify({ ...readThreadRouting(threadId), ...routing, threadId, updatedAt: Date.now() }), { mode: 0o600 });
|
|
15
|
+
renameSync(temporary, path);
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
rmSync(temporary, { force: true });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function readThreadRouting(threadId) {
|
|
22
|
+
try {
|
|
23
|
+
const routing = JSON.parse(readFileSync(routingPath(threadId), 'utf8'));
|
|
24
|
+
if (typeof routing.workspaceId !== 'string' || typeof routing.channelId !== 'string' || typeof routing.directory !== 'string' || routing.brainId !== undefined && typeof routing.brainId !== 'string')
|
|
25
|
+
return undefined;
|
|
26
|
+
if (routing.processedDropIds !== undefined && (!Array.isArray(routing.processedDropIds) || !routing.processedDropIds.every((id) => typeof id === 'string')))
|
|
27
|
+
return undefined;
|
|
28
|
+
return routing;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function inboxDropId(threadId, filename, content) {
|
|
35
|
+
return createHash('sha256').update(JSON.stringify([threadId, filename, createHash('sha256').update(content).digest('hex')])).digest('hex');
|
|
36
|
+
}
|
|
37
|
+
export function recordProcessedDrop(threadId, filename, content) {
|
|
38
|
+
const routing = readThreadRouting(threadId);
|
|
39
|
+
if (!routing)
|
|
40
|
+
return;
|
|
41
|
+
const id = inboxDropId(threadId, filename, content);
|
|
42
|
+
saveThreadRouting(threadId, { ...routing, processedDropIds: [...new Set([...(routing.processedDropIds ?? []), id])] });
|
|
43
|
+
}
|
|
44
|
+
/** Older records have no threadId; discovery can match their hashed file name. */
|
|
45
|
+
export function listThreadRoutings() {
|
|
46
|
+
const root = join(config.TEAMVIBE_DATA_DIR, 'thread-routing');
|
|
47
|
+
let names;
|
|
48
|
+
try {
|
|
49
|
+
names = readdirSync(root);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
return names.filter(name => /^[a-f0-9]{64}\.json$/.test(name)).flatMap(name => {
|
|
55
|
+
try {
|
|
56
|
+
const path = join(root, name);
|
|
57
|
+
let modifiedAt = statSync(path).mtimeMs;
|
|
58
|
+
let threadId;
|
|
59
|
+
try {
|
|
60
|
+
const value = JSON.parse(readFileSync(path, 'utf8'));
|
|
61
|
+
if (typeof value.updatedAt === 'number' && Number.isFinite(value.updatedAt))
|
|
62
|
+
modifiedAt = value.updatedAt;
|
|
63
|
+
if (typeof value.threadId === 'string' && routingPath(value.threadId) === path)
|
|
64
|
+
threadId = value.threadId;
|
|
65
|
+
}
|
|
66
|
+
catch { /* Corrupt records still expire; match any drops by the hash. */ }
|
|
67
|
+
return [{ key: name, threadId, modifiedAt }];
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
export function threadRoutingKey(threadId) {
|
|
75
|
+
return `${createHash('sha256').update(threadId).digest('hex')}.json`;
|
|
76
|
+
}
|
|
77
|
+
export function removeThreadRouting(key) {
|
|
78
|
+
if (!/^[a-f0-9]{64}\.json$/.test(key))
|
|
79
|
+
throw new Error('Invalid routing key');
|
|
80
|
+
rmSync(join(config.TEAMVIBE_DATA_DIR, 'thread-routing', key), { force: true });
|
|
81
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { UsageProbeMessage } from './usage-probe-contract.js';
|
|
2
|
+
export type { UsageProbeMessage, UsageProbeFailureReason } from './usage-probe-contract.js';
|
|
1
3
|
export interface ControlMessage {
|
|
2
4
|
id: string;
|
|
3
5
|
source: 'control';
|
|
@@ -69,6 +71,7 @@ export interface TeamVibeQueueMessage {
|
|
|
69
71
|
export interface SubscriptionPin {
|
|
70
72
|
inboxFilename?: string;
|
|
71
73
|
resumePending?: boolean;
|
|
74
|
+
/** Comparison evidence only; replay derives execution paths from current routing. */
|
|
72
75
|
effectiveCwd?: string;
|
|
73
76
|
claudeConfigDir?: string;
|
|
74
77
|
sawSendMessageCall?: boolean;
|
|
@@ -92,7 +95,7 @@ export interface SessionRecord {
|
|
|
92
95
|
last_message_ts?: string;
|
|
93
96
|
ttl: number;
|
|
94
97
|
}
|
|
95
|
-
export type SessionStatus = 'processing' | 'idle' | 'waiting_for_input';
|
|
98
|
+
export type SessionStatus = 'processing' | 'idle' | 'waiting_for_input' | 'completed' | 'failed' | 'aborted';
|
|
96
99
|
export interface SessionMetrics {
|
|
97
100
|
/** Input occupancy of the last assistant message, independent of run totals. */
|
|
98
101
|
context_tokens?: number;
|
|
@@ -107,12 +110,3 @@ export interface SessionMetrics {
|
|
|
107
110
|
cache_creation_input_tokens?: number;
|
|
108
111
|
model?: string;
|
|
109
112
|
}
|
|
110
|
-
export interface UsageProbeMessage {
|
|
111
|
-
kind: 'usage_probe';
|
|
112
|
-
id: string;
|
|
113
|
-
workspaceId: string;
|
|
114
|
-
accountId: string;
|
|
115
|
-
credentialVersion: string;
|
|
116
|
-
requestId: string;
|
|
117
|
-
expiresAt: number;
|
|
118
|
-
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Owner-facing account policy location. */
|
|
2
|
-
export declare const FULL_QUOTA_SETTINGS_PATH = "
|
|
2
|
+
export declare const FULL_QUOTA_SETTINGS_PATH = "Connections (account menu \u2192 Use until exhausted)";
|
|
3
3
|
export interface UsageResetTiming {
|
|
4
4
|
resetAt?: number;
|
|
5
5
|
now?: number;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Owner-facing account policy location. */
|
|
2
|
-
export const FULL_QUOTA_SETTINGS_PATH = '
|
|
2
|
+
export const FULL_QUOTA_SETTINGS_PATH = 'Connections (account menu → Use until exhausted)';
|
|
3
3
|
export function formatUsageReset(input) {
|
|
4
4
|
if (input.resetAt === undefined || !Number.isFinite(input.resetAt))
|
|
5
5
|
return undefined;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Shared wire contract for request-scoped usage probes. */
|
|
2
|
+
export declare const ACCOUNT_PROBE_FAILURE_REASONS: readonly ["account_changed", "account_disabled", "account_missing"];
|
|
3
|
+
export declare const USAGE_PROBE_FAILURE_REASONS: readonly ["account_changed", "account_disabled", "account_missing", "token_rejected", "cli_at_capacity", "timeout", "unknown"];
|
|
4
|
+
export type AccountProbeFailureReason = (typeof ACCOUNT_PROBE_FAILURE_REASONS)[number];
|
|
5
|
+
export type UsageProbeFailureReason = (typeof USAGE_PROBE_FAILURE_REASONS)[number];
|
|
6
|
+
export declare const USAGE_UPDATE_TTL_MS: number;
|
|
7
|
+
export interface UsageProbeMessage {
|
|
8
|
+
kind: 'usage_probe';
|
|
9
|
+
id: string;
|
|
10
|
+
workspaceId: string;
|
|
11
|
+
accountId: string;
|
|
12
|
+
credentialVersion: string;
|
|
13
|
+
requestId: string;
|
|
14
|
+
expiresAt: number;
|
|
15
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Shared wire contract for request-scoped usage probes. */
|
|
2
|
+
export const ACCOUNT_PROBE_FAILURE_REASONS = [
|
|
3
|
+
'account_changed',
|
|
4
|
+
'account_disabled',
|
|
5
|
+
'account_missing',
|
|
6
|
+
];
|
|
7
|
+
export const USAGE_PROBE_FAILURE_REASONS = [
|
|
8
|
+
...ACCOUNT_PROBE_FAILURE_REASONS,
|
|
9
|
+
'token_rejected',
|
|
10
|
+
'cli_at_capacity',
|
|
11
|
+
'timeout',
|
|
12
|
+
'unknown',
|
|
13
|
+
];
|
|
14
|
+
export const USAGE_UPDATE_TTL_MS = 10 * 60_000;
|