@brainervirus/workit-core 2.1.5 → 2.2.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.
@@ -1,7 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
3
  import * as fs from "node:fs";
4
- import { hostname } from "node:os";
5
4
  import path from "node:path";
6
5
  import * as z from "zod";
7
6
  import { packageRoot } from "./package-root";
@@ -17,6 +16,7 @@ import {
17
16
  intentSchema,
18
17
  newId,
19
18
  newRevision,
19
+ parseStoredRecord,
20
20
  provenanceSchema,
21
21
  refSchema,
22
22
  sha256,
@@ -33,6 +33,41 @@ import {
33
33
  type Utc,
34
34
  type WorkspaceRecord,
35
35
  } from "./task-contract";
36
+ import {
37
+ classifyLockOwner,
38
+ defaultLockTimeout,
39
+ localLockHost,
40
+ clearAbandonedReclaimGuard,
41
+ parseMetadataLock,
42
+ parseMetadataLockOrNull,
43
+ processStartOf,
44
+ sameMetadataLock,
45
+ type MetadataLock,
46
+ } from "./store-lock";
47
+
48
+ export type { MetadataLock } from "./store-lock";
49
+ /** Recovery copies kept per record (task or workspace); older copies are pruned. */
50
+ export const RECOVERY_COPIES_PER_RECORD = 3;
51
+ /** A temp file older than this was left by a crashed writer. */
52
+ const STALE_TEMPORARY_MS = 60 * 60_000;
53
+ const RECOVERY_NAME = /^(task|workspace)\.([^.]+)\.([0-9a-f]{64})\.json$/;
54
+ export type GarbageReport = {
55
+ dryRun: boolean;
56
+ recovery: { removed: number; removedBytes: number; kept: number };
57
+ temporary: { removed: number };
58
+ candidates: {
59
+ removed: number;
60
+ tasks: Id[];
61
+ skippedActive: Id[];
62
+ skippedClosed: Id[];
63
+ failed: Id[];
64
+ };
65
+ };
66
+ export type TaskStoreOptions = {
67
+ /** Total time a mutation retries a lock held by a live writer before `busy`
68
+ * (default: `defaultLockTimeout()`, short for in-process hosts). */
69
+ lockTimeoutMs?: number;
70
+ };
36
71
 
37
72
  export type MutationContext = { now: Utc; revision: Revision };
38
73
  export type TaskMutation = (task: TaskRecord, context: MutationContext) => Result<TaskRecord>;
@@ -72,13 +107,6 @@ export type RecoveryInput = {
72
107
  writer: WorkspaceRecord["writer"],
73
108
  ) => Result<ProcessEvidence>;
74
109
  };
75
- export type MetadataLock = {
76
- pid: number;
77
- processStart: string | null;
78
- host: string;
79
- nonce: string;
80
- externalAction?: true;
81
- };
82
110
  export type ProcessEvidence = {
83
111
  state: "stopped" | "accounted_for";
84
112
  pid: number;
@@ -96,15 +124,6 @@ const processEvidenceSchema = z
96
124
  .nullable(),
97
125
  })
98
126
  .strict();
99
- const metadataLockSchema = z
100
- .object({
101
- pid: z.number().int().nonnegative().safe(),
102
- processStart: z.string().nullable(),
103
- host: z.string().min(1),
104
- nonce: z.string().min(1),
105
- externalAction: z.literal(true).optional(),
106
- })
107
- .strict();
108
127
  /** One task's listing facts, kept in `.workit/index.json` so per-turn host
109
128
  * hooks can find a session's task without parsing every full record. */
110
129
  export type TaskIndexEntry = {
@@ -224,22 +243,6 @@ const isObject = (value: unknown): value is Record<string, unknown> =>
224
243
  const validId = (value: string): boolean =>
225
244
  /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value);
226
245
  const validDigest = (value: string): boolean => /^[0-9a-f]{64}$/.test(value);
227
- const parseMetadataLock = (raw: string): MetadataLock => {
228
- let value: unknown;
229
- try {
230
- value = JSON.parse(raw);
231
- } catch {
232
- throw Object.assign(new Error("metadata lock is invalid"), { code: "metadata_lock_invalid" });
233
- }
234
- const parsed = metadataLockSchema.safeParse(value);
235
- if (!parsed.success)
236
- throw Object.assign(new Error("metadata lock is invalid"), { code: "metadata_lock_invalid" });
237
- return parsed.data;
238
- };
239
- const sameMetadataLock = (left: unknown, right: MetadataLock): boolean => {
240
- const parsed = metadataLockSchema.safeParse(left);
241
- return parsed.success && canonicalJson(parsed.data) === canonicalJson(right);
242
- };
243
246
  export const sameDirectoryIdentity = (left: string, right: string): boolean => {
244
247
  if (!path.isAbsolute(left) || !path.isAbsolute(right)) return false;
245
248
  const normalizedLeft = path.resolve(left);
@@ -268,13 +271,49 @@ export const sameDirectoryIdentity = (left: string, right: string): boolean => {
268
271
  }
269
272
  };
270
273
  type LockSnapshot = { raw: string; data: MetadataLock };
274
+ const TRANSIENT_WINDOWS_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
275
+ /** Windows briefly refuses to replace or open a file that another process is
276
+ * reading or renaming at that instant. That is contention, not damage: retry
277
+ * for about a second before surfacing the error. */
278
+ const isTransientWindowsError = (error: unknown): boolean => {
279
+ if (process.platform !== "win32") return false;
280
+ const code = (error as { code?: unknown } | null)?.code;
281
+ return typeof code === "string" && TRANSIENT_WINDOWS_CODES.has(code);
282
+ };
283
+ const retryTransient = <T>(run: () => T): T => {
284
+ if (process.platform !== "win32") return run();
285
+ for (let attempt = 0; ; attempt += 1) {
286
+ try {
287
+ return run();
288
+ } catch (error) {
289
+ if (attempt >= 20 || !isTransientWindowsError(error)) throw error;
290
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5 * (attempt + 1));
291
+ }
292
+ }
293
+ };
271
294
  const externalActionLockRoots = new AsyncLocalStorage<ReadonlySet<string>>();
295
+ /** Roots whose metadata lock this process holds; waiting on them can only time out. */
296
+ const heldInProcess = new Map<string, number>();
297
+ const holdRoot = (root: string) => heldInProcess.set(root, (heldInProcess.get(root) ?? 0) + 1);
298
+ const dropRoot = (root: string) => {
299
+ const count = (heldInProcess.get(root) ?? 1) - 1;
300
+ if (count > 0) heldInProcess.set(root, count);
301
+ else heldInProcess.delete(root);
302
+ };
303
+
304
+ /** Drop earlier copies of a repeated candidate ID; content is identical by ID. */
305
+ const dedupeCandidates = (candidates: TaskRecord["candidates"]): TaskRecord["candidates"] => {
306
+ const last = new Map(candidates.map((candidate, index) => [candidate.id, index]));
307
+ return candidates.filter((candidate, index) => last.get(candidate.id) === index);
308
+ };
272
309
 
273
310
  export class TaskStore {
274
311
  readonly root: string;
312
+ private readonly lockTimeoutMs: number;
275
313
 
276
- constructor(root: string) {
314
+ constructor(root: string, options: TaskStoreOptions = {}) {
277
315
  this.root = fs.existsSync(root) ? fs.realpathSync(root) : path.resolve(root);
316
+ this.lockTimeoutMs = options.lockTimeoutMs ?? defaultLockTimeout();
278
317
  }
279
318
 
280
319
  readTask(taskId: Id): Result<TaskRecord> {
@@ -792,7 +831,7 @@ export class TaskStore {
792
831
  try {
793
832
  const candidates: RecoveryCandidate[] = [];
794
833
  for (const name of fs.readdirSync(this.recoveryDir)) {
795
- const match = /^(task|workspace)\.([^.]+)\.([0-9a-f]{64})\.json$/.exec(name);
834
+ const match = RECOVERY_NAME.exec(name);
796
835
  if (match)
797
836
  candidates.push({
798
837
  target: match[1] as "task" | "workspace",
@@ -853,8 +892,8 @@ export class TaskStore {
853
892
  }
854
893
  const selectedRecord =
855
894
  target === "workspace"
856
- ? this.parseBytes<WorkspaceRecord>(selectedBytes, workspaceRecordSchema)
857
- : this.parseBytes<TaskRecord>(selectedBytes, taskRecordSchema);
895
+ ? this.parseBytes<WorkspaceRecord>(selectedBytes, workspaceRecordSchema, "rewrite")
896
+ : this.parseBytes<TaskRecord>(selectedBytes, taskRecordSchema, "rewrite");
858
897
  if (!selectedRecord.ok) return selectedRecord;
859
898
  if (target === "workspace") {
860
899
  const parsed = selectedRecord as Result<WorkspaceRecord>;
@@ -914,13 +953,17 @@ export class TaskStore {
914
953
  return this.conflict(input.expectedWorkspaceRevision, currentWorkspace.data.revision);
915
954
  }
916
955
  if (target === "workspace") {
917
- const parsed = this.parseBytes<WorkspaceRecord>(selectedBytes, workspaceRecordSchema);
956
+ const parsed = this.parseBytes<WorkspaceRecord>(
957
+ selectedBytes,
958
+ workspaceRecordSchema,
959
+ "rewrite",
960
+ );
918
961
  if (!parsed.ok) return parsed;
919
962
  const value = { ...parsed.data, revision: newRevision(), writer: null };
920
963
  const replaced = this.replaceSnapshot(file, value, reacquiredBytes);
921
964
  return replaced.ok ? success(value.revision, value.revision, value) : replaced;
922
965
  }
923
- const parsed = this.parseBytes<TaskRecord>(selectedBytes, taskRecordSchema);
966
+ const parsed = this.parseBytes<TaskRecord>(selectedBytes, taskRecordSchema, "rewrite");
924
967
  if (!parsed.ok) return parsed;
925
968
  const value = {
926
969
  ...parsed.data,
@@ -932,6 +975,7 @@ export class TaskStore {
932
975
  return replaced.ok ? success(value.revision, null, value) : replaced;
933
976
  },
934
977
  this.recoveryLockOptions(lock.data, evidence.data, recoveryGate),
978
+ "recovery_required",
935
979
  );
936
980
  return result;
937
981
  } catch (error) {
@@ -989,24 +1033,76 @@ export class TaskStore {
989
1033
  }
990
1034
  }
991
1035
 
1036
+ /**
1037
+ * Mutation lock: a holder that is gone (dead pid, reused pid, or a foreign or
1038
+ * unreadable lock past its TTL) is reclaimed; a live holder is waited on
1039
+ * briefly and then reported as retryable `busy`.
1040
+ */
992
1041
  private metadataLockOptions(): FileLockSyncAcquireOptions<MetadataLock> {
1042
+ const inProcess = heldInProcess.has(this.root);
993
1043
  return {
994
1044
  lockPath: this.lockPath,
995
1045
  staleMs: Number.MAX_SAFE_INTEGER,
996
- timeoutMs: 0,
997
- retry: { retries: 0 },
998
- staleRecovery: "fail-closed",
999
- shouldReclaim: () => false,
1000
- parsePayload: parseMetadataLock,
1046
+ timeoutMs: inProcess ? 0 : this.lockTimeoutMs,
1047
+ retry: inProcess
1048
+ ? { retries: 0 }
1049
+ : { minTimeout: 5, maxTimeout: 100, factor: 1.5, randomize: true },
1050
+ staleRecovery: "remove-if-unchanged",
1051
+ shouldReclaim: ({ payload, nowMs }) => {
1052
+ let ageMs: number | null = null;
1053
+ try {
1054
+ ageMs = nowMs - fs.lstatSync(this.lockPath).mtimeMs;
1055
+ } catch {}
1056
+ return classifyLockOwner(payload, ageMs).state === "stale";
1057
+ },
1058
+ // The library re-checks the bytes before removal, so a lock replaced
1059
+ // after classification is never deleted.
1060
+ shouldRemoveStaleLock: () => true,
1061
+ parsePayload: parseMetadataLockOrNull,
1001
1062
  payload: () => ({
1002
1063
  pid: process.pid,
1003
- processStart: this.processStart(process.pid),
1004
- host: hostname(),
1064
+ processStart: processStartOf(process.pid),
1065
+ host: localLockHost(),
1005
1066
  nonce: randomUUID(),
1006
1067
  }),
1007
1068
  };
1008
1069
  }
1009
1070
 
1071
+ private acquireMetadataLock(
1072
+ options: FileLockSyncAcquireOptions<MetadataLock>,
1073
+ ): FileLockSyncHandle {
1074
+ clearAbandonedReclaimGuard(this.lockPath);
1075
+ // One budget for the whole acquisition: a lost reclaim race retries with
1076
+ // the remaining time, never a fresh timeout.
1077
+ const deadline = Date.now() + (options.timeoutMs ?? 0);
1078
+ while (true) {
1079
+ try {
1080
+ return acquireFileLockSync(this.workspacePath, {
1081
+ ...options,
1082
+ timeoutMs: Math.max(0, deadline - Date.now()),
1083
+ });
1084
+ } catch (error) {
1085
+ // Losing a reclaim race to another process, or Windows refusing the
1086
+ // lock file while another process opens or deletes it, is contention.
1087
+ const code = (error as { code?: unknown })?.code;
1088
+ if (
1089
+ (code !== "file_lock_stale" && !isTransientWindowsError(error)) ||
1090
+ Date.now() >= deadline
1091
+ )
1092
+ throw error;
1093
+ // Windows also denies the create while a just-released lock is still
1094
+ // pending delete, and that entry is already invisible to lstat. So a
1095
+ // denial with no visible holder is told apart by probing whether the
1096
+ // directory accepts a new file: if it does not, this is a permission
1097
+ // problem (read-only attribute, ACL) and fails fast.
1098
+ if (code !== "file_lock_stale" && !this.lockHolderPresent() && !this.lockDirWritable())
1099
+ throw error;
1100
+ if (code !== "file_lock_stale")
1101
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
1102
+ }
1103
+ }
1104
+ }
1105
+
1010
1106
  private externalActionLockOptions(): FileLockSyncAcquireOptions<MetadataLock> {
1011
1107
  const options = this.metadataLockOptions();
1012
1108
  return { ...options, payload: () => ({ ...options.payload(), externalAction: true }) };
@@ -1032,10 +1128,11 @@ export class TaskStore {
1032
1128
  }
1033
1129
  let handle: FileLockSyncHandle;
1034
1130
  try {
1035
- handle = acquireFileLockSync(this.workspacePath, this.externalActionLockOptions());
1131
+ handle = this.acquireMetadataLock(this.externalActionLockOptions());
1036
1132
  } catch (error) {
1037
1133
  return this.lockFailure(error);
1038
1134
  }
1135
+ holdRoot(this.root);
1039
1136
  let result: Result<T>;
1040
1137
  try {
1041
1138
  if (!handle.verifyStillHeld())
@@ -1072,8 +1169,9 @@ export class TaskStore {
1072
1169
  path: this.lockPath,
1073
1170
  });
1074
1171
  }
1172
+ dropRoot(this.root);
1075
1173
  try {
1076
- handle.release();
1174
+ retryTransient(() => handle.release());
1077
1175
  } catch (error) {
1078
1176
  const released = this.lockFailure(error);
1079
1177
  const releaseError = released.ok ? "metadata lock release failed" : released.error;
@@ -1097,6 +1195,9 @@ export class TaskStore {
1097
1195
  gate: { reclaimed: boolean },
1098
1196
  ): FileLockSyncAcquireOptions<MetadataLock> {
1099
1197
  const options = this.metadataLockOptions();
1198
+ options.timeoutMs = 0;
1199
+ options.retry = { retries: 0 };
1200
+ options.parsePayload = parseMetadataLock;
1100
1201
  options.staleRecovery = "remove-if-unchanged";
1101
1202
  options.shouldReclaim = ({ payload }) =>
1102
1203
  Boolean(
@@ -1127,6 +1228,7 @@ export class TaskStore {
1127
1228
  private withLock<T>(
1128
1229
  operation: (handle: FileLockSyncHandle) => Result<T>,
1129
1230
  options: FileLockSyncAcquireOptions<MetadataLock> = this.metadataLockOptions(),
1231
+ contention: "busy" | "recovery_required" = "busy",
1130
1232
  ): Result<T> {
1131
1233
  try {
1132
1234
  this.initializeMutationStorage();
@@ -1141,10 +1243,11 @@ export class TaskStore {
1141
1243
  "metadata lock operation did not produce a result",
1142
1244
  );
1143
1245
  try {
1144
- handle = acquireFileLockSync(this.workspacePath, options);
1246
+ handle = this.acquireMetadataLock(options);
1145
1247
  } catch (error) {
1146
- result = this.lockFailure(error);
1248
+ result = this.lockFailure(error, contention);
1147
1249
  }
1250
+ if (handle) holdRoot(this.root);
1148
1251
  if (handle) {
1149
1252
  try {
1150
1253
  if (!handle.verifyStillHeld())
@@ -1163,8 +1266,9 @@ export class TaskStore {
1163
1266
  }
1164
1267
  }
1165
1268
  if (handle) {
1269
+ dropRoot(this.root);
1166
1270
  try {
1167
- handle.release();
1271
+ retryTransient(() => handle.release());
1168
1272
  } catch (error) {
1169
1273
  const releaseFailure = this.lockFailure(error);
1170
1274
  const releaseError = releaseFailure.ok
@@ -1178,16 +1282,72 @@ export class TaskStore {
1178
1282
  return result;
1179
1283
  }
1180
1284
 
1181
- private lockFailure(error: unknown): Result<never> {
1285
+ /** Whether the lock's directory accepts a new file right now. */
1286
+ private lockDirWritable(): boolean {
1287
+ const probe = `${this.lockPath}.${process.pid}.${randomUUID()}.probe`;
1288
+ try {
1289
+ fs.closeSync(fs.openSync(probe, "wx", 0o600));
1290
+ } catch {
1291
+ return false;
1292
+ }
1293
+ try {
1294
+ fs.rmSync(probe, { force: true });
1295
+ } catch {}
1296
+ return true;
1297
+ }
1298
+
1299
+ /** A lock file (or an entry Windows is still tearing down) exists. */
1300
+ private lockHolderPresent(): boolean {
1301
+ try {
1302
+ fs.lstatSync(this.lockPath);
1303
+ return true;
1304
+ } catch (error) {
1305
+ return (error as { code?: unknown } | null)?.code !== "ENOENT";
1306
+ }
1307
+ }
1308
+
1309
+ private lockFailure(
1310
+ error: unknown,
1311
+ contention: "busy" | "recovery_required" = "busy",
1312
+ ): Result<never> {
1182
1313
  const value = error as { code?: unknown; message?: unknown };
1183
1314
  const code = typeof value?.code === "string" ? value.code : "";
1184
- if (code === "EEXIST" || code === "file_lock_timeout") {
1185
- const lock = this.readLockSnapshot();
1186
- if (lock.ok && lock.data?.data.externalAction)
1315
+ // A sharing-class denial is contention only when someone holds the lock.
1316
+ if (isTransientWindowsError(error) && !this.lockHolderPresent() && !this.lockDirWritable())
1317
+ return failure(
1318
+ "storage_error",
1319
+ `cannot create the workspace metadata lock (${code}); no other Workit call holds it`,
1320
+ {
1321
+ path: this.lockPath,
1322
+ guidance: `Check permissions and the read-only attribute on ${path.dirname(this.lockPath)}.`,
1323
+ },
1324
+ );
1325
+ if (
1326
+ code === "EEXIST" ||
1327
+ code === "file_lock_timeout" ||
1328
+ code === "file_lock_stale" ||
1329
+ isTransientWindowsError(error)
1330
+ ) {
1331
+ let lock: MetadataLock | null = null;
1332
+ try {
1333
+ lock = parseMetadataLockOrNull(fs.readFileSync(this.lockPath, "utf8"));
1334
+ } catch {}
1335
+ if (lock?.externalAction)
1187
1336
  return failure("writer_conflict", "workspace is reserved by a managed external action", {
1188
1337
  outcome: "not_started",
1189
1338
  path: this.lockPath,
1190
1339
  });
1340
+ if (contention === "busy")
1341
+ return failure(
1342
+ "busy",
1343
+ `workspace metadata lock is held by another Workit call${lock ? ` (pid ${lock.pid} on ${lock.host})` : ""}; retry shortly`,
1344
+ {
1345
+ outcome: "not_started",
1346
+ path: this.lockPath,
1347
+ guidance:
1348
+ "Retry the same call. If it stays busy, run `workit doctor --fix-lock` to clear a lock left by a dead process.",
1349
+ },
1350
+ );
1191
1351
  }
1192
1352
  const recovery =
1193
1353
  code === "EEXIST" ||
@@ -1204,9 +1364,16 @@ export class TaskStore {
1204
1364
  }
1205
1365
 
1206
1366
  private initializeMutationStorage() {
1207
- fs.mkdirSync(this.tasksDir, { recursive: true });
1208
- fs.mkdirSync(this.recoveryDir, { recursive: true });
1209
- fs.writeFileSync(this.gitignorePath, "*\n");
1367
+ retryTransient(() => fs.mkdirSync(this.tasksDir, { recursive: true }));
1368
+ retryTransient(() => fs.mkdirSync(this.recoveryDir, { recursive: true }));
1369
+ // Rewriting an unchanged .gitignore on every call makes concurrent
1370
+ // writers collide on it (Windows sharing violations); write it only when
1371
+ // it is missing or different.
1372
+ let current: string | null = null;
1373
+ try {
1374
+ current = retryTransient(() => fs.readFileSync(this.gitignorePath, "utf8"));
1375
+ } catch {}
1376
+ if (current !== "*\n") retryTransient(() => fs.writeFileSync(this.gitignorePath, "*\n"));
1210
1377
  }
1211
1378
 
1212
1379
  private replaceSnapshot(file: string, value: unknown, previous: unknown): Result<any> {
@@ -1224,7 +1391,7 @@ export class TaskStore {
1224
1391
  } finally {
1225
1392
  fs.closeSync(fd);
1226
1393
  }
1227
- fs.renameSync(temporary, file);
1394
+ retryTransient(() => fs.renameSync(temporary!, file));
1228
1395
  temporary = undefined;
1229
1396
  this.fsyncDirectory(path.dirname(file));
1230
1397
  if (path.dirname(file) === this.tasksDir) this.indexTaskWrite(file, value as TaskRecord);
@@ -1283,7 +1450,7 @@ export class TaskStore {
1283
1450
  }),
1284
1451
  { mode: 0o600, flag: "wx" },
1285
1452
  );
1286
- fs.renameSync(temporary, this.indexPath);
1453
+ retryTransient(() => fs.renameSync(temporary, this.indexPath));
1287
1454
  } catch {
1288
1455
  try {
1289
1456
  fs.unlinkSync(temporary);
@@ -1308,8 +1475,13 @@ export class TaskStore {
1308
1475
  const id = target === "task" ? path.basename(file, ".json") : "workspace";
1309
1476
  const destination = path.join(this.recoveryDir, `${target}.${id}.${digestBytes(bytes)}.json`);
1310
1477
  if (fs.existsSync(destination)) {
1311
- if (digestBytes(fs.readFileSync(destination)) === digestBytes(bytes)) return;
1312
- throw new Error("recovery copy already exists with different bytes");
1478
+ if (digestBytes(retryTransient(() => fs.readFileSync(destination))) !== digestBytes(bytes))
1479
+ throw new Error("recovery copy already exists with different bytes");
1480
+ // Re-saved bytes are the newest copy again for pruning purposes.
1481
+ const stamp = new Date();
1482
+ retryTransient(() => fs.utimesSync(destination, stamp, stamp));
1483
+ this.pruneRecovery(`${target}.${id}.`, destination);
1484
+ return;
1313
1485
  }
1314
1486
  let temporary: string | undefined;
1315
1487
  try {
@@ -1321,9 +1493,10 @@ export class TaskStore {
1321
1493
  } finally {
1322
1494
  fs.closeSync(fd);
1323
1495
  }
1324
- fs.renameSync(temporary, destination);
1496
+ retryTransient(() => fs.renameSync(temporary!, destination));
1325
1497
  temporary = undefined;
1326
1498
  this.fsyncDirectory(this.recoveryDir);
1499
+ this.pruneRecovery(`${target}.${id}.`, destination);
1327
1500
  } finally {
1328
1501
  if (temporary)
1329
1502
  try {
@@ -1332,6 +1505,144 @@ export class TaskStore {
1332
1505
  }
1333
1506
  }
1334
1507
 
1508
+ /**
1509
+ * Keep the newest RECOVERY_COPIES_PER_RECORD copies of one record (always
1510
+ * including `keep`, the copy just written). Pruning is best-effort: a
1511
+ * failure never fails the mutation that triggered it.
1512
+ */
1513
+ private pruneRecovery(prefix: string, keep: string) {
1514
+ try {
1515
+ const copies = this.recoveryCopies(prefix);
1516
+ const surplus = this.surplusCopies(copies, path.basename(keep));
1517
+ for (const copy of surplus) fs.rmSync(copy.path, { force: true });
1518
+ } catch {}
1519
+ }
1520
+
1521
+ private recoveryCopies(
1522
+ prefix = "",
1523
+ ): { name: string; path: string; group: string; mtimeNs: bigint }[] {
1524
+ if (!fs.existsSync(this.recoveryDir)) return [];
1525
+ const copies = [];
1526
+ for (const name of fs.readdirSync(this.recoveryDir)) {
1527
+ if (!name.startsWith(prefix)) continue;
1528
+ const match = RECOVERY_NAME.exec(name);
1529
+ if (!match) continue;
1530
+ const file = path.join(this.recoveryDir, name);
1531
+ try {
1532
+ const stat = fs.lstatSync(file, { bigint: true });
1533
+ if (!stat.isFile()) continue;
1534
+ copies.push({ name, path: file, group: `${match[1]}.${match[2]}`, mtimeNs: stat.mtimeNs });
1535
+ } catch {}
1536
+ }
1537
+ return copies;
1538
+ }
1539
+
1540
+ /** Copies of one record beyond the cap, oldest first out; `keep` is never surplus. */
1541
+ private surplusCopies<T extends { name: string; mtimeNs: bigint }>(copies: T[], keep?: string) {
1542
+ const ordered = copies.toSorted((left, right) =>
1543
+ left.name === keep
1544
+ ? -1
1545
+ : right.name === keep
1546
+ ? 1
1547
+ : left.mtimeNs === right.mtimeNs
1548
+ ? left.name.localeCompare(right.name)
1549
+ : left.mtimeNs > right.mtimeNs
1550
+ ? -1
1551
+ : 1,
1552
+ );
1553
+ return ordered.slice(RECOVERY_COPIES_PER_RECORD);
1554
+ }
1555
+
1556
+ /**
1557
+ * `workit gc`: prune recovery copies beyond the per-record cap, remove temp
1558
+ * files left by crashed writers, and collapse duplicate stored candidates in
1559
+ * paused tasks. Live records (tasks/*.json, workspace.json) are never
1560
+ * deleted; candidate dedupe keeps every candidate ID and the latest position.
1561
+ * Closed tasks are history and are never rewritten. A dry run is read-only:
1562
+ * no lock, no directory creation, no .gitignore rewrite.
1563
+ */
1564
+ collectGarbage(options: { dryRun?: boolean } = {}): Result<GarbageReport> {
1565
+ const dryRun = options.dryRun === true;
1566
+ const report: GarbageReport = {
1567
+ dryRun,
1568
+ recovery: { removed: 0, removedBytes: 0, kept: 0 },
1569
+ temporary: { removed: 0 },
1570
+ candidates: { removed: 0, tasks: [], skippedActive: [], skippedClosed: [], failed: [] },
1571
+ };
1572
+ if (!fs.existsSync(this.workitDir)) return success(null, null, report);
1573
+ const sweep = (): Result<null> => {
1574
+ try {
1575
+ const groups = new Map<string, ReturnType<TaskStore["recoveryCopies"]>>();
1576
+ for (const copy of this.recoveryCopies())
1577
+ groups.set(copy.group, [...(groups.get(copy.group) ?? []), copy]);
1578
+ for (const copies of groups.values()) {
1579
+ const surplus = this.surplusCopies(copies);
1580
+ report.recovery.kept += copies.length - surplus.length;
1581
+ for (const copy of surplus) {
1582
+ report.recovery.removed += 1;
1583
+ try {
1584
+ report.recovery.removedBytes += fs.statSync(copy.path).size;
1585
+ } catch {}
1586
+ if (!dryRun) fs.rmSync(copy.path, { force: true });
1587
+ }
1588
+ }
1589
+ const nowMs = Date.now();
1590
+ for (const directory of [this.recoveryDir, this.tasksDir, this.workitDir]) {
1591
+ if (!fs.existsSync(directory)) continue;
1592
+ for (const name of fs.readdirSync(directory)) {
1593
+ if (!name.endsWith(".tmp") && !name.endsWith(".probe")) continue;
1594
+ const file = path.join(directory, name);
1595
+ const stat = fs.lstatSync(file);
1596
+ if (!stat.isFile() || nowMs - stat.mtimeMs <= STALE_TEMPORARY_MS) continue;
1597
+ report.temporary.removed += 1;
1598
+ if (!dryRun) fs.rmSync(file, { force: true });
1599
+ }
1600
+ }
1601
+ return success(null, null, null);
1602
+ } catch (error) {
1603
+ return failure("storage_error", `garbage collection failed: ${String(error)}`, {
1604
+ path: this.recoveryDir,
1605
+ });
1606
+ }
1607
+ };
1608
+ // withLock initializes storage (mkdir, .gitignore); a dry run must not.
1609
+ const pruned = dryRun ? sweep() : this.withLock<null>(sweep);
1610
+ if (!pruned.ok) return pruned;
1611
+ const tasks = this.listTasks();
1612
+ if (!tasks.ok) return tasks;
1613
+ for (const task of tasks.data) {
1614
+ const deduped = dedupeCandidates(task.candidates);
1615
+ const removed = task.candidates.length - deduped.length;
1616
+ if (removed === 0) continue;
1617
+ // An active task belongs to a live session; rewriting it would bump the
1618
+ // revision under that session's feet.
1619
+ if (task.status === "active") {
1620
+ report.candidates.skippedActive.push(task.id);
1621
+ continue;
1622
+ }
1623
+ // Closed records are immutable history (docs/workit-v1/contracts.md).
1624
+ if (task.status === "closed") {
1625
+ report.candidates.skippedClosed.push(task.id);
1626
+ continue;
1627
+ }
1628
+ if (!dryRun) {
1629
+ const written = this.mutateTask(task.id, task.revision, (current) =>
1630
+ success(current.revision, null, {
1631
+ ...current,
1632
+ candidates: dedupeCandidates(current.candidates),
1633
+ }),
1634
+ );
1635
+ if (!written.ok) {
1636
+ report.candidates.failed.push(task.id);
1637
+ continue;
1638
+ }
1639
+ }
1640
+ report.candidates.removed += removed;
1641
+ report.candidates.tasks.push(task.id);
1642
+ }
1643
+ return success(null, null, report);
1644
+ }
1645
+
1335
1646
  private findRecovery(
1336
1647
  target: "task" | "workspace",
1337
1648
  taskId: Id | null,
@@ -1398,15 +1709,15 @@ export class TaskStore {
1398
1709
 
1399
1710
  private snapshotBytes(file: string): Buffer | null {
1400
1711
  try {
1401
- return fs.readFileSync(file);
1712
+ return retryTransient(() => fs.readFileSync(file));
1402
1713
  } catch {
1403
1714
  return null;
1404
1715
  }
1405
1716
  }
1406
1717
 
1407
- private readRecord<T>(file: string, schema: { safeParse(value: unknown): any }) {
1718
+ private readRecord<T>(file: string, schema: z.ZodType) {
1408
1719
  try {
1409
- const bytes = fs.readFileSync(file, "utf8");
1720
+ const bytes = retryTransient(() => fs.readFileSync(file, "utf8"));
1410
1721
  return { exists: true, result: this.parseBytes<T>(bytes, schema) };
1411
1722
  } catch (error: any) {
1412
1723
  if (error?.code === "ENOENT")
@@ -1420,9 +1731,12 @@ export class TaskStore {
1420
1731
  }
1421
1732
  }
1422
1733
 
1734
+ /** `rewrite` refuses a record that only parses after dropping unknown keys:
1735
+ * recovery copies a snapshot back verbatim and must not lose its fields. */
1423
1736
  private parseBytes<T>(
1424
1737
  bytes: string | Buffer,
1425
- schema: { safeParse(value: unknown): any },
1738
+ schema: z.ZodType,
1739
+ mode: "read" | "rewrite" = "read",
1426
1740
  ): Result<T> {
1427
1741
  let value: unknown;
1428
1742
  try {
@@ -1432,12 +1746,24 @@ export class TaskStore {
1432
1746
  }
1433
1747
  if (isObject(value) && "schemaVersion" in value && value.schemaVersion !== SCHEMA_VERSION)
1434
1748
  return failure("unsupported_version", "unsupported snapshot schema version");
1435
- const parsed = schema.safeParse(value);
1436
- if (parsed.success) return success(null, null, parsed.data);
1749
+ const parsed = parseStoredRecord(schema, value);
1750
+ if (parsed.success && (mode === "read" || parsed.stripped.length === 0))
1751
+ return success(null, null, parsed.data as T);
1437
1752
  const writerVersion =
1438
1753
  isObject(value) && isObject((value as { runtime?: unknown }).runtime)
1439
1754
  ? (value as { runtime: { updatedWith?: unknown } }).runtime.updatedWith
1440
1755
  : null;
1756
+ const upgrade = `upgrade Workit before ${parsed.success ? "recovering" : "mutating"} this checkout`;
1757
+ if (parsed.success)
1758
+ return failure(
1759
+ "recovery_required",
1760
+ `snapshot carries fields this Workit cannot preserve (${parsed.stripped.join(", ")}); ${upgrade}`,
1761
+ );
1762
+ if (parsed.critical.length > 0)
1763
+ return failure(
1764
+ "recovery_required",
1765
+ `snapshot requires fields this Workit cannot read (${parsed.critical.join(", ")}); ${upgrade}`,
1766
+ );
1441
1767
  if (typeof writerVersion === "string" && isNewerVersion(writerVersion, runtimeVersion()))
1442
1768
  return failure(
1443
1769
  "recovery_required",
@@ -1446,14 +1772,6 @@ export class TaskStore {
1446
1772
  return failure("recovery_required", "snapshot does not satisfy its schema");
1447
1773
  }
1448
1774
 
1449
- private processStart(pid: number): string | null {
1450
- try {
1451
- return fs.readFileSync(`/proc/${pid}/stat`, "utf8").split(" ")[21] ?? null;
1452
- } catch {
1453
- return null;
1454
- }
1455
- }
1456
-
1457
1775
  private conflict(expected: Revision, actual: Revision): Result<never> {
1458
1776
  return failure(
1459
1777
  "revision_conflict",