agent-dealer 1.2.1 → 1.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundle/server/dist/adapters/linear-inbox.js +9 -5
- package/bundle/server/dist/capacity/adapter.js +2 -0
- package/bundle/server/dist/capacity/claude-events.js +45 -10
- package/bundle/server/dist/capacity/claude-events.test.js +7 -0
- package/bundle/server/dist/capacity/claude-local-cache.js +788 -0
- package/bundle/server/dist/capacity/claude-local-cache.test.js +520 -0
- package/bundle/server/dist/capacity/codex-app-server.js +100 -10
- package/bundle/server/dist/capacity/codex-app-server.test.js +195 -2
- package/bundle/server/dist/capacity/cursor-individual-credentials.js +425 -0
- package/bundle/server/dist/capacity/cursor-individual-credentials.test.js +369 -0
- package/bundle/server/dist/capacity/cursor-individual.js +1037 -0
- package/bundle/server/dist/capacity/cursor-individual.test.js +1081 -0
- package/bundle/server/dist/capacity/muse-host.js +748 -0
- package/bundle/server/dist/capacity/muse-host.test.js +480 -0
- package/bundle/server/dist/capacity/muse-lifecycle.test.js +97 -0
- package/bundle/server/dist/capacity/muse.js +389 -91
- package/bundle/server/dist/capacity/muse.test.js +100 -61
- package/bundle/server/dist/capacity/service.js +1 -0
- package/bundle/server/dist/coordinator/muse-spawn.js +143 -1
- package/bundle/server/dist/db/index.js +30 -0
- package/bundle/server/dist/db/schema.sql +27 -0
- package/bundle/server/dist/index.js +17 -2
- package/bundle/server/dist/repository/cursor-individual-billing.js +84 -0
- package/bundle/server/dist/repository/repository-mappings.js +46 -0
- package/bundle/server/dist/repository/repository-mappings.test.js +83 -0
- package/bundle/server/dist/repository/runtime-capacity.js +8 -3
- package/bundle/server/dist/repository/runtime-capacity.test.js +4 -0
- package/bundle/server/dist/routes/cursor-individual-billing.test.js +80 -0
- package/bundle/server/dist/routes/index.js +58 -9
- package/bundle/server/dist/routes/intake-linear.test.js +55 -1
- package/bundle/server/dist/routes/repository-mappings.js +22 -0
- package/bundle/server/dist/routes/repository-mappings.test.js +110 -0
- package/bundle/server/dist/routes/runtime-capacity.test.js +84 -8
- package/bundle/server/dist/runners/muse-code-jsonl.js +7 -1
- package/bundle/server/dist/runners/muse-serve-session.js +313 -0
- package/bundle/server/dist/runners/muse-serve-session.test.js +325 -0
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/codex-YZSpL-SB.png +0 -0
- package/bundle/server/static-ui/assets/index-CYqRh_-S.css +1 -0
- package/bundle/server/static-ui/assets/index-UO4lHZw4.js +60 -0
- package/bundle/server/static-ui/assets/muse-CtdcC6ve.svg +33 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/index.d.ts +6 -6
- package/bundle/shared/dist/linear-intake.d.ts +71 -0
- package/bundle/shared/dist/linear-intake.js +90 -0
- package/bundle/shared/dist/linear-intake.test.js +80 -1
- package/bundle/shared/dist/runtime-capacity.d.ts +89 -0
- package/bundle/shared/dist/runtime-capacity.js +60 -0
- package/bundle/shared/dist/runtime-capacity.test.js +1 -0
- package/bundle/shared/package.json +1 -1
- package/dist/doctor.d.ts +57 -0
- package/dist/doctor.js +211 -0
- package/dist/doctor.test.d.ts +1 -0
- package/dist/doctor.test.js +246 -0
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-ChnI0F02.css +0 -1
- package/bundle/server/static-ui/assets/index-CyB1573Q.js +0 -60
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
// packages/server/src/capacity/claude-local-cache.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-268: Claude account capacity — local-first source ladder with a
|
|
4
|
+
// one-hour paid fallback.
|
|
5
|
+
//
|
|
6
|
+
// Goal: keep Claude 5H/1W useful between Dealer runs. Production's last
|
|
7
|
+
// Dealer-observed sample can be days old (no recent Dealer-managed Claude
|
|
8
|
+
// run), while Claude Code itself maintains exact provider usage in the
|
|
9
|
+
// `cachedUsageUtilization` key of `~/.claude.json` on every run — including
|
|
10
|
+
// interactive runs outside Dealer. So the freshest valid observation wins
|
|
11
|
+
// from this ladder:
|
|
12
|
+
//
|
|
13
|
+
// 1. Existing Dealer `rate_limit_event` ingestion (claude-events.ts — kept
|
|
14
|
+
// as-is, session-end, per-window newer-wins).
|
|
15
|
+
// 2. Read-only local cache: the `cachedUsageUtilization` subtree of
|
|
16
|
+
// `~/.claude.json` (this module — free, ingested on every capacity
|
|
17
|
+
// read; only that subtree is ever parsed, the rest of the config —
|
|
18
|
+
// accountUuid, email, credentials, projects — is never retained).
|
|
19
|
+
// 3. One minimal bounded paid probe, only when every valid 5H/1W
|
|
20
|
+
// observation is older than 60 minutes AND the explicit opt-in
|
|
21
|
+
// `AGENT_DEALER_CLAUDE_CAPACITY_REFRESH=paid-after-1h` is set.
|
|
22
|
+
//
|
|
23
|
+
// Both file sources write the same `claude_unified_five_hour` /
|
|
24
|
+
// `claude_unified_seven_day` window keys through the shared
|
|
25
|
+
// `recordClaudeWindowReadings` newer-wins path, so freshness arbitration is
|
|
26
|
+
// automatic per window and an older cache can never clobber a newer event
|
|
27
|
+
// (or vice versa). `source` stays `observed_event` for both; `evidenceRef`
|
|
28
|
+
// tells them apart (`claude-session:unified-windows` vs
|
|
29
|
+
// `claude-cache:cachedUsageUtilization` vs `claude-probe:minimal-print`).
|
|
30
|
+
//
|
|
31
|
+
// Local-cache safety: only the `cachedUsageUtilization` subtree
|
|
32
|
+
// (`fetchedAtMs`, `utilization.five_hour`, `utilization.seven_day`,
|
|
33
|
+
// `utilization.limits[]`) is ever read. `accountUuid`, email, credentials,
|
|
34
|
+
// extra-usage spend, experiments, and the full raw object are never
|
|
35
|
+
// persisted, returned, or logged. Only the account-wide five-hour and
|
|
36
|
+
// seven-day windows are normalized — every other bucket is dropped.
|
|
37
|
+
//
|
|
38
|
+
// Observed provider shapes (verified against a real `~/.claude.json` at
|
|
39
|
+
// 2.1.283 — the cache is percent-scale, not 0–1 fractions):
|
|
40
|
+
// - Named fields: `{ utilization: <0–100 percent>, resets_at: <ISO-8601> }`.
|
|
41
|
+
// Bare `utilization` ≤ 1 still reads as a fraction (compatibility); values
|
|
42
|
+
// in (1, 100] read as percent. Anything else is malformed — rejected.
|
|
43
|
+
// - `limits[]`: `{ kind|group: "session"|"weekly_all", percent: <0–100>,
|
|
44
|
+
// resets_at: <ISO-8601>, ... }`. Model-specific and overage entries are
|
|
45
|
+
// dropped — only the account-wide pair is ever normalized.
|
|
46
|
+
//
|
|
47
|
+
// Probe argv (verified live at 2.1.283 — `claude -p --max-turns 1 --model
|
|
48
|
+
// <bogus>` parses flags and fails only on model resolution, spending
|
|
49
|
+
// nothing, even though `--help` hides the flag):
|
|
50
|
+
// - `--model haiku` — the cheapest supported alias (matches
|
|
51
|
+
// runners/models.ts `haiku` "latest alias").
|
|
52
|
+
// - `--max-turns 1` — hard one-turn bound, belt-and-braces with the
|
|
53
|
+
// structural bound below.
|
|
54
|
+
// - `--tools ""` + `--strict-mcp-config` (with no `--mcp-config`) — no tools,
|
|
55
|
+
// no MCP. With no tools the model cannot continue past its first response,
|
|
56
|
+
// the fixed minimal prompt asks for a single word, and
|
|
57
|
+
// `--max-budget-usd 0.01` hard-caps spend.
|
|
58
|
+
// - `--no-session-persistence` — the probe leaves no resumable session.
|
|
59
|
+
// - `--output-format stream-json` (+ `--verbose`, matching Dealer's own
|
|
60
|
+
// stream-json parsing) so `rate_limit_event`s can be ingested.
|
|
61
|
+
// - `--bare` is deliberately NOT used: it restricts auth to
|
|
62
|
+
// ANTHROPIC_API_KEY/apiKeyHelper and would bypass the account's OAuth
|
|
63
|
+
// login — the probe must bill to the account whose capacity it measures.
|
|
64
|
+
// - Ambient settings are kept (auth must resolve); no worktree is created
|
|
65
|
+
// (cwd is the OS temp dir) and nothing touches Dealer workflow/session
|
|
66
|
+
// rows, worktrees, commits, PRs, or queue events — the probe spawns
|
|
67
|
+
// `claude` directly, never through the coordinator.
|
|
68
|
+
//
|
|
69
|
+
// If a live proof ever shows the minimal probe does not reliably emit 5H/1W
|
|
70
|
+
// (neither in its stream nor via the cache side effect), the probe is a
|
|
71
|
+
// recurring paid no-op: disable the opt-in and revise this ticket instead of
|
|
72
|
+
// shipping it. The `no_windows` diagnostic below exists to make that visible.
|
|
73
|
+
import { spawn } from "node:child_process";
|
|
74
|
+
import fs from "node:fs";
|
|
75
|
+
import os from "node:os";
|
|
76
|
+
import path from "node:path";
|
|
77
|
+
import { getDataDir } from "../db/index.js";
|
|
78
|
+
import { resolveClaudeBin } from "../cli-env.js";
|
|
79
|
+
import { parseNdjson } from "../runners/stream-json.js";
|
|
80
|
+
import { listCapacitySnapshots } from "../repository/runtime-capacity.js";
|
|
81
|
+
import { configuredCapacityRuntimes } from "./service.js";
|
|
82
|
+
import { CLAUDE_RUNTIME, extractClaudeCapacityFromEvents, normalizeClaudeResetsAt, recordClaudeCapacityFromEvents, recordClaudeWindowReadings, } from "./claude-events.js";
|
|
83
|
+
/** Override for the Claude cache file (tests, smoke). */
|
|
84
|
+
export const CLAUDE_CACHE_FILE_ENV = "AGENT_DEALER_CLAUDE_CACHE_FILE";
|
|
85
|
+
/** Paid-fallback opt-in. Any value other than PAID_AFTER_1H disables probing. */
|
|
86
|
+
export const CLAUDE_CAPACITY_REFRESH_ENV = "AGENT_DEALER_CLAUDE_CAPACITY_REFRESH";
|
|
87
|
+
export const CLAUDE_CAPACITY_REFRESH_PAID_VALUE = "paid-after-1h";
|
|
88
|
+
/** Upper bound for one probe spawn (ms). */
|
|
89
|
+
export const CLAUDE_CAPACITY_PROBE_TIMEOUT_ENV = "AGENT_DEALER_CLAUDE_PROBE_TIMEOUT_MS";
|
|
90
|
+
export const CLAUDE_PROBE_TIMEOUT_MS_DEFAULT = 90_000;
|
|
91
|
+
/** Static evidence pointers — never credentials or raw payloads. */
|
|
92
|
+
export const CLAUDE_CACHE_EVIDENCE_REF = "claude-cache:cachedUsageUtilization";
|
|
93
|
+
export const CLAUDE_PROBE_EVIDENCE_REF = "claude-probe:minimal-print";
|
|
94
|
+
/** Cheapest supported model alias for the probe (cf. runners/models.ts). */
|
|
95
|
+
export const CLAUDE_PROBE_MODEL = "haiku";
|
|
96
|
+
/** Fixed minimal prompt — asserted in tests; never interpolated. */
|
|
97
|
+
export const CLAUDE_PROBE_PROMPT = "Reply with exactly: ok";
|
|
98
|
+
/** Hard spend cap for the probe (USD). */
|
|
99
|
+
export const CLAUDE_PROBE_MAX_BUDGET_USD = 0.01;
|
|
100
|
+
/** A 5H/1W observation newer than this suppresses the paid probe. */
|
|
101
|
+
export const CLAUDE_PROBE_STALE_AFTER_MS = 60 * 60 * 1000;
|
|
102
|
+
/** Minimum gap between probe attempts (per account); failures back off. */
|
|
103
|
+
export const CLAUDE_PROBE_ATTEMPT_COOLDOWN_MS = 60 * 60 * 1000;
|
|
104
|
+
export const CLAUDE_PROBE_BACKOFF_CAP_MS = 8 * 60 * 60 * 1000;
|
|
105
|
+
/** Account-wide window identities for the local cache (exact, lowercase). */
|
|
106
|
+
const FIVE_HOUR_LIMIT_NAMES = new Set(["five_hour", "session"]);
|
|
107
|
+
const WEEKLY_LIMIT_NAMES = new Set(["seven_day", "weekly", "weekly_all"]);
|
|
108
|
+
const CACHE_WINDOW_IDENTITIES = {
|
|
109
|
+
five_hour: {
|
|
110
|
+
windowKey: "claude_unified_five_hour",
|
|
111
|
+
providerBucket: "five_hour",
|
|
112
|
+
durationMinutes: 300,
|
|
113
|
+
criticalRole: "five_hour",
|
|
114
|
+
},
|
|
115
|
+
weekly: {
|
|
116
|
+
windowKey: "claude_unified_seven_day",
|
|
117
|
+
providerBucket: "seven_day",
|
|
118
|
+
durationMinutes: 10080,
|
|
119
|
+
criticalRole: "weekly",
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
function asFiniteNumber(value) {
|
|
123
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
124
|
+
}
|
|
125
|
+
function pickNumber(candidates) {
|
|
126
|
+
for (const c of candidates) {
|
|
127
|
+
const n = asFiniteNumber(c);
|
|
128
|
+
if (n !== null)
|
|
129
|
+
return n;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
function pickString(candidates) {
|
|
134
|
+
for (const c of candidates) {
|
|
135
|
+
if (typeof c === "string" && c.length > 0)
|
|
136
|
+
return c;
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Path to the Claude Code config file whose `cachedUsageUtilization` key
|
|
142
|
+
* carries the local 5H/1W observation (override env for tests/smoke points
|
|
143
|
+
* at a fixture file instead). Only that key is ever parsed — see
|
|
144
|
+
* `extractClaudeCacheSubtree`.
|
|
145
|
+
*/
|
|
146
|
+
export function claudeCacheFilePath() {
|
|
147
|
+
const override = process.env[CLAUDE_CACHE_FILE_ENV]?.trim();
|
|
148
|
+
if (override)
|
|
149
|
+
return override;
|
|
150
|
+
return path.join(process.env.HOME ?? os.homedir(), ".claude.json");
|
|
151
|
+
}
|
|
152
|
+
export function claudeProbeTimeoutMs() {
|
|
153
|
+
const raw = process.env[CLAUDE_CAPACITY_PROBE_TIMEOUT_ENV];
|
|
154
|
+
if (raw !== undefined && raw !== "") {
|
|
155
|
+
const n = Number(raw);
|
|
156
|
+
if (Number.isFinite(n) && n > 0)
|
|
157
|
+
return n;
|
|
158
|
+
}
|
|
159
|
+
return CLAUDE_PROBE_TIMEOUT_MS_DEFAULT;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Fixed probe argv. Pinned by tests: the fixed minimal prompt, cheapest
|
|
163
|
+
* model, `--max-turns 1`, no tools, no MCP, no session persistence, stream
|
|
164
|
+
* JSON, ≤$0.01 budget.
|
|
165
|
+
*/
|
|
166
|
+
export function buildClaudeProbeArgv() {
|
|
167
|
+
return [
|
|
168
|
+
"-p",
|
|
169
|
+
CLAUDE_PROBE_PROMPT,
|
|
170
|
+
"--model",
|
|
171
|
+
CLAUDE_PROBE_MODEL,
|
|
172
|
+
"--max-turns",
|
|
173
|
+
"1",
|
|
174
|
+
"--tools",
|
|
175
|
+
"",
|
|
176
|
+
"--strict-mcp-config",
|
|
177
|
+
"--no-session-persistence",
|
|
178
|
+
"--output-format",
|
|
179
|
+
"stream-json",
|
|
180
|
+
"--verbose",
|
|
181
|
+
"--max-budget-usd",
|
|
182
|
+
String(CLAUDE_PROBE_MAX_BUDGET_USD),
|
|
183
|
+
];
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Utilization scale for one cache window entry. Explicit percent spellings
|
|
187
|
+
* (`usedPercent`, `percent`, …) are 0–100. Bare `utilization` (and its
|
|
188
|
+
* `used*` spellings) is dual-scale, matching the observed provider data: a
|
|
189
|
+
* real `~/.claude.json` at 2.1.283 carries percent values there
|
|
190
|
+
* (`{ utilization: 9, … }`), while older shapes carry 0–1 fractions — so
|
|
191
|
+
* ≤ 1 reads as a fraction and (1, 100] reads as percent. Anything else —
|
|
192
|
+
* wrong type, negative, or above 100 on both scales — is malformed and the
|
|
193
|
+
* window is rejected, never guessed.
|
|
194
|
+
*/
|
|
195
|
+
function cacheEntryScale(entry) {
|
|
196
|
+
const usedPercent = pickNumber([
|
|
197
|
+
entry.usedPercent,
|
|
198
|
+
entry.used_percent,
|
|
199
|
+
entry.utilizationPercent,
|
|
200
|
+
entry.utilization_percent,
|
|
201
|
+
entry.percent,
|
|
202
|
+
]);
|
|
203
|
+
if (usedPercent !== null) {
|
|
204
|
+
if (usedPercent < 0 || usedPercent > 100)
|
|
205
|
+
return null;
|
|
206
|
+
return { usedPercent, usedFraction: null };
|
|
207
|
+
}
|
|
208
|
+
// Explicit fraction spellings stay strict 0–1.
|
|
209
|
+
const strictFraction = pickNumber([entry.usedFraction, entry.used_fraction]);
|
|
210
|
+
if (strictFraction !== null) {
|
|
211
|
+
if (strictFraction < 0 || strictFraction > 1)
|
|
212
|
+
return null;
|
|
213
|
+
return { usedPercent: null, usedFraction: strictFraction };
|
|
214
|
+
}
|
|
215
|
+
// Bare `utilization`/`used` is dual-scale (fraction ≤ 1, percent above).
|
|
216
|
+
const usedValue = pickNumber([entry.utilization, entry.used]);
|
|
217
|
+
if (usedValue === null)
|
|
218
|
+
return null;
|
|
219
|
+
if (usedValue < 0 || usedValue > 100)
|
|
220
|
+
return null;
|
|
221
|
+
if (usedValue <= 1)
|
|
222
|
+
return { usedPercent: null, usedFraction: usedValue };
|
|
223
|
+
return { usedPercent: usedValue, usedFraction: null };
|
|
224
|
+
}
|
|
225
|
+
function cacheWindowReading(role, entry, observedAt, observedMs, evidenceRef) {
|
|
226
|
+
const identity = CACHE_WINDOW_IDENTITIES[role];
|
|
227
|
+
let scale = null;
|
|
228
|
+
let resetRaw = null;
|
|
229
|
+
if (typeof entry === "number") {
|
|
230
|
+
const f = asFiniteNumber(entry);
|
|
231
|
+
if (f === null || f < 0 || f > 1)
|
|
232
|
+
return null;
|
|
233
|
+
scale = { usedPercent: null, usedFraction: f };
|
|
234
|
+
}
|
|
235
|
+
else if (entry && typeof entry === "object") {
|
|
236
|
+
const w = entry;
|
|
237
|
+
scale = cacheEntryScale(w);
|
|
238
|
+
if (!scale)
|
|
239
|
+
return null;
|
|
240
|
+
resetRaw =
|
|
241
|
+
w.resets_at ?? w.resetsAt ?? w.reset_at ?? w.resetAt ?? w.resetsAtMs ?? null;
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
const resetAt = normalizeClaudeResetsAt(resetRaw);
|
|
247
|
+
if (resetAt !== null) {
|
|
248
|
+
const resetMs = Date.parse(resetAt);
|
|
249
|
+
// A past reset is never current capacity — reject the window so the
|
|
250
|
+
// last-good row survives instead of being overwritten by expired data.
|
|
251
|
+
if (!Number.isFinite(resetMs) || resetMs <= observedMs)
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
windowKey: identity.windowKey,
|
|
256
|
+
providerBucket: identity.providerBucket,
|
|
257
|
+
durationMinutes: identity.durationMinutes,
|
|
258
|
+
providerLabel: identity.providerBucket,
|
|
259
|
+
usedValue: scale.usedPercent ?? scale.usedFraction,
|
|
260
|
+
usedUnit: scale.usedPercent !== null ? "percent" : "fraction",
|
|
261
|
+
...(scale.usedPercent !== null
|
|
262
|
+
? { usedPercent: scale.usedPercent }
|
|
263
|
+
: { usedFraction: scale.usedFraction }),
|
|
264
|
+
resetAt,
|
|
265
|
+
observedAt,
|
|
266
|
+
source: "observed_event",
|
|
267
|
+
evidenceRef,
|
|
268
|
+
criticalRole: identity.criticalRole,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
function limitEntryRole(name) {
|
|
272
|
+
const b = name.trim().toLowerCase();
|
|
273
|
+
if (FIVE_HOUR_LIMIT_NAMES.has(b))
|
|
274
|
+
return "five_hour";
|
|
275
|
+
if (WEEKLY_LIMIT_NAMES.has(b))
|
|
276
|
+
return "weekly";
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Normalize the `utilization` subtree of `cachedUsageUtilization` into
|
|
281
|
+
* account-wide 5H/1W readings. `fetchedAtMs` is the observed time.
|
|
282
|
+
*
|
|
283
|
+
* - `utilization.limits[]` is preferred when it carries the explicit
|
|
284
|
+
* account-wide windows (`session` → five-hour, `weekly_all` → weekly,
|
|
285
|
+
* named via `kind`/`group`, scaled via `percent`); any other limit entry
|
|
286
|
+
* (model-specific, overage) is dropped — only the account-wide pair is
|
|
287
|
+
* ever normalized.
|
|
288
|
+
* - The named `five_hour` / `seven_day` fields are the compatibility path
|
|
289
|
+
* for whichever role `limits[]` does not cover. Bare `utilization` values
|
|
290
|
+
* are dual-scale (≤ 1 fraction, above percent to 100).
|
|
291
|
+
* - Returns null when nothing usable is present: missing/future
|
|
292
|
+
* `fetchedAtMs`, no account-wide window with a usable scale, or every
|
|
293
|
+
* window rejected (malformed scale, expired reset). Account identity,
|
|
294
|
+
* extra-usage spend, experiments, and the raw object never leave this
|
|
295
|
+
* function.
|
|
296
|
+
*/
|
|
297
|
+
export function parseClaudeCachedUtilization(raw, nowMs = Date.now(), evidenceRef = CLAUDE_CACHE_EVIDENCE_REF) {
|
|
298
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
299
|
+
return null;
|
|
300
|
+
const root = raw;
|
|
301
|
+
const fetchedAtMs = asFiniteNumber(root.fetchedAtMs);
|
|
302
|
+
if (fetchedAtMs === null || fetchedAtMs <= 0 || fetchedAtMs > nowMs)
|
|
303
|
+
return null;
|
|
304
|
+
const observedAt = new Date(fetchedAtMs).toISOString();
|
|
305
|
+
const utilization = root.utilization;
|
|
306
|
+
if (!utilization || typeof utilization !== "object" || Array.isArray(utilization)) {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
const u = utilization;
|
|
310
|
+
const byRole = new Map();
|
|
311
|
+
const consider = (role, entry) => {
|
|
312
|
+
if (byRole.has(role))
|
|
313
|
+
return;
|
|
314
|
+
const reading = cacheWindowReading(role, entry, observedAt, fetchedAtMs, evidenceRef);
|
|
315
|
+
if (reading)
|
|
316
|
+
byRole.set(role, reading);
|
|
317
|
+
};
|
|
318
|
+
// Preferred path: explicit account-wide entries in limits[]. Real
|
|
319
|
+
// entries name their window via `kind`/`group` (`session`, `weekly_all`)
|
|
320
|
+
// and scale via `percent`; the `name`/`window`/… + `utilization` spellings
|
|
321
|
+
// are the compatibility path for older shapes.
|
|
322
|
+
const limits = u.limits;
|
|
323
|
+
if (Array.isArray(limits)) {
|
|
324
|
+
for (const item of limits) {
|
|
325
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
326
|
+
continue;
|
|
327
|
+
const w = item;
|
|
328
|
+
const name = pickString([
|
|
329
|
+
w.name,
|
|
330
|
+
w.window,
|
|
331
|
+
w.bucket,
|
|
332
|
+
w.key,
|
|
333
|
+
w.id,
|
|
334
|
+
w.kind,
|
|
335
|
+
w.group,
|
|
336
|
+
]);
|
|
337
|
+
if (!name)
|
|
338
|
+
continue;
|
|
339
|
+
const role = limitEntryRole(name);
|
|
340
|
+
if (!role)
|
|
341
|
+
continue;
|
|
342
|
+
consider(role, item);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
// Compatibility path: named fields fill whichever role limits[] missed.
|
|
346
|
+
const named = u;
|
|
347
|
+
if (!byRole.has("five_hour") && named.five_hour !== undefined) {
|
|
348
|
+
consider("five_hour", named.five_hour);
|
|
349
|
+
}
|
|
350
|
+
if (!byRole.has("weekly") && named.seven_day !== undefined) {
|
|
351
|
+
consider("weekly", named.seven_day);
|
|
352
|
+
}
|
|
353
|
+
return byRole.size > 0 ? [...byRole.values()] : null;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Extract the `cachedUsageUtilization` subtree from a parsed config file.
|
|
357
|
+
* A bare cache object (no such key — the shape override fixtures use) is
|
|
358
|
+
* accepted as-is so tests can point the override at a minimal file. The
|
|
359
|
+
* caller must drop the input immediately after: siblings carry accountUuid,
|
|
360
|
+
* email, credentials, and projects, none of which may persist.
|
|
361
|
+
*/
|
|
362
|
+
export function extractClaudeCacheSubtree(parsed) {
|
|
363
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
364
|
+
return null;
|
|
365
|
+
const root = parsed;
|
|
366
|
+
const subtree = root.cachedUsageUtilization;
|
|
367
|
+
if (subtree && typeof subtree === "object" && !Array.isArray(subtree))
|
|
368
|
+
return subtree;
|
|
369
|
+
return parsed;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Read-only local-cache read: parse `~/.claude.json`, extract only the
|
|
373
|
+
* `cachedUsageUtilization` subtree, normalize it, and drop everything else.
|
|
374
|
+
* Returns null when the file is missing, unparsable, or carries no usable
|
|
375
|
+
* 5H/1W observation — never throws, never spawns Claude, never spends
|
|
376
|
+
* money.
|
|
377
|
+
*/
|
|
378
|
+
export function readClaudeLocalCache(nowMs = Date.now()) {
|
|
379
|
+
let text;
|
|
380
|
+
try {
|
|
381
|
+
text = fs.readFileSync(claudeCacheFilePath(), "utf8");
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
let parsed;
|
|
387
|
+
try {
|
|
388
|
+
parsed = JSON.parse(text);
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
const windows = parseClaudeCachedUtilization(extractClaudeCacheSubtree(parsed), nowMs);
|
|
394
|
+
if (!windows)
|
|
395
|
+
return null;
|
|
396
|
+
return { runtime: CLAUDE_RUNTIME, windows, unavailable: [] };
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Ingest the local cache through the shared newer-wins path. Returns the
|
|
400
|
+
* persisted window count, or null when no usable observation exists. A stale
|
|
401
|
+
* cache ingests with its true `fetchedAtMs` (read-time rules render it N/A
|
|
402
|
+
* with honest age); an older cache never overwrites a newer event row.
|
|
403
|
+
*/
|
|
404
|
+
export function ingestClaudeLocalCache(nowMs = Date.now()) {
|
|
405
|
+
const result = readClaudeLocalCache(nowMs);
|
|
406
|
+
if (!result)
|
|
407
|
+
return null;
|
|
408
|
+
return recordClaudeWindowReadings(result.windows, CLAUDE_RUNTIME, nowMs);
|
|
409
|
+
}
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
// Paid fallback probe (explicit opt-in only)
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
/** True only under the exact opt-in; any other value disables paid probing. */
|
|
414
|
+
export function isClaudePaidFallbackEnabled() {
|
|
415
|
+
return process.env[CLAUDE_CAPACITY_REFRESH_ENV] === CLAUDE_CAPACITY_REFRESH_PAID_VALUE;
|
|
416
|
+
}
|
|
417
|
+
/** Bounded direct spawn of `claude` — never the coordinator, never a run. */
|
|
418
|
+
export function defaultProbeRunner(bin, argv, opts) {
|
|
419
|
+
return new Promise((resolve) => {
|
|
420
|
+
let child;
|
|
421
|
+
try {
|
|
422
|
+
child = spawn(bin, argv, {
|
|
423
|
+
cwd: opts.cwd,
|
|
424
|
+
env: { ...process.env },
|
|
425
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
catch (err) {
|
|
429
|
+
resolve({
|
|
430
|
+
stdout: "",
|
|
431
|
+
exitCode: null,
|
|
432
|
+
timedOut: false,
|
|
433
|
+
spawnError: err?.code ?? "spawn_failed",
|
|
434
|
+
});
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
const chunks = [];
|
|
438
|
+
let size = 0;
|
|
439
|
+
let settled = false;
|
|
440
|
+
const finish = (out) => {
|
|
441
|
+
if (settled)
|
|
442
|
+
return;
|
|
443
|
+
settled = true;
|
|
444
|
+
clearTimeout(timer);
|
|
445
|
+
try {
|
|
446
|
+
child?.kill();
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
// Already exited — nothing to signal.
|
|
450
|
+
}
|
|
451
|
+
resolve(out);
|
|
452
|
+
};
|
|
453
|
+
const timer = setTimeout(() => {
|
|
454
|
+
finish({ stdout: chunks.join(""), exitCode: null, timedOut: true, spawnError: null });
|
|
455
|
+
}, opts.timeoutMs);
|
|
456
|
+
timer.unref?.();
|
|
457
|
+
child.stdout?.on("data", (buf) => {
|
|
458
|
+
const s = buf.toString();
|
|
459
|
+
// Cap retained output: only rate_limit events are parsed, the probe's
|
|
460
|
+
// own text is never kept, logged, or returned.
|
|
461
|
+
if (size + s.length <= 4 * 1024 * 1024) {
|
|
462
|
+
chunks.push(s);
|
|
463
|
+
size += s.length;
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
child.on("error", (err) => {
|
|
467
|
+
finish({
|
|
468
|
+
stdout: chunks.join(""),
|
|
469
|
+
exitCode: null,
|
|
470
|
+
timedOut: false,
|
|
471
|
+
spawnError: err?.code ?? "spawn_failed",
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
child.on("close", (code) => {
|
|
475
|
+
finish({ stdout: chunks.join(""), exitCode: code, timedOut: false, spawnError: null });
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
function probeCostFromEvents(events) {
|
|
480
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
481
|
+
const e = events[i];
|
|
482
|
+
if (e.type !== "result")
|
|
483
|
+
continue;
|
|
484
|
+
const cost = e.total_cost_usd;
|
|
485
|
+
if (typeof cost === "number" && Number.isFinite(cost) && cost >= 0)
|
|
486
|
+
return cost;
|
|
487
|
+
}
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
function rolesCoveredByReadings(readings) {
|
|
491
|
+
const out = new Set();
|
|
492
|
+
for (const w of readings) {
|
|
493
|
+
if (w.criticalRole === "five_hour" || w.criticalRole === "weekly")
|
|
494
|
+
out.add(w.criticalRole);
|
|
495
|
+
}
|
|
496
|
+
return out;
|
|
497
|
+
}
|
|
498
|
+
function maxReadingObservedMs(readings) {
|
|
499
|
+
let max = null;
|
|
500
|
+
for (const w of readings) {
|
|
501
|
+
if (!w.observedAt)
|
|
502
|
+
continue;
|
|
503
|
+
const ms = Date.parse(w.observedAt);
|
|
504
|
+
if (!Number.isFinite(ms))
|
|
505
|
+
continue;
|
|
506
|
+
max = max === null ? ms : Math.max(max, ms);
|
|
507
|
+
}
|
|
508
|
+
return max;
|
|
509
|
+
}
|
|
510
|
+
/** Operator-safe probe log: static kinds and numbers only. */
|
|
511
|
+
function logProbeOutcome(outcome) {
|
|
512
|
+
console.error(`[claude-capacity] probe ${outcome.ok ? "ok" : `failed: ${outcome.failureKind ?? "unknown"}`} ` +
|
|
513
|
+
`(cost_usd=${outcome.costUsd ?? "unknown"})`);
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Capacity diagnostic log: the actual probe cost/result per attempt, without
|
|
517
|
+
* prompt/output/credentials. Append-only JSON lines under the data dir.
|
|
518
|
+
*/
|
|
519
|
+
export function probeDiagnosticLogPath() {
|
|
520
|
+
return path.join(getDataDir(), "capacity", "claude-probe.log");
|
|
521
|
+
}
|
|
522
|
+
function appendProbeDiagnostic(entry) {
|
|
523
|
+
try {
|
|
524
|
+
const file = probeDiagnosticLogPath();
|
|
525
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
526
|
+
fs.appendFileSync(file, `${JSON.stringify(entry)}\n`);
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
// Diagnostics are advisory — a full disk must not fail the probe path.
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Run one minimal bounded probe and ingest whatever 5H/1W it yields — first
|
|
534
|
+
* the stream's `rate_limit_event`s, then a re-read of the local cache (the
|
|
535
|
+
* probe run itself refreshes Claude's own cache file, so the side effect
|
|
536
|
+
* counts even when the stream carries no windows). Success means the union
|
|
537
|
+
* covers both critical roles. Never throws; never creates Dealer
|
|
538
|
+
* workflow/session rows, worktrees, commits, PRs, or queue events.
|
|
539
|
+
*/
|
|
540
|
+
export async function runClaudeCapacityProbe(nowMs = Date.now(), opts = {}) {
|
|
541
|
+
const startedRealMs = Date.now();
|
|
542
|
+
const bin = opts.bin ?? resolveClaudeBin();
|
|
543
|
+
const argv = buildClaudeProbeArgv();
|
|
544
|
+
const timeoutMs = opts.timeoutMs ?? claudeProbeTimeoutMs();
|
|
545
|
+
const runner = opts.runner ?? defaultProbeRunner;
|
|
546
|
+
// Pre-probe cache age (read BEFORE the spawn): the re-read below only
|
|
547
|
+
// counts toward success when the probe actually refreshed the file
|
|
548
|
+
// (strictly newer observation) — a stale-but-parseable file must not turn
|
|
549
|
+
// a failed probe into a success.
|
|
550
|
+
let preProbeCacheObservedMs = null;
|
|
551
|
+
try {
|
|
552
|
+
preProbeCacheObservedMs = maxReadingObservedMs(readClaudeLocalCache(nowMs)?.windows ?? []);
|
|
553
|
+
}
|
|
554
|
+
catch {
|
|
555
|
+
preProbeCacheObservedMs = null;
|
|
556
|
+
}
|
|
557
|
+
let spawnResult;
|
|
558
|
+
try {
|
|
559
|
+
spawnResult = await runner(bin, argv, { timeoutMs, cwd: os.tmpdir() });
|
|
560
|
+
}
|
|
561
|
+
catch {
|
|
562
|
+
spawnResult = { stdout: "", exitCode: null, timedOut: false, spawnError: "runner_threw" };
|
|
563
|
+
}
|
|
564
|
+
const durationMs = Date.now() - startedRealMs;
|
|
565
|
+
const fail = (failureKind, extra = {}) => {
|
|
566
|
+
const out = {
|
|
567
|
+
ok: false,
|
|
568
|
+
windowsUpdated: [],
|
|
569
|
+
costUsd: null,
|
|
570
|
+
exitCode: spawnResult.exitCode,
|
|
571
|
+
timedOut: spawnResult.timedOut,
|
|
572
|
+
durationMs,
|
|
573
|
+
failureKind,
|
|
574
|
+
...extra,
|
|
575
|
+
};
|
|
576
|
+
logProbeOutcome(out);
|
|
577
|
+
appendProbeDiagnostic({
|
|
578
|
+
ts: new Date(nowMs).toISOString(),
|
|
579
|
+
event: "claude_capacity_probe",
|
|
580
|
+
trigger: "stale_60m",
|
|
581
|
+
model: CLAUDE_PROBE_MODEL,
|
|
582
|
+
budgetUsd: CLAUDE_PROBE_MAX_BUDGET_USD,
|
|
583
|
+
...out,
|
|
584
|
+
});
|
|
585
|
+
return out;
|
|
586
|
+
};
|
|
587
|
+
if (spawnResult.spawnError)
|
|
588
|
+
return fail("spawn");
|
|
589
|
+
if (spawnResult.timedOut)
|
|
590
|
+
return fail("timeout");
|
|
591
|
+
let events = [];
|
|
592
|
+
try {
|
|
593
|
+
events = parseNdjson(spawnResult.stdout);
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
events = [];
|
|
597
|
+
}
|
|
598
|
+
const costUsd = probeCostFromEvents(events);
|
|
599
|
+
// Ingest the stream first (event timestamps clamp to nowMs inside).
|
|
600
|
+
let streamRoles = new Set();
|
|
601
|
+
try {
|
|
602
|
+
const extracted = extractClaudeCapacityFromEvents(events, nowMs, nowMs);
|
|
603
|
+
if (extracted)
|
|
604
|
+
streamRoles = rolesCoveredByReadings(extracted.windows);
|
|
605
|
+
recordClaudeCapacityFromEvents(events, CLAUDE_RUNTIME, nowMs, nowMs);
|
|
606
|
+
}
|
|
607
|
+
catch {
|
|
608
|
+
// A broken stream must not fail the probe path — the cache re-read below
|
|
609
|
+
// may still yield fresh rows.
|
|
610
|
+
}
|
|
611
|
+
// Then the cache side effect: the probe run refreshes Claude's own file.
|
|
612
|
+
// Ingest regardless (newer-wins protects stored rows), but only a
|
|
613
|
+
// strictly newer observation counts toward success. The re-read uses a
|
|
614
|
+
// post-spawn clock: Claude writes `fetchedAtMs` during the probe, so it is
|
|
615
|
+
// later than the pre-spawn `nowMs` and would be rejected as future
|
|
616
|
+
// against the stale stamp.
|
|
617
|
+
const postProbeNowMs = Math.max(nowMs, Date.now());
|
|
618
|
+
let cacheRoles = new Set();
|
|
619
|
+
try {
|
|
620
|
+
const reread = readClaudeLocalCache(postProbeNowMs);
|
|
621
|
+
if (reread) {
|
|
622
|
+
const afterObservedMs = maxReadingObservedMs(reread.windows);
|
|
623
|
+
if (afterObservedMs !== null &&
|
|
624
|
+
(preProbeCacheObservedMs === null || afterObservedMs > preProbeCacheObservedMs)) {
|
|
625
|
+
cacheRoles = rolesCoveredByReadings(reread.windows);
|
|
626
|
+
}
|
|
627
|
+
recordClaudeWindowReadings(reread.windows, CLAUDE_RUNTIME, postProbeNowMs);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
// Advisory — stream coverage below still counts.
|
|
632
|
+
}
|
|
633
|
+
const covered = new Set([...streamRoles, ...cacheRoles]);
|
|
634
|
+
if (spawnResult.exitCode !== 0 && covered.size === 0)
|
|
635
|
+
return fail("nonzero_exit", { costUsd });
|
|
636
|
+
if (!covered.has("five_hour") || !covered.has("weekly")) {
|
|
637
|
+
// The probe spent money but produced no 5H/1W pair — a `no_windows`
|
|
638
|
+
// streak is the signal to disable the opt-in and revise the ticket
|
|
639
|
+
// rather than ship a recurring paid no-op.
|
|
640
|
+
return fail("no_windows", { costUsd });
|
|
641
|
+
}
|
|
642
|
+
const out = {
|
|
643
|
+
ok: true,
|
|
644
|
+
windowsUpdated: [CACHE_WINDOW_IDENTITIES.five_hour.windowKey, CACHE_WINDOW_IDENTITIES.weekly.windowKey],
|
|
645
|
+
costUsd,
|
|
646
|
+
exitCode: spawnResult.exitCode,
|
|
647
|
+
timedOut: false,
|
|
648
|
+
durationMs,
|
|
649
|
+
failureKind: null,
|
|
650
|
+
};
|
|
651
|
+
logProbeOutcome(out);
|
|
652
|
+
appendProbeDiagnostic({
|
|
653
|
+
ts: new Date(nowMs).toISOString(),
|
|
654
|
+
event: "claude_capacity_probe",
|
|
655
|
+
trigger: "stale_60m",
|
|
656
|
+
model: CLAUDE_PROBE_MODEL,
|
|
657
|
+
budgetUsd: CLAUDE_PROBE_MAX_BUDGET_USD,
|
|
658
|
+
...out,
|
|
659
|
+
});
|
|
660
|
+
return out;
|
|
661
|
+
}
|
|
662
|
+
// ---------------------------------------------------------------------------
|
|
663
|
+
// Freshness gate, single-flight, backoff
|
|
664
|
+
// ---------------------------------------------------------------------------
|
|
665
|
+
/**
|
|
666
|
+
* Newest valid account-wide 5H/1W observation (event or cache — both share
|
|
667
|
+
* window keys and critical roles). A row counts when it carries a remaining
|
|
668
|
+
* %, its reset is absent or future, and its observed time is not in the
|
|
669
|
+
* future. Returns null when no such sample exists.
|
|
670
|
+
*/
|
|
671
|
+
export function newestValidClaudeObservationMs(nowMs = Date.now()) {
|
|
672
|
+
let max = null;
|
|
673
|
+
for (const row of listCapacitySnapshots(CLAUDE_RUNTIME)) {
|
|
674
|
+
if (row.criticalRole !== "five_hour" && row.criticalRole !== "weekly")
|
|
675
|
+
continue;
|
|
676
|
+
if (row.remainingPercent === null)
|
|
677
|
+
continue;
|
|
678
|
+
if (row.resetAt !== null) {
|
|
679
|
+
const resetMs = Date.parse(row.resetAt);
|
|
680
|
+
if (!Number.isFinite(resetMs) || resetMs <= nowMs)
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
const observedMs = Date.parse(row.observedAt);
|
|
684
|
+
if (!Number.isFinite(observedMs) || observedMs > nowMs)
|
|
685
|
+
continue;
|
|
686
|
+
max = max === null ? observedMs : Math.max(max, observedMs);
|
|
687
|
+
}
|
|
688
|
+
return max;
|
|
689
|
+
}
|
|
690
|
+
let claudeProbeInFlight = null;
|
|
691
|
+
let lastClaudeProbeAttemptMs = 0;
|
|
692
|
+
let consecutiveClaudeProbeFailures = 0;
|
|
693
|
+
/** Test helper — clear single-flight, attempt, and backoff state. */
|
|
694
|
+
export function resetClaudeCapacityRefreshState() {
|
|
695
|
+
claudeProbeInFlight = null;
|
|
696
|
+
lastClaudeProbeAttemptMs = 0;
|
|
697
|
+
consecutiveClaudeProbeFailures = 0;
|
|
698
|
+
}
|
|
699
|
+
/** Test helper — observe backoff/attempt state without spawning. */
|
|
700
|
+
export function claudeProbeRefreshStateForTests() {
|
|
701
|
+
return {
|
|
702
|
+
lastAttemptMs: lastClaudeProbeAttemptMs,
|
|
703
|
+
consecutiveFailures: consecutiveClaudeProbeFailures,
|
|
704
|
+
inFlight: claudeProbeInFlight !== null,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
function probeCooldownMs() {
|
|
708
|
+
const shift = Math.min(consecutiveClaudeProbeFailures, 3);
|
|
709
|
+
return Math.min(CLAUDE_PROBE_ATTEMPT_COOLDOWN_MS * 2 ** shift, CLAUDE_PROBE_BACKOFF_CAP_MS);
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* On-demand paid fallback: when `claude_code` is configured and no valid
|
|
713
|
+
* 5H/1W observation is newer than 60 minutes, run one minimal bounded probe
|
|
714
|
+
* (single-flight across concurrent readers; at most one attempt per account
|
|
715
|
+
* per 60 minutes, backing off exponentially on failure). Disabled is a
|
|
716
|
+
* strict no-op — no spawn, no spend. Never throws, never touches Dealer
|
|
717
|
+
* workflow/session state or `runtime_availability`.
|
|
718
|
+
*/
|
|
719
|
+
export async function maybeProbeClaudeCapacity(nowMs = Date.now(), opts = {}) {
|
|
720
|
+
try {
|
|
721
|
+
if (!isClaudePaidFallbackEnabled())
|
|
722
|
+
return { probed: false, reason: "disabled" };
|
|
723
|
+
if (!configuredCapacityRuntimes().includes(CLAUDE_RUNTIME)) {
|
|
724
|
+
return { probed: false, reason: "unconfigured" };
|
|
725
|
+
}
|
|
726
|
+
const newest = newestValidClaudeObservationMs(nowMs);
|
|
727
|
+
if (newest !== null && nowMs - newest < CLAUDE_PROBE_STALE_AFTER_MS) {
|
|
728
|
+
return { probed: false, reason: "fresh" };
|
|
729
|
+
}
|
|
730
|
+
// Single-flight first: readers arriving while a probe runs share it
|
|
731
|
+
// rather than hitting the cooldown the owner just stamped.
|
|
732
|
+
if (claudeProbeInFlight) {
|
|
733
|
+
const shared = await claudeProbeInFlight;
|
|
734
|
+
return {
|
|
735
|
+
probed: true,
|
|
736
|
+
reason: "shared",
|
|
737
|
+
ok: shared.ok,
|
|
738
|
+
windowsUpdated: shared.windowsUpdated,
|
|
739
|
+
costUsd: shared.costUsd,
|
|
740
|
+
failureKind: shared.failureKind,
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
if (nowMs - lastClaudeProbeAttemptMs < probeCooldownMs()) {
|
|
744
|
+
return { probed: false, reason: "backoff" };
|
|
745
|
+
}
|
|
746
|
+
lastClaudeProbeAttemptMs = nowMs;
|
|
747
|
+
const run = runClaudeCapacityProbe(nowMs, opts);
|
|
748
|
+
claudeProbeInFlight = run;
|
|
749
|
+
try {
|
|
750
|
+
const result = await run;
|
|
751
|
+
consecutiveClaudeProbeFailures = result.ok ? 0 : consecutiveClaudeProbeFailures + 1;
|
|
752
|
+
return {
|
|
753
|
+
probed: true,
|
|
754
|
+
reason: "completed",
|
|
755
|
+
ok: result.ok,
|
|
756
|
+
windowsUpdated: result.windowsUpdated,
|
|
757
|
+
costUsd: result.costUsd,
|
|
758
|
+
failureKind: result.failureKind,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
finally {
|
|
762
|
+
if (claudeProbeInFlight === run)
|
|
763
|
+
claudeProbeInFlight = null;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
catch {
|
|
767
|
+
return { probed: false, reason: "error" };
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Full on-demand refresh for `GET /api/runtime-capacity`: ingest the free
|
|
772
|
+
* local cache first, then consider the paid probe. Best-effort — never
|
|
773
|
+
* throws. The route serves the stored snapshot regardless.
|
|
774
|
+
*/
|
|
775
|
+
export async function refreshClaudeCapacityIfStale(nowMs = Date.now(), opts = {}) {
|
|
776
|
+
// Skip the file read entirely when no Claude account is configured:
|
|
777
|
+
// ~/.claude.json can be several MB and this runs on every capacity poll.
|
|
778
|
+
if (!configuredCapacityRuntimes().includes(CLAUDE_RUNTIME)) {
|
|
779
|
+
return { probed: false, reason: "unconfigured" };
|
|
780
|
+
}
|
|
781
|
+
try {
|
|
782
|
+
ingestClaudeLocalCache(nowMs);
|
|
783
|
+
}
|
|
784
|
+
catch {
|
|
785
|
+
// Advisory — the probe gate below still applies.
|
|
786
|
+
}
|
|
787
|
+
return maybeProbeClaudeCapacity(nowMs, opts);
|
|
788
|
+
}
|