@bridge4dev/runner 0.55.1 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -53,13 +53,134 @@ export declare function parseUsageText(text: string, now?: Date): UsageRow[];
53
53
  * reworded CLI must never be able to move a wake-up — therefore still holds.
54
54
  * `claude-usage.test.ts` pins it.
55
55
  */
56
- export declare function applyUsagePercentages(windows: AgentRateLimitWindow[], rows: UsageRow[]): AgentRateLimitWindow[];
56
+ export declare function applyUsagePercentages(windows: AgentRateLimitWindow[], rows: UsageRow[], options?: {
57
+ onlyFillGaps?: boolean;
58
+ }): AgentRateLimitWindow[];
59
+ /**
60
+ * How long the probe may run before it is written off (#390).
61
+ *
62
+ * Ninety seconds, and the number is measured rather than chosen. `/usage` was
63
+ * 3.7s when the old ceiling of 20s was set on 2026-08-15; it is not that
64
+ * command any more. Since then the CLI prints «What's contributing to your
65
+ * limits usage?», computed from the machine's OWN session history — 1.4 GB and
66
+ * ~4900 files on the busiest dev server — so the work grows with the history
67
+ * and the load rather than with anything we ask for. It is also CPU-bound, not
68
+ * network-bound: 23.7s wall against 32s of CPU.
69
+ *
70
+ * Measured 2026-09-07 on a six-core box under load 4-15:
71
+ *
72
+ * plain, /tmp 17.7s
73
+ * plain, a project checkout 19.0 20.8 22.2 24.0 27.5s
74
+ * in the reporter's own runs 15.0 15.3 16.5 17.1 20.8 23.7 28.3s
75
+ *
76
+ * So 20s was not a margin, it was the middle of the distribution, and a failed
77
+ * probe was an ordinary event rather than an exception. The cost of waiting is
78
+ * nil — the probe is free (0 tokens, 0 turns, 0 dollars, verified 2026-08-15),
79
+ * runs in the background, and since #390 there is at most ONE of it per
80
+ * machine. The cost of NOT waiting was the panel losing two of its three rows.
81
+ */
82
+ export declare const USAGE_PROBE_TIMEOUT_MS = 20000;
83
+ /**
84
+ * How long a successful reading is believed — the throttle for the whole box.
85
+ *
86
+ * Three minutes, unchanged from when this was a per-session throttle. What
87
+ * changed is the scope: five sessions used to keep five independent clocks and
88
+ * run five copies of a 20-second process, all of them measuring the same
89
+ * account. The windows on screen are five hours and a week; three minutes is
90
+ * already far finer than they move.
91
+ */
92
+ export declare const USAGE_CACHE_MS: number;
93
+ /**
94
+ * How long we wait before retrying after a probe that produced nothing.
95
+ *
96
+ * Shorter than the success interval, because a machine that has just failed
97
+ * once is the machine most worth asking again — but not so short that a box
98
+ * with no subscription at all (own API key, Bedrock, Vertex) spends its life
99
+ * spawning CLIs. One at a time is guaranteed by the in-flight join below, so
100
+ * the worst case is a single ~90s process a minute apart, not a pile.
101
+ */
102
+ export declare const USAGE_FAILURE_COOLDOWN_MS: number;
103
+ export type UsageProbeRunner = (binary: string, cwd: string) => Promise<string | null>;
57
104
  /**
58
105
  * Run `/usage` in a throwaway process and return what it printed.
59
106
  *
60
107
  * `--output-format text` rather than the stream: we want the rendering, and the
61
108
  * JSON wrapper would only have to be unwrapped again. Never throws — a probe
62
109
  * that fails is a percentage we do not show, not a session that breaks.
110
+ *
111
+ * Three things about HOW it is run, all of them measured (#390):
112
+ *
113
+ * - `--strict-mcp-config` stops the CLI starting the project's MCP servers,
114
+ * which a question about the plan has no use for. Measured in a project
115
+ * checkout: 13.8 and 18.5s with the flag against 20.8 and 22.2s without, and
116
+ * the output is identical — the same three rows. It narrows the spread; it
117
+ * does not remove it, which is why the ceiling above moved as well.
118
+ * - the process is niced like every other thing we spawn for an agent. It used
119
+ * to be the one child of the runner that was neither caged nor renice'd, and
120
+ * at a 90-second ceiling that would have made it a real neighbour on a busy
121
+ * box (#387 is the same lesson from the other side).
122
+ * - stdin is closed. `-p` does not read it, but `agent-versions.ts` was bitten
123
+ * by a CLI that did, and a probe that hangs on an open pipe would now hang
124
+ * for a minute and a half. (Not the cause of the slowness here: measured
125
+ * with and without, the times overlap.)
126
+ *
127
+ * `--bare` was measured too and rejected: it finishes in 2.5s and prints no
128
+ * usage at all, because it reads neither OAuth nor the keychain — on the
129
+ * subscription this whole file exists to report, it has nothing to say.
63
130
  */
64
131
  export declare function probeUsageText(binary: string, cwd: string, timeoutMs?: number): Promise<string | null>;
132
+ /** A reading of the machine's plan, with the moment it was taken. */
133
+ export interface UsageReading {
134
+ rows: UsageRow[];
135
+ measuredAtMs: number;
136
+ }
137
+ export declare function setUsageProbeRunner(run: UsageProbeRunner | null): void;
138
+ /** Forget everything measured. For tests, and for a re-login that changes account. */
139
+ export declare function invalidateUsageCache(): void;
140
+ /**
141
+ * What the machine last read, WITHOUT starting a measurement.
142
+ *
143
+ * The reason this exists is the defect #390 was actually about. A session's
144
+ * window map starts empty, and its first `rate_limit_event` arrives 12-28
145
+ * seconds before any probe of its own could answer. That event describes ONE
146
+ * window, the snapshot built from it therefore has one row, and the API stores
147
+ * the newest snapshot as the state of the whole machine — so a session that had
148
+ * only just started would overwrite three good rows with one, every time, on a
149
+ * machine with a single session as much as on a busy one.
150
+ *
151
+ * Seeding from the box's last reading closes that: a new session publishes what
152
+ * the machine already knows in its very first snapshot, and the probe that
153
+ * follows only refreshes it.
154
+ */
155
+ export declare function lastUsageRows(): UsageReading | null;
156
+ export interface ReadUsageOptions {
157
+ binary: string;
158
+ /**
159
+ * Where to run it. Defaults to {@link probeDir} — a directory of the runner's
160
+ * own, never a shared one and never a session's working copy. The reading is
161
+ * identical in every directory (verified), so the choice is made entirely on
162
+ * safety and tidiness: see `probeDir` for why a shared directory is a hole,
163
+ * and note that `-p` leaves a transcript behind, which in a checkout would
164
+ * grow the very local history that makes `/usage` slow.
165
+ */
166
+ cwd?: string;
167
+ /** Fixed clock for tests; both the throttle and the stamp read it. */
168
+ now?: number;
169
+ }
170
+ /**
171
+ * The machine's plan percentages — measured at most once per {@link USAGE_CACHE_MS}.
172
+ *
173
+ * Two guards, and they answer different questions. The throttle stops us
174
+ * measuring again too SOON; the in-flight join stops us measuring twice AT
175
+ * ONCE. Only the second one fixes the pile-up this change is about: five
176
+ * sessions reaching a turn end together used to start five copies of a
177
+ * 20-second CPU-bound process, each of which made the others slower — the same
178
+ * self-sustaining shape `gitops.ts` writes about for `git_status`.
179
+ *
180
+ * Always resolves to the best reading we have, never to «nothing» just because
181
+ * this attempt failed. A stale true number beats an empty panel — and the
182
+ * reading carries the moment it was TAKEN, so the caller can tell a fresh
183
+ * measurement from the one it already folded in (see {@link measuredAtMs}).
184
+ */
185
+ export declare function readUsageRows(options: ReadUsageOptions): Promise<UsageReading | null>;
65
186
  //# sourceMappingURL=claude-usage.d.ts.map
@@ -1,5 +1,9 @@
1
1
  import { execFile } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
2
4
  import { log } from '../log.js';
5
+ import { stateDir } from '../paths.js';
6
+ import { lowerPriority } from '../process-priority.js';
3
7
  /**
4
8
  * How much of the plan is spent — read from the CLI's own `/usage` (#279).
5
9
  *
@@ -191,7 +195,7 @@ export function parseUsageText(text, now = new Date()) {
191
195
  * reworded CLI must never be able to move a wake-up — therefore still holds.
192
196
  * `claude-usage.test.ts` pins it.
193
197
  */
194
- export function applyUsagePercentages(windows, rows) {
198
+ export function applyUsagePercentages(windows, rows, options = {}) {
195
199
  // Keyed by window AND label: a per-model weekly row is its own line, not an
196
200
  // overwrite of the plan-wide one.
197
201
  const id = (window) => `${window.key}:${window.label ?? ''}`;
@@ -202,7 +206,19 @@ export function applyUsagePercentages(windows, rows) {
202
206
  merged.set(key, {
203
207
  key: row.key,
204
208
  windowMinutes: existing?.windowMinutes ?? (row.key === 'five_hour' ? 300 : 10_080),
205
- usedPercent: row.percent,
209
+ // `onlyFillGaps` is what a STALE reading gets (#390, QA MAJOR). The
210
+ // reading belongs to the machine and is handed to a session at the end of
211
+ // every turn; meanwhile a `rate_limit_event` may have put a newer
212
+ // percentage into this very window. Overwriting it rolled 95% back to the
213
+ // 60% measured three minutes earlier, once per turn, in both directions
214
+ // (too low inside a window, too high just after it resets). A fresh
215
+ // measurement still wins outright — it is the authority for percentages,
216
+ // which is the whole premise of #279.
217
+ usedPercent: options.onlyFillGaps &&
218
+ existing?.usedPercent !== null &&
219
+ existing?.usedPercent !== undefined
220
+ ? existing.usedPercent
221
+ : row.percent,
206
222
  // Event first, text second, nothing third. See the docblock above for why
207
223
  // this ordering is the whole safety argument.
208
224
  resetsAt: existing?.resetsAt ?? row.resetsAt ?? null,
@@ -213,23 +229,310 @@ export function applyUsagePercentages(windows, rows) {
213
229
  return [...merged.values()].sort((a, b) => (a.windowMinutes ?? Number.MAX_SAFE_INTEGER) - (b.windowMinutes ?? Number.MAX_SAFE_INTEGER) ||
214
230
  (a.label ?? '').localeCompare(b.label ?? ''));
215
231
  }
232
+ /**
233
+ * How long the probe may run before it is written off (#390).
234
+ *
235
+ * Ninety seconds, and the number is measured rather than chosen. `/usage` was
236
+ * 3.7s when the old ceiling of 20s was set on 2026-08-15; it is not that
237
+ * command any more. Since then the CLI prints «What's contributing to your
238
+ * limits usage?», computed from the machine's OWN session history — 1.4 GB and
239
+ * ~4900 files on the busiest dev server — so the work grows with the history
240
+ * and the load rather than with anything we ask for. It is also CPU-bound, not
241
+ * network-bound: 23.7s wall against 32s of CPU.
242
+ *
243
+ * Measured 2026-09-07 on a six-core box under load 4-15:
244
+ *
245
+ * plain, /tmp 17.7s
246
+ * plain, a project checkout 19.0 20.8 22.2 24.0 27.5s
247
+ * in the reporter's own runs 15.0 15.3 16.5 17.1 20.8 23.7 28.3s
248
+ *
249
+ * So 20s was not a margin, it was the middle of the distribution, and a failed
250
+ * probe was an ordinary event rather than an exception. The cost of waiting is
251
+ * nil — the probe is free (0 tokens, 0 turns, 0 dollars, verified 2026-08-15),
252
+ * runs in the background, and since #390 there is at most ONE of it per
253
+ * machine. The cost of NOT waiting was the panel losing two of its three rows.
254
+ */
255
+ export const USAGE_PROBE_TIMEOUT_MS = 20_000;
256
+ /**
257
+ * How long a successful reading is believed — the throttle for the whole box.
258
+ *
259
+ * Three minutes, unchanged from when this was a per-session throttle. What
260
+ * changed is the scope: five sessions used to keep five independent clocks and
261
+ * run five copies of a 20-second process, all of them measuring the same
262
+ * account. The windows on screen are five hours and a week; three minutes is
263
+ * already far finer than they move.
264
+ */
265
+ export const USAGE_CACHE_MS = 3 * 60 * 1_000;
266
+ /**
267
+ * How long we wait before retrying after a probe that produced nothing.
268
+ *
269
+ * Shorter than the success interval, because a machine that has just failed
270
+ * once is the machine most worth asking again — but not so short that a box
271
+ * with no subscription at all (own API key, Bedrock, Vertex) spends its life
272
+ * spawning CLIs. One at a time is guaranteed by the in-flight join below, so
273
+ * the worst case is a single ~90s process a minute apart, not a pile.
274
+ */
275
+ export const USAGE_FAILURE_COOLDOWN_MS = 60 * 1_000;
276
+ /** At most one line per reason per this long. See `warnProbeFailure`. */
277
+ const WARN_THROTTLE_MS = 15 * 60 * 1_000;
278
+ /**
279
+ * A probe that failed is worth SAYING (#390).
280
+ *
281
+ * It used to be `log.debug` while the runner logs at `info`, so the one thing
282
+ * that explains a panel losing two of its three rows was invisible from
283
+ * outside: the percentages simply stopped, and every other signal said the
284
+ * machine was healthy.
285
+ *
286
+ * Throttled per reason rather than said every time, because the failure that
287
+ * repeats forever is the boring one — a machine with no subscription probes,
288
+ * fails, and would otherwise write a line into journald every minute until the
289
+ * log is useless. The first line of a new failure is the one that carries the
290
+ * information; the hundredth only buries it.
291
+ */
292
+ const warnedAtMs = new Map();
293
+ function warnProbeFailure(error, timeoutMs, now) {
294
+ const killed = typeof error === 'object' &&
295
+ error !== null &&
296
+ 'killed' in error &&
297
+ error.killed === true;
298
+ const reason = killed ? 'timeout' : 'error';
299
+ const last = warnedAtMs.get(reason);
300
+ if (last !== undefined && now - last < WARN_THROTTLE_MS) {
301
+ log.debug('claude: /usage probe failed again', { reason, error: String(error) });
302
+ return;
303
+ }
304
+ warnedAtMs.set(reason, now);
305
+ log.warn(killed
306
+ ? 'claude: /usage did not finish in time — plan percentages will go stale'
307
+ : 'claude: /usage could not be read — plan percentages will go stale', { reason, timeoutMs, error: String(error) });
308
+ }
216
309
  /**
217
310
  * Run `/usage` in a throwaway process and return what it printed.
218
311
  *
219
312
  * `--output-format text` rather than the stream: we want the rendering, and the
220
313
  * JSON wrapper would only have to be unwrapped again. Never throws — a probe
221
314
  * that fails is a percentage we do not show, not a session that breaks.
315
+ *
316
+ * Three things about HOW it is run, all of them measured (#390):
317
+ *
318
+ * - `--strict-mcp-config` stops the CLI starting the project's MCP servers,
319
+ * which a question about the plan has no use for. Measured in a project
320
+ * checkout: 13.8 and 18.5s with the flag against 20.8 and 22.2s without, and
321
+ * the output is identical — the same three rows. It narrows the spread; it
322
+ * does not remove it, which is why the ceiling above moved as well.
323
+ * - the process is niced like every other thing we spawn for an agent. It used
324
+ * to be the one child of the runner that was neither caged nor renice'd, and
325
+ * at a 90-second ceiling that would have made it a real neighbour on a busy
326
+ * box (#387 is the same lesson from the other side).
327
+ * - stdin is closed. `-p` does not read it, but `agent-versions.ts` was bitten
328
+ * by a CLI that did, and a probe that hangs on an open pipe would now hang
329
+ * for a minute and a half. (Not the cause of the slowness here: measured
330
+ * with and without, the times overlap.)
331
+ *
332
+ * `--bare` was measured too and rejected: it finishes in 2.5s and prints no
333
+ * usage at all, because it reads neither OAuth nor the keychain — on the
334
+ * subscription this whole file exists to report, it has nothing to say.
222
335
  */
223
- export function probeUsageText(binary, cwd, timeoutMs = 20_000) {
336
+ export function probeUsageText(binary, cwd, timeoutMs = USAGE_PROBE_TIMEOUT_MS) {
224
337
  return new Promise((resolve) => {
225
- execFile(binary, ['-p', '/usage', '--output-format', 'text'], { cwd, timeout: timeoutMs, maxBuffer: 1024 * 1024 }, (error, stdout) => {
338
+ let settled = false;
339
+ const finish = (text) => {
340
+ if (settled)
341
+ return;
342
+ settled = true;
343
+ clearTimeout(watchdog);
344
+ resolve(text);
345
+ };
346
+ const child = execFile(binary, ['--strict-mcp-config', '-p', '/usage', '--output-format', 'text'], {
347
+ cwd,
348
+ timeout: timeoutMs,
349
+ maxBuffer: 1024 * 1024,
350
+ // SIGTERM can be trapped, SIGKILL cannot. A probe that ignored the
351
+ // polite signal would never call this callback, and since the cache
352
+ // above clears its in-flight slot only when the callback runs, ONE such
353
+ // process would stop the machine ever measuring again (QA-390 MINOR).
354
+ // Nothing here needs a graceful exit: it reads and prints.
355
+ killSignal: 'SIGKILL',
356
+ }, (error, stdout) => {
226
357
  if (error) {
227
- log.debug('claude: /usage probe failed', { error: String(error) });
228
- resolve(null);
358
+ warnProbeFailure(error, timeoutMs, Date.now());
359
+ finish(null);
229
360
  return;
230
361
  }
231
- resolve(stdout);
362
+ finish(stdout);
232
363
  });
364
+ // Belt as well as braces: the callback also waits for the output streams to
365
+ // close, and a grandchild holding the pipe can keep them open after the CLI
366
+ // itself is gone. This promise must settle no matter what, because the
367
+ // in-flight slot is released from it.
368
+ const watchdog = setTimeout(() => {
369
+ log.warn('claude: /usage probe left its pipes open — giving up on it', { timeoutMs });
370
+ finish(null);
371
+ }, timeoutMs + 15_000);
372
+ watchdog.unref?.();
373
+ child.stdin?.end();
374
+ lowerPriority(child.pid);
375
+ });
376
+ }
377
+ /**
378
+ * Where the probe runs — a directory only this runner can write to.
379
+ *
380
+ * NOT a shared directory, and this is a security property rather than a
381
+ * preference (QA-390 BLOCKER). For Claude Code the working directory is the
382
+ * root of a project's SETTINGS: it reads `<cwd>/.claude/settings.json`, and
383
+ * that file may define hooks — commands the CLI then runs. `-p` asks for no
384
+ * trust confirmation at all, verified live. The runner is root by default
385
+ * (`--user <name>` is opt-in), so a world-writable `cwd` means any local user
386
+ * can drop `/tmp/.claude/settings.json` and have it executed as root within
387
+ * three minutes. Demonstrated end to end during review: a non-root user's hook
388
+ * wrote `uid=0(root)`.
389
+ *
390
+ * Before this ticket the probe inherited the session's own working copy, which
391
+ * is inside the project's trust boundary; `os.tmpdir()` was the accident that
392
+ * this comment exists to prevent someone re-introducing.
393
+ *
394
+ * Second, smaller reason for a directory of our own: with `cwd` on a busy
395
+ * `/tmp` the CLI walks it (`rg --files --hidden`) — 162,595 files and 1.16s on
396
+ * this machine, spent for nothing.
397
+ */
398
+ function probeDir() {
399
+ const dir = path.join(stateDir(), 'usage-probe');
400
+ try {
401
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
402
+ return dir;
403
+ }
404
+ catch (error) {
405
+ // No private directory, no probe. Falling back to a shared one is exactly
406
+ // the hole above, and a missing percentage is a far smaller loss.
407
+ log.warn('claude: no private directory for the /usage probe — skipping it', {
408
+ dir,
409
+ error: String(error),
410
+ });
411
+ return null;
412
+ }
413
+ }
414
+ /**
415
+ * The last reading that SUCCEEDED. A failed probe never clears it.
416
+ *
417
+ * This is the whole difference between «we could not measure just now» and «we
418
+ * know nothing»: the first must keep showing the last true numbers, and only
419
+ * the second is allowed to leave the panel empty.
420
+ */
421
+ let lastRows = null;
422
+ /**
423
+ * When {@link lastRows} was MEASURED — not when it was last handed out.
424
+ *
425
+ * Load-bearing rather than diagnostic (QA-390 MAJOR). A reading may be up to
426
+ * {@link USAGE_CACHE_MS} old, and it is handed to a session at the end of every
427
+ * turn. Meanwhile a `rate_limit_event` can have put a NEWER percentage into
428
+ * that session's window, and `applyUsagePercentages` overwrites percentages
429
+ * without asking how old they are — so an unqualified reading rolled 95% back
430
+ * to the 60% the machine measured three minutes ago, on every turn. The stamp
431
+ * is what lets the adapter tell «this is news» from «this is what you already
432
+ * folded in».
433
+ */
434
+ let measuredAtMs = 0;
435
+ /** When the last attempt FINISHED, successful or not — the throttle's clock. */
436
+ let attemptedAtMs = 0;
437
+ /** Did that attempt produce rows? Decides which of the two intervals applies. */
438
+ let lastAttemptOk = false;
439
+ /** The probe currently running, so overlapping callers share it. */
440
+ let inFlight = null;
441
+ /**
442
+ * The probe the cache runs. Replaced only by tests.
443
+ *
444
+ * A module-level seat rather than an argument, because the caller that needs it
445
+ * is a session buried inside the adapter and threading a test-only parameter
446
+ * through 126 constructions of `ClaudeAdapter` would put it on the product's
447
+ * own seam. It has to exist at all for a sharper reason than tidiness: until
448
+ * #390 the only thing keeping the adapter's own test file from spawning a real
449
+ * `claude` on this machine — which also hosts production — was that its fake
450
+ * `cwd` did not exist, and this change stopped the probe using the session's
451
+ * `cwd` at all.
452
+ */
453
+ let probeRunner = (binary, cwd) => probeUsageText(binary, cwd);
454
+ export function setUsageProbeRunner(run) {
455
+ probeRunner = run ?? ((binary, cwd) => probeUsageText(binary, cwd));
456
+ }
457
+ /** Forget everything measured. For tests, and for a re-login that changes account. */
458
+ export function invalidateUsageCache() {
459
+ lastRows = null;
460
+ measuredAtMs = 0;
461
+ attemptedAtMs = 0;
462
+ lastAttemptOk = false;
463
+ inFlight = null;
464
+ warnedAtMs.clear();
465
+ }
466
+ /**
467
+ * What the machine last read, WITHOUT starting a measurement.
468
+ *
469
+ * The reason this exists is the defect #390 was actually about. A session's
470
+ * window map starts empty, and its first `rate_limit_event` arrives 12-28
471
+ * seconds before any probe of its own could answer. That event describes ONE
472
+ * window, the snapshot built from it therefore has one row, and the API stores
473
+ * the newest snapshot as the state of the whole machine — so a session that had
474
+ * only just started would overwrite three good rows with one, every time, on a
475
+ * machine with a single session as much as on a busy one.
476
+ *
477
+ * Seeding from the box's last reading closes that: a new session publishes what
478
+ * the machine already knows in its very first snapshot, and the probe that
479
+ * follows only refreshes it.
480
+ */
481
+ export function lastUsageRows() {
482
+ return lastRows ? { rows: lastRows, measuredAtMs } : null;
483
+ }
484
+ /**
485
+ * The machine's plan percentages — measured at most once per {@link USAGE_CACHE_MS}.
486
+ *
487
+ * Two guards, and they answer different questions. The throttle stops us
488
+ * measuring again too SOON; the in-flight join stops us measuring twice AT
489
+ * ONCE. Only the second one fixes the pile-up this change is about: five
490
+ * sessions reaching a turn end together used to start five copies of a
491
+ * 20-second CPU-bound process, each of which made the others slower — the same
492
+ * self-sustaining shape `gitops.ts` writes about for `git_status`.
493
+ *
494
+ * Always resolves to the best reading we have, never to «nothing» just because
495
+ * this attempt failed. A stale true number beats an empty panel — and the
496
+ * reading carries the moment it was TAKEN, so the caller can tell a fresh
497
+ * measurement from the one it already folded in (see {@link measuredAtMs}).
498
+ */
499
+ export function readUsageRows(options) {
500
+ const now = options.now ?? Date.now();
501
+ const interval = lastAttemptOk ? USAGE_CACHE_MS : USAGE_FAILURE_COOLDOWN_MS;
502
+ if (attemptedAtMs !== 0 && now - attemptedAtMs < interval) {
503
+ return Promise.resolve(lastUsageRows());
504
+ }
505
+ if (inFlight)
506
+ return inFlight;
507
+ const cwd = options.cwd ?? probeDir();
508
+ if (cwd === null)
509
+ return Promise.resolve(lastUsageRows());
510
+ const pending = probeRunner(options.binary, cwd)
511
+ .then((text) => {
512
+ const rows = text ? parseUsageText(text) : [];
513
+ attemptedAtMs = options.now ?? Date.now();
514
+ lastAttemptOk = rows.length > 0;
515
+ // Zero rows is not an answer worth keeping: it means the wording moved or
516
+ // the CLI said nothing, and the previous true reading is still the best
517
+ // thing we have to show.
518
+ if (rows.length > 0) {
519
+ lastRows = rows;
520
+ measuredAtMs = attemptedAtMs;
521
+ }
522
+ return lastUsageRows();
523
+ })
524
+ .catch((error) => {
525
+ // `probeUsageText` resolves rather than rejects, so this is a seat that
526
+ // threw or a parser that did — either way the reading is unchanged.
527
+ log.debug('claude: /usage probe threw', { error: String(error) });
528
+ attemptedAtMs = options.now ?? Date.now();
529
+ lastAttemptOk = false;
530
+ return lastUsageRows();
531
+ })
532
+ .finally(() => {
533
+ inFlight = null;
233
534
  });
535
+ inFlight = pending;
536
+ return pending;
234
537
  }
235
538
  //# sourceMappingURL=claude-usage.js.map
@@ -10,10 +10,8 @@ import { cageSpawn, memoryDeathSentence, releaseSessionScope } from '../session-
10
10
  import { evaluateToolUse, maskSecrets, maskString, } from '../policy.js';
11
11
  import { availableModes, cardDescription, DIRECT_BRANCH_RULE, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, policyContextFor, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
12
12
  import { percentFromUtilization, RATE_WINDOW_MINUTES, rateWindowKey } from './rate-limits.js';
13
- import { applyUsagePercentages, parseUsageText, probeUsageText } from './claude-usage.js';
13
+ import { applyUsagePercentages, lastUsageRows, readUsageRows } from './claude-usage.js';
14
14
  import { claudeExecutableOption, sessionClaudePath } from '../agent-binary.js';
15
- /** How often `/usage` may be read. Free, but still a process. */
16
- const USAGE_PROBE_INTERVAL_MS = 3 * 60 * 1000;
17
15
  /** Same 2KB the SDK keeps: enough for the CLI's last words, not a log sink. */
18
16
  const STDERR_TAIL_LIMIT = 2048;
19
17
  import { answerSummary, answerValue, discussMessage, invalidationMessage, mirrorOptions, newAskId, MAX_OPTIONS, MAX_QUESTIONS, OPTION_TEXT_LIMIT, QUESTION_TEXT_LIMIT, } from './questions.js';
@@ -832,8 +830,6 @@ class ClaudeSession {
832
830
  rateLimitsAvailable = false;
833
831
  /** A refusal seen since the last turn ended, waiting to be reported with it. */
834
832
  limitBlockPending = false;
835
- /** When `/usage` was last read, so a busy session does not spawn a process a second. */
836
- usageProbedAt = 0;
837
833
  /**
838
834
  * The one key the window map is written under, from every source.
839
835
  *
@@ -870,41 +866,85 @@ class ClaudeSession {
870
866
  return providerName === 'five_hour' || providerName === 'seven_day';
871
867
  }
872
868
  /**
873
- * Read the percentages out of the CLI's own `/usage` (#279).
869
+ * Read the percentages out of the CLI's own `/usage` (#279, #390).
874
870
  *
875
- * Fire-and-forget, throttled, and free — a local command, not a request
876
- * (probe 2026-08-15: zero tokens, zero turns, zero dollars). The live event
877
- * gives us the clock and almost never the number; this gives the number and
878
- * never touches the clock.
871
+ * Fire-and-forget and free — a local command, not a request (probe
872
+ * 2026-08-15: zero tokens, zero turns, zero dollars). The live event gives us
873
+ * the clock and almost never the number; this gives the number and never
874
+ * touches the clock.
875
+ *
876
+ * Two steps, and the FIRST one is the fix for #390. The reading belongs to
877
+ * the machine, not to this session — one account, one set of numbers — so
878
+ * before anything is measured we take what the box already knows. Without it
879
+ * a session that started thirty seconds ago publishes a snapshot built from
880
+ * its single `rate_limit_event`, and because the API stores the newest
881
+ * snapshot as the state of the whole server, that one row replaced three good
882
+ * ones. Seeding costs nothing, needs no binary, and is why raising the
883
+ * timeout alone would not have fixed the report.
884
+ *
885
+ * The second step refreshes it. The throttle, the ceiling and the guarantee
886
+ * that only one probe runs per machine all live in `readUsageRows`.
879
887
  */
880
888
  refreshUsage() {
881
889
  if (this.stopped)
882
890
  return;
883
- if (this.usageProbedAt !== 0 && Date.now() - this.usageProbedAt < USAGE_PROBE_INTERVAL_MS) {
884
- return;
885
- }
891
+ if (this.absorbUsageReading(lastUsageRows()))
892
+ this.emitRateLimits();
886
893
  const binary = sessionClaudePath();
887
894
  if (!binary)
888
895
  return;
889
- this.usageProbedAt = Date.now();
890
- void probeUsageText(binary, this.spec.cwd)
891
- .then((text) => {
892
- if (!text || this.stopped)
896
+ void readUsageRows({ binary })
897
+ .then((reading) => {
898
+ if (this.stopped)
893
899
  return;
894
- const rows = parseUsageText(text);
895
- if (rows.length === 0)
896
- return;
897
- // A percentage proves a plan as surely as the event does.
898
- this.rateLimitsAvailable = true;
899
- const merged = applyUsagePercentages([...this.rateLimitWindows.values()], rows);
900
- this.rateLimitWindows.clear();
901
- for (const window of merged) {
902
- this.rateLimitWindows.set(ClaudeSession.windowKey(window.key, window.label), window);
903
- }
904
- this.emitRateLimits();
900
+ if (this.absorbUsageReading(reading))
901
+ this.emitRateLimits();
905
902
  })
906
903
  .catch((error) => log.debug('claude: usage probe failed', { error: String(error) }));
907
904
  }
905
+ /** The measurement already folded in, so an older one cannot undo a newer event. */
906
+ usageAppliedAtMs = 0;
907
+ /**
908
+ * Fold a `/usage` reading into this session's window map.
909
+ *
910
+ * The reading belongs to the MACHINE and arrives at both ends of every turn,
911
+ * so most of the time it is something this session has already applied. That
912
+ * distinction is load-bearing rather than an optimisation (found by review):
913
+ * between two turns a `rate_limit_event` can put a NEWER percentage into a
914
+ * window, and re-applying a three-minute-old measurement over it rolled 95%
915
+ * back to 60% — every turn, and in both directions, because usage only climbs
916
+ * inside a window and drops to nothing when it resets. The warning badge is
917
+ * driven off that number.
918
+ *
919
+ * So a reading we have not seen before is applied outright — it is the
920
+ * authority for percentages, which is #279's whole premise — and one we have
921
+ * only FILLS what is still missing.
922
+ *
923
+ * Returns whether anything actually moved, so a repeat costs no frame. The
924
+ * supervisor's level gate would drop the duplicate anyway, but the cheapest
925
+ * frame is the one never built.
926
+ */
927
+ absorbUsageReading(reading) {
928
+ if (!reading || reading.rows.length === 0)
929
+ return false;
930
+ const fresh = reading.measuredAtMs > this.usageAppliedAtMs;
931
+ const before = this.rateLimitsFingerprint();
932
+ // A percentage proves a plan as surely as the event does.
933
+ this.rateLimitsAvailable = true;
934
+ const merged = applyUsagePercentages([...this.rateLimitWindows.values()], reading.rows, {
935
+ onlyFillGaps: !fresh,
936
+ });
937
+ this.rateLimitWindows.clear();
938
+ for (const window of merged) {
939
+ this.rateLimitWindows.set(ClaudeSession.windowKey(window.key, window.label), window);
940
+ }
941
+ if (fresh)
942
+ this.usageAppliedAtMs = reading.measuredAtMs;
943
+ return this.rateLimitsFingerprint() !== before;
944
+ }
945
+ rateLimitsFingerprint() {
946
+ return JSON.stringify([this.rateLimitsAvailable, [...this.rateLimitWindows.values()]]);
947
+ }
908
948
  /** Read the flag and clear it: one refusal marks exactly one turn end. */
909
949
  consumeLimitBlock() {
910
950
  const blocked = this.limitBlockPending;
@@ -7,6 +7,7 @@ import { log } from './log.js';
7
7
  import { maskString } from './policy.js';
8
8
  import { runnerIdentity, whichExecutable } from './environment.js';
9
9
  import { applyStoredClaudeToken, clearStoredClaudeToken, extractOauthToken, storeClaudeToken, storedClaudeToken, } from './agent-auth.js';
10
+ import { invalidateUsageCache } from './adapters/claude-usage.js';
10
11
  import { adoptLoginResult, discardStagingHome, prepareStagingHome, repairCodexAuth, stagingCodexHomePath, } from './adapters/codex-home.js';
11
12
  const execFileAsync = promisify(execFile);
12
13
  /* eslint-disable no-control-regex -- this module parses raw pty output, so
@@ -48,6 +49,22 @@ export function extractLoginUrl(agent, raw) {
48
49
  return null;
49
50
  return candidate.replace(/[.,)\]}>'"]+$/, '');
50
51
  }
52
+ /**
53
+ * A new login means the plan figures belong to somebody else (#390, #380).
54
+ *
55
+ * The `/usage` reading is cached for the whole MACHINE, so after a re-login the
56
+ * panel would keep showing the previous account's percentages — and since #380
57
+ * it shows them next to the NEW account's address, which turns a stale number
58
+ * into a wrong statement about a named person. The reading is thrown away here
59
+ * for the same reason `invalidateAgentVersions()` is thrown away after an
60
+ * install: the fact it described is no longer the fact.
61
+ *
62
+ * Does not cover a login performed by hand on the server (`claude auth login`
63
+ * outside DevBridge) — that one corrects itself within the cache interval.
64
+ */
65
+ function forgetClaudeUsage() {
66
+ invalidateUsageCache();
67
+ }
51
68
  export function extractDeviceCode(raw) {
52
69
  // Device-auth user codes look like XXXX-XXXX (letters/digits).
53
70
  return stripControl(raw).match(/\b[A-Z0-9]{4,8}-[A-Z0-9]{4,8}\b/)?.[0] ?? null;
@@ -334,6 +351,7 @@ export class AuthRelay {
334
351
  }
335
352
  log.info('auth-relay: stored a long-lived Claude token for this runner');
336
353
  clearAgentAuthFailure('claude');
354
+ forgetClaudeUsage();
337
355
  return { ok: true, detail: 'signed in with a long-lived token stored on this server' };
338
356
  }
339
357
  // `claude auth login` writes the credential just before it exits; give the
@@ -343,6 +361,7 @@ export class AuthRelay {
343
361
  const status = await probe();
344
362
  if (status.status === 'ok') {
345
363
  clearAgentAuthFailure('claude');
364
+ forgetClaudeUsage();
346
365
  return { ok: true };
347
366
  }
348
367
  await sleep(300);
@@ -144,6 +144,11 @@ export interface RewindPreview {
144
144
  *
145
145
  * Never throws for an ordinary failure: a checkpoint that could not be taken
146
146
  * must not stop the message it was taken for from reaching the agent.
147
+ *
148
+ * The store is held for the whole of it (#388): until the closing `update-ref`
149
+ * nothing names the objects being written, and a collection running in the
150
+ * same store would take them for garbage — which is what they are, right up
151
+ * until they are not.
147
152
  */
148
153
  export declare function createCheckpoint(input: CreateCheckpointInput): Promise<CreateCheckpointResult>;
149
154
  export declare function listCheckpoints(worktreePath: string, sessionId: string): Promise<CheckpointRecord[]>;
@@ -156,7 +161,13 @@ export declare function listCheckpoints(worktreePath: string, sessionId: string)
156
161
  * a cap on a courtesy must degrade, never reject.
157
162
  */
158
163
  export declare const MAX_BUSY_SESSIONS = 10;
159
- /** What a rewind to this checkpoint would do, without doing any of it. */
164
+ /**
165
+ * What a rewind to this checkpoint would do, without doing any of it.
166
+ *
167
+ * Holds the store (#388): building the preview writes a tree of «where we are
168
+ * now», and that tree is named by no ref ever — a collection running beside it
169
+ * takes it, and `diff-tree` then fails on the oid it was just handed.
170
+ */
160
171
  export declare function previewRewind(input: {
161
172
  worktreePath: string;
162
173
  sessionId: string;
@@ -149,9 +149,154 @@ async function ensureStore(worktreePath) {
149
149
  }
150
150
  return store;
151
151
  }
152
+ /**
153
+ * One store, one thing at a time: snapshots OR collection (#388).
154
+ *
155
+ * The store is keyed by REPOSITORY, so every session working in one folder
156
+ * writes into the same objects directory — and `pruneCheckpoints` collects in
157
+ * it. Between the first `update-index --add` and the closing `update-ref` the
158
+ * objects of a snapshot are named by nothing, and `gc --prune=now` collects
159
+ * exactly what nothing names. Measured on production 07.09.2026: a reconnect
160
+ * fired the collection while a turn was taking its point, and `write-tree`
161
+ * died on its own blobs («invalid object … error building trees»). A rewind
162
+ * preview is the same shape — its tree is never named by a ref at all.
163
+ *
164
+ * Shared for the writers, exclusive for the collection. Three properties are
165
+ * load-bearing:
166
+ *
167
+ * 1. Writers do not exclude each other. Two sessions in one folder take their
168
+ * points at the same time, as they always did — `tempIndexFile` is what
169
+ * keeps them apart, and this gate must not quietly serialise them.
170
+ * 2. The queue is fair: a later writer never overtakes a waiting collection.
171
+ * A busy folder takes a point every few seconds, and a collection that can
172
+ * be overtaken is a collection that never runs — i.e. the disk it exists to
173
+ * give back is never given back.
174
+ * 3. A collection WAITS; it never gives up and runs anyway. Waiting costs it
175
+ * nothing (nobody awaits it — `reconcile` fires it and moves on), while
176
+ * running anyway is precisely the defect this closes.
177
+ *
178
+ * In-process, deliberately. The directory is shared per MACHINE, but the runner
179
+ * daemon is the only process that ever opens it — this module has exactly one
180
+ * importer — so ordering inside this process is ordering, full stop. A second
181
+ * runner started by hand beside the service would need a lock in the
182
+ * filesystem; that is not a shape this product has.
183
+ */
184
+ class StoreGate {
185
+ /** Snapshots and previews in flight. */
186
+ writing = 0;
187
+ /** A collection has the store to itself. */
188
+ collecting = false;
189
+ queue = [];
190
+ async enter(exclusive) {
191
+ // The empty-queue test is the fairness rule: with somebody already waiting,
192
+ // even a writer that could go now takes its place at the back.
193
+ if (this.queue.length === 0 && this.free(exclusive)) {
194
+ this.take(exclusive);
195
+ return;
196
+ }
197
+ await new Promise((admit) => {
198
+ this.queue.push({ exclusive, admit });
199
+ });
200
+ }
201
+ leave(exclusive) {
202
+ if (exclusive)
203
+ this.collecting = false;
204
+ else
205
+ this.writing -= 1;
206
+ while (this.queue.length > 0) {
207
+ const next = this.queue[0];
208
+ if (!next || !this.free(next.exclusive))
209
+ return;
210
+ this.queue.shift();
211
+ this.take(next.exclusive);
212
+ next.admit();
213
+ // A collection is alone in there; the writers behind it wait for its turn
214
+ // to end.
215
+ if (next.exclusive)
216
+ return;
217
+ }
218
+ }
219
+ /** Nobody holds it and nobody is waiting — the entry can be forgotten. */
220
+ idle() {
221
+ return this.writing === 0 && !this.collecting && this.queue.length === 0;
222
+ }
223
+ free(exclusive) {
224
+ return exclusive ? !this.collecting && this.writing === 0 : !this.collecting;
225
+ }
226
+ take(exclusive) {
227
+ if (exclusive)
228
+ this.collecting = true;
229
+ else
230
+ this.writing += 1;
231
+ }
232
+ }
233
+ const storeGates = new Map();
234
+ /**
235
+ * Hold this store while `fn` runs.
236
+ *
237
+ * `writing` for anything that puts objects in the store OR reads objects a
238
+ * collection could take; `collecting` for the collection itself.
239
+ *
240
+ * ONE lease per operation, taken at the entry point and never inside it. The
241
+ * rewind is why: it previews, takes a safety point and reads the checkpoint's
242
+ * tree, and if each of those took its own lease, a collection queuing between
243
+ * two of them would be waiting for a lease the rewind cannot release until the
244
+ * collection lets it continue. That is a deadlock, and the fair queue in the
245
+ * point above is exactly what makes it possible — so the fairness and the
246
+ * single lease are one decision, not two.
247
+ */
248
+ async function withStore(store, mode, fn) {
249
+ // Resolved, because the two sides name the store from different ends: the
250
+ // writers build it out of `storeFor`, the collection out of a directory
251
+ // listing.
252
+ const key = path.resolve(store);
253
+ const exclusive = mode === 'collecting';
254
+ const gate = storeGates.get(key) ?? new StoreGate();
255
+ storeGates.set(key, gate);
256
+ // No `await` between the lookup and the claim — `enter` takes its place
257
+ // synchronously, so the entry cannot be swept out from under it below.
258
+ const askedAt = Date.now();
259
+ await gate.enter(exclusive);
260
+ const waitedMs = Date.now() - askedAt;
261
+ if (waitedMs > 1_000) {
262
+ // The one thing this gate can do that is felt from outside: a restore point
263
+ // — and with it the message in front of it — waiting for a collection to
264
+ // finish. Unlogged, that is a delay nobody can explain afterwards.
265
+ log.info('checkpoints: waited for the store to be free', {
266
+ store: path.basename(key),
267
+ mode,
268
+ waitedMs,
269
+ });
270
+ }
271
+ try {
272
+ return await fn();
273
+ }
274
+ finally {
275
+ gate.leave(exclusive);
276
+ if (gate.idle() && storeGates.get(key) === gate)
277
+ storeGates.delete(key);
278
+ }
279
+ }
152
280
  function indexFileFor(sessionId) {
153
281
  return path.join(checkpointsDir(), 'index', `${sessionId}.idx`);
154
282
  }
283
+ /**
284
+ * Drop a temporary index, and never fail because of it.
285
+ *
286
+ * These calls sit in `finally` blocks, and a throw from there escapes PAST the
287
+ * catch that classifies failures — which would turn «could not take a restore
288
+ * point» into a rejected promise, and `createCheckpoint` promises never to
289
+ * throw. The file is scratch; losing the ability to delete it is not worth a
290
+ * message that never reaches the agent.
291
+ */
292
+ function removeIndex(indexFile) {
293
+ try {
294
+ fs.rmSync(indexFile, { force: true });
295
+ }
296
+ catch (error) {
297
+ log.warn('checkpoints: could not remove a temporary index', { error: String(error) });
298
+ }
299
+ }
155
300
  /**
156
301
  * A private index file for ONE operation.
157
302
  *
@@ -383,17 +528,42 @@ function decodeMeta(message) {
383
528
  *
384
529
  * Never throws for an ordinary failure: a checkpoint that could not be taken
385
530
  * must not stop the message it was taken for from reaching the agent.
531
+ *
532
+ * The store is held for the whole of it (#388): until the closing `update-ref`
533
+ * nothing names the objects being written, and a collection running in the
534
+ * same store would take them for garbage — which is what they are, right up
535
+ * until they are not.
386
536
  */
387
537
  export async function createCheckpoint(input) {
538
+ let store;
539
+ try {
540
+ store = await ensureStore(input.worktreePath);
541
+ }
542
+ catch (error) {
543
+ // Almost always «this folder is not a git repository» — the store is built
544
+ // from the repo's own common dir, so there is nothing to open.
545
+ return checkpointRefusal(input.sessionId, error);
546
+ }
547
+ return withStore(store, 'writing', () => takeCheckpoint(store, input));
548
+ }
549
+ /** An error on the way to a restore point, read as a reason to report. */
550
+ function checkpointRefusal(sessionId, error) {
551
+ const detail = String(error instanceof Error ? error.message : error).slice(0, 300);
552
+ if (/not a git repository|ambiguous argument 'HEAD'|unknown revision/i.test(detail)) {
553
+ return { created: false, reason: 'not-a-repo', detail };
554
+ }
555
+ log.warn('checkpoints: could not create a restore point', { sessionId, error: detail });
556
+ return { created: false, reason: 'failed', detail };
557
+ }
558
+ /** The point itself. The caller holds the store; this never takes it (#388). */
559
+ async function takeCheckpoint(store, input) {
388
560
  const { worktreePath, sessionId, kind } = input;
561
+ const indexFile = tempIndexFile(sessionId, 'create');
389
562
  try {
390
- const store = await ensureStore(worktreePath);
391
- const indexFile = tempIndexFile(sessionId, 'create');
392
563
  // Both ends of the shutter — see `CreateCheckpointInput.busySessions`.
393
564
  const busyBefore = input.busySessions?.() ?? [];
394
565
  const built = await buildIndex(store, worktreePath, indexFile);
395
566
  if (built.tooLarge) {
396
- fs.rmSync(indexFile, { force: true });
397
567
  return { created: false, reason: 'too-large' };
398
568
  }
399
569
  const { headSha, included, excluded: skippedFiles, byteCount } = built;
@@ -417,7 +587,6 @@ export async function createCheckpoint(input) {
417
587
  const commit = await gitStore(store, worktreePath, indexFile, 'commit-tree', tree, '-m', encodeMeta(meta));
418
588
  const ordinal = await nextOrdinal(store, worktreePath, sessionId);
419
589
  await gitStore(store, worktreePath, indexFile, 'update-ref', refFor(sessionId, ordinal), commit);
420
- fs.rmSync(indexFile, { force: true });
421
590
  return {
422
591
  created: true,
423
592
  record: { ordinal, commit, ...meta },
@@ -426,12 +595,12 @@ export async function createCheckpoint(input) {
426
595
  };
427
596
  }
428
597
  catch (error) {
429
- const detail = String(error instanceof Error ? error.message : error).slice(0, 300);
430
- if (/not a git repository|ambiguous argument 'HEAD'|unknown revision/i.test(detail)) {
431
- return { created: false, reason: 'not-a-repo', detail };
432
- }
433
- log.warn('checkpoints: could not create a restore point', { sessionId, error: detail });
434
- return { created: false, reason: 'failed', detail };
598
+ return checkpointRefusal(sessionId, error);
599
+ }
600
+ finally {
601
+ // In `finally` rather than on the way out: every refusal above used to
602
+ // leave its index file behind, and `checkpoints/index/` only ever grew.
603
+ removeIndex(indexFile);
435
604
  }
436
605
  }
437
606
  async function readCheckpoint(store, worktreePath, sessionId, ordinal) {
@@ -492,11 +661,28 @@ async function currentTree(store, worktreePath, indexFile) {
492
661
  */
493
662
  export const MAX_BUSY_SESSIONS = 10;
494
663
  const MAX_PREVIEW_ENTRIES = 5_000;
495
- /** What a rewind to this checkpoint would do, without doing any of it. */
664
+ /**
665
+ * What a rewind to this checkpoint would do, without doing any of it.
666
+ *
667
+ * Holds the store (#388): building the preview writes a tree of «where we are
668
+ * now», and that tree is named by no ref ever — a collection running beside it
669
+ * takes it, and `diff-tree` then fails on the oid it was just handed.
670
+ */
496
671
  export async function previewRewind(input) {
672
+ const store = await ensureStore(input.worktreePath);
673
+ const indexFile = tempIndexFile(input.sessionId, 'preview');
674
+ try {
675
+ return await withStore(store, 'writing', () => buildPreview(store, indexFile, input));
676
+ }
677
+ finally {
678
+ // Named out here so that every way out of the preview — including the two
679
+ // that used to walk past the cleanup — leaves the index behind it.
680
+ removeIndex(indexFile);
681
+ }
682
+ }
683
+ /** The preview itself. The caller holds the store; this never takes it (#388). */
684
+ async function buildPreview(store, indexFile, input) {
497
685
  const { worktreePath, sessionId, ordinal } = input;
498
- const store = await ensureStore(worktreePath);
499
- const indexFile = tempIndexFile(sessionId, 'preview');
500
686
  const record = await readCheckpoint(store, worktreePath, sessionId, ordinal);
501
687
  const { tree, headSha } = await currentTree(store, worktreePath, indexFile);
502
688
  if (!record) {
@@ -511,7 +697,6 @@ export async function previewRewind(input) {
511
697
  };
512
698
  }
513
699
  const raw = await gitStore(store, worktreePath, indexFile, 'diff-tree', '-r', '--no-renames', '--name-status', '-z', `${record.commit}^{tree}`, tree);
514
- fs.rmSync(indexFile, { force: true });
515
700
  const restore = [];
516
701
  const remove = [];
517
702
  const recreate = [];
@@ -597,12 +782,53 @@ async function mergeInProgress(worktreePath) {
597
782
  * by list, and a rule is exactly what nobody confirmed.
598
783
  */
599
784
  export async function applyRewind(input) {
600
- const { worktreePath, sessionId, ordinal, confirmDeletes, expectedTreeOid } = input;
785
+ const { worktreePath } = input;
601
786
  const store = await ensureStore(worktreePath);
787
+ // ONE lease over the whole of it (#388), and it covers the READS as much as
788
+ // the writes: the collection unlinks refs and then collects, so a checkpoint
789
+ // whose ref ages out mid-rewind would lose its tree between the safety point
790
+ // and the `read-tree` that restores it — a rewind that fails after it has
791
+ // already promised. Nesting a second lease inside this one would deadlock
792
+ // against a waiting collection, which is why `buildPreview` and
793
+ // `takeCheckpoint` are called here rather than their exported wrappers.
794
+ const { record, preview, safety } = await withStore(store, 'writing', () => rewindToPoint(store, input));
795
+ // Outside the lease from here on: this is the worktree and the PROJECT's
796
+ // index, and nothing in the checkpoint store depends on it.
797
+ //
798
+ // `read-tree --reset -u` removes the files that are in the seeded index and
799
+ // not in the checkpoint — which is the same set the user just confirmed,
800
+ // because both come from the same tree diff. This pass is the belt to that
801
+ // brace: it names each path explicitly, re-checks it against the worktree
802
+ // root, and reports what is actually gone. Nothing here deletes by rule, and
803
+ // there is no `git clean` anywhere in this file.
804
+ await deletePaths(worktreePath, preview.delete);
805
+ const deleted = preview.delete.filter((rel) => !fs.existsSync(path.join(worktreePath, rel))).length;
806
+ await reconcileIndex(worktreePath, preview, record.stagedPaths);
807
+ return {
808
+ restored: preview.restore.length,
809
+ deleted,
810
+ recreated: preview.recreate.length,
811
+ safety,
812
+ rewoundToKind: record.kind,
813
+ };
814
+ }
815
+ /**
816
+ * Everything the rewind does INSIDE the store: check, take the safety point,
817
+ * put the tree back. The caller holds the lease; nothing here takes one (#388).
818
+ */
819
+ async function rewindToPoint(store, input) {
820
+ const { worktreePath, sessionId, ordinal, confirmDeletes, expectedTreeOid } = input;
602
821
  const record = await readCheckpoint(store, worktreePath, sessionId, ordinal);
603
822
  if (!record)
604
823
  throw new Error('This restore point is no longer available');
605
- const preview = await previewRewind({ worktreePath, sessionId, ordinal });
824
+ const previewIndex = tempIndexFile(sessionId, 'preview');
825
+ let preview;
826
+ try {
827
+ preview = await buildPreview(store, previewIndex, { worktreePath, sessionId, ordinal });
828
+ }
829
+ finally {
830
+ removeIndex(previewIndex);
831
+ }
606
832
  if (preview.blockedReason) {
607
833
  throw new Error(rewindBlockMessage(preview.blockedReason));
608
834
  }
@@ -624,7 +850,7 @@ export async function applyRewind(input) {
624
850
  if (expected.length !== echoed.length || expected.some((p, i) => p !== echoed[i])) {
625
851
  throw new Error(MOVED);
626
852
  }
627
- const safetyResult = await createCheckpoint({
853
+ const safetyResult = await takeCheckpoint(store, {
628
854
  worktreePath,
629
855
  sessionId,
630
856
  kind: 'SAFETY',
@@ -636,29 +862,18 @@ export async function applyRewind(input) {
636
862
  : 'Could not take a safety point before the rewind — nothing was changed');
637
863
  }
638
864
  const indexFile = tempIndexFile(sessionId, 'rewind');
639
- // Seed the index with the CURRENT state so `read-tree --reset -u` only
640
- // touches files that actually differ. Against an empty index git rewrites
641
- // every file in the repository, and an mtime bump on a whole tree is a full
642
- // rebuild for every watcher on the machine.
643
- await currentTree(store, worktreePath, indexFile);
644
- await gitStore(store, worktreePath, indexFile, 'read-tree', '--reset', '-u', `${record.commit}^{tree}`);
645
- fs.rmSync(indexFile, { force: true });
646
- // `read-tree --reset -u` removes the files that are in the seeded index and
647
- // not in the checkpoint — which is the same set the user just confirmed,
648
- // because both come from the same tree diff. This pass is the belt to that
649
- // brace: it names each path explicitly, re-checks it against the worktree
650
- // root, and reports what is actually gone. Nothing here deletes by rule, and
651
- // there is no `git clean` anywhere in this file.
652
- await deletePaths(worktreePath, preview.delete);
653
- const deleted = preview.delete.filter((rel) => !fs.existsSync(path.join(worktreePath, rel))).length;
654
- await reconcileIndex(worktreePath, preview, record.stagedPaths);
655
- return {
656
- restored: preview.restore.length,
657
- deleted,
658
- recreated: preview.recreate.length,
659
- safety: safetyResult.record,
660
- rewoundToKind: record.kind,
661
- };
865
+ try {
866
+ // Seed the index with the CURRENT state so `read-tree --reset -u` only
867
+ // touches files that actually differ. Against an empty index git rewrites
868
+ // every file in the repository, and an mtime bump on a whole tree is a full
869
+ // rebuild for every watcher on the machine.
870
+ await currentTree(store, worktreePath, indexFile);
871
+ await gitStore(store, worktreePath, indexFile, 'read-tree', '--reset', '-u', `${record.commit}^{tree}`);
872
+ }
873
+ finally {
874
+ removeIndex(indexFile);
875
+ }
876
+ return { record, preview, safety: safetyResult.record };
662
877
  }
663
878
  /**
664
879
  * Said when the files of a restore point cannot be trusted (#310). Its own
@@ -852,8 +1067,23 @@ export async function pruneCheckpoints(input) {
852
1067
  // Dropping a ref only unlinks it; the trees and blobs it named stay on
853
1068
  // disk until they are collected. Skipping this would make "retention"
854
1069
  // mean nothing at all for the thing that actually takes the space.
855
- await gitRefs(store, 'reflog', 'expire', '--expire=now', '--all');
856
- await gitRefs(store, 'gc', '--prune=now', '--quiet');
1070
+ //
1071
+ // Under the store's lease, and taken HERE rather than around the walk
1072
+ // above (#388): `--prune=now` deletes everything no ref names, and a
1073
+ // snapshot being written in this same store is a set of objects no ref
1074
+ // names YET. The lease is claimed per store and only around these two
1075
+ // commands, so the ref walk of every other store stays outside it —
1076
+ // though the loop itself is sequential, so a folder that keeps this one
1077
+ // waiting does postpone the stores after it. That is acceptable and
1078
+ // was already true of `gc` itself: collection is best-effort and comes
1079
+ // round again on the next reconnect.
1080
+ //
1081
+ // It WAITS rather than skipping: a collection that runs anyway is the
1082
+ // whole defect.
1083
+ await withStore(store, 'collecting', async () => {
1084
+ await gitRefs(store, 'reflog', 'expire', '--expire=now', '--all');
1085
+ await gitRefs(store, 'gc', '--prune=now', '--quiet');
1086
+ });
857
1087
  }
858
1088
  }
859
1089
  catch (error) {
@@ -227,6 +227,8 @@ export declare class Supervisor {
227
227
  * which of the two is running.
228
228
  */
229
229
  private installInFlight;
230
+ /** A restore-point collection is running; a second reconnect must not start another (#388). */
231
+ private checkpointGcInFlight;
230
232
  /** Session 14: one project-recipe run per machine, and its verdict queue. */
231
233
  private readonly verify;
232
234
  private readonly verifyReports;
@@ -890,24 +892,37 @@ export declare class Supervisor {
890
892
  * a second session opened. The neighbours are recorded on the point instead —
891
893
  * the conversation can always be rewound to it, the files cannot.
892
894
  *
893
- * Every refusal is now audible. A restore point that was never taken is
894
- * invisible until the day somebody reaches for it, and «the button is not
895
- * there» is not a sentence anybody can act on.
895
+ * A refusal is audible when it is a refusal — when the person can do
896
+ * something about it, or when a way back they might reach for is not there.
897
+ * A restore point that was never taken is invisible until the day somebody
898
+ * reaches for it, and «the button is not there» is not a sentence anybody can
899
+ * act on.
900
+ *
901
+ * «This session was still answering» is the exception, and it is the only one
902
+ * (#384). It is not a fault and not a state to act on: it is what a follow-up
903
+ * note to a working agent looks like from in here, thirty times in a day on
904
+ * one machine, and it costs almost nothing — the point in front of the turn
905
+ * already stands, and rewinding to it takes back the files AND the
906
+ * conversation, including the note. So it goes to the runner's own log, where
907
+ * support can answer «why is there no point for that step», and not into the
908
+ * feed, where it read as breakage.
896
909
  */
897
910
  private captureCheckpoint;
898
911
  /**
899
912
  * Say something once per BUSY PERIOD, not once per message (#310).
900
913
  *
901
- * A folder held by a neighbour stays held for minutes, and a session mid-turn
902
- * can be sent three follow-up notes inside one answer. Keying this on the
903
- * message seq would have counted each of those as its own turn and said the
904
- * same sentence three times — the noise the frequency policy exists to
905
- * prevent. The set is cleared when the session next comes to rest
906
- * (`reportStatus`), which is exactly when the reason stops being true.
914
+ * A repository held by another git command stays held for as long as that
915
+ * command runs, and three follow-up notes can arrive inside one answer.
916
+ * Keying this on the message seq would have counted each of those as its own
917
+ * turn and said the same sentence three times — the noise the frequency
918
+ * policy exists to prevent. The set is cleared when the session next comes to
919
+ * rest (`reportStatus`), which is exactly when the reason stops being true.
907
920
  *
908
921
  * A SET of keys, not the last one said: two different reasons can both come
909
922
  * up inside one period, and remembering only the most recent would let them
910
- * take turns re-announcing each other.
923
+ * take turns re-announcing each other. One key uses this today — «another git
924
+ * command holds this repository» — and the set stays a set for that reason,
925
+ * not out of habit: the mid-turn key left when it stopped being said (#384).
911
926
  */
912
927
  private noticeOncePerTurn;
913
928
  /**
@@ -144,6 +144,8 @@ export class Supervisor {
144
144
  * which of the two is running.
145
145
  */
146
146
  installInFlight = null;
147
+ /** A restore-point collection is running; a second reconnect must not start another (#388). */
148
+ checkpointGcInFlight = false;
147
149
  /** Session 14: one project-recipe run per machine, and its verdict queue. */
148
150
  verify;
149
151
  verifyReports = new VerifyReportQueue();
@@ -3401,9 +3403,20 @@ export class Supervisor {
3401
3403
  * a second session opened. The neighbours are recorded on the point instead —
3402
3404
  * the conversation can always be rewound to it, the files cannot.
3403
3405
  *
3404
- * Every refusal is now audible. A restore point that was never taken is
3405
- * invisible until the day somebody reaches for it, and «the button is not
3406
- * there» is not a sentence anybody can act on.
3406
+ * A refusal is audible when it is a refusal — when the person can do
3407
+ * something about it, or when a way back they might reach for is not there.
3408
+ * A restore point that was never taken is invisible until the day somebody
3409
+ * reaches for it, and «the button is not there» is not a sentence anybody can
3410
+ * act on.
3411
+ *
3412
+ * «This session was still answering» is the exception, and it is the only one
3413
+ * (#384). It is not a fault and not a state to act on: it is what a follow-up
3414
+ * note to a working agent looks like from in here, thirty times in a day on
3415
+ * one machine, and it costs almost nothing — the point in front of the turn
3416
+ * already stands, and rewinding to it takes back the files AND the
3417
+ * conversation, including the note. So it goes to the runner's own log, where
3418
+ * support can answer «why is there no point for that step», and not into the
3419
+ * feed, where it read as breakage.
3407
3420
  */
3408
3421
  async captureCheckpoint(running, kind, messageSeq) {
3409
3422
  const worktreePath = running.worktreePath;
@@ -3421,7 +3434,13 @@ export class Supervisor {
3421
3434
  return;
3422
3435
  }
3423
3436
  if (kind === 'TURN' && this.isSessionMidTurn(running)) {
3424
- this.noticeOncePerTurn(running, 'checkpoint-self-busy', 'No restore point was taken for this step: this session was still answering when it was due.');
3437
+ // Silent in the feed, on purpose (#384) — see the note above. The `return`
3438
+ // is not cosmetic and does not go: reading a tree the agent is writing
3439
+ // produces a restore point that restores half a file (gotcha 438).
3440
+ log.info('supervisor: no restore point — the session was mid-turn', {
3441
+ sessionId: running.descriptor.id,
3442
+ ...(messageSeq === undefined ? {} : { messageSeq }),
3443
+ });
3425
3444
  return;
3426
3445
  }
3427
3446
  if (await this.isRepoLocked(worktreePath)) {
@@ -3487,16 +3506,18 @@ export class Supervisor {
3487
3506
  /**
3488
3507
  * Say something once per BUSY PERIOD, not once per message (#310).
3489
3508
  *
3490
- * A folder held by a neighbour stays held for minutes, and a session mid-turn
3491
- * can be sent three follow-up notes inside one answer. Keying this on the
3492
- * message seq would have counted each of those as its own turn and said the
3493
- * same sentence three times — the noise the frequency policy exists to
3494
- * prevent. The set is cleared when the session next comes to rest
3495
- * (`reportStatus`), which is exactly when the reason stops being true.
3509
+ * A repository held by another git command stays held for as long as that
3510
+ * command runs, and three follow-up notes can arrive inside one answer.
3511
+ * Keying this on the message seq would have counted each of those as its own
3512
+ * turn and said the same sentence three times — the noise the frequency
3513
+ * policy exists to prevent. The set is cleared when the session next comes to
3514
+ * rest (`reportStatus`), which is exactly when the reason stops being true.
3496
3515
  *
3497
3516
  * A SET of keys, not the last one said: two different reasons can both come
3498
3517
  * up inside one period, and remembering only the most recent would let them
3499
- * take turns re-announcing each other.
3518
+ * take turns re-announcing each other. One key uses this today — «another git
3519
+ * command holds this repository» — and the set stays a set for that reason,
3520
+ * not out of habit: the mid-turn key left when it stopped being said (#384).
3500
3521
  */
3501
3522
  noticeOncePerTurn(running, key, text) {
3502
3523
  running.noticesThisTurn ??= new Set();
@@ -4275,14 +4296,26 @@ export class Supervisor {
4275
4296
  // switched off leaves restore points — a full copy of a working tree —
4276
4297
  // with no row anywhere pointing at them, and nothing else on this machine
4277
4298
  // would ever collect them.
4278
- void pruneCheckpoints({ liveSessionIds: known }).then((result) => {
4279
- if (result.droppedRefs > 0) {
4280
- log.info('supervisor: collected orphaned restore points', {
4281
- sessions: result.droppedSessions.length,
4282
- refs: result.droppedRefs,
4283
- });
4284
- }
4285
- }, (error) => log.warn('supervisor: restore-point GC failed', { error: String(error) }));
4299
+ //
4300
+ // One at a time (#388): reconnects come in runs, and since the collection
4301
+ // holds the store while it collects, a second one started on top of the
4302
+ // first would only queue — in front of the restore points of whoever is
4303
+ // working. The next reconnect collects whatever this pass leaves.
4304
+ if (!this.checkpointGcInFlight) {
4305
+ this.checkpointGcInFlight = true;
4306
+ void pruneCheckpoints({ liveSessionIds: known })
4307
+ .then((result) => {
4308
+ if (result.droppedRefs > 0) {
4309
+ log.info('supervisor: collected orphaned restore points', {
4310
+ sessions: result.droppedSessions.length,
4311
+ refs: result.droppedRefs,
4312
+ });
4313
+ }
4314
+ }, (error) => log.warn('supervisor: restore-point GC failed', { error: String(error) }))
4315
+ .finally(() => {
4316
+ this.checkpointGcInFlight = false;
4317
+ });
4318
+ }
4286
4319
  for (const [sessionId, running] of [...this.sessions]) {
4287
4320
  if (known.has(sessionId))
4288
4321
  continue;
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.55.1";
1
+ export declare const RUNNER_VERSION = "0.56.0";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kept in sync with package.json by the release script (manual for now).
2
- export const RUNNER_VERSION = '0.55.1';
2
+ export const RUNNER_VERSION = '0.56.0';
3
3
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge4dev/runner",
3
- "version": "0.55.1",
3
+ "version": "0.56.0",
4
4
  "description": "DevBridge dev runner — connects a dev server to DevBridge and runs agent sessions (Claude Code / Codex)",
5
5
  "homepage": "https://bridge4.dev",
6
6
  "license": "MIT",