@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.
- package/dist/adapters/claude-usage.d.ts +122 -1
- package/dist/adapters/claude-usage.js +310 -7
- package/dist/adapters/claude.js +68 -28
- package/dist/adapters/questions.d.ts +15 -0
- package/dist/adapters/questions.js +32 -0
- package/dist/auth-relay.js +19 -0
- package/dist/checkpoints.d.ts +12 -1
- package/dist/checkpoints.js +272 -42
- package/dist/supervisor.d.ts +37 -10
- package/dist/supervisor.js +385 -190
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
*
|
package/dist/auth-relay.js
CHANGED
|
@@ -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);
|
package/dist/checkpoints.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|
package/dist/checkpoints.js
CHANGED
|
@@ -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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
|
|
856
|
-
|
|
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) {
|
package/dist/supervisor.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
894
|
-
*
|
|
895
|
-
*
|
|
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
|
|
902
|
-
*
|
|
903
|
-
* message seq would have counted each of those as its own
|
|
904
|
-
* same sentence three times — the noise the frequency
|
|
905
|
-
* prevent. The set is cleared when the session next comes to
|
|
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
|
/**
|