@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.
- package/package.json +2 -1
- package/src/core/doctor.ts +45 -0
- package/src/core/methods.ts +5 -0
- package/src/core/store-lock.ts +301 -0
- package/src/core/task-contract.ts +121 -1
- package/src/core/task-store.ts +395 -77
- package/src/core.ts +1 -1
- package/src/git/rev.ts +736 -0
- package/src/{core/session-context.ts → hooks/context.ts} +95 -8
- package/src/hooks/descriptor.ts +113 -0
- package/src/hooks/handle.ts +111 -0
- package/src/hooks/hosts/claude-code.ts +281 -0
- package/src/hooks/hosts/codex.ts +298 -0
- package/src/hooks/hosts/cursor.ts +321 -0
- package/src/hooks/hosts/fields.ts +55 -0
- package/src/hooks/hosts/opencode.ts +79 -0
- package/src/hooks/hosts/pi.ts +80 -0
- package/src/hooks/index.ts +43 -0
- package/src/hooks/policy.ts +14 -0
- package/src/hooks/protocol.ts +89 -0
- package/src/hooks/run.ts +52 -0
package/src/core/task-store.ts
CHANGED
|
@@ -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 =
|
|
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>(
|
|
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:
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
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:
|
|
1004
|
-
host:
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
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
|
-
|
|
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
|
|
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))
|
|
1312
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
1436
|
-
if (parsed.success
|
|
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",
|