brainclaw 1.26.2 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +13 -0
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli/register-coordination.js +65 -1
  4. package/dist/commands/attempt-authority.js +80 -0
  5. package/dist/commands/harvest.js +140 -61
  6. package/dist/commands/loop.js +34 -0
  7. package/dist/commands/loops-handlers.js +87 -14
  8. package/dist/commands/mcp-catalog.js +42 -18
  9. package/dist/commands/mcp-schemas.generated.js +44 -0
  10. package/dist/commands/mcp-write-claims.js +128 -1
  11. package/dist/commands/mcp-write-coordination.js +146 -76
  12. package/dist/core/agent-capability.js +1 -1
  13. package/dist/core/agentrun-reconciler.js +148 -22
  14. package/dist/core/agentruns.js +254 -29
  15. package/dist/core/assignment-request-schema.js +7 -0
  16. package/dist/core/assignment-sweeper.js +5 -3
  17. package/dist/core/assignments.js +131 -33
  18. package/dist/core/claim-request-schema.js +7 -0
  19. package/dist/core/claims.js +53 -2
  20. package/dist/core/dispatch-status.js +16 -6
  21. package/dist/core/dispatcher.js +51 -51
  22. package/dist/core/entity-operations.js +20 -0
  23. package/dist/core/events.js +4 -0
  24. package/dist/core/execution-adapters.js +160 -14
  25. package/dist/core/execution-contract.js +345 -0
  26. package/dist/core/execution.js +130 -16
  27. package/dist/core/harness-adapters/base.js +150 -0
  28. package/dist/core/harness-adapters/claude.js +39 -0
  29. package/dist/core/harness-adapters/codex.js +57 -0
  30. package/dist/core/harness-adapters/harvest.js +109 -0
  31. package/dist/core/harness-adapters/index.js +8 -0
  32. package/dist/core/harness-adapters/prompt-only.js +13 -0
  33. package/dist/core/harness-adapters/registry.js +48 -0
  34. package/dist/core/harness-adapters/result.js +33 -0
  35. package/dist/core/harness-adapters/types.js +2 -0
  36. package/dist/core/ideation-loop-close.js +25 -2
  37. package/dist/core/instruction-templates.js +3 -2
  38. package/dist/core/loop-turn-dispatch.js +207 -0
  39. package/dist/core/loops/artifact-contract.js +11 -0
  40. package/dist/core/loops/attempt-authority.js +476 -0
  41. package/dist/core/loops/attempt-generations.js +509 -0
  42. package/dist/core/loops/attempt-reservation.js +197 -35
  43. package/dist/core/loops/attempt-rollout.js +404 -0
  44. package/dist/core/loops/attempt-takeover.js +155 -0
  45. package/dist/core/loops/bootstrap-acquire.js +7 -3
  46. package/dist/core/loops/evidence.js +187 -0
  47. package/dist/core/loops/facade-schema.js +41 -10
  48. package/dist/core/loops/gate-policy.js +485 -0
  49. package/dist/core/loops/impl-bind.js +37 -79
  50. package/dist/core/loops/index.js +9 -0
  51. package/dist/core/loops/iteration-engine.js +31 -19
  52. package/dist/core/loops/kind-policies.js +90 -0
  53. package/dist/core/loops/lock.js +71 -13
  54. package/dist/core/loops/reconcile-turn.js +235 -18
  55. package/dist/core/loops/result-reducers.js +99 -10
  56. package/dist/core/loops/store.js +30 -3
  57. package/dist/core/loops/turn-execution.js +480 -0
  58. package/dist/core/loops/types.js +113 -2
  59. package/dist/core/loops/verbs.js +332 -99
  60. package/dist/core/loops/verify-command.js +31 -8
  61. package/dist/core/loops/workspace-digest.js +54 -0
  62. package/dist/core/review-loop-close.js +25 -3
  63. package/dist/core/review-loop-turn-dispatch.js +210 -161
  64. package/dist/core/runtime-signals.js +62 -25
  65. package/dist/core/schema.js +35 -0
  66. package/dist/core/spawn-check.js +3 -2
  67. package/dist/core/upgrades/backup.js +27 -4
  68. package/dist/facts.js +7 -6
  69. package/dist/facts.json +6 -5
  70. package/docs/cli.md +49 -1
  71. package/docs/concepts/attempt-authority.md +407 -0
  72. package/docs/concepts/evidence-attestations.md +135 -0
  73. package/docs/concepts/execution-contract.md +166 -0
  74. package/docs/concepts/harness-adapters.md +166 -0
  75. package/docs/concepts/ideation-loop.md +5 -4
  76. package/docs/concepts/loop-engine.md +302 -113
  77. package/docs/index.md +4 -1
  78. package/docs/integrations/codex.md +3 -3
  79. package/docs/integrations/mcp.md +59 -5
  80. package/docs/loops/debug.md +144 -0
  81. package/docs/loops/ideation.md +158 -0
  82. package/docs/loops/implementation.md +154 -0
  83. package/docs/loops/research.md +136 -0
  84. package/docs/loops/review.md +200 -0
  85. package/docs/mcp-schema-changelog.md +14 -5
  86. package/package.json +1 -1
@@ -1,4 +1,10 @@
1
1
  export * from './types.js';
2
+ export * from './attempt-generations.js';
3
+ export * from './attempt-rollout.js';
4
+ export * from './attempt-takeover.js';
5
+ export { GATE_POLICY_VERSION, artifactEvidenceProvenance, artifactEvidenceDigest, evidenceDigest, evidencePolicyForNewLoop, evidenceWriterEnabled, validateArtifactEvidence, validateThreadEvidence, } from './evidence.js';
6
+ export { GATE_POLICIES, eligibleArtifactsForPurpose, evaluateCommandGreen, evaluateCriticSignal, evaluateGateCondition, evaluateNoNewCritique, } from './gate-policy.js';
7
+ export { EXECUTION_CONTRACT_PROTOCOL_VERSION, EXECUTION_CONTRACT_VERSION, CapabilityRequirementSchema, CapabilityResolutionReasonSchema, CapabilitySnapshotSchema, HarnessCapabilityBindingSchema, ExecutionContractRefSchema, ExecutionContractSchema, RuntimeCapabilityObservationSchema, assertExecutionContractIntegrity, capabilitySnapshotHash, canonicalExecutionContract, executionContractHash, executionContractRef, resolveCapabilitySnapshot, validateWorkerContractAcceptance, } from '../execution-contract.js';
2
8
  export { AwaitingFileApplyApprovalError, closeLoop, ensureLoopsDir, generateLoopId, generateMutationId, generateSlotId, getLoop, listLoopEvents, listLoops, openLoop, writeThreadFile, } from './store.js';
3
9
  export { add_artifact, advance, complete_turn, evaluatePhaseAdvanceGate, evaluateStopCondition, pause, provideInput, reconcileOpenQuestions, requestInput, resume, sweepPauseTimeouts, turn, } from './verbs.js';
4
10
  export { decideNextPhase, artifactsInIteration, noNewCritiqueInIteration, hasCriticSignalInIteration, hasPassingVerifyReportInIteration, } from './iteration-engine.js';
@@ -12,4 +18,7 @@ export { buildSurveySignalsBaseline, } from './hooks/survey-signals-baseline.js'
12
18
  export { acquireLock, hashRequest, recordConflict, withLoopLock, DEFAULT_MAX_MUTATION_DURATION_MS, IDEMPOTENCY_TTL_MS, LEASE_GRACE_MS, LEASE_WINDOW_MS, IdempotencyKeyReusedError, IdempotencyOwnerMismatchError, LockLostError, LockTimeoutError, VersionConflictError, } from './lock.js';
13
19
  export { acquireBootstrapLoop, findExistingBootstrapLoop, BootstrapCoordinationInProgressError, } from './bootstrap-acquire.js';
14
20
  export { deriveWorkerReplyContract, renderWorkerReplyProse, workerReplyNextAction, } from './worker-reply-contract.js';
21
+ export { abortAttempt, inspectAttempt, matchEvidence, prepareAttempt, projectAndCross, revokeAttempt, } from './attempt-authority.js';
22
+ export { LOOP_KIND_POLICIES, assertLoopKindPoliciesComplete, isWorkerPhase, phasePolicy, policyForKind, } from './kind-policies.js';
23
+ export { ensureTurnExecutionProjections, prepareTurnExecution, } from './turn-execution.js';
15
24
  //# sourceMappingURL=index.js.map
@@ -12,6 +12,7 @@
12
12
  * transitions = exit_when conditions + cycle membership, guards = phase
13
13
  * advance_gate, actions = system event emissions.
14
14
  */
15
+ import { evaluateCommandGreen, evaluateCriticSignal, evaluateNoNewCritique, } from './gate-policy.js';
15
16
  /**
16
17
  * Decide the next phase given the current thread state and the protocol.
17
18
  *
@@ -19,7 +20,7 @@
19
20
  * is no successor (last phase + no iteration block). Callers that want
20
21
  * to handle "already at end" should check beforehand.
21
22
  */
22
- export function decideNextPhase(thread, protocol) {
23
+ export function decideNextPhase(thread, protocol, cwd) {
23
24
  const phaseNames = protocol.phases.map((p) => p.name);
24
25
  const currentIndex = phaseNames.indexOf(thread.current_phase);
25
26
  if (currentIndex < 0) {
@@ -44,6 +45,26 @@ export function decideNextPhase(thread, protocol) {
44
45
  // exit by max_iterations.
45
46
  const cycleIndex = cycle.indexOf(thread.current_phase);
46
47
  const atCycleEnd = cycleIndex === cycle.length - 1;
48
+ // A saturation exit is a negative observation: after at least one full
49
+ // cycle, a settled critique phase with no eligible new critique exits
50
+ // directly. Waiting until the revision boundary made this condition
51
+ // unreachable because the critique phase's quantitative gate refused the
52
+ // very zero-artifact round the exit condition needs to observe.
53
+ if (cycleIndex === 0 &&
54
+ thread.iteration_count > 0 &&
55
+ protocol.iteration?.exit_when === 'no_new_critique_artifacts' &&
56
+ noNewCritiqueInIteration(thread, thread.iteration_count, cwd)) {
57
+ const lastCyclePhaseIndex = phaseNames.indexOf(cycle[cycle.length - 1]);
58
+ if (lastCyclePhaseIndex < 0 || lastCyclePhaseIndex + 1 >= phaseNames.length) {
59
+ throw new Error(`decideNextPhase: cycle's last phase "${cycle[cycle.length - 1]}" has no post-cycle successor`);
60
+ }
61
+ return {
62
+ kind: 'exit_cycle',
63
+ target: phaseNames[lastCyclePhaseIndex + 1],
64
+ iteration: thread.iteration_count,
65
+ reason: 'no_new_critique_artifacts',
66
+ };
67
+ }
47
68
  if (!atCycleEnd) {
48
69
  return {
49
70
  kind: 'advance_to',
@@ -75,7 +96,7 @@ export function decideNextPhase(thread, protocol) {
75
96
  // iteration_count is the one that just completed (the engine has not
76
97
  // yet incremented).
77
98
  if (iterationBlock.exit_when === 'critic_signal' &&
78
- hasCriticSignalInIteration(thread, thread.iteration_count)) {
99
+ hasCriticSignalInIteration(thread, thread.iteration_count, cwd)) {
79
100
  return {
80
101
  kind: 'exit_cycle',
81
102
  target: postCycleTarget,
@@ -84,7 +105,7 @@ export function decideNextPhase(thread, protocol) {
84
105
  };
85
106
  }
86
107
  if (iterationBlock.exit_when === 'no_new_critique_artifacts' &&
87
- noNewCritiqueInIteration(thread, thread.iteration_count)) {
108
+ noNewCritiqueInIteration(thread, thread.iteration_count, cwd)) {
88
109
  return {
89
110
  kind: 'exit_cycle',
90
111
  target: postCycleTarget,
@@ -93,7 +114,7 @@ export function decideNextPhase(thread, protocol) {
93
114
  };
94
115
  }
95
116
  if (iterationBlock.exit_when === 'command_green' &&
96
- hasPassingVerifyReportInIteration(thread, thread.iteration_count)) {
117
+ hasPassingVerifyReportInIteration(thread, thread.iteration_count, cwd)) {
97
118
  return {
98
119
  kind: 'exit_cycle',
99
120
  target: postCycleTarget,
@@ -134,16 +155,16 @@ export function artifactsInIteration(thread, iteration) {
134
155
  * just-completed iteration produced no critique-typed artifacts. Used
135
156
  * by `decideNextPhase` at the cycle boundary.
136
157
  */
137
- export function noNewCritiqueInIteration(thread, iteration) {
138
- return !thread.artifacts.some((a) => (a.iteration ?? 0) === iteration && a.type === 'critique');
158
+ export function noNewCritiqueInIteration(thread, iteration, cwd) {
159
+ return evaluateNoNewCritique(thread, iteration, cwd).passed;
139
160
  }
140
161
  /**
141
162
  * `exit_when='critic_signal'` predicate: true when the iteration
142
163
  * contains a `type='critic_signal'` artifact (any subtype/body). The
143
164
  * critic emits this when it judges the proposal sufficient.
144
165
  */
145
- export function hasCriticSignalInIteration(thread, iteration) {
146
- return thread.artifacts.some((a) => (a.iteration ?? 0) === iteration && a.type === 'critic_signal');
166
+ export function hasCriticSignalInIteration(thread, iteration, cwd) {
167
+ return evaluateCriticSignal(thread, iteration, cwd).passed;
147
168
  }
148
169
  /**
149
170
  * `exit_when='command_green'` predicate (pln#609): true when the just-finished
@@ -153,16 +174,7 @@ export function hasCriticSignalInIteration(thread, iteration) {
153
174
  * Increment 2). Iteration-window scoped so a green from a prior iteration can
154
175
  * never satisfy the current one; absence reads as false.
155
176
  */
156
- export function hasPassingVerifyReportInIteration(thread, iteration) {
157
- return artifactsInIteration(thread, iteration).some((a) => {
158
- if (a.type !== 'verify_report')
159
- return false;
160
- try {
161
- return JSON.parse(a.body ?? '{}').passed === true;
162
- }
163
- catch {
164
- return false;
165
- }
166
- });
177
+ export function hasPassingVerifyReportInIteration(thread, iteration, cwd) {
178
+ return evaluateCommandGreen(thread, iteration, cwd).passed;
167
179
  }
168
180
  //# sourceMappingURL=iteration-engine.js.map
@@ -0,0 +1,90 @@
1
+ import { DEFAULT_PROTOCOLS, LOOP_KINDS } from './types.js';
2
+ const expected = (logicalName, loopArtifactType = logicalName) => ({
3
+ logical_name: logicalName,
4
+ worker_path: 'LANE-RESULT.json',
5
+ loop_artifact_type: loopArtifactType,
6
+ completion_policy: 'required',
7
+ });
8
+ /**
9
+ * Execution metadata only. DEFAULT_PROTOCOLS remains canonical for phase
10
+ * graphs, advance gates, iteration and stop conditions.
11
+ */
12
+ export const LOOP_KIND_POLICIES = {
13
+ review: {
14
+ phases: {
15
+ change_summary: { execution: 'manual' },
16
+ findings: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('review_verdict', 'verdict')], finalization: 'report' },
17
+ author_response: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('author_response')], finalization: 'integrate' },
18
+ followup_review: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('review_verdict', 'verdict')], finalization: 'report' },
19
+ verdict: { execution: 'engine' },
20
+ },
21
+ },
22
+ ideation: {
23
+ phases: {
24
+ proposal: { execution: 'manual' },
25
+ critique: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('critique')], finalization: 'report' },
26
+ // Critic and champion work use the same generic production driver. The
27
+ // coordinator still chooses when to cross each phase; the worker only
28
+ // produces the declared artifact and never advances the loop.
29
+ revision: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('revision')], finalization: 'report' },
30
+ synthesis: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('plan_draft')], finalization: 'report' },
31
+ },
32
+ },
33
+ implementation: {
34
+ phases: {
35
+ bind: { execution: 'engine' },
36
+ execute: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('execute_report')], finalization: 'integrate' },
37
+ verify: { execution: 'engine' },
38
+ handoff_ready: { execution: 'manual' },
39
+ },
40
+ },
41
+ research: {
42
+ phases: {
43
+ investigate: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('finding')], finalization: 'report' },
44
+ synthesize: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('synthesis')], finalization: 'report' },
45
+ conclude: { execution: 'engine' },
46
+ },
47
+ },
48
+ debug: {
49
+ phases: {
50
+ reproduce: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('repro')], finalization: 'report' },
51
+ hypothesize: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('hypothesis')], finalization: 'report' },
52
+ isolate: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('isolation_report')], finalization: 'report' },
53
+ fix: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('verify_report')], finalization: 'integrate' },
54
+ handoff: { execution: 'manual' },
55
+ },
56
+ },
57
+ };
58
+ export function policyForKind(kind) {
59
+ return LOOP_KIND_POLICIES[kind];
60
+ }
61
+ export function phasePolicy(kind, phase) {
62
+ return LOOP_KIND_POLICIES[kind].phases[phase];
63
+ }
64
+ export function isWorkerPhase(kind, phase) {
65
+ return phasePolicy(kind, phase)?.execution === 'worker';
66
+ }
67
+ /** Runtime assertion used by conformance tests and startup diagnostics. */
68
+ export function assertLoopKindPoliciesComplete() {
69
+ for (const kind of LOOP_KINDS) {
70
+ const policy = LOOP_KIND_POLICIES[kind];
71
+ if (!policy)
72
+ throw new Error(`missing LoopKindPolicy for ${kind}`);
73
+ const protocolPhases = DEFAULT_PROTOCOLS[kind].phases.map((phase) => phase.name).sort();
74
+ const policyPhases = Object.keys(policy.phases).sort();
75
+ if (JSON.stringify(protocolPhases) !== JSON.stringify(policyPhases)) {
76
+ throw new Error(`${kind} policy phases diverge from DEFAULT_PROTOCOLS: policy=${policyPhases.join(',')} protocol=${protocolPhases.join(',')}`);
77
+ }
78
+ for (const [phase, phaseExecution] of Object.entries(policy.phases)) {
79
+ if (phaseExecution.execution === 'worker') {
80
+ if (!phaseExecution.completion_mode || !phaseExecution.expected_artifacts?.length || !phaseExecution.finalization) {
81
+ throw new Error(`${kind}.${phase} worker policy is incomplete`);
82
+ }
83
+ }
84
+ else if (phaseExecution.completion_mode || phaseExecution.expected_artifacts?.length || phaseExecution.finalization) {
85
+ throw new Error(`${kind}.${phase} ${phaseExecution.execution} policy must not declare worker execution metadata`);
86
+ }
87
+ }
88
+ }
89
+ }
90
+ //# sourceMappingURL=kind-policies.js.map
@@ -9,9 +9,11 @@ import { recoverPendingIntents } from './commit-intent.js';
9
9
  * Per-loop exclusive lock + idempotency + fencing helpers.
10
10
  *
11
11
  * Implements the commit protocol from docs/concepts/loop-engine.md §Persistence.
12
- * For synchronous MVP mutations, the lock window is short (< 100ms typically) so
13
- * lease renewal via an internal heartbeat is not yet wired up; the hard_deadline
14
- * is still recorded in the lock blob and used by the stale-lock recovery rules.
12
+ * For synchronous mutations, the lock window is short (< 100ms typically), so
13
+ * lease renewal via an internal heartbeat is not wired up. `lease_until` and
14
+ * `hard_deadline` remain durable diagnostics, but elapsed time alone never
15
+ * authorizes automatic takeover: without per-write fencing, a suspended live
16
+ * process could otherwise resume after takeover and commit as a second owner.
15
17
  */
16
18
  export const LOCK_BACKOFF_BASE_MS = 10;
17
19
  export const LOCK_BACKOFF_TOTAL_MS = 500;
@@ -179,14 +181,70 @@ function processIsAlive(pid) {
179
181
  return code === 'EPERM';
180
182
  }
181
183
  }
182
- function lockIsStale(blob, now) {
183
- if (now > Date.parse(blob.hard_deadline))
184
- return true;
185
- if (blob.host_id === os.hostname() && !processIsAlive(blob.pid))
186
- return true;
187
- if (now > Date.parse(blob.lease_until) + LEASE_GRACE_MS)
188
- return true;
189
- return false;
184
+ function lockIsStale(blob, _now) {
185
+ // Automatic reaping is deliberately proof-based. On the local host we can
186
+ // prove the owning process is gone. A deadline or lease expiry only proves
187
+ // that a process was delayed; on Windows a suspended live process can resume.
188
+ // Cross-host liveness is likewise unknowable from this file alone, so those
189
+ // locks fail closed and require explicit operator recovery.
190
+ return blob.host_id === os.hostname() && !processIsAlive(blob.pid);
191
+ }
192
+ function takeoverClaimPath(lockPath, observedMutationId) {
193
+ // Lock blobs are read from disk and may predate schema validation. Hash the
194
+ // token before using it as a filename so a corrupt value cannot escape the
195
+ // takeover directory or create a Windows-invalid path.
196
+ const generationKey = crypto.createHash('sha256').update(observedMutationId).digest('hex');
197
+ return path.join(`${lockPath}.takeovers`, `${generationKey}.lock`);
198
+ }
199
+ /**
200
+ * Remove exactly the stale generation that was observed.
201
+ *
202
+ * A plain `read stale -> unlink(path)` has an ABA race on Windows: another
203
+ * reaper can replace generation X with Y between the read and unlink, after
204
+ * which the late reaper deletes Y. The immutable, generation-keyed takeover
205
+ * claim elects one reaper for X. Losers never unlink the shared path.
206
+ *
207
+ * If the elected reaper crashes before unlinking, the generation fails closed
208
+ * (the tiny takeover claim is retained for operator recovery). If it crashes
209
+ * after unlinking, contenders can safely acquire the now-empty main path.
210
+ */
211
+ function reapObservedLock(lockPath, observed, agentId) {
212
+ const nowMs = Date.now();
213
+ const claimPath = takeoverClaimPath(lockPath, observed.mutation_id);
214
+ const claim = {
215
+ pid: process.pid,
216
+ host_id: os.hostname(),
217
+ agent_id: agentId,
218
+ acquired_at: new Date(nowMs).toISOString(),
219
+ lease_until: new Date(nowMs + LEASE_WINDOW_MS).toISOString(),
220
+ hard_deadline: new Date(nowMs + 5_000).toISOString(),
221
+ mutation_id: crypto.randomUUID().replace(/-/g, ''),
222
+ };
223
+ if (!acquireRaw(claimPath, claim))
224
+ return false;
225
+ let removed = false;
226
+ try {
227
+ const current = readLockBlob(lockPath);
228
+ if (current
229
+ && current.mutation_id === observed.mutation_id
230
+ && lockIsStale(current, Date.now())) {
231
+ fs.unlinkSync(lockPath);
232
+ removed = true;
233
+ }
234
+ return removed;
235
+ }
236
+ finally {
237
+ // Delete only our own claim. A crash before this point intentionally leaves
238
+ // a fail-closed marker instead of guessing that a different process is dead.
239
+ try {
240
+ const currentClaim = readLockBlob(claimPath);
241
+ if (currentClaim?.mutation_id === claim.mutation_id)
242
+ fs.unlinkSync(claimPath);
243
+ }
244
+ catch {
245
+ /* best-effort; a retained claim fails closed for this stale generation */
246
+ }
247
+ }
190
248
  }
191
249
  function acquireRaw(lockPath, blob) {
192
250
  ensureDirFor(lockPath);
@@ -221,10 +279,10 @@ export function acquireLock(options) {
221
279
  if (existing) {
222
280
  if (lockIsStale(existing, Date.now())) {
223
281
  try {
224
- fs.unlinkSync(options.lockPath);
282
+ reapObservedLock(options.lockPath, existing, options.agentId);
225
283
  }
226
284
  catch {
227
- /* race with another reaper; retry */
285
+ /* another reaper won or I/O failed; exclusive acquire below retries */
228
286
  }
229
287
  }
230
288
  }