hilos-agent 0.10.1 → 0.11.2
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/LICENSE +21 -0
- package/README.md +54 -10
- package/bin/hilos-agent.mjs +87 -14
- package/package.json +7 -5
- package/src/acp-session.mjs +16 -1
- package/src/cli.mjs +34 -1
- package/src/codex-mcp-session.mjs +36 -0
- package/src/config.mjs +39 -5
- package/src/conflicts.mjs +156 -0
- package/src/cursor-store.mjs +43 -8
- package/src/handler.mjs +1197 -127
- package/src/iterate-claim-recovery.mjs +574 -0
- package/src/local-harness-events.mjs +370 -0
- package/src/local-harness-transport.mjs +157 -0
- package/src/mcp.mjs +411 -34
- package/src/model-resolve.mjs +3 -1
- package/src/opencode-session.mjs +22 -2
- package/src/progress-emitter.mjs +1 -1
- package/src/queue.mjs +70 -3
- package/src/redact.mjs +360 -7
- package/src/run-inputs.mjs +211 -0
- package/src/run.mjs +462 -76
- package/src/thread-pr.mjs +26 -4
- package/src/webmcp-bridge.mjs +77 -4
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
// Durable recovery records for local-daemon Iterate claims. A claim changes the
|
|
2
|
+
// canonical run generation before its HTTP response reaches this machine, so a
|
|
3
|
+
// response-lost process must retain the exact owner token across restarts.
|
|
4
|
+
|
|
5
|
+
import * as nodeFs from "node:fs";
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
8
|
+
import { homedir, platform } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
export const ITERATE_CLAIM_RECOVERY_DIR = join(homedir(), ".hilos", "iterate-claims");
|
|
12
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
13
|
+
const INTRINSIC_SELF_ID_PREFIX = "intrinsic-self:";
|
|
14
|
+
const SELF_PROCESS_INSTANCE_ID = `${INTRINSIC_SELF_ID_PREFIX}${platform()}:${Math.round(
|
|
15
|
+
Date.now() - process.uptime() * 1000,
|
|
16
|
+
)}:${randomUUID()}`;
|
|
17
|
+
|
|
18
|
+
function safeSegment(value) {
|
|
19
|
+
return String(value || "").replace(/[^a-zA-Z0-9-]/g, "");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function canonicalIterateClaimServerIdentity(url) {
|
|
23
|
+
try {
|
|
24
|
+
const parsed = new URL(String(url || "").trim());
|
|
25
|
+
parsed.hash = "";
|
|
26
|
+
parsed.username = "";
|
|
27
|
+
parsed.password = "";
|
|
28
|
+
parsed.pathname = parsed.pathname.replace(/\/+$/, "") || "/";
|
|
29
|
+
parsed.searchParams.sort();
|
|
30
|
+
// URL normalizes scheme/host casing for us. Preserve the path's casing:
|
|
31
|
+
// unlike hostnames, HTTP paths can name different resources by case.
|
|
32
|
+
return parsed.toString().replace(/\/$/, "");
|
|
33
|
+
} catch {
|
|
34
|
+
return String(url || "").trim().replace(/\/+$/, "").toLowerCase();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Stable across bearer rotation; the bearer token is never hashed or written. */
|
|
39
|
+
export function iterateClaimConnectionFingerprint({ url }) {
|
|
40
|
+
return createHash("sha256")
|
|
41
|
+
.update(canonicalIterateClaimServerIdentity(url))
|
|
42
|
+
.digest("hex")
|
|
43
|
+
.slice(0, 24);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function validRecord(value) {
|
|
47
|
+
return Boolean(
|
|
48
|
+
value &&
|
|
49
|
+
typeof value.runId === "string" &&
|
|
50
|
+
UUID_RE.test(value.runId) &&
|
|
51
|
+
Number.isSafeInteger(value.expectedIterateTurn) &&
|
|
52
|
+
value.expectedIterateTurn >= 0 &&
|
|
53
|
+
typeof value.claimId === "string" &&
|
|
54
|
+
UUID_RE.test(value.claimId),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function defaultProcessAlive(pid) {
|
|
59
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
60
|
+
try {
|
|
61
|
+
process.kill(pid, 0);
|
|
62
|
+
return true;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return error?.code !== "ESRCH";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A PID only identifies a process until the OS reuses it. Persist an identity
|
|
69
|
+
// tied to the process incarnation as well, so an owner left by a crash or
|
|
70
|
+
// reboot cannot be mistaken for the later process that inherited its PID.
|
|
71
|
+
// Linux exposes a boot UUID plus the process start tick without spawning. The
|
|
72
|
+
// supported desktop platforms expose an absolute process start time through
|
|
73
|
+
// their standard process tools.
|
|
74
|
+
export function defaultProcessInstanceIdentity(
|
|
75
|
+
pid,
|
|
76
|
+
{
|
|
77
|
+
readFileSync = nodeFs.readFileSync,
|
|
78
|
+
execute = execFileSync,
|
|
79
|
+
currentPid = process.pid,
|
|
80
|
+
selfIdentity = SELF_PROCESS_INSTANCE_ID,
|
|
81
|
+
platformName = platform(),
|
|
82
|
+
} = {},
|
|
83
|
+
) {
|
|
84
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return null;
|
|
85
|
+
try {
|
|
86
|
+
if (platformName === "linux") {
|
|
87
|
+
try {
|
|
88
|
+
const bootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
|
|
89
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
90
|
+
const commandEnd = stat.lastIndexOf(")");
|
|
91
|
+
if (bootId && commandEnd >= 0) {
|
|
92
|
+
// After the command's closing parenthesis, index 0 is field 3
|
|
93
|
+
// (state), making index 19 field 22 (start tick since boot).
|
|
94
|
+
const startTick = stat.slice(commandEnd + 1).trim().split(/\s+/)[19];
|
|
95
|
+
if (startTick) return `linux:${bootId}:${startTick}`;
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
// Sandboxed Linux environments may not mount procfs. Fall through to
|
|
99
|
+
// the same POSIX process-start query used by macOS/BSD.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (platformName === "win32") {
|
|
103
|
+
const ticks = execute(
|
|
104
|
+
"powershell.exe",
|
|
105
|
+
[
|
|
106
|
+
"-NoProfile",
|
|
107
|
+
"-NonInteractive",
|
|
108
|
+
"-Command",
|
|
109
|
+
`(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`,
|
|
110
|
+
],
|
|
111
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
|
|
112
|
+
).trim();
|
|
113
|
+
return ticks ? `win32:${ticks}` : null;
|
|
114
|
+
}
|
|
115
|
+
const started = execute("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
116
|
+
encoding: "utf8",
|
|
117
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
118
|
+
}).trim();
|
|
119
|
+
if (!started) return null;
|
|
120
|
+
if (["darwin", "freebsd", "openbsd", "netbsd"].includes(platformName)) {
|
|
121
|
+
const boot = execute("sysctl", ["-n", "kern.boottime"], {
|
|
122
|
+
encoding: "utf8",
|
|
123
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
124
|
+
}).trim();
|
|
125
|
+
// BSD ps exposes lstart only to the second. Mark that precision in the
|
|
126
|
+
// durable identity: unequal values prove reuse, while equal values are
|
|
127
|
+
// intentionally treated as ambiguous/live rather than risking two
|
|
128
|
+
// daemons when a PID is recycled inside the same second.
|
|
129
|
+
return boot ? `${platformName}:coarse-second:${boot}:${started}` : null;
|
|
130
|
+
}
|
|
131
|
+
return `${platformName}:coarse-second:${started}`;
|
|
132
|
+
} catch {
|
|
133
|
+
// This remains available in restricted runtimes that deny procfs/ps
|
|
134
|
+
// access, and is shared by every store created in this process. A
|
|
135
|
+
// different process that cannot inspect it will conservatively fail
|
|
136
|
+
// closed rather than preempting a possibly-live owner.
|
|
137
|
+
return pid === currentPid ? selfIdentity : null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function syncDirectory(fs, path) {
|
|
142
|
+
const fd = fs.openSync(path, "r");
|
|
143
|
+
try {
|
|
144
|
+
fs.fsyncSync(fd);
|
|
145
|
+
} finally {
|
|
146
|
+
fs.closeSync(fd);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* One file per claim plus one exclusive agent/server scope owner.
|
|
152
|
+
* @param {{ agentId: string, url: string, token?: string, dir?: string,
|
|
153
|
+
* log?: { error?: (message: string) => void }, fs?: any,
|
|
154
|
+
* isProcessAlive?: (pid: number) => boolean,
|
|
155
|
+
* getProcessInstanceIdentity?: (pid: number) => string | null,
|
|
156
|
+
* pid?: number, ownerId?: string }} options
|
|
157
|
+
*/
|
|
158
|
+
export function createIterateClaimRecoveryStore({
|
|
159
|
+
agentId,
|
|
160
|
+
url,
|
|
161
|
+
// Accepted for call-site compatibility only. Deliberately unused.
|
|
162
|
+
token: _token,
|
|
163
|
+
dir = ITERATE_CLAIM_RECOVERY_DIR,
|
|
164
|
+
log,
|
|
165
|
+
fs = nodeFs,
|
|
166
|
+
isProcessAlive = defaultProcessAlive,
|
|
167
|
+
getProcessInstanceIdentity = defaultProcessInstanceIdentity,
|
|
168
|
+
pid = process.pid,
|
|
169
|
+
ownerId = randomUUID(),
|
|
170
|
+
} = {}) {
|
|
171
|
+
const scope = `${safeSegment(agentId)}-${iterateClaimConnectionFingerprint({ url })}`;
|
|
172
|
+
const scopeDir = join(dir, scope);
|
|
173
|
+
const ownerKey = safeSegment(ownerId);
|
|
174
|
+
const lockName = `.owner.${ownerKey}.lock`;
|
|
175
|
+
const lockPath = join(scopeDir, lockName);
|
|
176
|
+
const electionPath = join(scopeDir, ".owner-election.lock");
|
|
177
|
+
const recordPath = (claimId) => join(scopeDir, `${safeSegment(claimId)}.json`);
|
|
178
|
+
const processInstanceIdentity = getProcessInstanceIdentity(pid);
|
|
179
|
+
let lockHeld = false;
|
|
180
|
+
let heldElectionPath = null;
|
|
181
|
+
|
|
182
|
+
function ensureScope() {
|
|
183
|
+
fs.mkdirSync(scopeDir, { recursive: true, mode: 0o700 });
|
|
184
|
+
fs.chmodSync(scopeDir, 0o700);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function durableWrite(path, value, { exclusive = false } = {}) {
|
|
188
|
+
const fd = fs.openSync(path, exclusive ? "wx" : "w", 0o600);
|
|
189
|
+
try {
|
|
190
|
+
fs.writeFileSync(fd, value, { encoding: "utf8" });
|
|
191
|
+
fs.chmodSync(path, 0o600);
|
|
192
|
+
fs.fsyncSync(fd);
|
|
193
|
+
} finally {
|
|
194
|
+
fs.closeSync(fd);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function readLock(path = lockPath) {
|
|
199
|
+
try {
|
|
200
|
+
return JSON.parse(fs.readFileSync(path, "utf8"));
|
|
201
|
+
} catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function matchesOwner(value) {
|
|
207
|
+
return Boolean(
|
|
208
|
+
value?.ownerId === ownerId &&
|
|
209
|
+
value?.pid === pid &&
|
|
210
|
+
value?.processInstanceIdentity === processInstanceIdentity,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function validLock(value, name) {
|
|
215
|
+
return Boolean(
|
|
216
|
+
value &&
|
|
217
|
+
Number.isSafeInteger(value.pid) &&
|
|
218
|
+
value.pid > 0 &&
|
|
219
|
+
typeof value.ownerId === "string" &&
|
|
220
|
+
safeSegment(value.ownerId) &&
|
|
221
|
+
(value.processInstanceIdentity === undefined ||
|
|
222
|
+
(typeof value.processInstanceIdentity === "string" &&
|
|
223
|
+
value.processInstanceIdentity.length > 0)) &&
|
|
224
|
+
name === `.owner.${safeSegment(value.ownerId)}.lock`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function ownerIsLive(owner) {
|
|
229
|
+
if (!isProcessAlive(owner.pid)) return false;
|
|
230
|
+
// Locks written by the previous daemon version have no incarnation
|
|
231
|
+
// identity. During a rolling upgrade, numeric liveness is the only safe
|
|
232
|
+
// evidence available: never preempt a live legacy owner, but reclaim it
|
|
233
|
+
// once that PID is gone.
|
|
234
|
+
if (owner.processInstanceIdentity === undefined) return true;
|
|
235
|
+
// This marker is written only when the owner could not inspect even its
|
|
236
|
+
// own OS process identity. It is deliberately not cross-process
|
|
237
|
+
// comparable: a later contender may have broader procfs/ps permissions,
|
|
238
|
+
// but its different real identity does not prove that the live fallback
|
|
239
|
+
// owner was replaced.
|
|
240
|
+
if (owner.processInstanceIdentity.startsWith(INTRINSIC_SELF_ID_PREFIX)) return true;
|
|
241
|
+
let currentIdentity;
|
|
242
|
+
try {
|
|
243
|
+
currentIdentity = getProcessInstanceIdentity(owner.pid);
|
|
244
|
+
} catch {
|
|
245
|
+
// A live-looking process whose identity cannot be inspected is not safe
|
|
246
|
+
// to preempt. A later poll can recover it once liveness is knowable.
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
if (!currentIdentity) return true;
|
|
250
|
+
// The reverse transition is equally non-comparable: this process may have
|
|
251
|
+
// published a durable OS identity while inspection worked, then fallen
|
|
252
|
+
// back later (sandbox/profile change, transient procfs denial). Numeric
|
|
253
|
+
// liveness plus an intrinsic current-process marker is ambiguous, not
|
|
254
|
+
// evidence that the published owner was replaced.
|
|
255
|
+
if (currentIdentity.startsWith(INTRINSIC_SELF_ID_PREFIX)) return true;
|
|
256
|
+
return currentIdentity === owner.processInstanceIdentity;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function ownsPublishedLock() {
|
|
260
|
+
if (!lockHeld) return false;
|
|
261
|
+
const owner = readLock();
|
|
262
|
+
const elected = heldElectionPath ? readLock(heldElectionPath) : null;
|
|
263
|
+
if (matchesOwner(owner) && matchesOwner(elected)) {
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
lockHeld = false;
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function removeOwnPublishedLock() {
|
|
271
|
+
const owner = readLock();
|
|
272
|
+
const elected = heldElectionPath ? readLock(heldElectionPath) : null;
|
|
273
|
+
if (!matchesOwner(owner) || !matchesOwner(elected)) return false;
|
|
274
|
+
try {
|
|
275
|
+
fs.unlinkSync(heldElectionPath);
|
|
276
|
+
fs.unlinkSync(lockPath);
|
|
277
|
+
syncDirectory(fs, scopeDir);
|
|
278
|
+
lockHeld = false;
|
|
279
|
+
heldElectionPath = null;
|
|
280
|
+
return true;
|
|
281
|
+
} catch {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function removeOwnCandidate() {
|
|
287
|
+
if (!matchesOwner(readLock())) return false;
|
|
288
|
+
try {
|
|
289
|
+
fs.unlinkSync(lockPath);
|
|
290
|
+
syncDirectory(fs, scopeDir);
|
|
291
|
+
return true;
|
|
292
|
+
} catch {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function acquireElection() {
|
|
298
|
+
let candidatePath = electionPath;
|
|
299
|
+
let traversalBudget;
|
|
300
|
+
try {
|
|
301
|
+
// A valid chain with N existing anchors reaches either a live owner or
|
|
302
|
+
// its first missing successor in at most N + 1 hops. Deriving the bound
|
|
303
|
+
// from durable state avoids a lifetime crash-generation ceiling while
|
|
304
|
+
// still bounding work on corrupt or adversarial directories.
|
|
305
|
+
traversalBudget =
|
|
306
|
+
fs
|
|
307
|
+
.readdirSync(scopeDir)
|
|
308
|
+
.filter(
|
|
309
|
+
(name) =>
|
|
310
|
+
name === ".owner-election.lock" ||
|
|
311
|
+
(name.startsWith(".owner-election-after-") && name.endsWith(".lock")),
|
|
312
|
+
).length + 1;
|
|
313
|
+
} catch {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
const visited = new Set();
|
|
317
|
+
for (let attempt = 0; attempt < traversalBudget; attempt += 1) {
|
|
318
|
+
if (visited.has(candidatePath)) return false;
|
|
319
|
+
visited.add(candidatePath);
|
|
320
|
+
try {
|
|
321
|
+
fs.linkSync(lockPath, candidatePath);
|
|
322
|
+
syncDirectory(fs, scopeDir);
|
|
323
|
+
heldElectionPath = candidatePath;
|
|
324
|
+
return true;
|
|
325
|
+
} catch (error) {
|
|
326
|
+
if (error?.code !== "EEXIST") return false;
|
|
327
|
+
}
|
|
328
|
+
const elected = readLock(candidatePath);
|
|
329
|
+
if (matchesOwner(elected)) {
|
|
330
|
+
heldElectionPath = candidatePath;
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
if (elected && validLock(elected, `.owner.${safeSegment(elected.ownerId)}.lock`) && ownerIsLive(elected)) {
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
// Never replace or unlink a stale anchor: path-based cleanup cannot be
|
|
337
|
+
// conditional on the inode inspected above, so a delayed contender
|
|
338
|
+
// could otherwise remove a newer winner. Every contender instead walks
|
|
339
|
+
// the same immutable successor chain and atomically races to publish the
|
|
340
|
+
// first missing link.
|
|
341
|
+
try {
|
|
342
|
+
const stat = fs.statSync(candidatePath);
|
|
343
|
+
const staleKey = createHash("sha256")
|
|
344
|
+
.update(`${stat.dev}:${stat.ino}:${stat.size}:${safeSegment(elected?.ownerId)}`)
|
|
345
|
+
.digest("hex")
|
|
346
|
+
.slice(0, 24);
|
|
347
|
+
candidatePath = join(scopeDir, `.owner-election-after-${staleKey}.lock`);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
if (error?.code === "ENOENT") {
|
|
350
|
+
candidatePath = electionPath;
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
acquireLock() {
|
|
361
|
+
if (lockHeld) return true;
|
|
362
|
+
if (!ownerKey || !processInstanceIdentity) return false;
|
|
363
|
+
try {
|
|
364
|
+
ensureScope();
|
|
365
|
+
} catch (error) {
|
|
366
|
+
log?.error?.(`iterate claim scope unavailable: ${error?.message || error}`);
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
// Publish through an fsynced private file and an atomic hard link. Readers
|
|
370
|
+
// can therefore see either no contender or a complete contender, never a
|
|
371
|
+
// partially-written lock. Every process has an immutable, unique path;
|
|
372
|
+
// stale cleanup can never unlink a later contender at another path.
|
|
373
|
+
let temp = join(scopeDir, `.owner-pending.${ownerKey}.${pid}.${randomUUID()}.tmp`);
|
|
374
|
+
try {
|
|
375
|
+
durableWrite(temp, JSON.stringify({
|
|
376
|
+
pid,
|
|
377
|
+
ownerId,
|
|
378
|
+
processInstanceIdentity,
|
|
379
|
+
lockProtocol: 2,
|
|
380
|
+
state: "contender",
|
|
381
|
+
}), { exclusive: true });
|
|
382
|
+
fs.linkSync(temp, lockPath);
|
|
383
|
+
fs.unlinkSync(temp);
|
|
384
|
+
temp = null;
|
|
385
|
+
syncDirectory(fs, scopeDir);
|
|
386
|
+
} catch (error) {
|
|
387
|
+
if (temp) {
|
|
388
|
+
try {
|
|
389
|
+
fs.unlinkSync(temp);
|
|
390
|
+
} catch {
|
|
391
|
+
/* best-effort cleanup of an unpublished contender */
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
removeOwnCandidate();
|
|
395
|
+
log?.error?.(`iterate claim scope not locked: ${error?.message || error}`);
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (!acquireElection()) {
|
|
400
|
+
removeOwnCandidate();
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
let names;
|
|
405
|
+
try {
|
|
406
|
+
names = fs
|
|
407
|
+
.readdirSync(scopeDir)
|
|
408
|
+
.filter((name) => name.startsWith(".owner.") && name.endsWith(".lock"));
|
|
409
|
+
} catch (error) {
|
|
410
|
+
removeOwnPublishedLock();
|
|
411
|
+
log?.error?.(`iterate claim scope not inspected: ${error?.message || error}`);
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
let ownSeen = false;
|
|
416
|
+
let liveContender = false;
|
|
417
|
+
const stalePaths = [];
|
|
418
|
+
for (const name of names) {
|
|
419
|
+
const path = join(scopeDir, name);
|
|
420
|
+
const owner = readLock(path);
|
|
421
|
+
if (
|
|
422
|
+
name === lockName &&
|
|
423
|
+
owner?.ownerId === ownerId &&
|
|
424
|
+
owner?.pid === pid &&
|
|
425
|
+
owner?.processInstanceIdentity === processInstanceIdentity
|
|
426
|
+
) {
|
|
427
|
+
ownSeen = true;
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (owner?.lockProtocol === 2 && owner?.state === "contender") {
|
|
431
|
+
if (!ownerIsLive(owner)) stalePaths.push(path);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (!validLock(owner, name) || !ownerIsLive(owner)) {
|
|
435
|
+
stalePaths.push(path);
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
liveContender = true;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (!ownSeen || liveContender) {
|
|
442
|
+
removeOwnPublishedLock();
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// These paths are immutable per owner UUID. A newly-published contender
|
|
447
|
+
// necessarily has a different path and is left untouched; if it appears
|
|
448
|
+
// after our scan it will observe this live owner and withdraw itself.
|
|
449
|
+
for (const path of stalePaths) {
|
|
450
|
+
try {
|
|
451
|
+
fs.unlinkSync(path);
|
|
452
|
+
syncDirectory(fs, scopeDir);
|
|
453
|
+
} catch (error) {
|
|
454
|
+
if (error?.code !== "ENOENT") {
|
|
455
|
+
log?.error?.(`stale iterate claim lock not cleared: ${error?.message || error}`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
lockHeld = true;
|
|
460
|
+
return true;
|
|
461
|
+
},
|
|
462
|
+
|
|
463
|
+
releaseLock() {
|
|
464
|
+
if (!ownsPublishedLock()) return false;
|
|
465
|
+
if (!removeOwnPublishedLock()) {
|
|
466
|
+
const error = new Error("published owner changed or could not be removed");
|
|
467
|
+
log?.error?.(`iterate claim scope lock not released: ${error?.message || error}`);
|
|
468
|
+
return false;
|
|
469
|
+
}
|
|
470
|
+
return true;
|
|
471
|
+
},
|
|
472
|
+
|
|
473
|
+
holdsLock() {
|
|
474
|
+
return ownsPublishedLock();
|
|
475
|
+
},
|
|
476
|
+
|
|
477
|
+
persist(record) {
|
|
478
|
+
if (!ownsPublishedLock() || !validRecord(record) || !safeSegment(record.claimId)) return false;
|
|
479
|
+
let temp = null;
|
|
480
|
+
try {
|
|
481
|
+
ensureScope();
|
|
482
|
+
temp = join(scopeDir, `.${safeSegment(record.claimId)}.${pid}.${randomUUID()}.tmp`);
|
|
483
|
+
durableWrite(temp, JSON.stringify(record), { exclusive: true });
|
|
484
|
+
fs.renameSync(temp, recordPath(record.claimId));
|
|
485
|
+
fs.chmodSync(recordPath(record.claimId), 0o600);
|
|
486
|
+
syncDirectory(fs, scopeDir);
|
|
487
|
+
return true;
|
|
488
|
+
} catch (error) {
|
|
489
|
+
if (temp) {
|
|
490
|
+
try {
|
|
491
|
+
fs.unlinkSync(temp);
|
|
492
|
+
} catch {
|
|
493
|
+
/* best-effort cleanup of an incomplete temp file */
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
log?.error?.(`iterate claim recovery not persisted: ${error?.message || error}`);
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
},
|
|
500
|
+
|
|
501
|
+
remove(claimId) {
|
|
502
|
+
if (!ownsPublishedLock()) return false;
|
|
503
|
+
try {
|
|
504
|
+
fs.unlinkSync(recordPath(claimId));
|
|
505
|
+
syncDirectory(fs, scopeDir);
|
|
506
|
+
return true;
|
|
507
|
+
} catch (error) {
|
|
508
|
+
if (error?.code === "ENOENT" && ownsPublishedLock()) return true;
|
|
509
|
+
log?.error?.(`iterate claim recovery not cleared: ${error?.message || error}`);
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
512
|
+
},
|
|
513
|
+
|
|
514
|
+
list() {
|
|
515
|
+
if (!ownsPublishedLock()) return { records: [], unresolved: 1 };
|
|
516
|
+
let names;
|
|
517
|
+
try {
|
|
518
|
+
names = fs.readdirSync(scopeDir).filter((name) => name.endsWith(".json"));
|
|
519
|
+
} catch (error) {
|
|
520
|
+
log?.error?.(`iterate claim recovery not read: ${error?.message || error}`);
|
|
521
|
+
return { records: [], unresolved: 1 };
|
|
522
|
+
}
|
|
523
|
+
const records = [];
|
|
524
|
+
let unresolved = 0;
|
|
525
|
+
for (const name of names) {
|
|
526
|
+
try {
|
|
527
|
+
const value = JSON.parse(fs.readFileSync(join(scopeDir, name), "utf8"));
|
|
528
|
+
if (validRecord(value)) records.push(value);
|
|
529
|
+
else unresolved += 1;
|
|
530
|
+
} catch (error) {
|
|
531
|
+
unresolved += 1;
|
|
532
|
+
log?.error?.(`iterate claim recovery record unreadable (${name}): ${error?.message || error}`);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return { records, unresolved };
|
|
536
|
+
},
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
export const TERMINAL_ITERATE_RELEASE_KINDS = new Set([
|
|
541
|
+
"released",
|
|
542
|
+
"already-released",
|
|
543
|
+
"not-owned",
|
|
544
|
+
]);
|
|
545
|
+
|
|
546
|
+
export async function releaseDaemonIterateClaim({ tool, recoveryStore, record }) {
|
|
547
|
+
const result = await tool("update_run", {
|
|
548
|
+
runId: record.runId,
|
|
549
|
+
releaseIterateClaim: true,
|
|
550
|
+
expectedIterateTurn: record.expectedIterateTurn,
|
|
551
|
+
claimId: record.claimId,
|
|
552
|
+
});
|
|
553
|
+
if (!TERMINAL_ITERATE_RELEASE_KINDS.has(result?.releaseKind)) return result;
|
|
554
|
+
if (!recoveryStore?.remove(record.claimId)) {
|
|
555
|
+
throw new Error("the Iterate claim recovery record could not be cleared");
|
|
556
|
+
}
|
|
557
|
+
return result;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export async function reconcileDaemonIterateClaims({ tool, recoveryStore, log = console }) {
|
|
561
|
+
if (!recoveryStore) return { unresolved: 0 };
|
|
562
|
+
const listed = recoveryStore.list();
|
|
563
|
+
let unresolved = listed.unresolved;
|
|
564
|
+
for (const record of listed.records) {
|
|
565
|
+
try {
|
|
566
|
+
const result = await releaseDaemonIterateClaim({ tool, recoveryStore, record });
|
|
567
|
+
if (!TERMINAL_ITERATE_RELEASE_KINDS.has(result?.releaseKind)) unresolved += 1;
|
|
568
|
+
} catch (error) {
|
|
569
|
+
unresolved += 1;
|
|
570
|
+
log?.error?.(`iterate claim recovery deferred: ${error?.message || error}`);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return { unresolved };
|
|
574
|
+
}
|