opencode-goal-plugin 0.6.6 → 0.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,91 @@
1
1
  import { randomUUID } from "node:crypto"
2
- import { promises as fs } from "node:fs"
2
+ import { constants as fsConstants, promises as fs } from "node:fs"
3
3
  import { hostname } from "node:os"
4
- import { dirname } from "node:path"
4
+ import { dirname, join } from "node:path"
5
+
6
+ export const PERSISTENCE_LEASE_CONTENDED = "GOAL_PERSISTENCE_LEASE_CONTENDED"
7
+ const LEASE_PROTOCOL_VERSION = 2
8
+ const LEGACY_SENTINEL_TOKEN = "opencode-goal-plugin-immutable-claims-v2"
9
+ const LEGACY_SENTINEL_HOSTNAME = "opencode-goal-plugin-v2.invalid"
10
+ const LEGACY_GUARD_MTIME_MS = Date.UTC(2100, 0, 1)
11
+ const LEGACY_GUARD_MTIME_TOLERANCE_MS = 2_000
12
+ const CLAIM_DIRECTORY_SUFFIX = ".claims-v2"
13
+ const CLAIM_PREFIX = "claim-"
14
+ const CLAIM_SUFFIX = ".json"
15
+ const MAX_OWNER_FILE_BYTES = 4 * 1024
16
+ const MAX_OWNER_TOKEN_LENGTH = 256
17
+ const MAX_OWNER_HOSTNAME_LENGTH = 255
18
+ const MAX_ACQUIRE_ATTEMPTS = 5
19
+
20
+ function validStoredHostname(value) {
21
+ return (
22
+ typeof value === "string" &&
23
+ value.length >= 1 &&
24
+ value.length <= MAX_OWNER_HOSTNAME_LENGTH
25
+ )
26
+ }
27
+
28
+ function validDisplayHostname(value) {
29
+ return validStoredHostname(value) && /^[A-Za-z0-9._-]+$/.test(value)
30
+ }
31
+
32
+ function validOwner(owner) {
33
+ return (
34
+ owner !== null &&
35
+ typeof owner === "object" &&
36
+ !Array.isArray(owner) &&
37
+ typeof owner.token === "string" &&
38
+ owner.token.length >= 1 &&
39
+ owner.token.length <= MAX_OWNER_TOKEN_LENGTH &&
40
+ Number.isSafeInteger(owner.pid) &&
41
+ owner.pid > 0 &&
42
+ validStoredHostname(owner.hostname)
43
+ )
44
+ }
45
+
46
+ function sanitizeOwner(owner) {
47
+ const pid = Number.isSafeInteger(owner?.pid) && owner.pid > 0 ? owner.pid : null
48
+ const hostname = validDisplayHostname(owner?.hostname) ? owner.hostname : null
49
+ return Object.freeze({ pid, hostname })
50
+ }
51
+
52
+ function describeOwner(owner) {
53
+ return owner?.pid && owner?.hostname
54
+ ? `pid ${owner.pid} on ${owner.hostname}`
55
+ : "an unknown owner"
56
+ }
57
+
58
+ export class PersistenceLeaseContendedError extends Error {
59
+ constructor(owner, reason = "owned_elsewhere") {
60
+ const safeOwner = sanitizeOwner(owner)
61
+ const safeReason = reason === "legacy_lock" ? "legacy_lock" : "owned_elsewhere"
62
+ super(safeReason === "legacy_lock"
63
+ ? "goal persistence uses a legacy or incomplete lease; close every OpenCode instance using this session, then remove its lease artifacts before retrying"
64
+ : `goal persistence is already owned by ${describeOwner(safeOwner)}; close the other OpenCode instance or open a fork`)
65
+ this.name = "PersistenceLeaseContendedError"
66
+ this.code = PERSISTENCE_LEASE_CONTENDED
67
+ this.owner = safeOwner
68
+ this.reason = safeReason
69
+ }
70
+ }
71
+
72
+ export function isPersistenceLeaseContendedError(error) {
73
+ return error instanceof PersistenceLeaseContendedError
74
+ }
75
+
76
+ function persistenceLeasePathError() {
77
+ const error = new Error("goal persistence lease paths must use their expected real file types")
78
+ error.code = "ERR_GOAL_PERSISTENCE_LEASE_PATH"
79
+ return error
80
+ }
81
+
82
+ function persistenceLeaseHardLinkError() {
83
+ const error = new Error(
84
+ "goal persistence requires same-filesystem hard-link support for its compatibility guard",
85
+ )
86
+ error.code = "ERR_GOAL_PERSISTENCE_LEASE_HARDLINK"
87
+ return error
88
+ }
5
89
 
6
90
  function processIsAlive(pid) {
7
91
  if (!Number.isSafeInteger(pid) || pid <= 0) return null
@@ -14,81 +98,532 @@ function processIsAlive(pid) {
14
98
  }
15
99
  }
16
100
 
17
- async function readOwner(lockPath) {
101
+ async function assertLockDirectory(lockPath) {
102
+ let lockInfo
18
103
  try {
19
- return JSON.parse(await fs.readFile(`${lockPath}/owner.json`, "utf8"))
20
- } catch {
104
+ lockInfo = await fs.lstat(lockPath)
105
+ } catch (error) {
106
+ if (error?.code === "ENOENT") return null
107
+ throw error
108
+ }
109
+ if (lockInfo.isSymbolicLink() || !lockInfo.isDirectory()) {
110
+ throw persistenceLeasePathError()
111
+ }
112
+ return lockInfo
113
+ }
114
+
115
+ async function readBoundedOwnerFile(handle, expectedInfo) {
116
+ const buffer = Buffer.alloc(MAX_OWNER_FILE_BYTES + 1)
117
+ let bytesReadTotal = 0
118
+ while (bytesReadTotal < buffer.length) {
119
+ const { bytesRead } = await handle.read(
120
+ buffer,
121
+ bytesReadTotal,
122
+ buffer.length - bytesReadTotal,
123
+ bytesReadTotal,
124
+ )
125
+ if (bytesRead === 0) break
126
+ bytesReadTotal += bytesRead
127
+ }
128
+
129
+ const finalInfo = await handle.stat()
130
+ if (
131
+ !finalInfo.isFile() ||
132
+ bytesReadTotal === 0 ||
133
+ bytesReadTotal > MAX_OWNER_FILE_BYTES ||
134
+ bytesReadTotal !== expectedInfo.size ||
135
+ finalInfo.size !== expectedInfo.size ||
136
+ finalInfo.dev !== expectedInfo.dev ||
137
+ finalInfo.ino !== expectedInfo.ino
138
+ ) {
21
139
  return null
22
140
  }
141
+ return buffer.toString("utf8", 0, bytesReadTotal)
142
+ }
143
+
144
+ async function readOwnerRecord(ownerPath) {
145
+ let ownerInfo
146
+ try {
147
+ ownerInfo = await fs.lstat(ownerPath)
148
+ } catch (error) {
149
+ if (error?.code === "ENOENT") return { status: "missing", owner: null, info: null }
150
+ throw error
151
+ }
152
+ if (
153
+ ownerInfo.isSymbolicLink() ||
154
+ !ownerInfo.isFile() ||
155
+ ownerInfo.size === 0 ||
156
+ ownerInfo.size > MAX_OWNER_FILE_BYTES
157
+ ) {
158
+ return { status: "malformed", owner: null, info: ownerInfo }
159
+ }
160
+
161
+ let handle
162
+ try {
163
+ const flags =
164
+ fsConstants.O_RDONLY |
165
+ (fsConstants.O_NOFOLLOW ?? 0) |
166
+ (fsConstants.O_NONBLOCK ?? 0)
167
+ handle = await fs.open(ownerPath, flags)
168
+ const openedInfo = await handle.stat()
169
+ if (
170
+ !openedInfo.isFile() ||
171
+ openedInfo.size === 0 ||
172
+ openedInfo.size > MAX_OWNER_FILE_BYTES ||
173
+ openedInfo.dev !== ownerInfo.dev ||
174
+ openedInfo.ino !== ownerInfo.ino
175
+ ) {
176
+ return { status: "malformed", owner: null, info: ownerInfo }
177
+ }
178
+
179
+ const raw = await readBoundedOwnerFile(handle, openedInfo)
180
+ if (raw === null) return { status: "malformed", owner: null, info: ownerInfo }
181
+ const parsed = JSON.parse(raw)
182
+ return validOwner(parsed)
183
+ ? { status: "valid", owner: parsed, info: ownerInfo }
184
+ : { status: "malformed", owner: null, info: ownerInfo }
185
+ } catch (error) {
186
+ if (error instanceof SyntaxError || error?.code === "ENOENT" || error?.code === "ELOOP") {
187
+ return { status: "malformed", owner: null, info: ownerInfo }
188
+ }
189
+ throw error
190
+ } finally {
191
+ await handle?.close().catch(() => {})
192
+ }
193
+ }
194
+
195
+ async function readOwner(lockPath) {
196
+ let lockInfo
197
+ try {
198
+ lockInfo = await fs.lstat(lockPath)
199
+ } catch (error) {
200
+ if (error?.code === "ENOENT") return null
201
+ throw error
202
+ }
203
+ if (lockInfo.isSymbolicLink()) throw persistenceLeasePathError()
204
+ const ownerPath = lockInfo.isDirectory()
205
+ ? join(lockPath, "owner.json")
206
+ : lockInfo.isFile()
207
+ ? lockPath
208
+ : null
209
+ if (!ownerPath) throw persistenceLeasePathError()
210
+ const record = await readOwnerRecord(ownerPath)
211
+ return record.status === "valid" ? record.owner : null
212
+ }
213
+
214
+ function ownerIsBlocking(owner, localHostname) {
215
+ if (owner.hostname !== localHostname) return true
216
+ return processIsAlive(owner.pid) !== false
217
+ }
218
+
219
+ function claimNameFor(token) {
220
+ return `${CLAIM_PREFIX}${token}${CLAIM_SUFFIX}`
221
+ }
222
+
223
+ function isClaimLikeName(name) {
224
+ return name.startsWith(CLAIM_PREFIX) && name.endsWith(CLAIM_SUFFIX)
225
+ }
226
+
227
+ function tokenFromClaimName(name) {
228
+ if (!isClaimLikeName(name)) return null
229
+ const token = name.slice(CLAIM_PREFIX.length, -CLAIM_SUFFIX.length)
230
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(token)
231
+ ? token
232
+ : null
233
+ }
234
+
235
+ async function writeAtomicJSON(path, value) {
236
+ const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`
237
+ try {
238
+ await fs.writeFile(temporaryPath, JSON.stringify(value), { mode: 0o600, flag: "wx" })
239
+ await fs.rename(temporaryPath, path)
240
+ } finally {
241
+ await fs.unlink(temporaryPath).catch((error) => {
242
+ if (error?.code !== "ENOENT") throw error
243
+ })
244
+ }
245
+ }
246
+
247
+ function legacySentinel() {
248
+ return {
249
+ protocol: LEASE_PROTOCOL_VERSION,
250
+ sentinel: true,
251
+ token: LEGACY_SENTINEL_TOKEN,
252
+ pid: 1,
253
+ hostname: LEGACY_SENTINEL_HOSTNAME,
254
+ createdAt: Date.now(),
255
+ }
256
+ }
257
+
258
+ function validLegacySentinel(owner) {
259
+ return (
260
+ validOwner(owner) &&
261
+ Object.keys(owner).sort().join(",") ===
262
+ "createdAt,hostname,pid,protocol,sentinel,token" &&
263
+ owner.protocol === LEASE_PROTOCOL_VERSION &&
264
+ owner.sentinel === true &&
265
+ owner.token === LEGACY_SENTINEL_TOKEN &&
266
+ owner.pid === 1 &&
267
+ owner.hostname === LEGACY_SENTINEL_HOSTNAME &&
268
+ Number.isFinite(owner.createdAt) &&
269
+ owner.createdAt >= 0
270
+ )
271
+ }
272
+
273
+ function legacyGuardMtimeIsSafe(info) {
274
+ return (
275
+ Number.isFinite(info?.mtimeMs) &&
276
+ info.mtimeMs >= LEGACY_GUARD_MTIME_MS - LEGACY_GUARD_MTIME_TOLERANCE_MS
277
+ )
278
+ }
279
+
280
+ function claimDirectoryPathFor(lockPath) {
281
+ return `${lockPath}${CLAIM_DIRECTORY_SUFFIX}`
282
+ }
283
+
284
+ async function inspectLegacyGuard(lockPath) {
285
+ let info
286
+ try {
287
+ info = await fs.lstat(lockPath)
288
+ } catch (error) {
289
+ if (error?.code === "ENOENT") return { status: "missing", owner: null, info: null }
290
+ throw error
291
+ }
292
+ if (info.isSymbolicLink() || (!info.isFile() && !info.isDirectory())) {
293
+ throw persistenceLeasePathError()
294
+ }
295
+ if (info.isDirectory()) {
296
+ const legacy = await readOwnerRecord(join(lockPath, "owner.json"))
297
+ return { status: "legacy", owner: legacy.owner, info }
298
+ }
299
+
300
+ const record = await readOwnerRecord(lockPath)
301
+ if (
302
+ record.status === "valid" &&
303
+ validLegacySentinel(record.owner) &&
304
+ legacyGuardMtimeIsSafe(record.info)
305
+ ) {
306
+ return { status: "valid", owner: record.owner, info: record.info }
307
+ }
308
+ return { status: "incomplete", owner: record.owner, info: record.info }
309
+ }
310
+
311
+ function throwForGuardStatus(guard) {
312
+ if (guard.status === "valid" || guard.status === "missing") return
313
+ throw new PersistenceLeaseContendedError(guard.owner, "legacy_lock")
314
+ }
315
+
316
+ function hardLinkUnsupported(error) {
317
+ return ["EPERM", "EOPNOTSUPP", "ENOTSUP", "EXDEV"].includes(error?.code)
318
+ }
319
+
320
+ async function publishLegacyGuard(
321
+ lockPath,
322
+ { beforeGuardLink, afterGuardLink, linkGuard = (source, target) => fs.link(source, target) } = {},
323
+ ) {
324
+ const temporaryPath = `${lockPath}.guard.${process.pid}.${randomUUID()}.tmp`
325
+ const sentinel = legacySentinel()
326
+ let handle
327
+ let preparedInfo
328
+ let linked = false
329
+ try {
330
+ handle = await fs.open(temporaryPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600)
331
+ await handle.writeFile(JSON.stringify(sentinel))
332
+ await handle.sync()
333
+ const guardDate = new Date(LEGACY_GUARD_MTIME_MS)
334
+ await handle.utimes(guardDate, guardDate)
335
+ await handle.sync()
336
+ preparedInfo = await handle.stat()
337
+ if (!preparedInfo.isFile() || !legacyGuardMtimeIsSafe(preparedInfo)) {
338
+ throw persistenceLeasePathError()
339
+ }
340
+ await handle.close()
341
+ handle = null
342
+
343
+ await beforeGuardLink?.({ lockPath, temporaryPath, sentinel: { ...sentinel } })
344
+ try {
345
+ await linkGuard(temporaryPath, lockPath)
346
+ linked = true
347
+ } catch (error) {
348
+ const racedGuard = await inspectLegacyGuard(lockPath)
349
+ if (racedGuard.status !== "missing") {
350
+ throwForGuardStatus(racedGuard)
351
+ return racedGuard
352
+ }
353
+ if (error?.code === "EEXIST") return null
354
+ if (hardLinkUnsupported(error)) throw persistenceLeaseHardLinkError()
355
+ throw error
356
+ }
357
+ await afterGuardLink?.({ lockPath, temporaryPath, sentinel: { ...sentinel } })
358
+
359
+ const guard = await inspectLegacyGuard(lockPath)
360
+ if (guard.status === "missing") return null
361
+ throwForGuardStatus(guard)
362
+ if (
363
+ !linked ||
364
+ guard.info.dev !== preparedInfo.dev ||
365
+ guard.info.ino !== preparedInfo.ino
366
+ ) {
367
+ throw persistenceLeasePathError()
368
+ }
369
+ return guard
370
+ } finally {
371
+ await handle?.close().catch(() => {})
372
+ await fs.unlink(temporaryPath).catch((error) => {
373
+ if (error?.code !== "ENOENT") throw error
374
+ })
375
+ }
376
+ }
377
+
378
+ async function ensureLegacyGuard(lockPath, hooks = {}) {
379
+ for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt += 1) {
380
+ const guard = await inspectLegacyGuard(lockPath)
381
+ if (guard.status === "valid") return guard
382
+ if (guard.status !== "missing") {
383
+ throwForGuardStatus(guard)
384
+ }
385
+ const published = await publishLegacyGuard(lockPath, hooks)
386
+ if (published) return published
387
+ await retryDelay(randomUUID(), attempt)
388
+ }
389
+ throw new PersistenceLeaseContendedError(null)
390
+ }
391
+
392
+ async function ensureClaimDirectory(claimDirectoryPath) {
393
+ try {
394
+ await fs.mkdir(claimDirectoryPath, { mode: 0o700 })
395
+ } catch (error) {
396
+ if (error?.code !== "EEXIST") throw error
397
+ }
398
+ return assertLockDirectory(claimDirectoryPath)
399
+ }
400
+
401
+ async function removeUniqueClaim(claimPath) {
402
+ try {
403
+ await fs.unlink(claimPath)
404
+ return true
405
+ } catch (error) {
406
+ if (error?.code === "ENOENT") return false
407
+ throw error
408
+ }
409
+ }
410
+
411
+ async function inspectClaims(
412
+ lockPath,
413
+ ownToken,
414
+ localHostname,
415
+ { malformedGraceMs, now },
416
+ ) {
417
+ const entries = await fs.readdir(lockPath, { withFileTypes: true })
418
+ let ownFound = false
419
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
420
+ const expectedToken = tokenFromClaimName(entry.name)
421
+ if (!expectedToken) {
422
+ if (isClaimLikeName(entry.name)) {
423
+ return { blocker: null, blocked: true, ownFound }
424
+ }
425
+ continue
426
+ }
427
+ const claimPath = join(lockPath, entry.name)
428
+ const record = await readOwnerRecord(claimPath)
429
+ if (record.status === "missing") continue
430
+
431
+ const structurallyValidClaim =
432
+ record.status === "valid" &&
433
+ record.owner.token === expectedToken
434
+ if (!structurallyValidClaim) {
435
+ const age = record.info ? now() - record.info.mtimeMs : 0
436
+ if (age < malformedGraceMs) {
437
+ return { blocker: null, blocked: true, ownFound }
438
+ }
439
+ await removeUniqueClaim(claimPath)
440
+ continue
441
+ }
442
+
443
+ // A token-matching claim from an unknown protocol is authoritative to that
444
+ // protocol. Never infer that it is stale or safe to remove.
445
+ if (record.owner.protocol !== LEASE_PROTOCOL_VERSION) {
446
+ return { blocker: record.owner, blocked: true, ownFound }
447
+ }
448
+
449
+ if (record.owner.token === ownToken) {
450
+ ownFound = true
451
+ continue
452
+ }
453
+ if (ownerIsBlocking(record.owner, localHostname)) {
454
+ return { blocker: record.owner, blocked: true, ownFound }
455
+ }
456
+ await removeUniqueClaim(claimPath)
457
+ }
458
+ return { blocker: null, blocked: false, ownFound }
459
+ }
460
+
461
+ function retryDelay(token, attempt) {
462
+ const offset = Number.parseInt(token.slice(attempt * 2, attempt * 2 + 2), 16) || 0
463
+ return new Promise((resolve) => setTimeout(resolve, 1 + (offset % 7)))
464
+ }
465
+
466
+ function createLease(
467
+ lockPath,
468
+ claimDirectoryPath,
469
+ claimPath,
470
+ owner,
471
+ { beforeClaimRemove } = {},
472
+ ) {
473
+ let releasing = false
474
+ let released = false
475
+ return {
476
+ lockPath,
477
+ claimDirectoryPath,
478
+ owner,
479
+ async release() {
480
+ if (released || releasing) return false
481
+ releasing = true
482
+ try {
483
+ const claim = await readOwnerRecord(claimPath)
484
+ if (claim.status === "missing") {
485
+ released = true
486
+ return false
487
+ }
488
+ if (claim.status !== "valid" || claim.owner.token !== owner.token) return false
489
+
490
+ await beforeClaimRemove?.({
491
+ lockPath,
492
+ claimDirectoryPath,
493
+ claimPath,
494
+ owner: { ...owner },
495
+ })
496
+ const removed = await removeUniqueClaim(claimPath)
497
+ if (removed) released = true
498
+ return removed
499
+ } finally {
500
+ releasing = false
501
+ }
502
+ },
503
+ }
23
504
  }
24
505
 
25
506
  /**
26
- * Hold an exclusive session lease for the plugin instance lifetime. This
27
- * deliberately rejects a second writer instead of allowing stale snapshots to
28
- * overwrite the same session's state.
507
+ * Hold an exclusive session lease for the plugin instance lifetime.
508
+ *
509
+ * Version 2 atomically publishes a long-lived regular-file guard at the legacy
510
+ * `.lock` path. Its far-future mtime makes version-1's malformed-directory
511
+ * recovery fail closed, while hard-link publication ensures the legacy path is
512
+ * never visible partially initialized. Version-2 peers elect ownership from
513
+ * never-reused UUID claims in a stable sibling directory. Stale cleanup and
514
+ * release therefore unlink only an immutable claim; neither operation can
515
+ * delete a newer owner's lease after a delayed filesystem call.
29
516
  */
30
- export async function acquirePersistenceLease(
517
+ async function acquirePersistenceLeaseWithHooks(
31
518
  stateFilePath,
32
519
  { malformedGraceMs = 30_000, now = () => Date.now() } = {},
520
+ hooks = {},
33
521
  ) {
522
+ const {
523
+ beforeGuardLink,
524
+ afterGuardLink,
525
+ linkGuard,
526
+ beforeOwnerWrite,
527
+ afterOwnerWrite,
528
+ beforeClaimRemove,
529
+ } = hooks
34
530
  const lockPath = `${stateFilePath}.lock`
531
+ const claimDirectoryPath = claimDirectoryPathFor(lockPath)
532
+ const localHostname = hostname()
35
533
  await fs.mkdir(dirname(stateFilePath), { recursive: true, mode: 0o700 })
36
- const owner = {
37
- token: randomUUID(),
38
- pid: process.pid,
39
- hostname: hostname(),
40
- createdAt: Date.now(),
41
- }
534
+ let lastBlocker = null
42
535
 
43
- for (let attempt = 0; attempt < 3; attempt += 1) {
536
+ for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt += 1) {
537
+ await ensureLegacyGuard(lockPath, { beforeGuardLink, afterGuardLink, linkGuard })
538
+ if (!(await ensureClaimDirectory(claimDirectoryPath))) {
539
+ continue
540
+ }
541
+
542
+ const existing = await inspectClaims(
543
+ claimDirectoryPath,
544
+ null,
545
+ localHostname,
546
+ { malformedGraceMs, now },
547
+ )
548
+ if (existing.blocked) throw new PersistenceLeaseContendedError(existing.blocker)
549
+ const owner = {
550
+ protocol: LEASE_PROTOCOL_VERSION,
551
+ token: randomUUID(),
552
+ pid: process.pid,
553
+ hostname: localHostname,
554
+ createdAt: Date.now(),
555
+ }
556
+ const claimPath = join(claimDirectoryPath, claimNameFor(owner.token))
557
+ let claimPublished = false
44
558
  try {
45
- await fs.mkdir(lockPath, { mode: 0o700 })
46
- await fs.writeFile(`${lockPath}/owner.json`, JSON.stringify(owner), { mode: 0o600 })
47
- return {
559
+ await beforeOwnerWrite?.({
48
560
  lockPath,
49
- owner,
50
- async release() {
51
- const current = await readOwner(lockPath)
52
- if (current?.token !== owner.token) return false
53
- await fs.rm(lockPath, { recursive: true, force: true })
54
- return true
55
- },
56
- }
57
- } catch (error) {
58
- if (error?.code !== "EEXIST") {
59
- await fs.rm(lockPath, { recursive: true, force: true }).catch(() => {})
60
- throw error
61
- }
62
- const existing = await readOwner(lockPath)
63
- const sameHost = existing?.hostname === owner.hostname
64
- let reclaimableMalformed = false
65
- if (!existing) {
66
- try {
67
- const info = await fs.lstat(lockPath)
68
- reclaimableMalformed = now() - info.mtimeMs >= malformedGraceMs
69
- } catch (statError) {
70
- if (statError?.code === "ENOENT") continue
71
- }
72
- }
73
- if ((sameHost && processIsAlive(existing?.pid) === false) || reclaimableMalformed) {
74
- const stalePath = `${lockPath}.stale.${randomUUID()}`
75
- try {
76
- await fs.rename(lockPath, stalePath)
77
- await fs.rm(stalePath, { recursive: true, force: true })
78
- continue
79
- } catch (reclaimError) {
80
- if (reclaimError?.code === "ENOENT") continue
81
- }
561
+ claimDirectoryPath,
562
+ claimPath,
563
+ owner: { ...owner },
564
+ attempt,
565
+ })
566
+ await writeAtomicJSON(claimPath, owner)
567
+ claimPublished = true
568
+ await afterOwnerWrite?.({
569
+ lockPath,
570
+ claimDirectoryPath,
571
+ claimPath,
572
+ owner: { ...owner },
573
+ attempt,
574
+ })
575
+
576
+ const observed = await inspectClaims(
577
+ claimDirectoryPath,
578
+ owner.token,
579
+ localHostname,
580
+ { malformedGraceMs, now },
581
+ )
582
+ if (!observed.ownFound || observed.blocked) {
583
+ lastBlocker = observed.blocker
584
+ await removeUniqueClaim(claimPath)
585
+ claimPublished = false
586
+ await retryDelay(owner.token, attempt)
587
+ continue
82
588
  }
83
- const description = existing
84
- ? `pid ${existing.pid} on ${existing.hostname}`
85
- : "an unknown owner"
86
- throw new Error(
87
- `goal persistence is already owned by ${description}; close the other OpenCode instance or configure a different stateFilePath`,
589
+
590
+ await ensureLegacyGuard(lockPath, { beforeGuardLink, afterGuardLink, linkGuard })
591
+ return createLease(
592
+ lockPath,
593
+ claimDirectoryPath,
594
+ claimPath,
595
+ owner,
596
+ { beforeClaimRemove },
88
597
  )
598
+ } catch (error) {
599
+ if (claimPublished) await removeUniqueClaim(claimPath).catch(() => false)
600
+ if (error?.code === "ENOENT") continue
601
+ throw error
89
602
  }
90
603
  }
91
- throw new Error("could not acquire goal persistence lease")
604
+ throw new PersistenceLeaseContendedError(lastBlocker)
605
+ }
606
+
607
+ export async function acquirePersistenceLease(stateFilePath, options = {}) {
608
+ return acquirePersistenceLeaseWithHooks(stateFilePath, options)
92
609
  }
93
610
 
94
- export const persistenceLeaseInternals = Object.freeze({ processIsAlive, readOwner })
611
+ export const persistenceLeaseInternals = Object.freeze({
612
+ acquirePersistenceLeaseWithHooks,
613
+ claimDirectoryPathFor,
614
+ claimNameFor,
615
+ inspectLegacyGuard,
616
+ inspectClaims,
617
+ legacyGuardMtimeIsSafe,
618
+ legacySentinel,
619
+ publishLegacyGuard,
620
+ processIsAlive,
621
+ readBoundedOwnerFile,
622
+ readOwner,
623
+ readOwnerRecord,
624
+ sanitizeOwner,
625
+ validLegacySentinel,
626
+ validDisplayHostname,
627
+ validOwner,
628
+ validStoredHostname,
629
+ })