@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.
- package/package.json +2 -1
- package/src/core/doctor.ts +45 -0
- package/src/core/methods.ts +7 -0
- package/src/core/store-lock.ts +301 -0
- package/src/core/task-contract.ts +121 -1
- package/src/core/task-engine.ts +150 -7
- package/src/core/task-store.ts +406 -79
- 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> {
|
|
@@ -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)
|
|
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.
|
|
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 =
|
|
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>(
|
|
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:
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
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:
|
|
1004
|
-
host:
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
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
|
-
|
|
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
|
|
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))
|
|
1312
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
1436
|
-
if (parsed.success
|
|
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
|
}
|