@brainervirus/workit-core 2.1.5 → 2.2.1

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> {
@@ -664,7 +703,8 @@ export class TaskStore {
664
703
  const current = this.readWorkspace();
665
704
  if (!current.ok) return current;
666
705
  if (!current.data) return failure("not_found", "workspace not found");
667
- if (current.data.revision !== expected) return this.conflict(expected, current.data.revision);
706
+ if (current.data.revision !== expected)
707
+ return this.workspaceConflict(expected, current.data.revision);
668
708
  const previousBytes = this.snapshotBytes(this.workspacePath);
669
709
  if (!previousBytes)
670
710
  return failure("storage_error", "workspace snapshot disappeared during mutation");
@@ -702,7 +742,7 @@ export class TaskStore {
702
742
  if (!expected) return failure("invalid_input", "task revision is required");
703
743
  if (task.data.revision !== expected) return this.conflict(expected, task.data.revision);
704
744
  if (workspace.data.revision !== input.expectedWorkspaceRevision)
705
- return this.conflict(input.expectedWorkspaceRevision, workspace.data.revision);
745
+ return this.workspaceConflict(input.expectedWorkspaceRevision, workspace.data.revision);
706
746
  const previousWorkspaceBytes = this.snapshotBytes(this.workspacePath);
707
747
  const previousTaskBytes = this.snapshotBytes(this.taskPath(input.taskId));
708
748
  if (!previousWorkspaceBytes || !previousTaskBytes)
@@ -792,7 +832,7 @@ export class TaskStore {
792
832
  try {
793
833
  const candidates: RecoveryCandidate[] = [];
794
834
  for (const name of fs.readdirSync(this.recoveryDir)) {
795
- const match = /^(task|workspace)\.([^.]+)\.([0-9a-f]{64})\.json$/.exec(name);
835
+ const match = RECOVERY_NAME.exec(name);
796
836
  if (match)
797
837
  candidates.push({
798
838
  target: match[1] as "task" | "workspace",
@@ -853,8 +893,8 @@ export class TaskStore {
853
893
  }
854
894
  const selectedRecord =
855
895
  target === "workspace"
856
- ? this.parseBytes<WorkspaceRecord>(selectedBytes, workspaceRecordSchema)
857
- : this.parseBytes<TaskRecord>(selectedBytes, taskRecordSchema);
896
+ ? this.parseBytes<WorkspaceRecord>(selectedBytes, workspaceRecordSchema, "rewrite")
897
+ : this.parseBytes<TaskRecord>(selectedBytes, taskRecordSchema, "rewrite");
858
898
  if (!selectedRecord.ok) return selectedRecord;
859
899
  if (target === "workspace") {
860
900
  const parsed = selectedRecord as Result<WorkspaceRecord>;
@@ -914,13 +954,17 @@ export class TaskStore {
914
954
  return this.conflict(input.expectedWorkspaceRevision, currentWorkspace.data.revision);
915
955
  }
916
956
  if (target === "workspace") {
917
- const parsed = this.parseBytes<WorkspaceRecord>(selectedBytes, workspaceRecordSchema);
957
+ const parsed = this.parseBytes<WorkspaceRecord>(
958
+ selectedBytes,
959
+ workspaceRecordSchema,
960
+ "rewrite",
961
+ );
918
962
  if (!parsed.ok) return parsed;
919
963
  const value = { ...parsed.data, revision: newRevision(), writer: null };
920
964
  const replaced = this.replaceSnapshot(file, value, reacquiredBytes);
921
965
  return replaced.ok ? success(value.revision, value.revision, value) : replaced;
922
966
  }
923
- const parsed = this.parseBytes<TaskRecord>(selectedBytes, taskRecordSchema);
967
+ const parsed = this.parseBytes<TaskRecord>(selectedBytes, taskRecordSchema, "rewrite");
924
968
  if (!parsed.ok) return parsed;
925
969
  const value = {
926
970
  ...parsed.data,
@@ -932,6 +976,7 @@ export class TaskStore {
932
976
  return replaced.ok ? success(value.revision, null, value) : replaced;
933
977
  },
934
978
  this.recoveryLockOptions(lock.data, evidence.data, recoveryGate),
979
+ "recovery_required",
935
980
  );
936
981
  return result;
937
982
  } catch (error) {
@@ -989,24 +1034,76 @@ export class TaskStore {
989
1034
  }
990
1035
  }
991
1036
 
1037
+ /**
1038
+ * Mutation lock: a holder that is gone (dead pid, reused pid, or a foreign or
1039
+ * unreadable lock past its TTL) is reclaimed; a live holder is waited on
1040
+ * briefly and then reported as retryable `busy`.
1041
+ */
992
1042
  private metadataLockOptions(): FileLockSyncAcquireOptions<MetadataLock> {
1043
+ const inProcess = heldInProcess.has(this.root);
993
1044
  return {
994
1045
  lockPath: this.lockPath,
995
1046
  staleMs: Number.MAX_SAFE_INTEGER,
996
- timeoutMs: 0,
997
- retry: { retries: 0 },
998
- staleRecovery: "fail-closed",
999
- shouldReclaim: () => false,
1000
- parsePayload: parseMetadataLock,
1047
+ timeoutMs: inProcess ? 0 : this.lockTimeoutMs,
1048
+ retry: inProcess
1049
+ ? { retries: 0 }
1050
+ : { minTimeout: 5, maxTimeout: 100, factor: 1.5, randomize: true },
1051
+ staleRecovery: "remove-if-unchanged",
1052
+ shouldReclaim: ({ payload, nowMs }) => {
1053
+ let ageMs: number | null = null;
1054
+ try {
1055
+ ageMs = nowMs - fs.lstatSync(this.lockPath).mtimeMs;
1056
+ } catch {}
1057
+ return classifyLockOwner(payload, ageMs).state === "stale";
1058
+ },
1059
+ // The library re-checks the bytes before removal, so a lock replaced
1060
+ // after classification is never deleted.
1061
+ shouldRemoveStaleLock: () => true,
1062
+ parsePayload: parseMetadataLockOrNull,
1001
1063
  payload: () => ({
1002
1064
  pid: process.pid,
1003
- processStart: this.processStart(process.pid),
1004
- host: hostname(),
1065
+ processStart: processStartOf(process.pid),
1066
+ host: localLockHost(),
1005
1067
  nonce: randomUUID(),
1006
1068
  }),
1007
1069
  };
1008
1070
  }
1009
1071
 
1072
+ private acquireMetadataLock(
1073
+ options: FileLockSyncAcquireOptions<MetadataLock>,
1074
+ ): FileLockSyncHandle {
1075
+ clearAbandonedReclaimGuard(this.lockPath);
1076
+ // One budget for the whole acquisition: a lost reclaim race retries with
1077
+ // the remaining time, never a fresh timeout.
1078
+ const deadline = Date.now() + (options.timeoutMs ?? 0);
1079
+ while (true) {
1080
+ try {
1081
+ return acquireFileLockSync(this.workspacePath, {
1082
+ ...options,
1083
+ timeoutMs: Math.max(0, deadline - Date.now()),
1084
+ });
1085
+ } catch (error) {
1086
+ // Losing a reclaim race to another process, or Windows refusing the
1087
+ // lock file while another process opens or deletes it, is contention.
1088
+ const code = (error as { code?: unknown })?.code;
1089
+ if (
1090
+ (code !== "file_lock_stale" && !isTransientWindowsError(error)) ||
1091
+ Date.now() >= deadline
1092
+ )
1093
+ throw error;
1094
+ // Windows also denies the create while a just-released lock is still
1095
+ // pending delete, and that entry is already invisible to lstat. So a
1096
+ // denial with no visible holder is told apart by probing whether the
1097
+ // directory accepts a new file: if it does not, this is a permission
1098
+ // problem (read-only attribute, ACL) and fails fast.
1099
+ if (code !== "file_lock_stale" && !this.lockHolderPresent() && !this.lockDirWritable())
1100
+ throw error;
1101
+ if (code !== "file_lock_stale")
1102
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
1103
+ }
1104
+ }
1105
+ }
1106
+
1010
1107
  private externalActionLockOptions(): FileLockSyncAcquireOptions<MetadataLock> {
1011
1108
  const options = this.metadataLockOptions();
1012
1109
  return { ...options, payload: () => ({ ...options.payload(), externalAction: true }) };
@@ -1032,10 +1129,11 @@ export class TaskStore {
1032
1129
  }
1033
1130
  let handle: FileLockSyncHandle;
1034
1131
  try {
1035
- handle = acquireFileLockSync(this.workspacePath, this.externalActionLockOptions());
1132
+ handle = this.acquireMetadataLock(this.externalActionLockOptions());
1036
1133
  } catch (error) {
1037
1134
  return this.lockFailure(error);
1038
1135
  }
1136
+ holdRoot(this.root);
1039
1137
  let result: Result<T>;
1040
1138
  try {
1041
1139
  if (!handle.verifyStillHeld())
@@ -1072,8 +1170,9 @@ export class TaskStore {
1072
1170
  path: this.lockPath,
1073
1171
  });
1074
1172
  }
1173
+ dropRoot(this.root);
1075
1174
  try {
1076
- handle.release();
1175
+ retryTransient(() => handle.release());
1077
1176
  } catch (error) {
1078
1177
  const released = this.lockFailure(error);
1079
1178
  const releaseError = released.ok ? "metadata lock release failed" : released.error;
@@ -1097,6 +1196,9 @@ export class TaskStore {
1097
1196
  gate: { reclaimed: boolean },
1098
1197
  ): FileLockSyncAcquireOptions<MetadataLock> {
1099
1198
  const options = this.metadataLockOptions();
1199
+ options.timeoutMs = 0;
1200
+ options.retry = { retries: 0 };
1201
+ options.parsePayload = parseMetadataLock;
1100
1202
  options.staleRecovery = "remove-if-unchanged";
1101
1203
  options.shouldReclaim = ({ payload }) =>
1102
1204
  Boolean(
@@ -1127,6 +1229,7 @@ export class TaskStore {
1127
1229
  private withLock<T>(
1128
1230
  operation: (handle: FileLockSyncHandle) => Result<T>,
1129
1231
  options: FileLockSyncAcquireOptions<MetadataLock> = this.metadataLockOptions(),
1232
+ contention: "busy" | "recovery_required" = "busy",
1130
1233
  ): Result<T> {
1131
1234
  try {
1132
1235
  this.initializeMutationStorage();
@@ -1141,10 +1244,11 @@ export class TaskStore {
1141
1244
  "metadata lock operation did not produce a result",
1142
1245
  );
1143
1246
  try {
1144
- handle = acquireFileLockSync(this.workspacePath, options);
1247
+ handle = this.acquireMetadataLock(options);
1145
1248
  } catch (error) {
1146
- result = this.lockFailure(error);
1249
+ result = this.lockFailure(error, contention);
1147
1250
  }
1251
+ if (handle) holdRoot(this.root);
1148
1252
  if (handle) {
1149
1253
  try {
1150
1254
  if (!handle.verifyStillHeld())
@@ -1163,8 +1267,9 @@ export class TaskStore {
1163
1267
  }
1164
1268
  }
1165
1269
  if (handle) {
1270
+ dropRoot(this.root);
1166
1271
  try {
1167
- handle.release();
1272
+ retryTransient(() => handle.release());
1168
1273
  } catch (error) {
1169
1274
  const releaseFailure = this.lockFailure(error);
1170
1275
  const releaseError = releaseFailure.ok
@@ -1178,16 +1283,72 @@ export class TaskStore {
1178
1283
  return result;
1179
1284
  }
1180
1285
 
1181
- private lockFailure(error: unknown): Result<never> {
1286
+ /** Whether the lock's directory accepts a new file right now. */
1287
+ private lockDirWritable(): boolean {
1288
+ const probe = `${this.lockPath}.${process.pid}.${randomUUID()}.probe`;
1289
+ try {
1290
+ fs.closeSync(fs.openSync(probe, "wx", 0o600));
1291
+ } catch {
1292
+ return false;
1293
+ }
1294
+ try {
1295
+ fs.rmSync(probe, { force: true });
1296
+ } catch {}
1297
+ return true;
1298
+ }
1299
+
1300
+ /** A lock file (or an entry Windows is still tearing down) exists. */
1301
+ private lockHolderPresent(): boolean {
1302
+ try {
1303
+ fs.lstatSync(this.lockPath);
1304
+ return true;
1305
+ } catch (error) {
1306
+ return (error as { code?: unknown } | null)?.code !== "ENOENT";
1307
+ }
1308
+ }
1309
+
1310
+ private lockFailure(
1311
+ error: unknown,
1312
+ contention: "busy" | "recovery_required" = "busy",
1313
+ ): Result<never> {
1182
1314
  const value = error as { code?: unknown; message?: unknown };
1183
1315
  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)
1316
+ // A sharing-class denial is contention only when someone holds the lock.
1317
+ if (isTransientWindowsError(error) && !this.lockHolderPresent() && !this.lockDirWritable())
1318
+ return failure(
1319
+ "storage_error",
1320
+ `cannot create the workspace metadata lock (${code}); no other Workit call holds it`,
1321
+ {
1322
+ path: this.lockPath,
1323
+ guidance: `Check permissions and the read-only attribute on ${path.dirname(this.lockPath)}.`,
1324
+ },
1325
+ );
1326
+ if (
1327
+ code === "EEXIST" ||
1328
+ code === "file_lock_timeout" ||
1329
+ code === "file_lock_stale" ||
1330
+ isTransientWindowsError(error)
1331
+ ) {
1332
+ let lock: MetadataLock | null = null;
1333
+ try {
1334
+ lock = parseMetadataLockOrNull(fs.readFileSync(this.lockPath, "utf8"));
1335
+ } catch {}
1336
+ if (lock?.externalAction)
1187
1337
  return failure("writer_conflict", "workspace is reserved by a managed external action", {
1188
1338
  outcome: "not_started",
1189
1339
  path: this.lockPath,
1190
1340
  });
1341
+ if (contention === "busy")
1342
+ return failure(
1343
+ "busy",
1344
+ `workspace metadata lock is held by another Workit call${lock ? ` (pid ${lock.pid} on ${lock.host})` : ""}; retry shortly`,
1345
+ {
1346
+ outcome: "not_started",
1347
+ path: this.lockPath,
1348
+ guidance:
1349
+ "Retry the same call. If it stays busy, run `workit doctor --fix-lock` to clear a lock left by a dead process.",
1350
+ },
1351
+ );
1191
1352
  }
1192
1353
  const recovery =
1193
1354
  code === "EEXIST" ||
@@ -1204,9 +1365,16 @@ export class TaskStore {
1204
1365
  }
1205
1366
 
1206
1367
  private initializeMutationStorage() {
1207
- fs.mkdirSync(this.tasksDir, { recursive: true });
1208
- fs.mkdirSync(this.recoveryDir, { recursive: true });
1209
- fs.writeFileSync(this.gitignorePath, "*\n");
1368
+ retryTransient(() => fs.mkdirSync(this.tasksDir, { recursive: true }));
1369
+ retryTransient(() => fs.mkdirSync(this.recoveryDir, { recursive: true }));
1370
+ // Rewriting an unchanged .gitignore on every call makes concurrent
1371
+ // writers collide on it (Windows sharing violations); write it only when
1372
+ // it is missing or different.
1373
+ let current: string | null = null;
1374
+ try {
1375
+ current = retryTransient(() => fs.readFileSync(this.gitignorePath, "utf8"));
1376
+ } catch {}
1377
+ if (current !== "*\n") retryTransient(() => fs.writeFileSync(this.gitignorePath, "*\n"));
1210
1378
  }
1211
1379
 
1212
1380
  private replaceSnapshot(file: string, value: unknown, previous: unknown): Result<any> {
@@ -1224,7 +1392,7 @@ export class TaskStore {
1224
1392
  } finally {
1225
1393
  fs.closeSync(fd);
1226
1394
  }
1227
- fs.renameSync(temporary, file);
1395
+ retryTransient(() => fs.renameSync(temporary!, file));
1228
1396
  temporary = undefined;
1229
1397
  this.fsyncDirectory(path.dirname(file));
1230
1398
  if (path.dirname(file) === this.tasksDir) this.indexTaskWrite(file, value as TaskRecord);
@@ -1283,7 +1451,7 @@ export class TaskStore {
1283
1451
  }),
1284
1452
  { mode: 0o600, flag: "wx" },
1285
1453
  );
1286
- fs.renameSync(temporary, this.indexPath);
1454
+ retryTransient(() => fs.renameSync(temporary, this.indexPath));
1287
1455
  } catch {
1288
1456
  try {
1289
1457
  fs.unlinkSync(temporary);
@@ -1308,8 +1476,13 @@ export class TaskStore {
1308
1476
  const id = target === "task" ? path.basename(file, ".json") : "workspace";
1309
1477
  const destination = path.join(this.recoveryDir, `${target}.${id}.${digestBytes(bytes)}.json`);
1310
1478
  if (fs.existsSync(destination)) {
1311
- if (digestBytes(fs.readFileSync(destination)) === digestBytes(bytes)) return;
1312
- throw new Error("recovery copy already exists with different bytes");
1479
+ if (digestBytes(retryTransient(() => fs.readFileSync(destination))) !== digestBytes(bytes))
1480
+ throw new Error("recovery copy already exists with different bytes");
1481
+ // Re-saved bytes are the newest copy again for pruning purposes.
1482
+ const stamp = new Date();
1483
+ retryTransient(() => fs.utimesSync(destination, stamp, stamp));
1484
+ this.pruneRecovery(`${target}.${id}.`, destination);
1485
+ return;
1313
1486
  }
1314
1487
  let temporary: string | undefined;
1315
1488
  try {
@@ -1321,9 +1494,10 @@ export class TaskStore {
1321
1494
  } finally {
1322
1495
  fs.closeSync(fd);
1323
1496
  }
1324
- fs.renameSync(temporary, destination);
1497
+ retryTransient(() => fs.renameSync(temporary!, destination));
1325
1498
  temporary = undefined;
1326
1499
  this.fsyncDirectory(this.recoveryDir);
1500
+ this.pruneRecovery(`${target}.${id}.`, destination);
1327
1501
  } finally {
1328
1502
  if (temporary)
1329
1503
  try {
@@ -1332,6 +1506,144 @@ export class TaskStore {
1332
1506
  }
1333
1507
  }
1334
1508
 
1509
+ /**
1510
+ * Keep the newest RECOVERY_COPIES_PER_RECORD copies of one record (always
1511
+ * including `keep`, the copy just written). Pruning is best-effort: a
1512
+ * failure never fails the mutation that triggered it.
1513
+ */
1514
+ private pruneRecovery(prefix: string, keep: string) {
1515
+ try {
1516
+ const copies = this.recoveryCopies(prefix);
1517
+ const surplus = this.surplusCopies(copies, path.basename(keep));
1518
+ for (const copy of surplus) fs.rmSync(copy.path, { force: true });
1519
+ } catch {}
1520
+ }
1521
+
1522
+ private recoveryCopies(
1523
+ prefix = "",
1524
+ ): { name: string; path: string; group: string; mtimeNs: bigint }[] {
1525
+ if (!fs.existsSync(this.recoveryDir)) return [];
1526
+ const copies = [];
1527
+ for (const name of fs.readdirSync(this.recoveryDir)) {
1528
+ if (!name.startsWith(prefix)) continue;
1529
+ const match = RECOVERY_NAME.exec(name);
1530
+ if (!match) continue;
1531
+ const file = path.join(this.recoveryDir, name);
1532
+ try {
1533
+ const stat = fs.lstatSync(file, { bigint: true });
1534
+ if (!stat.isFile()) continue;
1535
+ copies.push({ name, path: file, group: `${match[1]}.${match[2]}`, mtimeNs: stat.mtimeNs });
1536
+ } catch {}
1537
+ }
1538
+ return copies;
1539
+ }
1540
+
1541
+ /** Copies of one record beyond the cap, oldest first out; `keep` is never surplus. */
1542
+ private surplusCopies<T extends { name: string; mtimeNs: bigint }>(copies: T[], keep?: string) {
1543
+ const ordered = copies.toSorted((left, right) =>
1544
+ left.name === keep
1545
+ ? -1
1546
+ : right.name === keep
1547
+ ? 1
1548
+ : left.mtimeNs === right.mtimeNs
1549
+ ? left.name.localeCompare(right.name)
1550
+ : left.mtimeNs > right.mtimeNs
1551
+ ? -1
1552
+ : 1,
1553
+ );
1554
+ return ordered.slice(RECOVERY_COPIES_PER_RECORD);
1555
+ }
1556
+
1557
+ /**
1558
+ * `workit gc`: prune recovery copies beyond the per-record cap, remove temp
1559
+ * files left by crashed writers, and collapse duplicate stored candidates in
1560
+ * paused tasks. Live records (tasks/*.json, workspace.json) are never
1561
+ * deleted; candidate dedupe keeps every candidate ID and the latest position.
1562
+ * Closed tasks are history and are never rewritten. A dry run is read-only:
1563
+ * no lock, no directory creation, no .gitignore rewrite.
1564
+ */
1565
+ collectGarbage(options: { dryRun?: boolean } = {}): Result<GarbageReport> {
1566
+ const dryRun = options.dryRun === true;
1567
+ const report: GarbageReport = {
1568
+ dryRun,
1569
+ recovery: { removed: 0, removedBytes: 0, kept: 0 },
1570
+ temporary: { removed: 0 },
1571
+ candidates: { removed: 0, tasks: [], skippedActive: [], skippedClosed: [], failed: [] },
1572
+ };
1573
+ if (!fs.existsSync(this.workitDir)) return success(null, null, report);
1574
+ const sweep = (): Result<null> => {
1575
+ try {
1576
+ const groups = new Map<string, ReturnType<TaskStore["recoveryCopies"]>>();
1577
+ for (const copy of this.recoveryCopies())
1578
+ groups.set(copy.group, [...(groups.get(copy.group) ?? []), copy]);
1579
+ for (const copies of groups.values()) {
1580
+ const surplus = this.surplusCopies(copies);
1581
+ report.recovery.kept += copies.length - surplus.length;
1582
+ for (const copy of surplus) {
1583
+ report.recovery.removed += 1;
1584
+ try {
1585
+ report.recovery.removedBytes += fs.statSync(copy.path).size;
1586
+ } catch {}
1587
+ if (!dryRun) fs.rmSync(copy.path, { force: true });
1588
+ }
1589
+ }
1590
+ const nowMs = Date.now();
1591
+ for (const directory of [this.recoveryDir, this.tasksDir, this.workitDir]) {
1592
+ if (!fs.existsSync(directory)) continue;
1593
+ for (const name of fs.readdirSync(directory)) {
1594
+ if (!name.endsWith(".tmp") && !name.endsWith(".probe")) continue;
1595
+ const file = path.join(directory, name);
1596
+ const stat = fs.lstatSync(file);
1597
+ if (!stat.isFile() || nowMs - stat.mtimeMs <= STALE_TEMPORARY_MS) continue;
1598
+ report.temporary.removed += 1;
1599
+ if (!dryRun) fs.rmSync(file, { force: true });
1600
+ }
1601
+ }
1602
+ return success(null, null, null);
1603
+ } catch (error) {
1604
+ return failure("storage_error", `garbage collection failed: ${String(error)}`, {
1605
+ path: this.recoveryDir,
1606
+ });
1607
+ }
1608
+ };
1609
+ // withLock initializes storage (mkdir, .gitignore); a dry run must not.
1610
+ const pruned = dryRun ? sweep() : this.withLock<null>(sweep);
1611
+ if (!pruned.ok) return pruned;
1612
+ const tasks = this.listTasks();
1613
+ if (!tasks.ok) return tasks;
1614
+ for (const task of tasks.data) {
1615
+ const deduped = dedupeCandidates(task.candidates);
1616
+ const removed = task.candidates.length - deduped.length;
1617
+ if (removed === 0) continue;
1618
+ // An active task belongs to a live session; rewriting it would bump the
1619
+ // revision under that session's feet.
1620
+ if (task.status === "active") {
1621
+ report.candidates.skippedActive.push(task.id);
1622
+ continue;
1623
+ }
1624
+ // Closed records are immutable history (docs/workit-v1/contracts.md).
1625
+ if (task.status === "closed") {
1626
+ report.candidates.skippedClosed.push(task.id);
1627
+ continue;
1628
+ }
1629
+ if (!dryRun) {
1630
+ const written = this.mutateTask(task.id, task.revision, (current) =>
1631
+ success(current.revision, null, {
1632
+ ...current,
1633
+ candidates: dedupeCandidates(current.candidates),
1634
+ }),
1635
+ );
1636
+ if (!written.ok) {
1637
+ report.candidates.failed.push(task.id);
1638
+ continue;
1639
+ }
1640
+ }
1641
+ report.candidates.removed += removed;
1642
+ report.candidates.tasks.push(task.id);
1643
+ }
1644
+ return success(null, null, report);
1645
+ }
1646
+
1335
1647
  private findRecovery(
1336
1648
  target: "task" | "workspace",
1337
1649
  taskId: Id | null,
@@ -1398,15 +1710,15 @@ export class TaskStore {
1398
1710
 
1399
1711
  private snapshotBytes(file: string): Buffer | null {
1400
1712
  try {
1401
- return fs.readFileSync(file);
1713
+ return retryTransient(() => fs.readFileSync(file));
1402
1714
  } catch {
1403
1715
  return null;
1404
1716
  }
1405
1717
  }
1406
1718
 
1407
- private readRecord<T>(file: string, schema: { safeParse(value: unknown): any }) {
1719
+ private readRecord<T>(file: string, schema: z.ZodType) {
1408
1720
  try {
1409
- const bytes = fs.readFileSync(file, "utf8");
1721
+ const bytes = retryTransient(() => fs.readFileSync(file, "utf8"));
1410
1722
  return { exists: true, result: this.parseBytes<T>(bytes, schema) };
1411
1723
  } catch (error: any) {
1412
1724
  if (error?.code === "ENOENT")
@@ -1420,9 +1732,12 @@ export class TaskStore {
1420
1732
  }
1421
1733
  }
1422
1734
 
1735
+ /** `rewrite` refuses a record that only parses after dropping unknown keys:
1736
+ * recovery copies a snapshot back verbatim and must not lose its fields. */
1423
1737
  private parseBytes<T>(
1424
1738
  bytes: string | Buffer,
1425
- schema: { safeParse(value: unknown): any },
1739
+ schema: z.ZodType,
1740
+ mode: "read" | "rewrite" = "read",
1426
1741
  ): Result<T> {
1427
1742
  let value: unknown;
1428
1743
  try {
@@ -1432,12 +1747,24 @@ export class TaskStore {
1432
1747
  }
1433
1748
  if (isObject(value) && "schemaVersion" in value && value.schemaVersion !== SCHEMA_VERSION)
1434
1749
  return failure("unsupported_version", "unsupported snapshot schema version");
1435
- const parsed = schema.safeParse(value);
1436
- if (parsed.success) return success(null, null, parsed.data);
1750
+ const parsed = parseStoredRecord(schema, value);
1751
+ if (parsed.success && (mode === "read" || parsed.stripped.length === 0))
1752
+ return success(null, null, parsed.data as T);
1437
1753
  const writerVersion =
1438
1754
  isObject(value) && isObject((value as { runtime?: unknown }).runtime)
1439
1755
  ? (value as { runtime: { updatedWith?: unknown } }).runtime.updatedWith
1440
1756
  : null;
1757
+ const upgrade = `upgrade Workit before ${parsed.success ? "recovering" : "mutating"} this checkout`;
1758
+ if (parsed.success)
1759
+ return failure(
1760
+ "recovery_required",
1761
+ `snapshot carries fields this Workit cannot preserve (${parsed.stripped.join(", ")}); ${upgrade}`,
1762
+ );
1763
+ if (parsed.critical.length > 0)
1764
+ return failure(
1765
+ "recovery_required",
1766
+ `snapshot requires fields this Workit cannot read (${parsed.critical.join(", ")}); ${upgrade}`,
1767
+ );
1441
1768
  if (typeof writerVersion === "string" && isNewerVersion(writerVersion, runtimeVersion()))
1442
1769
  return failure(
1443
1770
  "recovery_required",
@@ -1446,14 +1773,6 @@ export class TaskStore {
1446
1773
  return failure("recovery_required", "snapshot does not satisfy its schema");
1447
1774
  }
1448
1775
 
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
1776
  private conflict(expected: Revision, actual: Revision): Result<never> {
1458
1777
  return failure(
1459
1778
  "revision_conflict",
@@ -1465,6 +1784,14 @@ export class TaskStore {
1465
1784
  );
1466
1785
  }
1467
1786
 
1787
+ private workspaceConflict(expected: Revision, actual: Revision): Result<never> {
1788
+ return failure(
1789
+ "revision_conflict",
1790
+ "workspace revision does not match; omit expectedWorkspaceRevision to use the current record",
1791
+ { expectedWorkspaceRevision: expected, actualWorkspaceRevision: actual },
1792
+ );
1793
+ }
1794
+
1468
1795
  private taskPath(taskId: Id) {
1469
1796
  return path.join(this.tasksDir, `${taskId}.json`);
1470
1797
  }