agent-dag 1.42.0 → 1.44.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.
@@ -48,6 +48,85 @@ let _cacheAt = 0;
48
48
  // tracks what claude-swap is doing. No network cost to amortise.
49
49
  const CACHE_MS = 5_000;
50
50
 
51
+ // ── what a forced read may cost ──────────────────────────────────────────────
52
+ //
53
+ // Until #604 the cache above was the whole of the admission control here, and
54
+ // `force` walked straight past it. `handleClaudeAccounts` reads `refresh=1` off
55
+ // the query string and passes it through, and reads on this server are
56
+ // deliberately open — `isTrustedRead` does not apply the `Sec-Fetch-Site` test
57
+ // that `isTrustedMutation` does, because a cross-site read of
58
+ // `http://127.0.0.1:4317` is an ordinary top-level navigation — so any page the
59
+ // user had open could run a `?refresh=1` loop and get one roster read per
60
+ // request, concurrently.
61
+ //
62
+ // What that buys is the cheapest of the six forcible routes and it is worth
63
+ // saying so plainly rather than dressing it up: two small local JSON reads,
64
+ // sequence.json and cache/usage.json, plus a call into nudgeCollector that is
65
+ // throttled on its own terms and spawns nothing when nothing is due. There is no
66
+ // network here by design — see the note at the top of this file — and no
67
+ // subprocess per request. This is the SHAPE the four routes before it were fixed
68
+ // for, not a cost anyone would have noticed.
69
+ //
70
+ // The reason to fix it anyway is the second half. This is the only one of the
71
+ // six whose cache is invalidated from elsewhere — nine call sites in four
72
+ // modules: six mutations in cswap-admin.mjs, an auto-switch in cswap-auto.mjs, a
73
+ // manual one in index.mjs, and the first `cswap add` below — and #582 has
74
+ // already shown what a read that started before an invalidation does when it
75
+ // lands after one. So the in-flight slot this route was missing arrives with the
76
+ // generation guard that makes it safe, rather than after the next bug report.
77
+ const FORCE_POLL_MS = 60_000;
78
+
79
+ // A read in progress, offered to callers that arrive while it is running.
80
+ let _inflight = null;
81
+ // Stamped when a read STARTS rather than when it lands: what the floor rations
82
+ // is the trip to disk, and one that is still running has already been paid for.
83
+ let _lastReadAt = 0;
84
+ // Which roster the reading below is about — as a counter, because the answer is
85
+ // about whichever account claude-swap's store says is active, and that can move
86
+ // under a read that is already running. invalidateClaudeAccountsCache bumps it;
87
+ // every write is stamped with the value that was current when the read STARTED.
88
+ // quota.mjs's `_generation`, for quota.mjs's reason (#582).
89
+ let _generation = 0;
90
+
91
+ /**
92
+ * Whether we may go to disk for the roster again.
93
+ *
94
+ * The same shape and the same minute as quota.mjs's `maySelfPoll`, and exported
95
+ * for the same reason it is: this is the rule, it is pure, and it belongs
96
+ * somewhere a test can point at it.
97
+ *
98
+ * Two intervals, like `maySelfPoll`'s. A forced read may beat the cache, but not
99
+ * turn into a poll loop when the button is held down, so it takes the minute the
100
+ * other four forcible routes use. An unforced read takes the cache's own
101
+ * interval — it is the panel's ordinary poll, the cache above has already
102
+ * answered it, and measuring from the START of the last read rather than from
103
+ * its end is the only difference between the two rules.
104
+ */
105
+ export function mayReadAccounts({ now, force, lastReadAt }) {
106
+ return now - lastReadAt >= (force ? FORCE_POLL_MS : CACHE_MS);
107
+ }
108
+
109
+ /**
110
+ * The answer to a read the floor refused.
111
+ *
112
+ * A reading, not an error. AccountsPanel renders `data.accounts`, and an
113
+ * `{ ok: false }` refusal would empty the roster for a minute — the deck
114
+ * teaching itself a new failure mode in order to defend against a loop nobody
115
+ * ran. `stale` is the flag quota.mjs, codex-quota.mjs and codex-usage.mjs all
116
+ * use for exactly this, and every account row already carries its own
117
+ * `fetchedAt` from claude-swap's store, which is the age the panel draws and
118
+ * which nothing here touches.
119
+ */
120
+ function heldReading(now) {
121
+ if (_cache) return { ..._cache, stale: true };
122
+ // Unreachable in practice, and spelled the way codex-quota.mjs and
123
+ // codex-usage.mjs spell the same state. Every outcome below is cached, a read
124
+ // still running is served by `_inflight`, and the one moment `_cache` is empty
125
+ // with a recent stamp — just after an invalidation — is exactly the moment
126
+ // invalidateClaudeAccountsCache clears the stamp as well.
127
+ return { ok: false, reason: "waiting", fetchedAt: now };
128
+ }
129
+
51
130
  // Past this, claude-swap's own numbers are old enough that showing them
52
131
  // without a marker would misrepresent them (its own trust ceiling is 3600s).
53
132
  const STALE_AFTER_MS = 15 * 60_000;
@@ -255,8 +334,51 @@ function lane(id, label, win) {
255
334
  export async function fetchClaudeAccounts({ force = false } = {}) {
256
335
  const now = Date.now();
257
336
  if (!force && _cache && now - _cacheAt < CACHE_MS) return _cache;
337
+ // Offered before the floor: a read that has not finished yet is a reading
338
+ // newer than the cache, which is what refresh asked for, and joining it costs
339
+ // nothing.
340
+ if (_inflight) return _inflight;
341
+ if (!mayReadAccounts({ now, force, lastReadAt: _lastReadAt })) return heldReading(now);
342
+ _lastReadAt = now;
343
+
344
+ // `_inflight === mine` rather than a bare clear, which is quota.mjs's guard
345
+ // and is here for quota.mjs's reason: invalidateClaudeAccountsCache drops
346
+ // `_inflight` so the next caller starts a read that knows the roster moved,
347
+ // and that read installs its own promise here. A read from before the switch
348
+ // finishing afterwards would otherwise clear the NEW one on its way out.
349
+ const mine = readRoster(now, _generation)
350
+ .finally(() => { if (_inflight === mine) _inflight = null; });
351
+ _inflight = mine;
352
+ return mine;
353
+ }
258
354
 
259
- const finish = (r) => { _cache = r; _cacheAt = Date.now(); return r; };
355
+ /**
356
+ * The read itself, split out from the admission control above it so the guard is
357
+ * readable as the four lines it is.
358
+ *
359
+ * `gen` is the generation that was current when this read STARTED, and finish()
360
+ * refuses to write anything under it once that has moved.
361
+ *
362
+ * Every write here happens after at least one await, and
363
+ * invalidateClaudeAccountsCache clears variables — which does nothing to a
364
+ * function that is already running and still holds the old roster in a local. So
365
+ * a switch landing mid-read was followed, milliseconds later, by the pre-switch
366
+ * roster being written straight back over the cleared cache: the invalidation
367
+ * looked like it worked and was undone before the next poll could observe it,
368
+ * and the panel went on showing the account the user had just switched away
369
+ * from. That is #582's defect, in the module #582 did not touch.
370
+ *
371
+ * The read is deliberately not cancelled. Whoever asked for it is still owed an
372
+ * answer, and the answer is not wrong — it is about a roster that has since
373
+ * moved, which makes it a fine return value and a bad cached one.
374
+ */
375
+ async function readRoster(now, gen) {
376
+ const finish = (r) => {
377
+ if (gen !== _generation) return r;
378
+ _cache = r;
379
+ _cacheAt = Date.now();
380
+ return r;
381
+ };
260
382
 
261
383
  const root = backupRoot();
262
384
  const seq = await readJson(join(root, "sequence.json"));
@@ -395,9 +517,31 @@ export async function requestCollection() {
395
517
  return _lastNudge !== before;
396
518
  }
397
519
 
520
+ /**
521
+ * Forget the roster, because something just made it wrong.
522
+ *
523
+ * Called from nine places in four modules — every `cswap` mutation the deck
524
+ * performs — and every one of them is behind a POST that `isTrustedMutation`
525
+ * guards, so nothing a page can send in a loop reaches this.
526
+ *
527
+ * Three things go besides the reading itself:
528
+ *
529
+ * `_generation` so a read that STARTED before this call cannot write its
530
+ * answer into the cache afterwards. See finish() in readRoster.
531
+ * `_inflight` so a caller arriving after the switch is not handed the read
532
+ * that began before it — joining a run is only free when the
533
+ * run is still about the right thing.
534
+ * `_lastReadAt` so the very next read is real work rather than a refusal.
535
+ * Every one of these call sites is followed by the panel
536
+ * reloading with ?refresh=1, and a floor that answered THAT
537
+ * with the pre-switch roster would make the guard the bug.
538
+ */
398
539
  export function invalidateClaudeAccountsCache() {
399
540
  _cache = null;
400
541
  _cacheAt = 0;
542
+ _generation++;
543
+ _inflight = null;
544
+ _lastReadAt = 0;
401
545
  }
402
546
 
403
547
  /**
@@ -243,9 +243,84 @@ async function requestUsage(base, auth) {
243
243
  // to race over the single-use refresh token.
244
244
  let _inflight = null;
245
245
 
246
+ // ── what a forced read may cost ────────────────────────────────────────────
247
+ // The floor between two reads WE pay for, and it is the same number and the
248
+ // same rule quota.mjs gives the Claude half — see FORCE_POLL_MS and maySelfPoll
249
+ // there. The two routes are four lines apart in the router and had no business
250
+ // disagreeing about what `?refresh=1` costs.
251
+ //
252
+ // `force` used to mean "skip the cache", and the cache was the ONLY thing
253
+ // between a caller and chatgpt.com. `_inflight` deduplicates callers that
254
+ // overlap and nothing else, so a caller that waits for one fetch to settle and
255
+ // then asks again got a fresh round trip every time — two authenticated HTTPS
256
+ // GETs carrying the user's live ChatGPT session, as fast as the round trip
257
+ // allows, from any page the user happens to have open (#580). Reads on this
258
+ // server are deliberately open (isTrustedRead), so "any page" is the real
259
+ // threat model rather than a hypothetical one.
260
+ //
261
+ // The sharper half is the credential rather than the traffic. On a 401
262
+ // doFetchCodexQuota spends the SINGLE-USE refresh token via forceCodexRefresh,
263
+ // and `staleAccessToken` only stops that happening twice for the same rejected
264
+ // token — every turn re-reads auth.json and sees the token the previous turn
265
+ // rotated to, so a backend that keeps answering 401 rotated a fresh credential
266
+ // once per request, racing the Codex CLI for each one. codex-auth.mjs's own
267
+ // EXPIRY_SKEW_MS comment says what losing that race costs the user: a
268
+ // `refresh_token_reused` that reads as "your login is broken", recoverable only
269
+ // with `codex login`.
270
+ const FORCE_POLL_MS = 60_000;
271
+
272
+ // Set from a 429 or a rejected refresh: a backend that is refusing us must not
273
+ // be asked once per request, whoever is asking. Same shape as quota.mjs's
274
+ // _rateLimitedUntil, which is likewise never beaten by force.
275
+ let _rateLimitedUntil = 0;
276
+ const COOLDOWN_MS = 5 * 60_000;
277
+
278
+ // Stamped when a fetch STARTS rather than when it lands: what the floor is
279
+ // rationing is the round trip, and one that is still in flight has already been
280
+ // paid for.
281
+ let _lastFetchAt = 0;
282
+
283
+ /**
284
+ * Whether we may spend a request of the user's ChatGPT session right now.
285
+ *
286
+ * Exported for tests, for the same reason quota.mjs exports maySelfPoll: this
287
+ * is the rule, it is pure, and it is worth pinning down away from the fetch it
288
+ * guards.
289
+ */
290
+ export function mayFetchQuota({ now, lastFetchAt, rateLimitedUntil }) {
291
+ if (now < rateLimitedUntil) return false;
292
+ return now - lastFetchAt >= FORCE_POLL_MS;
293
+ }
294
+
295
+ /**
296
+ * The answer to a read the floor refused.
297
+ *
298
+ * A reading, not an error — a user who clicks ↻ twice in a second must get the
299
+ * numbers they already have rather than a red hint, which is exactly what
300
+ * quota.mjs does with `{ ...held, stale: true }`. The timestamp stays the one
301
+ * the data was fetched at, so the panel's age label keeps telling the truth
302
+ * instead of vouching for a reading it did not take.
303
+ */
304
+ function heldReading(now) {
305
+ if (_cache) return { ..._cache, stale: true };
306
+ // Only reachable before the first fetch has ever landed — `finish` caches
307
+ // every outcome, failures included — and spelled the way the Claude side
308
+ // spells the same two states.
309
+ return { ok: false, reason: now < _rateLimitedUntil ? "rate_limited" : "waiting", fetchedAt: now };
310
+ }
311
+
246
312
  export function fetchCodexQuota({ force = false } = {}) {
247
- if (!force && _cache && Date.now() - _cacheAt < CACHE_MS) return Promise.resolve(_cache);
248
- _inflight ??= doFetchCodexQuota().finally(() => { _inflight = null; });
313
+ const now = Date.now();
314
+ if (!force && _cache && now - _cacheAt < CACHE_MS) return Promise.resolve(_cache);
315
+ // Joining a run already in flight costs nothing, so it is offered before the
316
+ // floor: what refresh asks for is a reading newer than the cache, and a fetch
317
+ // that has not landed yet is one.
318
+ if (_inflight) return _inflight;
319
+ if (!mayFetchQuota({ now, lastFetchAt: _lastFetchAt, rateLimitedUntil: _rateLimitedUntil })) {
320
+ return Promise.resolve(heldReading(now));
321
+ }
322
+ _lastFetchAt = now;
323
+ _inflight = doFetchCodexQuota().finally(() => { _inflight = null; });
249
324
  return _inflight;
250
325
  }
251
326
 
@@ -256,6 +331,13 @@ async function doFetchCodexQuota() {
256
331
  // shortens the effective TTL for no reason.
257
332
  const finish = (r) => { _cache = r; _cacheAt = Date.now(); return r; };
258
333
  const fail = (reason) => finish({ ok: false, reason, fetchedAt: started });
334
+ // A refusal we were told about, rather than one we inferred: back off further
335
+ // than the ordinary floor before asking again. `retry-after` is honoured when
336
+ // the backend sends one, because it knows better than the constant does.
337
+ const cooldown = (res) => {
338
+ const after = parseInt(res?.headers?.get?.("retry-after") ?? "", 10);
339
+ _rateLimitedUntil = Date.now() + (Number.isFinite(after) ? after * 1000 : COOLDOWN_MS);
340
+ };
259
341
 
260
342
  let auth, base, res;
261
343
  try {
@@ -294,12 +376,22 @@ async function doFetchCodexQuota() {
294
376
  // destroyed.
295
377
  if (res.status === 401) {
296
378
  const refreshed = await forceCodexRefresh(auth.accessToken);
297
- if (!refreshed.ok) return fail(refreshed.reason);
379
+ if (!refreshed.ok) {
380
+ // The credential is gone and only `codex login` brings it back, so
381
+ // rotating another single-use token at the next request would burn the
382
+ // one the CLI is still holding. Wait.
383
+ if (refreshed.reason === "refresh_rejected") cooldown(null);
384
+ return fail(refreshed.reason);
385
+ }
298
386
  auth = refreshed;
299
387
  res = await requestUsage(base, auth);
300
388
  }
301
389
 
302
390
  if (!res.ok) {
391
+ // A second 401 means the token we just rotated to was rejected as well —
392
+ // the case that turned into one rotation per request. 429 is the backend
393
+ // saying the same thing in the ordinary way.
394
+ if (res.status === 401 || res.status === 429) cooldown(res);
303
395
  return fail(res.status === 401 ? "refresh_rejected" : `http_${res.status}`);
304
396
  }
305
397
  } catch (err) {
@@ -12,6 +12,88 @@ let _cache = null;
12
12
  let _cacheAt = 0;
13
13
  const CACHE_MS = 60_000;
14
14
 
15
+ // ── what a forced read may cost ─────────────────────────────────────────────
16
+ // Until #600 the cache above was the whole of the admission control here, and
17
+ // `force` walked straight past it. That made one GET worth a full week of the
18
+ // rollout tree: listRolloutFiles(WINDOW_7D_MS) and then a read of every file it
19
+ // returns. Measured on this repo's machine against 280 rollouts of ~90KB — a
20
+ // week of ordinary use — one forced call is 685ms, 280 file opens and a peak of
21
+ // four descriptors. Nothing bounded how many of those calls ran at once, and
22
+ // the cost scaled exactly linearly: 16 concurrent forced reads were 4,480 opens
23
+ // and 64 descriptors, 128 were 35,840 opens, 512 descriptors and 54.7s.
24
+ //
25
+ // Reads on this server are deliberately open — `isTrustedRead` does not apply
26
+ // the `Sec-Fetch-Site` test that `isTrustedMutation` does, because a cross-site
27
+ // read of `http://127.0.0.1:4317` is an ordinary top-level navigation — so any
28
+ // page the user has open could run
29
+ //
30
+ // for (;;) fetch("http://127.0.0.1:4317/api/codex-usage?refresh=1",
31
+ // { mode: "no-cors" });
32
+ //
33
+ // and get one of those scans per request. MAX_PARALLEL_READS below bounds the
34
+ // fan-out WITHIN one call, and it exists because opening a week of rollouts at
35
+ // once risked EMFILE — which readTokenSeries swallows into `return null`, a
36
+ // silent undercount rather than an error. Unbounded calls let that EMFILE back
37
+ // in through the door the pool does not cover.
38
+ //
39
+ // The two things between a caller and a scan are the ones quota.mjs established
40
+ // and codex-quota.mjs adopted in #597, spelled the same way in all three:
41
+ //
42
+ // _inflight — callers that overlap wait on the one scan already running,
43
+ // `force` included. What ?refresh=1 asks for is a reading
44
+ // newer than the cache, and a scan in progress is one, so
45
+ // joining it costs nothing and is offered before the floor.
46
+ // FORCE_POLL_MS — the minimum interval between two scans WE pay for.
47
+ // `_inflight` deduplicates callers that overlap and nothing
48
+ // else, so a caller that waits for one scan to settle and then
49
+ // asks again was a fresh week of the disk every time.
50
+ //
51
+ // This module has neither of the extra parts its two siblings carry, and
52
+ // deliberately: there is no upstream backend to rate-limit us, so no cooldown,
53
+ // and nothing outside this file invalidates the cache, so no generation guard.
54
+ let _inflight = null;
55
+
56
+ // Stamped when a scan STARTS rather than when it lands: what the floor rations
57
+ // is the walk of the disk, and one that is still running has already been paid
58
+ // for.
59
+ let _lastScanAt = 0;
60
+
61
+ // quota.mjs's number, and codex-quota.mjs's, for the reason those two give it:
62
+ // "The refresh button may beat that floor, but not turn into a poll loop when
63
+ // held down." Three routes within a few lines of each other in the router have
64
+ // no business disagreeing about what `?refresh=1` costs.
65
+ const FORCE_POLL_MS = 60_000;
66
+
67
+ /**
68
+ * Whether we may walk a week of the rollout tree right now.
69
+ *
70
+ * Exported for tests, for the same reason quota.mjs exports `maySelfPoll` and
71
+ * codex-quota.mjs exports `mayFetchQuota`: this is the rule, it is pure, and it
72
+ * is worth pinning down away from the scan it guards.
73
+ */
74
+ export function mayScanUsage({ now, lastScanAt }) {
75
+ return now - lastScanAt >= FORCE_POLL_MS;
76
+ }
77
+
78
+ /**
79
+ * The answer to a read the floor refused.
80
+ *
81
+ * A reading, not an error. The panel draws this number from `codexUsage?.ok &&
82
+ * window7d.sessionCount > 0`, so an `{ ok: false }` refusal would make the token
83
+ * line vanish for a minute — the deck teaching itself a new failure mode in
84
+ * order to defend against a loop nobody ran. `stale` is the flag quota.mjs and
85
+ * codex-quota.mjs both use for exactly this, and `fetchedAt` keeps the moment
86
+ * the DATA was read rather than the moment of the read that was refused, so an
87
+ * age label drawn from it never vouches for a scan that did not happen.
88
+ */
89
+ function heldReading(now) {
90
+ if (_cache) return { ..._cache, stale: true };
91
+ // Only reachable before the first scan has ever landed — every outcome below
92
+ // is cached, failures included, and a scan that is still running is served by
93
+ // `_inflight` — and spelled the way codex-quota.mjs spells the same state.
94
+ return { ok: false, reason: "waiting", fetchedAt: now };
95
+ }
96
+
15
97
  const WINDOW_5H_MS = 5 * 60 * 60 * 1000;
16
98
  const WINDOW_7D_MS = 7 * 24 * 60 * 60 * 1000;
17
99
 
@@ -106,7 +188,16 @@ async function readTokenSeries(filePath) {
106
188
  if (pending) foldTokenLine(series, pending);
107
189
  return series.length ? series : null;
108
190
  } catch { return null; }
109
- finally { fd?.close().catch(() => {}); }
191
+ // AWAITED. A close scheduled and abandoned means this function resolves while
192
+ // the descriptor is still open, and on Windows a file with any handle on it
193
+ // cannot be deleted: the unlink marks it delete-pending, the name stays in
194
+ // the directory, and the next rmdir of the parent fails with ENOTEMPTY. The
195
+ // deck sweeps its own rollout copies, and the suite tears down a sandbox full
196
+ // of them — this is how "the read is finished" stops being a lie about the
197
+ // handle. Awaiting in a finally costs one microtask and cannot change what is
198
+ // returned. Errors stay swallowed: a close that fails is nothing a reader can
199
+ // act on.
200
+ finally { await fd?.close().catch(() => {}); }
110
201
  }
111
202
 
112
203
  // Tokens spent within [windowStartMs, now]: the last cumulative snapshot minus
@@ -197,10 +288,24 @@ function emptyWindow() {
197
288
  return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, totalTokens: 0, sessionCount: 0 };
198
289
  }
199
290
 
200
- export async function fetchCodexUsage({ force = false } = {}) {
291
+ export function fetchCodexUsage({ force = false } = {}) {
201
292
  const now = Date.now();
202
- if (!force && _cache && now - _cacheAt < CACHE_MS) return _cache;
293
+ if (!force && _cache && now - _cacheAt < CACHE_MS) return Promise.resolve(_cache);
294
+ // Offered before the floor: a scan that has not finished yet is a reading
295
+ // newer than the cache, which is what refresh asked for, and joining it costs
296
+ // nothing.
297
+ if (_inflight) return _inflight;
298
+ if (!mayScanUsage({ now, lastScanAt: _lastScanAt })) return Promise.resolve(heldReading(now));
299
+ _lastScanAt = now;
300
+ // A bare clear rather than quota.mjs's `_inflight === mine` check: that guard
301
+ // is there because invalidateQuotaCache drops the slot mid-flight, and this
302
+ // module has no invalidator to race with. If one is ever added, it needs the
303
+ // same check adding with it.
304
+ _inflight = scanCodexUsage(now).finally(() => { _inflight = null; });
305
+ return _inflight;
306
+ }
203
307
 
308
+ async function scanCodexUsage(now) {
204
309
  const w5h = emptyWindow();
205
310
  const w7d = emptyWindow();
206
311
  const start5h = now - WINDOW_5H_MS;