pi-claude-supervisor 0.6.0 → 0.7.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/CHANGELOG.md +40 -0
- package/README.cn.md +342 -148
- package/README.md +394 -241
- package/docs/architecture.md +196 -12
- package/docs/testing.md +5 -4
- package/package.json +1 -1
- package/src/acceptance.ts +7 -1
- package/src/config.ts +110 -0
- package/src/cwd-lease.ts +166 -37
- package/src/decision-session-store.ts +43 -2
- package/src/decision-worker.ts +284 -18
- package/src/events.ts +125 -5
- package/src/hooks/install.ts +187 -0
- package/src/hooks/relay.ts +191 -0
- package/src/hooks/server.ts +260 -0
- package/src/hooks/settings.ts +44 -0
- package/src/hooks/types.ts +83 -0
- package/src/index.ts +267 -26
- package/src/json-extract.ts +49 -0
- package/src/notifications.ts +61 -10
- package/src/pi-model.ts +44 -0
- package/src/policy.ts +394 -21
- package/src/redaction.ts +1 -1
- package/src/reviewer.ts +86 -56
- package/src/supervisor.ts +774 -82
- package/src/types.ts +55 -3
- package/src/verifier.ts +49 -16
- package/src/worker/environment.ts +41 -2
- package/src/worker/process-adapter.ts +32 -7
- package/src/worker/runtime.ts +51 -0
- package/src/worker/tmux-adapter.ts +573 -28
package/src/cwd-lease.ts
CHANGED
|
@@ -276,6 +276,16 @@ export class CwdLeaseStore {
|
|
|
276
276
|
return this.#withLock(() => this.#readAll());
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
+
/** Sorted quarantined lease file names, for operator inspection/cleanup. */
|
|
280
|
+
async quarantined(): Promise<string[]> {
|
|
281
|
+
try {
|
|
282
|
+
return (await readdir(this.#quarantineDirectory())).sort();
|
|
283
|
+
} catch (error) {
|
|
284
|
+
if (error instanceof Error && /ENOENT/u.test(error.message)) return [];
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
279
289
|
#handle(initial: CwdLeaseRecord, replacedTaskId?: string): CwdLeaseHandle {
|
|
280
290
|
let current = { ...initial, worker: initial.worker ? { ...initial.worker } : undefined };
|
|
281
291
|
let released = false;
|
|
@@ -326,46 +336,101 @@ export class CwdLeaseStore {
|
|
|
326
336
|
const leases: CwdLeaseRecord[] = [];
|
|
327
337
|
for (const name of names.filter((item) => item.endsWith(".json"))) {
|
|
328
338
|
const leasePath = join(this.#directory, name);
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
let cleanupComplete = await cleanupPendingLease(lease.pendingCleanup);
|
|
338
|
-
// Reconciliation may have removed the cgroup successfully but failed
|
|
339
|
-
// on a later marker operation. Persist that stage before returning a
|
|
340
|
-
// still-pending record, so the next reader can continue fail-closed.
|
|
341
|
-
if (!cgroupCleanedBefore && lease.pendingCleanup.cgroupCleaned === true) {
|
|
342
|
-
await this.#write({ ...lease, updatedAt: new Date().toISOString() });
|
|
343
|
-
// The first pass deliberately stops before socket-marker removal;
|
|
344
|
-
// the cleanup stage must be durable before that second proof.
|
|
345
|
-
cleanupComplete = await cleanupPendingLease(lease.pendingCleanup);
|
|
339
|
+
let lease: CwdLeaseRecord;
|
|
340
|
+
try {
|
|
341
|
+
const leaseInfo = await lstat(leasePath);
|
|
342
|
+
if (!leaseInfo.isFile()) {
|
|
343
|
+
// A directory or symlink is never renamed or followed as part of
|
|
344
|
+
// quarantine; just skip it in place and let an operator inspect it.
|
|
345
|
+
console.error(`pi-claude-supervisor skipped a non-regular cwd lease entry: ${redactText(name)}`);
|
|
346
|
+
continue;
|
|
346
347
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
348
|
+
const value = JSON.parse(await readFile(leasePath, "utf8")) as Partial<CwdLeaseRecord>;
|
|
349
|
+
lease = normalizeLease(value);
|
|
350
|
+
} catch (error) {
|
|
351
|
+
if (isTransientIoError(error)) throw error;
|
|
352
|
+
// One unreadable or incompatible record (disk full, truncated write,
|
|
353
|
+
// a schema this build no longer accepts) must not block every other
|
|
354
|
+
// cwd lease in the registry. Quarantine it out of the directory this
|
|
355
|
+
// scan reads instead, and keep going.
|
|
356
|
+
await this.#quarantine(name, leasePath, error);
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (lease.pendingCleanup) {
|
|
360
|
+
try {
|
|
361
|
+
// A takeover transaction is not usable until the old cgroup/session
|
|
362
|
+
// and any socket marker have been independently confirmed clean.
|
|
363
|
+
const cgroupCleanedBefore = lease.pendingCleanup.cgroupCleaned === true;
|
|
364
|
+
let cleanupComplete = await cleanupPendingLease(lease.pendingCleanup);
|
|
365
|
+
// Reconciliation may have removed the cgroup successfully but failed
|
|
366
|
+
// on a later marker operation. Persist that stage before returning a
|
|
367
|
+
// still-pending record, so the next reader can continue fail-closed.
|
|
368
|
+
if (!cgroupCleanedBefore && lease.pendingCleanup.cgroupCleaned === true) {
|
|
369
|
+
await this.#write({ ...lease, updatedAt: new Date().toISOString() });
|
|
370
|
+
// The first pass deliberately stops before socket-marker removal;
|
|
371
|
+
// the cleanup stage must be durable before that second proof.
|
|
372
|
+
cleanupComplete = await cleanupPendingLease(lease.pendingCleanup);
|
|
358
373
|
}
|
|
374
|
+
if (cleanupComplete) {
|
|
375
|
+
if (lease.pendingCleanup.phase === "replacement") {
|
|
376
|
+
// The replacement is the live lease after recovery. Clear only
|
|
377
|
+
// its transaction marker; do not delete the cwd reservation.
|
|
378
|
+
delete lease.pendingCleanup;
|
|
379
|
+
await this.#write(lease);
|
|
380
|
+
} else {
|
|
381
|
+
// The old record was still in the preparation phase, so no new
|
|
382
|
+
// Worker lease exists to retain after cleanup.
|
|
383
|
+
await rm(leasePath, { force: true });
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
} catch (error) {
|
|
388
|
+
if (isTransientIoError(error)) throw error;
|
|
389
|
+
// A reconciliation failure is not proof the record itself is
|
|
390
|
+
// corrupt, and quarantining it would discard a durable
|
|
391
|
+
// pendingCleanup transaction a later read could still finish. Skip
|
|
392
|
+
// it for this read only and leave the file in place.
|
|
393
|
+
console.error(`pi-claude-supervisor skipped a cwd lease record with a failed pendingCleanup reconciliation: ${redactText(name)}: ${errorMessage(error)}`);
|
|
394
|
+
continue;
|
|
359
395
|
}
|
|
360
396
|
}
|
|
361
|
-
// A missing
|
|
362
|
-
//
|
|
363
|
-
lease
|
|
397
|
+
// A missing cwd is registry evidence that this lease's directory is
|
|
398
|
+
// gone, but it must not be treated the same as an unreadable record:
|
|
399
|
+
// silently dropping the lease would let a second worker start against
|
|
400
|
+
// the same path. Callers only ever compare `existing.cwd` against a
|
|
401
|
+
// freshly canonicalized candidate cwd, so keeping the raw (already
|
|
402
|
+
// `resolve`d by normalizeLease) path here still fails closed for that
|
|
403
|
+
// same/overlapping path without blocking every unrelated cwd lookup
|
|
404
|
+
// just because one lease's directory happened to disappear.
|
|
405
|
+
try {
|
|
406
|
+
lease.cwd = await realpath(lease.cwd);
|
|
407
|
+
} catch {
|
|
408
|
+
lease.cwd = resolve(lease.cwd);
|
|
409
|
+
}
|
|
364
410
|
leases.push(lease);
|
|
365
411
|
}
|
|
366
412
|
return leases;
|
|
367
413
|
}
|
|
368
414
|
|
|
415
|
+
async #quarantine(name: string, leasePath: string, error: unknown): Promise<void> {
|
|
416
|
+
const message = errorMessage(error);
|
|
417
|
+
const quarantineDir = this.#quarantineDirectory();
|
|
418
|
+
try {
|
|
419
|
+
await mkdir(quarantineDir, { recursive: true, mode: 0o700 });
|
|
420
|
+
await rename(leasePath, join(quarantineDir, `${name}.${Date.now()}`));
|
|
421
|
+
} catch {
|
|
422
|
+
// If the rename itself fails, leave the file in place and skip it for
|
|
423
|
+
// this read; the next read will retry rather than lose it silently.
|
|
424
|
+
console.error(`pi-claude-supervisor could not quarantine an unreadable cwd lease record: ${redactText(name)}: ${message}`);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
console.error(`pi-claude-supervisor quarantined an unreadable cwd lease record: ${redactText(name)}: ${message}`);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
#quarantineDirectory(): string {
|
|
431
|
+
return join(this.#directory, "quarantine");
|
|
432
|
+
}
|
|
433
|
+
|
|
369
434
|
async #write(lease: CwdLeaseRecord): Promise<void> {
|
|
370
435
|
await this.#ensureDirectory();
|
|
371
436
|
const target = this.#path(lease.leaseId);
|
|
@@ -570,8 +635,8 @@ async function cgroupHasProcesses(path: string): Promise<boolean> {
|
|
|
570
635
|
try {
|
|
571
636
|
const cgroupInfo = await lstat(cgroupPath);
|
|
572
637
|
if (!cgroupInfo.isDirectory() || cgroupInfo.isSymbolicLink()) return true;
|
|
573
|
-
const canonicalPath = await
|
|
574
|
-
if (
|
|
638
|
+
const canonicalPath = await canonicalCgroupPath(cgroupPath);
|
|
639
|
+
if (!canonicalPath.startsWith(`${cgroupRoot}/`)) return true;
|
|
575
640
|
const eventsPath = join(canonicalPath, "cgroup.events");
|
|
576
641
|
const eventsInfo = await lstat(eventsPath);
|
|
577
642
|
if (!eventsInfo.isFile() || eventsInfo.isSymbolicLink()) return true;
|
|
@@ -840,6 +905,24 @@ function isCgroupPath(path: string): boolean {
|
|
|
840
905
|
return candidate !== root && candidate.startsWith(`${root}/`);
|
|
841
906
|
}
|
|
842
907
|
|
|
908
|
+
/**
|
|
909
|
+
* Bun's realpath implementation decodes systemd's literal `\\x2d` cgroup
|
|
910
|
+
* escapes and then reports ENOENT. Verify every cgroup component with lstat
|
|
911
|
+
* instead; this also rejects symlinked components without relying on realpath.
|
|
912
|
+
*/
|
|
913
|
+
async function canonicalCgroupPath(path: string): Promise<string> {
|
|
914
|
+
const root = resolve("/sys/fs/cgroup");
|
|
915
|
+
const candidate = resolve(path);
|
|
916
|
+
if (candidate === root || !candidate.startsWith(`${root}/`)) throw new Error("invalid cgroup path");
|
|
917
|
+
let current = root;
|
|
918
|
+
for (const component of candidate.slice(root.length + 1).split("/")) {
|
|
919
|
+
current = join(current, component);
|
|
920
|
+
const info = await lstat(current);
|
|
921
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("cgroup path is not a real directory");
|
|
922
|
+
}
|
|
923
|
+
return candidate;
|
|
924
|
+
}
|
|
925
|
+
|
|
843
926
|
function matchesGeneratedCgroupName(path: string, transport: CwdLeaseTransport, workerId: string): boolean {
|
|
844
927
|
const prefix = transport === "tmux" ? "pi-claude-supervisor-tmux-" : "pi-claude-supervisor-";
|
|
845
928
|
return basename(resolve(path)) === `${prefix}${workerId}`;
|
|
@@ -847,11 +930,8 @@ function matchesGeneratedCgroupName(path: string, transport: CwdLeaseTransport,
|
|
|
847
930
|
|
|
848
931
|
async function readCgroupIdentity(path: string): Promise<{ device: string; inode: string }> {
|
|
849
932
|
if (!isCgroupPath(path)) throw new Error("invalid cgroup path");
|
|
850
|
-
const candidate =
|
|
933
|
+
const candidate = await canonicalCgroupPath(path);
|
|
851
934
|
const info = await lstat(candidate);
|
|
852
|
-
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("cgroup path is not a real directory");
|
|
853
|
-
const canonicalPath = await realpath(candidate);
|
|
854
|
-
if (canonicalPath !== candidate || !isCgroupPath(canonicalPath)) throw new Error("cgroup path is not canonical");
|
|
855
935
|
return { device: String(info.dev), inode: String(info.ino) };
|
|
856
936
|
}
|
|
857
937
|
|
|
@@ -864,11 +944,46 @@ async function cgroupIdentityMatches(path: string, expected: { device: string; i
|
|
|
864
944
|
}
|
|
865
945
|
}
|
|
866
946
|
|
|
947
|
+
/**
|
|
948
|
+
* systemd/claude can create nested cgroups under a Worker's own cgroup, so an
|
|
949
|
+
* empty parent (`populated 0`) can still contain an empty child directory
|
|
950
|
+
* that plain `rmdir` leaves behind with ENOTEMPTY. Remove bottom-up so the
|
|
951
|
+
* parent is only ever asked to `rmdir` once every descendant is gone.
|
|
952
|
+
* `hasProcesses` is injectable because `cgroupHasProcesses` refuses to
|
|
953
|
+
* inspect any path outside `/sys/fs/cgroup`, which this function's own
|
|
954
|
+
* (already `/sys/fs/cgroup`-scoped) recursion does not need to re-derive.
|
|
955
|
+
*/
|
|
956
|
+
export async function removeEmptyChildCgroups(path: string, hasProcesses: (candidate: string) => Promise<boolean> = cgroupHasProcesses): Promise<boolean> {
|
|
957
|
+
let entries;
|
|
958
|
+
try {
|
|
959
|
+
entries = await readdir(path, { withFileTypes: true });
|
|
960
|
+
} catch {
|
|
961
|
+
// A directory that has already been removed out from under us (e.g. by a
|
|
962
|
+
// concurrent guardian teardown) is not proof it is safe to remove; every
|
|
963
|
+
// neighboring check in this file (cgroupIdentityMatches, cgroupHasProcesses,
|
|
964
|
+
// the final rmdir) also fails closed on an unexpected fs error.
|
|
965
|
+
return false;
|
|
966
|
+
}
|
|
967
|
+
for (const entry of entries) {
|
|
968
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
|
|
969
|
+
const child = join(path, entry.name);
|
|
970
|
+
if (!await removeEmptyChildCgroups(child, hasProcesses)) return false;
|
|
971
|
+
if (await hasProcesses(child)) return false;
|
|
972
|
+
try {
|
|
973
|
+
await rmdir(child);
|
|
974
|
+
} catch {
|
|
975
|
+
return false;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
return true;
|
|
979
|
+
}
|
|
980
|
+
|
|
867
981
|
async function removeEmptyCgroup(path: string, expected: { device: string; inode: string }): Promise<boolean> {
|
|
868
982
|
// Re-check both the persisted directory identity and emptiness immediately
|
|
869
983
|
// before removal. The initial checks only authorize attempting takeover;
|
|
870
984
|
// they must not be reused after the pre-replacement hook has run.
|
|
871
985
|
if (!await cgroupIdentityMatches(path, expected) || await cgroupHasProcesses(path)) return false;
|
|
986
|
+
if (!await removeEmptyChildCgroups(resolve(path))) return false;
|
|
872
987
|
try {
|
|
873
988
|
await rmdir(resolve(path));
|
|
874
989
|
return true;
|
|
@@ -1116,6 +1231,20 @@ function redactText(value: string): string {
|
|
|
1116
1231
|
return String(redactSensitive(value));
|
|
1117
1232
|
}
|
|
1118
1233
|
|
|
1234
|
+
function errorMessage(error: unknown): string {
|
|
1235
|
+
return error instanceof Error ? error.message : String(error);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* A transient condition (resource exhaustion, a concurrent lock) is not
|
|
1240
|
+
* evidence a lease record is corrupt. Rethrow it instead of quarantining or
|
|
1241
|
+
* skipping the record on its account.
|
|
1242
|
+
*/
|
|
1243
|
+
function isTransientIoError(error: unknown): boolean {
|
|
1244
|
+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
1245
|
+
return code === "EAGAIN" || code === "EBUSY";
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1119
1248
|
function delay(ms: number): Promise<void> {
|
|
1120
1249
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1121
1250
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { chmod, lstat, mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
-
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { redactSensitive } from "./redaction.ts";
|
|
5
5
|
import { normalizeTaskSpec } from "./acceptance.ts";
|
|
6
6
|
import type { TaskSpec } from "./types.ts";
|
|
@@ -34,6 +34,8 @@ export interface DecisionSessionRecord {
|
|
|
34
34
|
turn: number;
|
|
35
35
|
repairRound?: number;
|
|
36
36
|
lastFindingSignature?: string;
|
|
37
|
+
/** Cumulative Claude Worker API cost so far; recovery seeds the next Worker's budget accounting with it. */
|
|
38
|
+
workerCostUsd?: number;
|
|
37
39
|
state: "active" | "closed";
|
|
38
40
|
recoveryState: DecisionRecoveryState;
|
|
39
41
|
recoveryAttempt: number;
|
|
@@ -92,7 +94,44 @@ export class DecisionSessionStore {
|
|
|
92
94
|
});
|
|
93
95
|
}
|
|
94
96
|
|
|
95
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Remove closed records (and their session directories) whose last update is
|
|
99
|
+
* older than maxAgeMs. Active records are never touched. A record whose
|
|
100
|
+
* directory cannot be safely identified as its own task directory (moved,
|
|
101
|
+
* missing, or replaced by a symlink) is left in place and logged, not removed.
|
|
102
|
+
*/
|
|
103
|
+
async prune(options: { maxAgeMs: number; now?: number }): Promise<{ removed: string[] }> {
|
|
104
|
+
if (options.maxAgeMs <= 0) return { removed: [] };
|
|
105
|
+
const now = options.now ?? Date.now();
|
|
106
|
+
return this.#withLock(async () => {
|
|
107
|
+
const removed: string[] = [];
|
|
108
|
+
for (const record of await this.list()) {
|
|
109
|
+
if (record.state !== "closed") continue;
|
|
110
|
+
const updatedAtMs = Date.parse(record.updatedAt);
|
|
111
|
+
if (!Number.isFinite(updatedAtMs) || now - updatedAtMs < options.maxAgeMs) continue;
|
|
112
|
+
try {
|
|
113
|
+
const directory = resolve(dirname(record.decisionSessionFile));
|
|
114
|
+
if (directory !== this.sessionDirectory(record.taskId)) {
|
|
115
|
+
throw new Error("session directory does not match its task id");
|
|
116
|
+
}
|
|
117
|
+
const relativeToStore = relative(this.#directory, directory);
|
|
118
|
+
if (isAbsolute(relativeToStore) || relativeToStore === ".." || relativeToStore.startsWith(`..${sep}`)) {
|
|
119
|
+
throw new Error("session directory escaped the store directory");
|
|
120
|
+
}
|
|
121
|
+
const info = await lstat(directory);
|
|
122
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("session directory is not a real directory");
|
|
123
|
+
await rm(directory, { recursive: true, force: true });
|
|
124
|
+
await rm(this.#recordPath(record.taskId), { force: true });
|
|
125
|
+
removed.push(record.taskId);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
console.error(`pi-claude-supervisor could not prune Decision Worker session ${record.taskId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { removed };
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async update(taskId: string, patch: Partial<Pick<DecisionSessionRecord, "turn" | "repairRound" | "lastFindingSignature" | "workerCostUsd" | "updatedAt">>): Promise<void> {
|
|
96
135
|
assertTaskId(taskId);
|
|
97
136
|
await this.#withLock(async () => {
|
|
98
137
|
const record = await this.#loadUnlocked(taskId);
|
|
@@ -366,6 +405,7 @@ function normalizeRecord(value: Partial<DecisionSessionRecord>, directory: strin
|
|
|
366
405
|
|| (value.recoveryOwnerStartTime !== undefined && (typeof value.recoveryOwnerStartTime !== "string" || !/^\d+$/u.test(value.recoveryOwnerStartTime)))
|
|
367
406
|
|| (value.recoveryState !== undefined && !isRecoveryState(value.recoveryState))
|
|
368
407
|
|| (value.lastFindingSignature !== undefined && (typeof value.lastFindingSignature !== "string" || value.lastFindingSignature.length > 128))
|
|
408
|
+
|| (value.workerCostUsd !== undefined && (typeof value.workerCostUsd !== "number" || !Number.isFinite(value.workerCostUsd) || value.workerCostUsd < 0))
|
|
369
409
|
|| (value.baseCommit !== undefined && (typeof value.baseCommit !== "string" || !/^[0-9a-f]{40,64}$/iu.test(value.baseCommit)))
|
|
370
410
|
|| (value.baseBranch !== undefined && (typeof value.baseBranch !== "string" || !/^[A-Za-z0-9._/-]+$/u.test(value.baseBranch)))
|
|
371
411
|
|| (value.resolvedExecutable !== undefined && (typeof value.resolvedExecutable !== "string" || !isAbsolute(value.resolvedExecutable) || value.resolvedExecutable.length > 4_096))
|
|
@@ -403,6 +443,7 @@ function normalizeRecord(value: Partial<DecisionSessionRecord>, directory: strin
|
|
|
403
443
|
turn: value.turn ?? 0,
|
|
404
444
|
repairRound: value.repairRound ?? 0,
|
|
405
445
|
...(typeof value.lastFindingSignature === "string" ? { lastFindingSignature: value.lastFindingSignature } : {}),
|
|
446
|
+
...(typeof value.workerCostUsd === "number" ? { workerCostUsd: value.workerCostUsd } : {}),
|
|
406
447
|
state: value.state,
|
|
407
448
|
recoveryState: value.recoveryState ?? "ready",
|
|
408
449
|
recoveryAttempt: value.recoveryAttempt ?? 0,
|