@bridge4dev/runner 0.55.1 → 0.57.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.
@@ -52,6 +52,38 @@ export function answerValue(answer) {
52
52
  export function answerSummary(answers) {
53
53
  return clip(answers.map(answerValue).filter(Boolean).join(' · '), OPTION_TEXT_LIMIT);
54
54
  }
55
+ /**
56
+ * The same answer as something to SAY, when the card that asked is gone (#401).
57
+ *
58
+ * Not `answerSummary`: that one is a label for a resolved card, so it is
59
+ * clipped to an option's width and drops the notes. This is the person's reply
60
+ * being handed to the agent as an ordinary message, and nothing they typed may
61
+ * be shortened away on the path. The questions themselves cannot be named —
62
+ * their text lived in the process that asked and is gone with it — so the
63
+ * answer is given as the words it was made of, which is what the person
64
+ * actually chose.
65
+ *
66
+ * Empty when there is nothing in it: the caller uses that to tell «the reply
67
+ * was lost» from «there was no reply to lose».
68
+ */
69
+ export function answersAsMessage(answers) {
70
+ return clip(answers
71
+ .map((answer) => {
72
+ const value = answerValue(answer);
73
+ const notes = answer.notes?.trim();
74
+ if (value && notes)
75
+ return `${value} (${notes})`;
76
+ return value || notes || '';
77
+ })
78
+ .filter(Boolean)
79
+ .join('\n'),
80
+ // The same ceiling `discussMessage` uses, and for a harder reason: this text
81
+ // becomes a feed event, and an event over the API's size limit is replaced
82
+ // wholesale by a truncation marker. The frame this is built from allows four
83
+ // answers of sixteen 2 000-char values plus a 10 000-char custom field —
84
+ // ~176 KB — and the runner takes that frame straight off the socket.
85
+ 8_000);
86
+ }
55
87
  /**
56
88
  * The «discuss instead» exit.
57
89
  *
@@ -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) {
@@ -6,6 +6,7 @@ import { pruneNativeClaudeVersions } from './agent-cleanup.js';
6
6
  import { type AgentVersionsMeasurement } from './agent-versions.js';
7
7
  import { type HostLoadFrame } from './host-load.js';
8
8
  import { type ScopeMemoryStatus } from './session-cage.js';
9
+ import { createCheckpoint } from './checkpoints.js';
9
10
  import type { RunnerWsClient } from './ws-client.js';
10
11
  import type { SessionDescriptor } from './protocol.js';
11
12
  import type { AgentAdapter } from './adapters/types.js';
@@ -50,6 +51,17 @@ export interface SupervisorOptions {
50
51
  verifyEnabled?: boolean;
51
52
  /** Test seam for the one-shot commit-message run. */
52
53
  proposeCommitMessage?: typeof proposeCommitMessage;
54
+ /**
55
+ * Test seam for taking a restore point — #401.
56
+ *
57
+ * A seam and not a detail: the opening restore point is the whole window this
58
+ * ticket is about. It runs between «the working folder is ready» and «the
59
+ * first process exists», it takes SECONDS on a dirty tree, and a message that
60
+ * arrives inside it used to open a second door to `launchAgent`. Timing that
61
+ * window from the outside is guesswork; holding it open from a test is the
62
+ * only way the race is reproducible on demand.
63
+ */
64
+ createCheckpoint?: typeof createCheckpoint;
53
65
  /**
54
66
  * `[checkpoints] enabled` from the runner's own config (ticket #126), by the
55
67
  * same rule as `[verify] enabled`: restore points are copies of the working
@@ -227,6 +239,8 @@ export declare class Supervisor {
227
239
  * which of the two is running.
228
240
  */
229
241
  private installInFlight;
242
+ /** A restore-point collection is running; a second reconnect must not start another (#388). */
243
+ private checkpointGcInFlight;
230
244
  /** Session 14: one project-recipe run per machine, and its verdict queue. */
231
245
  private readonly verify;
232
246
  private readonly verifyReports;
@@ -890,24 +904,37 @@ export declare class Supervisor {
890
904
  * a second session opened. The neighbours are recorded on the point instead —
891
905
  * the conversation can always be rewound to it, the files cannot.
892
906
  *
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.
907
+ * A refusal is audible when it is a refusal — when the person can do
908
+ * something about it, or when a way back they might reach for is not there.
909
+ * A restore point that was never taken is invisible until the day somebody
910
+ * reaches for it, and «the button is not there» is not a sentence anybody can
911
+ * act on.
912
+ *
913
+ * «This session was still answering» is the exception, and it is the only one
914
+ * (#384). It is not a fault and not a state to act on: it is what a follow-up
915
+ * note to a working agent looks like from in here, thirty times in a day on
916
+ * one machine, and it costs almost nothing — the point in front of the turn
917
+ * already stands, and rewinding to it takes back the files AND the
918
+ * conversation, including the note. So it goes to the runner's own log, where
919
+ * support can answer «why is there no point for that step», and not into the
920
+ * feed, where it read as breakage.
896
921
  */
897
922
  private captureCheckpoint;
898
923
  /**
899
924
  * Say something once per BUSY PERIOD, not once per message (#310).
900
925
  *
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.
926
+ * A repository held by another git command stays held for as long as that
927
+ * command runs, and three follow-up notes can arrive inside one answer.
928
+ * Keying this on the message seq would have counted each of those as its own
929
+ * turn and said the same sentence three times — the noise the frequency
930
+ * policy exists to prevent. The set is cleared when the session next comes to
931
+ * rest (`reportStatus`), which is exactly when the reason stops being true.
907
932
  *
908
933
  * A SET of keys, not the last one said: two different reasons can both come
909
934
  * up inside one period, and remembering only the most recent would let them
910
- * take turns re-announcing each other.
935
+ * take turns re-announcing each other. One key uses this today — «another git
936
+ * command holds this repository» — and the set stays a set for that reason,
937
+ * not out of habit: the mid-turn key left when it stopped being said (#384).
911
938
  */
912
939
  private noticeOncePerTurn;
913
940
  /**