@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brainervirus/workit-core",
|
|
3
|
-
"version": "2.1
|
|
3
|
+
"version": "2.2.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
|
|
6
6
|
"keywords": [
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"type": "module",
|
|
37
37
|
"main": "./src/core.ts",
|
|
38
38
|
"exports": {
|
|
39
|
+
"./hooks": "./src/hooks/index.ts",
|
|
39
40
|
"./src/*.ts": "./src/*.ts",
|
|
40
41
|
"./src/*": "./src/*.ts",
|
|
41
42
|
"./package.json": "./package.json"
|
package/src/core/doctor.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import os from "node:os";
|
|
20
20
|
import path from "node:path";
|
|
21
21
|
import { SUPPORT_MATRIX } from "./support-matrix";
|
|
22
|
+
import { inspectMetadataLock } from "./store-lock";
|
|
22
23
|
import { bundleHashOfFile, isEphemeralCachePath } from "./runtime-identity";
|
|
23
24
|
import { EVENT } from "./boundary";
|
|
24
25
|
import { getDiagnosticLogger, isConfigObject } from "./config";
|
|
@@ -61,6 +62,7 @@ export type DoctorCheckId =
|
|
|
61
62
|
| "duplicate_registration"
|
|
62
63
|
| "malformed_config"
|
|
63
64
|
| "workspace_mismatch"
|
|
65
|
+
| "workspace_lock"
|
|
64
66
|
| "credential_metadata"
|
|
65
67
|
| "github_identity"
|
|
66
68
|
| "gitlab_identity"
|
|
@@ -110,6 +112,8 @@ export type DoctorOptions = {
|
|
|
110
112
|
/** Checkout containing packages/ (monorepo or share clone). */
|
|
111
113
|
dev?: string;
|
|
112
114
|
cwd?: string;
|
|
115
|
+
/** Workit store root for the lock check (default: WORKFLOW_WORKSPACE_ROOT, then cwd). */
|
|
116
|
+
workspaceRoot?: string;
|
|
113
117
|
opencodeConfig?: string;
|
|
114
118
|
/** OpenCode npm `@latest` package cache root (test seam). */
|
|
115
119
|
opencodePackageCacheDir?: string;
|
|
@@ -129,6 +133,7 @@ type Resolved = {
|
|
|
129
133
|
configDir: string;
|
|
130
134
|
stateDir: string;
|
|
131
135
|
cwd: string;
|
|
136
|
+
workspaceRoot: string;
|
|
132
137
|
dev: string | null;
|
|
133
138
|
opencodeConfig: string;
|
|
134
139
|
opencodePackageCacheDir: string;
|
|
@@ -179,6 +184,7 @@ const resolve = (options: DoctorOptions): Resolved => {
|
|
|
179
184
|
configDir,
|
|
180
185
|
stateDir,
|
|
181
186
|
cwd,
|
|
187
|
+
workspaceRoot: options.workspaceRoot ?? env.WORKFLOW_WORKSPACE_ROOT ?? cwd,
|
|
182
188
|
dev,
|
|
183
189
|
opencodeConfig:
|
|
184
190
|
options.opencodeConfig ?? path.join(home, ".config", "opencode", "opencode.json"),
|
|
@@ -1693,6 +1699,44 @@ const checkManagedContentConflict = (res: Resolved): DoctorCheck => {
|
|
|
1693
1699
|
};
|
|
1694
1700
|
};
|
|
1695
1701
|
|
|
1702
|
+
// The checkout's `.workit/metadata.lock`. Writes reclaim a stale lock by
|
|
1703
|
+
// themselves, so a stale lock is a warning with an explicit cleanup command.
|
|
1704
|
+
const BLOCKING_LOCK_WARN_MS = 30_000;
|
|
1705
|
+
const checkWorkspaceLock = (res: Resolved): DoctorCheck => {
|
|
1706
|
+
const lock = inspectMetadataLock(res.workspaceRoot);
|
|
1707
|
+
const fix = "workit doctor --fix-lock";
|
|
1708
|
+
if (lock.guard === "abandoned")
|
|
1709
|
+
return {
|
|
1710
|
+
id: "workspace_lock",
|
|
1711
|
+
status: "warn",
|
|
1712
|
+
detail: `abandoned lock reclaim guard at ${lock.path}.reclaim`,
|
|
1713
|
+
fix,
|
|
1714
|
+
};
|
|
1715
|
+
if (lock.state === "absent")
|
|
1716
|
+
return { id: "workspace_lock", status: "pass", detail: "no metadata lock held" };
|
|
1717
|
+
if (lock.state === "stale")
|
|
1718
|
+
return {
|
|
1719
|
+
id: "workspace_lock",
|
|
1720
|
+
status: "warn",
|
|
1721
|
+
detail: `stale metadata lock at ${lock.path}: ${lock.reason}`,
|
|
1722
|
+
fix,
|
|
1723
|
+
};
|
|
1724
|
+
// An unverifiable owner (other host, pid namespace, or an older Workit's
|
|
1725
|
+
// lock) that has blocked writes this long needs an explicit decision.
|
|
1726
|
+
if (lock.state === "unknown" && (lock.ageMs ?? 0) > BLOCKING_LOCK_WARN_MS)
|
|
1727
|
+
return {
|
|
1728
|
+
id: "workspace_lock",
|
|
1729
|
+
status: "warn",
|
|
1730
|
+
detail: `metadata lock at ${lock.path} has blocked writes for ${Math.round((lock.ageMs ?? 0) / 1000)}s and its owner cannot be verified: ${lock.reason}`,
|
|
1731
|
+
fix: "workit doctor --fix-lock --force --yes",
|
|
1732
|
+
};
|
|
1733
|
+
return {
|
|
1734
|
+
id: "workspace_lock",
|
|
1735
|
+
status: "pass",
|
|
1736
|
+
detail: `metadata lock ${lock.reason} (writes retry, then report busy)`,
|
|
1737
|
+
};
|
|
1738
|
+
};
|
|
1739
|
+
|
|
1696
1740
|
const RUN_CHECKS: Array<(res: Resolved) => DoctorCheck> = [
|
|
1697
1741
|
checkRuntime,
|
|
1698
1742
|
checkVersions,
|
|
@@ -1705,6 +1749,7 @@ const RUN_CHECKS: Array<(res: Resolved) => DoctorCheck> = [
|
|
|
1705
1749
|
checkDuplicateRegistration,
|
|
1706
1750
|
checkMalformedConfig,
|
|
1707
1751
|
checkWorkspaceMismatch,
|
|
1752
|
+
checkWorkspaceLock,
|
|
1708
1753
|
checkCredentialMetadata,
|
|
1709
1754
|
checkGithubIdentity,
|
|
1710
1755
|
checkGitLabIdentity,
|
package/src/core/methods.ts
CHANGED
|
@@ -110,6 +110,13 @@ Start a record once for an explicit tracked objective; assess or reassess only
|
|
|
110
110
|
when policy selection or changed evidence/constraints requires it. Omitted
|
|
111
111
|
expectedRevision and expectedWorkspaceRevision use current values; explicit
|
|
112
112
|
values are still concurrency-checked, so never copy revisions between calls.
|
|
113
|
+
A busy result means another live Workit call holds the checkout lock: retry the
|
|
114
|
+
same call; it is not a recovery condition. A lock left by a dead process is
|
|
115
|
+
reclaimed on the next write, and \`workit doctor --fix-lock\` clears it on demand.
|
|
116
|
+
An omitted revision absorbs a concurrent write: Workit re-reads, re-checks
|
|
117
|
+
policy, and reapplies the call, returning busy under persistent contention. A
|
|
118
|
+
revision_conflict means a revision you passed is stale: re-read the record
|
|
119
|
+
before deciding whether to retry.
|
|
113
120
|
A solo edit does not need writer acquisition; use it when concurrent checkout
|
|
114
121
|
writers need coordination. Record only observed facts and checks. Evidence can
|
|
115
122
|
become stale when its bound candidate changes; reconcile findings against the
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import * as z from "zod";
|
|
6
|
+
import { canonicalJson } from "./task-contract";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Ownership rules for a checkout's `.workit/metadata.lock`.
|
|
10
|
+
*
|
|
11
|
+
* The lock is a short mutex around one store mutation (or one managed effect).
|
|
12
|
+
* A lock whose owner is gone is reclaimed automatically; a lock whose owner is
|
|
13
|
+
* alive is contention, which callers report as retryable `busy`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type MetadataLock = {
|
|
17
|
+
pid: number;
|
|
18
|
+
processStart: string | null;
|
|
19
|
+
host: string;
|
|
20
|
+
nonce: string;
|
|
21
|
+
externalAction?: true;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const metadataLockSchema = z
|
|
25
|
+
.object({
|
|
26
|
+
pid: z.number().int().nonnegative().safe(),
|
|
27
|
+
processStart: z.string().nullable(),
|
|
28
|
+
host: z.string().min(1),
|
|
29
|
+
nonce: z.string().min(1),
|
|
30
|
+
externalAction: z.literal(true).optional(),
|
|
31
|
+
})
|
|
32
|
+
.strict();
|
|
33
|
+
|
|
34
|
+
/** A lock from another host cannot be checked for liveness; trust it this long. */
|
|
35
|
+
export const FOREIGN_LOCK_TTL_MS = 10 * 60_000;
|
|
36
|
+
/** An empty or unparseable lock is a writer mid-create; after this it is debris. */
|
|
37
|
+
export const UNREADABLE_LOCK_TTL_MS = 30_000;
|
|
38
|
+
/** A reclaim guard lives for microseconds; one older than this was abandoned. */
|
|
39
|
+
export const RECLAIM_GUARD_TTL_MS = 30_000;
|
|
40
|
+
/**
|
|
41
|
+
* Total time a mutation waits for a live holder before returning `busy`. The
|
|
42
|
+
* wait blocks the calling thread, so in-process hosts (OpenCode, MCP, Pi) keep
|
|
43
|
+
* the short default; the CLI raises it for its own process.
|
|
44
|
+
*/
|
|
45
|
+
let defaultLockTimeoutMs = 250;
|
|
46
|
+
export const defaultLockTimeout = (): number => defaultLockTimeoutMs;
|
|
47
|
+
export const setDefaultLockTimeout = (ms: number): void => {
|
|
48
|
+
defaultLockTimeoutMs = ms;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const parseMetadataLock = (raw: string): MetadataLock => {
|
|
52
|
+
let value: unknown;
|
|
53
|
+
try {
|
|
54
|
+
value = JSON.parse(raw);
|
|
55
|
+
} catch {
|
|
56
|
+
throw Object.assign(new Error("metadata lock is invalid"), { code: "metadata_lock_invalid" });
|
|
57
|
+
}
|
|
58
|
+
const parsed = metadataLockSchema.safeParse(value);
|
|
59
|
+
if (!parsed.success)
|
|
60
|
+
throw Object.assign(new Error("metadata lock is invalid"), { code: "metadata_lock_invalid" });
|
|
61
|
+
return parsed.data;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Lenient variant for the acquire loop: an unreadable lock is classified by age. */
|
|
65
|
+
export const parseMetadataLockOrNull = (raw: string): MetadataLock | null => {
|
|
66
|
+
try {
|
|
67
|
+
return parseMetadataLock(raw);
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const sameMetadataLock = (left: unknown, right: MetadataLock): boolean => {
|
|
74
|
+
const parsed = metadataLockSchema.safeParse(left);
|
|
75
|
+
return parsed.success && canonicalJson(parsed.data) === canonicalJson(right);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Field 22 (starttime) of /proc/<pid>/stat; parsed after the last ")" so a comm with spaces cannot shift it. */
|
|
79
|
+
export const parseProcStatStart = (stat: string): string | null => {
|
|
80
|
+
const close = stat.lastIndexOf(")");
|
|
81
|
+
if (close < 0) return null;
|
|
82
|
+
// After ")": field 3 (state) is index 0, so field 22 is index 19.
|
|
83
|
+
return (
|
|
84
|
+
stat
|
|
85
|
+
.slice(close + 1)
|
|
86
|
+
.trim()
|
|
87
|
+
.split(/\s+/)[19] ?? null
|
|
88
|
+
);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const processStartOf = (pid: number): string | null => {
|
|
92
|
+
if (process.platform === "linux") {
|
|
93
|
+
try {
|
|
94
|
+
return parseProcStatStart(fs.readFileSync(`/proc/${pid}/stat`, "utf8"));
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (process.platform === "darwin" || process.platform === "freebsd") {
|
|
100
|
+
try {
|
|
101
|
+
const run = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
102
|
+
encoding: "utf8",
|
|
103
|
+
timeout: 1_000,
|
|
104
|
+
});
|
|
105
|
+
const value = run.status === 0 ? run.stdout.trim() : "";
|
|
106
|
+
return value || null;
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const readTrimmed = (file: string): string | null => {
|
|
115
|
+
try {
|
|
116
|
+
return fs.readFileSync(file, "utf8").trim() || null;
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Identity of the pid space this process lives in: hostname plus, on Linux,
|
|
124
|
+
* the pid-namespace inode and boot id. Containers that share the hostname
|
|
125
|
+
* (`--network host`) but not the pid namespace get a different identity, so
|
|
126
|
+
* their pids are never checked against this process table. Folded into the
|
|
127
|
+
* existing `host` string so older Workit versions still parse the lock.
|
|
128
|
+
*/
|
|
129
|
+
let cachedLockHost: string | null = null;
|
|
130
|
+
export const localLockHost = (): string => {
|
|
131
|
+
if (cachedLockHost !== null) return cachedLockHost;
|
|
132
|
+
let pidns: string | null = null;
|
|
133
|
+
try {
|
|
134
|
+
pidns = /\[(\d+)\]/.exec(fs.readlinkSync("/proc/self/ns/pid"))?.[1] ?? null;
|
|
135
|
+
} catch {}
|
|
136
|
+
const boot = readTrimmed("/proc/sys/kernel/random/boot_id");
|
|
137
|
+
cachedLockHost = pidns || boot ? `${hostname()}#${pidns ?? "?"}:${boot ?? "?"}` : hostname();
|
|
138
|
+
return cachedLockHost;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const pidAlive = (pid: number): boolean => {
|
|
142
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
143
|
+
try {
|
|
144
|
+
process.kill(pid, 0);
|
|
145
|
+
return true;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
// EPERM: the process exists but belongs to another user.
|
|
148
|
+
return (error as { code?: unknown }).code === "EPERM";
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export type LockOwnerState = {
|
|
153
|
+
/** live: wait. stale: reclaim. unknown: wait (cannot prove the owner is gone). */
|
|
154
|
+
state: "live" | "stale" | "unknown";
|
|
155
|
+
reason: string;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export const classifyLockOwner = (
|
|
159
|
+
payload: unknown,
|
|
160
|
+
ageMs: number | null,
|
|
161
|
+
localHost: string = localLockHost(),
|
|
162
|
+
): LockOwnerState => {
|
|
163
|
+
const lock = metadataLockSchema.safeParse(payload);
|
|
164
|
+
if (!lock.success)
|
|
165
|
+
return ageMs !== null && ageMs > UNREADABLE_LOCK_TTL_MS
|
|
166
|
+
? { state: "stale", reason: "unreadable lock left behind" }
|
|
167
|
+
: { state: "unknown", reason: "lock is being written" };
|
|
168
|
+
const { pid, processStart, host } = lock.data;
|
|
169
|
+
// Same host and pid namespace but another boot: the machine rebooted since
|
|
170
|
+
// the lock was taken, so its owner cannot still be running.
|
|
171
|
+
const [lockName, lockSpace] = host.split("#");
|
|
172
|
+
const [localName, localSpace] = localHost.split("#");
|
|
173
|
+
if (lockSpace && localSpace && lockName === localName) {
|
|
174
|
+
const [lockNs, lockBoot] = lockSpace.split(":");
|
|
175
|
+
const [localNs, localBoot] = localSpace.split(":");
|
|
176
|
+
if (lockNs === localNs && lockNs !== "?" && lockBoot !== "?" && lockBoot !== localBoot)
|
|
177
|
+
return { state: "stale", reason: "lock was taken before this machine rebooted" };
|
|
178
|
+
}
|
|
179
|
+
// Another host, another pid namespace, or a lock written by an older Workit
|
|
180
|
+
// without namespace identity: its pid cannot be checked here.
|
|
181
|
+
if (host !== localHost)
|
|
182
|
+
return ageMs !== null && ageMs > FOREIGN_LOCK_TTL_MS
|
|
183
|
+
? { state: "stale", reason: `lock from ${host} is older than its TTL` }
|
|
184
|
+
: { state: "unknown", reason: `lock is held from ${host}; its pid cannot be checked here` };
|
|
185
|
+
if (!pidAlive(pid)) return { state: "stale", reason: `pid ${pid} is not running` };
|
|
186
|
+
const currentStart = processStartOf(pid);
|
|
187
|
+
if (processStart !== null && currentStart !== null && processStart !== currentStart)
|
|
188
|
+
return { state: "stale", reason: `pid ${pid} now belongs to a different process` };
|
|
189
|
+
return { state: "live", reason: `held by running pid ${pid}` };
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const ageOf = (file: string, nowMs: number): number | null => {
|
|
193
|
+
try {
|
|
194
|
+
return nowMs - fs.lstatSync(file).mtimeMs;
|
|
195
|
+
} catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export const lockPathFor = (root: string) => path.join(root, ".workit", "metadata.lock");
|
|
201
|
+
|
|
202
|
+
/** Remove a reclaim guard abandoned by a crashed reclaimer. Returns true when removed. */
|
|
203
|
+
export const clearAbandonedReclaimGuard = (lockPath: string, nowMs = Date.now()): boolean => {
|
|
204
|
+
const guard = `${lockPath}.reclaim`;
|
|
205
|
+
const age = ageOf(guard, nowMs);
|
|
206
|
+
if (age === null || age <= RECLAIM_GUARD_TTL_MS) return false;
|
|
207
|
+
try {
|
|
208
|
+
fs.rmdirSync(guard);
|
|
209
|
+
return true;
|
|
210
|
+
} catch {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
export type MetadataLockStatus = {
|
|
216
|
+
path: string;
|
|
217
|
+
present: boolean;
|
|
218
|
+
owner: MetadataLock | null;
|
|
219
|
+
state: LockOwnerState["state"] | "absent";
|
|
220
|
+
reason: string;
|
|
221
|
+
guard: "absent" | "fresh" | "abandoned";
|
|
222
|
+
/** Exact lock bytes that were classified (for compare-before-remove). */
|
|
223
|
+
raw?: string;
|
|
224
|
+
/** Age of the lock file in milliseconds, when present. */
|
|
225
|
+
ageMs?: number | null;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
/** Read-only inspection of a checkout's metadata lock (doctor surface). */
|
|
229
|
+
export const inspectMetadataLock = (root: string, nowMs = Date.now()): MetadataLockStatus => {
|
|
230
|
+
const lockPath = lockPathFor(root);
|
|
231
|
+
const guardAge = ageOf(`${lockPath}.reclaim`, nowMs);
|
|
232
|
+
const guard =
|
|
233
|
+
guardAge === null ? "absent" : guardAge > RECLAIM_GUARD_TTL_MS ? "abandoned" : "fresh";
|
|
234
|
+
let raw: string;
|
|
235
|
+
try {
|
|
236
|
+
raw = fs.readFileSync(lockPath, "utf8");
|
|
237
|
+
} catch {
|
|
238
|
+
return {
|
|
239
|
+
path: lockPath,
|
|
240
|
+
present: false,
|
|
241
|
+
owner: null,
|
|
242
|
+
state: "absent",
|
|
243
|
+
reason: "no lock",
|
|
244
|
+
guard,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
const owner = parseMetadataLockOrNull(raw);
|
|
248
|
+
const ageMs = ageOf(lockPath, nowMs);
|
|
249
|
+
const verdict = classifyLockOwner(owner, ageMs);
|
|
250
|
+
return { path: lockPath, present: true, owner, ...verdict, guard, raw, ageMs };
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
export type ClearLockOutcome = MetadataLockStatus & {
|
|
254
|
+
cleared: boolean;
|
|
255
|
+
guardCleared: boolean;
|
|
256
|
+
/** Why the lock was kept, when it was present and not cleared. */
|
|
257
|
+
skipped?: string;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Clear a stale metadata lock (or, with `force`, any lock) and an abandoned
|
|
262
|
+
* reclaim guard. Removal holds the same `.reclaim` guard that writers take
|
|
263
|
+
* before reclaiming, so no writer can replace the lock between the final
|
|
264
|
+
* byte check and the unlink; a fresh guard means a reclaim is already in
|
|
265
|
+
* progress and the lock is left alone.
|
|
266
|
+
*/
|
|
267
|
+
export const clearStaleMetadataLock = (
|
|
268
|
+
root: string,
|
|
269
|
+
options: { force?: boolean; nowMs?: number } = {},
|
|
270
|
+
): ClearLockOutcome => {
|
|
271
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
272
|
+
const status = inspectMetadataLock(root, nowMs);
|
|
273
|
+
const guardCleared = status.guard === "abandoned" && clearAbandonedReclaimGuard(status.path);
|
|
274
|
+
const outcome = { ...status, cleared: false, guardCleared };
|
|
275
|
+
if (!status.present) return outcome;
|
|
276
|
+
if (status.state !== "stale" && !options.force) return { ...outcome, skipped: status.reason };
|
|
277
|
+
const guard = `${status.path}.reclaim`;
|
|
278
|
+
try {
|
|
279
|
+
fs.mkdirSync(guard);
|
|
280
|
+
} catch {
|
|
281
|
+
return { ...outcome, skipped: "a reclaim is in progress" };
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
const before = fs.readFileSync(status.path, "utf8");
|
|
285
|
+
if (before !== status.raw) return { ...outcome, skipped: "the lock changed" };
|
|
286
|
+
if (!options.force) {
|
|
287
|
+
const verdict = classifyLockOwner(parseMetadataLockOrNull(before), ageOf(status.path, nowMs));
|
|
288
|
+
if (verdict.state !== "stale") return { ...outcome, skipped: verdict.reason };
|
|
289
|
+
}
|
|
290
|
+
if (fs.readFileSync(status.path, "utf8") !== before)
|
|
291
|
+
return { ...outcome, skipped: "the lock changed" };
|
|
292
|
+
fs.rmSync(status.path);
|
|
293
|
+
return { ...outcome, cleared: true };
|
|
294
|
+
} catch (error) {
|
|
295
|
+
return { ...outcome, skipped: `could not clear: ${String(error)}` };
|
|
296
|
+
} finally {
|
|
297
|
+
try {
|
|
298
|
+
fs.rmdirSync(guard);
|
|
299
|
+
} catch {}
|
|
300
|
+
}
|
|
301
|
+
};
|
|
@@ -84,6 +84,7 @@ export const hostSchema = z.enum([
|
|
|
84
84
|
"codex_desktop",
|
|
85
85
|
"pi",
|
|
86
86
|
"workit_cli",
|
|
87
|
+
"claude_code",
|
|
87
88
|
]);
|
|
88
89
|
export type Host = z.infer<typeof hostSchema>;
|
|
89
90
|
export const assuranceSchema = z.enum(["enforced", "agent_guided", "unavailable"]);
|
|
@@ -746,9 +747,111 @@ export const taskRecordSchema = z
|
|
|
746
747
|
actionProgress: actionProgressListSchema.optional(),
|
|
747
748
|
findings: z.array(entrySchema(findingSchema)),
|
|
748
749
|
workers: z.array(entrySchema(workerSchema)),
|
|
750
|
+
/** Paths a reader must understand; see parseStoredRecord. */
|
|
751
|
+
critical: z.array(nonEmpty).optional(),
|
|
749
752
|
})
|
|
750
753
|
.strict();
|
|
751
754
|
export type TaskRecord = z.infer<typeof taskRecordSchema>;
|
|
755
|
+
|
|
756
|
+
type Strip = { path: PropertyKey[]; keys: string[] };
|
|
757
|
+
const stripCount = (strips: Strip[]) => strips.reduce((sum, strip) => sum + strip.keys.length, 0);
|
|
758
|
+
/** Unknown-key issues only, flattened; null when any issue is a real schema
|
|
759
|
+
* violation. For a union, the branch that strips the fewest keys wins, so a
|
|
760
|
+
* key one branch knows is never dropped in favor of a narrower branch. */
|
|
761
|
+
const strippable = (
|
|
762
|
+
issues: readonly z.core.$ZodIssue[],
|
|
763
|
+
prefix: PropertyKey[] = [],
|
|
764
|
+
): Strip[] | null => {
|
|
765
|
+
const strips: Strip[] = [];
|
|
766
|
+
for (const issue of issues) {
|
|
767
|
+
if (issue.code === "unrecognized_keys") {
|
|
768
|
+
strips.push({ path: [...prefix, ...issue.path], keys: issue.keys });
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
if (issue.code !== "invalid_union") return null;
|
|
772
|
+
const branch = issue.errors
|
|
773
|
+
.map((errors) => strippable(errors, [...prefix, ...issue.path]))
|
|
774
|
+
.filter((found): found is Strip[] => found !== null && found.length > 0)
|
|
775
|
+
.reduce<Strip[] | null>(
|
|
776
|
+
(best, found) => (best === null || stripCount(found) < stripCount(best) ? found : best),
|
|
777
|
+
null,
|
|
778
|
+
);
|
|
779
|
+
if (!branch) return null;
|
|
780
|
+
strips.push(...branch);
|
|
781
|
+
}
|
|
782
|
+
return strips;
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
/** A dotted record path with array indices as `*`, e.g. `evidence.*.data.observer`. */
|
|
786
|
+
const recordPath = (path: PropertyKey[]): string =>
|
|
787
|
+
path.map((key) => (typeof key === "number" ? "*" : String(key))).join(".");
|
|
788
|
+
const overlaps = (left: string, right: string) =>
|
|
789
|
+
left === right || left.startsWith(`${right}.`) || right.startsWith(`${left}.`);
|
|
790
|
+
|
|
791
|
+
export type StoredRecordParse<T> =
|
|
792
|
+
| { success: true; data: T; stripped: string[] }
|
|
793
|
+
| { success: false; error: z.ZodError; critical: string[] };
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Reader tolerance for stored records (D17). A record written by a newer
|
|
797
|
+
* runtime may carry keys this reader does not know; exactly the keys zod
|
|
798
|
+
* reports as unrecognized are dropped and the value is parsed again, and
|
|
799
|
+
* every other violation still fails. Writes keep parsing strictly.
|
|
800
|
+
*
|
|
801
|
+
* The rule for new record fields: a field must be safe for an older reader
|
|
802
|
+
* to ignore (and to lose when that reader rewrites the record), or the writer
|
|
803
|
+
* must list its path in the record's top-level `critical` array. A reader
|
|
804
|
+
* that would strip a critical path fails closed instead (`critical` names the
|
|
805
|
+
* paths), so it neither acts on nor rewrites a record it cannot represent.
|
|
806
|
+
*/
|
|
807
|
+
export const parseStoredRecord = <S extends z.ZodType>(
|
|
808
|
+
schema: S,
|
|
809
|
+
value: unknown,
|
|
810
|
+
): StoredRecordParse<z.output<S>> => {
|
|
811
|
+
let parsed = schema.safeParse(value);
|
|
812
|
+
if (parsed.success) return { success: true, data: parsed.data, stripped: [] };
|
|
813
|
+
const first = { success: false as const, error: parsed.error, critical: [] as string[] };
|
|
814
|
+
const declared =
|
|
815
|
+
typeof value === "object" &&
|
|
816
|
+
value !== null &&
|
|
817
|
+
Array.isArray((value as { critical?: unknown }).critical)
|
|
818
|
+
? (value as { critical: unknown[] }).critical
|
|
819
|
+
.filter((item): item is string => typeof item === "string")
|
|
820
|
+
// An array index in a declaration means any element, like `*`.
|
|
821
|
+
.map((item) =>
|
|
822
|
+
item
|
|
823
|
+
.split(".")
|
|
824
|
+
.map((key) => (/^\d+$/.test(key) ? "*" : key))
|
|
825
|
+
.join("."),
|
|
826
|
+
)
|
|
827
|
+
: [];
|
|
828
|
+
const stripped: string[] = [];
|
|
829
|
+
let current: unknown = structuredClone(value);
|
|
830
|
+
for (let round = 0; round < 8 && !parsed.success; round++) {
|
|
831
|
+
const strips = strippable(parsed.error.issues);
|
|
832
|
+
if (strips === null || strips.length === 0) return first;
|
|
833
|
+
for (const strip of strips) {
|
|
834
|
+
let target: unknown = current;
|
|
835
|
+
for (const key of strip.path)
|
|
836
|
+
target =
|
|
837
|
+
typeof target === "object" && target !== null
|
|
838
|
+
? (target as Record<PropertyKey, unknown>)[key]
|
|
839
|
+
: undefined;
|
|
840
|
+
if (typeof target !== "object" || target === null) return first;
|
|
841
|
+
for (const key of strip.keys) {
|
|
842
|
+
stripped.push(recordPath([...strip.path, key]));
|
|
843
|
+
delete (target as Record<string, unknown>)[key];
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
parsed = schema.safeParse(current);
|
|
847
|
+
}
|
|
848
|
+
if (!parsed.success) return first;
|
|
849
|
+
const critical = [
|
|
850
|
+
...new Set(stripped.filter((item) => declared.some((path) => overlaps(item, path)))),
|
|
851
|
+
];
|
|
852
|
+
if (critical.length > 0) return { ...first, critical };
|
|
853
|
+
return { success: true, data: parsed.data, stripped: [...new Set(stripped)] };
|
|
854
|
+
};
|
|
752
855
|
export const workspaceRecordSchema = z
|
|
753
856
|
.object({
|
|
754
857
|
schemaVersion: z.literal(1),
|
|
@@ -760,6 +863,8 @@ export const workspaceRecordSchema = z
|
|
|
760
863
|
.object({ state: z.enum(["held", "uncertain"]), owner: ownerSchema, acquiredAt: utc })
|
|
761
864
|
.strict()
|
|
762
865
|
.nullable(),
|
|
866
|
+
/** Paths a reader must understand; see parseStoredRecord. */
|
|
867
|
+
critical: z.array(nonEmpty).optional(),
|
|
763
868
|
})
|
|
764
869
|
.strict();
|
|
765
870
|
export type WorkspaceRecord = z.infer<typeof workspaceRecordSchema>;
|
|
@@ -1026,6 +1131,19 @@ export const operationSchemas = {
|
|
|
1026
1131
|
state: z.discriminatedUnion("action", Object.values(stateOperations) as any),
|
|
1027
1132
|
} as const;
|
|
1028
1133
|
export type OperationRequest = z.infer<(typeof operationSchemas)[OperationFamily]>;
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* Schemas advertised to hosts. `state.recover` needs host-supplied native
|
|
1137
|
+
* recovery authority (`OperationContext.nativeRecovery`), which no shipped
|
|
1138
|
+
* host provides, so advertising it only sends agents into a guaranteed
|
|
1139
|
+
* permission_denied. parseOperation still accepts it for embedders that do
|
|
1140
|
+
* supply that authority.
|
|
1141
|
+
*/
|
|
1142
|
+
const { recover: _unadvertisedRecover, ...advertisedStateOperations } = stateOperations;
|
|
1143
|
+
export const advertisedOperationSchemas = {
|
|
1144
|
+
...operationSchemas,
|
|
1145
|
+
state: z.discriminatedUnion("action", Object.values(advertisedStateOperations) as any),
|
|
1146
|
+
} as const;
|
|
1029
1147
|
export type TaskStartRequest = z.infer<typeof taskOperations.start>;
|
|
1030
1148
|
|
|
1031
1149
|
const compiledOperationSchemas = Object.fromEntries(
|
|
@@ -1048,6 +1166,8 @@ export type ErrorCode =
|
|
|
1048
1166
|
| "capability_unavailable"
|
|
1049
1167
|
| "requirements_unsatisfied"
|
|
1050
1168
|
| "writer_conflict"
|
|
1169
|
+
/** Retryable: another live Workit call holds the checkout's metadata lock. */
|
|
1170
|
+
| "busy"
|
|
1051
1171
|
| "recovery_required"
|
|
1052
1172
|
| "storage_error"
|
|
1053
1173
|
| "external_outcome_unknown";
|
|
@@ -1127,7 +1247,7 @@ export function parseOperation(family: OperationFamily, input: unknown): Result<
|
|
|
1127
1247
|
}
|
|
1128
1248
|
|
|
1129
1249
|
export function operationJsonSchema(family: OperationFamily): z.core.JSONSchema.BaseSchema {
|
|
1130
|
-
return z.toJSONSchema(
|
|
1250
|
+
return z.toJSONSchema(advertisedOperationSchemas[family], { target: "draft-2020-12" });
|
|
1131
1251
|
}
|
|
1132
1252
|
|
|
1133
1253
|
/**
|