brainclaw 1.16.0 → 1.18.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 (85) hide show
  1. package/README.md +22 -8
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli/register-review.js +2 -2
  4. package/dist/commands/code-map.js +4 -1
  5. package/dist/commands/codev.js +61 -30
  6. package/dist/commands/doctor.js +14 -1
  7. package/dist/commands/harvest.js +241 -25
  8. package/dist/commands/inbox.js +10 -4
  9. package/dist/commands/loop.js +2 -2
  10. package/dist/commands/loops-handlers.js +82 -1
  11. package/dist/commands/mcp-catalog.js +12 -4
  12. package/dist/commands/mcp-read-handlers.js +90 -7
  13. package/dist/commands/mcp-schemas.generated.js +3 -0
  14. package/dist/commands/mcp-write-coordination.js +159 -40
  15. package/dist/commands/mcp.js +11 -2
  16. package/dist/core/agent-capability.js +7 -2
  17. package/dist/core/agent-files.js +53 -2
  18. package/dist/core/agent-integrations.js +1 -0
  19. package/dist/core/agentrun-reconciler.js +171 -7
  20. package/dist/core/agentruns.js +6 -1
  21. package/dist/core/code-map/aggregate.js +473 -0
  22. package/dist/core/code-map/backend.js +36 -10
  23. package/dist/core/code-map/freshness.js +36 -1
  24. package/dist/core/code-map/lang/c/imports.scm +12 -0
  25. package/dist/core/code-map/lang/c/index.js +150 -0
  26. package/dist/core/code-map/lang/c/tags.scm +68 -0
  27. package/dist/core/code-map/lang/cpp/imports.scm +14 -0
  28. package/dist/core/code-map/lang/cpp/index.js +149 -0
  29. package/dist/core/code-map/lang/cpp/tags.scm +87 -0
  30. package/dist/core/code-map/lang/csharp/imports.scm +20 -0
  31. package/dist/core/code-map/lang/csharp/index.js +224 -0
  32. package/dist/core/code-map/lang/csharp/tags.scm +63 -0
  33. package/dist/core/code-map/lang/go/imports.scm +13 -0
  34. package/dist/core/code-map/lang/go/index.js +139 -0
  35. package/dist/core/code-map/lang/go/tags.scm +36 -0
  36. package/dist/core/code-map/lang/providers.js +12 -1
  37. package/dist/core/code-map/lang/ruby/imports.scm +24 -0
  38. package/dist/core/code-map/lang/ruby/index.js +198 -0
  39. package/dist/core/code-map/lang/ruby/tags.scm +49 -0
  40. package/dist/core/code-map/lang/rust/imports.scm +44 -0
  41. package/dist/core/code-map/lang/rust/index.js +136 -0
  42. package/dist/core/code-map/lang/rust/tags.scm +47 -0
  43. package/dist/core/code-map/query.js +229 -80
  44. package/dist/core/code-map/types.js +18 -0
  45. package/dist/core/code-map/work-section.js +8 -7
  46. package/dist/core/codev-responses.js +16 -0
  47. package/dist/core/dispatcher.js +209 -29
  48. package/dist/core/execution-adapters.js +29 -3
  49. package/dist/core/ideation-loop-close.js +124 -0
  50. package/dist/core/loops/artifact-resolver.js +197 -0
  51. package/dist/core/loops/attempt-reservation.js +576 -0
  52. package/dist/core/loops/commit-intent.js +494 -0
  53. package/dist/core/loops/facade-schema.js +48 -0
  54. package/dist/core/loops/impl-bind.js +144 -0
  55. package/dist/core/loops/index.js +1 -1
  56. package/dist/core/loops/iteration-engine.js +29 -0
  57. package/dist/core/loops/lock.js +14 -0
  58. package/dist/core/loops/project-resolution.js +157 -0
  59. package/dist/core/loops/reconcile-turn.js +369 -0
  60. package/dist/core/loops/result-reducers.js +88 -0
  61. package/dist/core/loops/store.js +46 -7
  62. package/dist/core/loops/types.js +139 -11
  63. package/dist/core/loops/verbs.js +9 -3
  64. package/dist/core/loops/verify-command.js +209 -0
  65. package/dist/core/messaging.js +58 -5
  66. package/dist/core/review-loop-close.js +106 -34
  67. package/dist/core/review-loop-turn-dispatch.js +445 -0
  68. package/dist/core/runtime-signals.js +68 -0
  69. package/dist/core/schema.js +34 -0
  70. package/dist/core/worktree.js +240 -22
  71. package/dist/facts.js +10 -10
  72. package/dist/facts.json +9 -9
  73. package/dist/wasm/tree-sitter-c.wasm +0 -0
  74. package/dist/wasm/tree-sitter-c_sharp.wasm +0 -0
  75. package/dist/wasm/tree-sitter-cpp.wasm +0 -0
  76. package/dist/wasm/tree-sitter-go.wasm +0 -0
  77. package/dist/wasm/tree-sitter-ruby.wasm +0 -0
  78. package/dist/wasm/tree-sitter-rust.wasm +0 -0
  79. package/docs/cli.md +1 -1
  80. package/docs/code-map.md +22 -6
  81. package/docs/concepts/loop-engine.md +28 -2
  82. package/docs/concepts/observer-protocol.md +22 -0
  83. package/docs/integrations/codex.md +19 -3
  84. package/docs/mcp-schema-changelog.md +43 -1
  85. package/package.json +1 -1
@@ -0,0 +1,576 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { z } from 'zod';
5
+ import { memoryDir, writeFileAtomic } from '../io.js';
6
+ import { nowISO } from '../ids.js';
7
+ import { acquireLock } from './lock.js';
8
+ /**
9
+ * An artifact the attempt's worker is expected to produce (spec §2 / §13 R1).
10
+ * Brainclaw generates the canonical target; `worker_path` is worker-relative and
11
+ * MUST be realpath-containment-validated before any read (invariant #7, wired in
12
+ * a later PR). `sha256` is filled at harvest and validated before state mutation.
13
+ */
14
+ export const ExpectedArtifactSchema = z.object({
15
+ logical_name: z.string().min(1),
16
+ worker_path: z.string().min(1),
17
+ loop_artifact_type: z.string().min(1),
18
+ schema_id: z.string().optional(),
19
+ completion_policy: z.enum(['required', 'optional']).default('required'),
20
+ sha256: z.string().optional(),
21
+ });
22
+ export const TurnReservationSchema = z.object({
23
+ turn_id: z.string().min(1),
24
+ epoch: z.number().int().nonnegative(),
25
+ loop_id: z.string().min(1),
26
+ slot_id: z.string().min(1),
27
+ target_slot_generation: z.number().int().nonnegative(),
28
+ loop_version_at_reserve: z.number().int().nonnegative(),
29
+ agent: z.string().min(1),
30
+ agent_id: z.string().optional(),
31
+ claim_id: z.string().min(1),
32
+ child_ids: z.object({
33
+ assignment_id: z.string().min(1),
34
+ run_id: z.string().min(1),
35
+ }),
36
+ phase: z.string().min(1),
37
+ iteration: z.number().int().nonnegative(),
38
+ // pln#630 PR2b-a (§13 R1): widened from z.literal('file'). The EFFECTIVE
39
+ // resolved policy for this attempt (§5). Default 'file' keeps PR1 records
40
+ // parsing; mcp/either are wired later.
41
+ completion_mode: z.enum(['file', 'mcp', 'either']).default('file'),
42
+ // pln#630 PR2b-a (§13 R1): artifacts this attempt's worker must produce.
43
+ // Default [] so PR1 on-disk records (which predate the field) still parse.
44
+ expected_artifacts: z.array(ExpectedArtifactSchema).default([]),
45
+ store_root: z.string().min(1),
46
+ cwd: z.string().min(1),
47
+ lease_deadline: z.string().min(1),
48
+ decision: z.enum(['prepared', 'committed', 'aborted']),
49
+ created_at: z.string().min(1),
50
+ decided_at: z.string().optional(),
51
+ abort_reason: z.string().optional(),
52
+ // pln#630 PR2a (dec#138) — the LAUNCH-GRANT fence. The decidable, atomic
53
+ // gate between "a committed attempt may spawn" and "a worker crossed into
54
+ // exec". The pre-exec supervisor CONSUMES the grant (armed→crossed) before
55
+ // invoking the worker; advance/close/reroute REVOKES it (armed→revoked). The
56
+ // two are mutually exclusive CAS transitions on ONE record, so an old token
57
+ // can never spawn after supersession, and a crossed grant is never re-spawned.
58
+ launch: z.object({
59
+ status: z.enum(['armed', 'crossed', 'revoked']),
60
+ token: z.string().min(1),
61
+ epoch: z.number().int().nonnegative(),
62
+ lease_deadline: z.string().min(1),
63
+ armed_at: z.string().min(1),
64
+ crossed_at: z.string().optional(),
65
+ revoked_at: z.string().optional(),
66
+ revoke_reason: z.string().optional(),
67
+ }).optional(),
68
+ });
69
+ /** Raised when a decision CAS is attempted from an incompatible terminal state. */
70
+ export class ReservationStateError extends Error {
71
+ turn_id;
72
+ code;
73
+ constructor(turn_id, code, message) {
74
+ super(message);
75
+ this.turn_id = turn_id;
76
+ this.code = code;
77
+ this.name = 'ReservationStateError';
78
+ }
79
+ }
80
+ /** Raised when a launch-grant CAS (arm/consume/revoke) is refused. */
81
+ export class LaunchFenceError extends Error {
82
+ turn_id;
83
+ code;
84
+ constructor(turn_id, code, message) {
85
+ super(message);
86
+ this.turn_id = turn_id;
87
+ this.code = code;
88
+ this.name = 'LaunchFenceError';
89
+ }
90
+ }
91
+ /* ============================ path resolution ============================= */
92
+ function reservationsDir(cwd) {
93
+ return path.join(memoryDir(cwd ?? process.cwd()), 'loops', 'reservations');
94
+ }
95
+ function reservationLocksDir(cwd) {
96
+ return path.join(reservationsDir(cwd), 'locks');
97
+ }
98
+ function reservationPath(turnId, cwd) {
99
+ return path.join(reservationsDir(cwd), `${turnId}.json`);
100
+ }
101
+ function reservationLockPath(turnId, cwd) {
102
+ return path.join(reservationLocksDir(cwd), `${turnId}.lock`);
103
+ }
104
+ function launchDecisionPath(turnId, epoch, cwd) {
105
+ return path.join(reservationsDir(cwd), `${turnId}.launch-${epoch}.decision.json`);
106
+ }
107
+ function readLaunchDecision(turnId, epoch, cwd) {
108
+ const p = launchDecisionPath(turnId, epoch, cwd);
109
+ if (!fs.existsSync(p))
110
+ return undefined;
111
+ try {
112
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
113
+ }
114
+ catch {
115
+ return undefined;
116
+ }
117
+ }
118
+ /** Atomically claim the decision via exclusive-create. Returns the committed
119
+ * decision (this caller's if it won, or the incumbent's if it lost). */
120
+ function claimLaunchDecision(turnId, decision, cwd) {
121
+ ensureDirs(cwd);
122
+ const p = launchDecisionPath(turnId, decision.epoch, cwd);
123
+ const body = `${JSON.stringify(decision, null, 2)}\n`;
124
+ try {
125
+ // 'wx' = O_CREAT | O_EXCL — atomic; fails with EEXIST if a decision exists.
126
+ const fd = fs.openSync(p, 'wx');
127
+ try {
128
+ const buf = Buffer.from(body, 'utf8');
129
+ let off = 0;
130
+ while (off < buf.length)
131
+ off += fs.writeSync(fd, buf, off, buf.length - off);
132
+ fs.fsyncSync(fd);
133
+ }
134
+ finally {
135
+ fs.closeSync(fd);
136
+ }
137
+ return { decision, won: true }; // THIS call performed the atomic create
138
+ }
139
+ catch (err) {
140
+ if (err.code !== 'EEXIST')
141
+ throw err;
142
+ const incumbent = readLaunchDecision(turnId, decision.epoch, cwd);
143
+ if (!incumbent)
144
+ throw err; // decision file vanished mid-race — surface it
145
+ return { decision: incumbent, won: false }; // lost — the incumbent decision stands (adopted)
146
+ }
147
+ }
148
+ function ensureDirs(cwd) {
149
+ for (const dir of [reservationsDir(cwd), reservationLocksDir(cwd)]) {
150
+ if (!fs.existsSync(dir))
151
+ fs.mkdirSync(dir, { recursive: true });
152
+ }
153
+ }
154
+ /* ============================ child id derivation ========================= */
155
+ /**
156
+ * Deterministic child ids from the turn_id. Deterministic so a crashed reserve
157
+ * is repairable idempotently: a recoverer re-derives the exact same ids and can
158
+ * complete the projection without minting a second assignment/run (r3/r4 fix #1).
159
+ */
160
+ export function deriveChildIds(turnId) {
161
+ const h = (salt) => crypto.createHash('sha256').update(`${turnId}:${salt}`).digest('hex').slice(0, 16);
162
+ return { assignment_id: `asgn_${h('assignment')}`, run_id: `run_${h('run')}` };
163
+ }
164
+ /**
165
+ * DETERMINISTIC turn_id from (loop_id, slot_id, iteration) — pln#630 PR2c (§13
166
+ * A2). A duplicate dispatch of the same slot in the same iteration re-derives
167
+ * the SAME turn_id, so reserve() hits `reservation_exists` and the caller adopts
168
+ * the existing attempt instead of minting a second one — the closure for
169
+ * double-spawn-per-slot (a random turn_id would let two concurrent dispatches
170
+ * both reserve+arm+consume+spawn the same slot). `tat_` prefix matches the
171
+ * attempt-id convention.
172
+ */
173
+ export function deriveTurnId(loopId, slotId, iteration) {
174
+ const h = crypto.createHash('sha256').update(`${loopId}:${slotId}:${iteration}`).digest('hex').slice(0, 16);
175
+ return `tat_${h}`;
176
+ }
177
+ /* ============================ persistence ================================= */
178
+ function readReservation(turnId, cwd) {
179
+ const filePath = reservationPath(turnId, cwd);
180
+ if (!fs.existsSync(filePath))
181
+ return undefined;
182
+ return TurnReservationSchema.parse(JSON.parse(fs.readFileSync(filePath, 'utf8')));
183
+ }
184
+ function writeReservation(record, cwd) {
185
+ const parsed = TurnReservationSchema.parse(record);
186
+ ensureDirs(cwd);
187
+ writeFileAtomic(reservationPath(parsed.turn_id, cwd), `${JSON.stringify(parsed, null, 2)}\n`);
188
+ }
189
+ /**
190
+ * Run `fn` under the per-reservation exclusive lock. Reuses the loop lock
191
+ * primitive (stale-reaping, O_EXCL, fencing) so the decision CAS is atomic
192
+ * across processes — two racing writers cannot both mutate the decision.
193
+ */
194
+ function withReservationLock(turnId, agentId, fn, cwd) {
195
+ ensureDirs(cwd);
196
+ const lock = acquireLock({
197
+ lockPath: reservationLockPath(turnId, cwd),
198
+ agentId,
199
+ intent: 'reservation',
200
+ maxMutationDurationMs: 30_000,
201
+ });
202
+ try {
203
+ // PR2a review (BLOCKING): the callback MUST invoke `fence()` immediately
204
+ // before any durable write. If this lock was reaped (holder suspended past
205
+ // the hard deadline) and re-acquired by another writer, fenceCheck throws
206
+ // LockLostError so a stale holder can never overwrite the other terminal
207
+ // transition — the consume-XOR-revoke fence stays durable across recovery.
208
+ return fn(lock.fenceCheck);
209
+ }
210
+ finally {
211
+ lock.release();
212
+ }
213
+ }
214
+ /* ============================ public API ================================== */
215
+ export function getReservation(turnId, cwd) {
216
+ return readReservation(turnId, cwd);
217
+ }
218
+ /**
219
+ * Write a fresh `prepared` reservation. Throws `reservation_exists` if one is
220
+ * already on disk for this turn_id (identity is written once).
221
+ */
222
+ export function reserve(input, cwd) {
223
+ const agentId = input.agent_id ?? input.agent;
224
+ // Fail-CLOSED at the boundary (review PR2b-b #1): an unparseable dispatch
225
+ // lease must be rejected here, so no committed reservation can ever carry a
226
+ // garbage lease that the armLaunch dispatch-lease gate would then skip.
227
+ if (!Number.isFinite(Date.parse(input.lease_deadline))) {
228
+ throw new ReservationStateError(input.turn_id, 'invalid_lease_deadline', `reserve: lease_deadline "${input.lease_deadline}" is not a parseable timestamp`);
229
+ }
230
+ return withReservationLock(input.turn_id, agentId, (fence) => {
231
+ const existing = readReservation(input.turn_id, cwd);
232
+ if (existing) {
233
+ throw new ReservationStateError(input.turn_id, 'reservation_exists', `reserve: turn_id ${input.turn_id} already reserved (decision=${existing.decision})`);
234
+ }
235
+ const record = {
236
+ turn_id: input.turn_id,
237
+ epoch: input.epoch ?? 0,
238
+ loop_id: input.loop_id,
239
+ slot_id: input.slot_id,
240
+ target_slot_generation: input.target_slot_generation,
241
+ loop_version_at_reserve: input.loop_version_at_reserve,
242
+ agent: input.agent,
243
+ agent_id: input.agent_id,
244
+ claim_id: input.claim_id,
245
+ child_ids: deriveChildIds(input.turn_id),
246
+ phase: input.phase,
247
+ iteration: input.iteration,
248
+ completion_mode: input.completion_mode ?? 'file',
249
+ expected_artifacts: input.expected_artifacts ?? [],
250
+ store_root: input.store_root,
251
+ cwd: input.cwd,
252
+ lease_deadline: input.lease_deadline,
253
+ decision: 'prepared',
254
+ created_at: nowISO(),
255
+ };
256
+ fence();
257
+ writeReservation(record, cwd);
258
+ return record;
259
+ }, cwd);
260
+ }
261
+ /**
262
+ * CAS `prepared → committed`. Idempotent when already `committed`. Throws
263
+ * `aborted_not_committable` if the reservation was aborted — an aborted
264
+ * reservation can NEVER become dispatchable (closes the "recoverer aborted,
265
+ * stale reserver resumes and commits" split-brain).
266
+ */
267
+ export function commitReservation(turnId, cwd, agentId = 'system') {
268
+ return withReservationLock(turnId, agentId, (fence) => {
269
+ const record = readReservation(turnId, cwd);
270
+ if (!record) {
271
+ throw new ReservationStateError(turnId, 'reservation_not_found', `commitReservation: unknown turn_id ${turnId}`);
272
+ }
273
+ if (record.decision === 'committed')
274
+ return record; // idempotent
275
+ if (record.decision === 'aborted') {
276
+ throw new ReservationStateError(turnId, 'aborted_not_committable', `commitReservation: turn_id ${turnId} is aborted and cannot be committed`);
277
+ }
278
+ const next = { ...record, decision: 'committed', decided_at: nowISO() };
279
+ fence();
280
+ writeReservation(next, cwd);
281
+ return next;
282
+ }, cwd);
283
+ }
284
+ /**
285
+ * CAS `prepared → aborted`. Idempotent when already `aborted`. Throws
286
+ * `committed_not_abortable` if the reservation was committed — a committed
287
+ * reservation is always REPAIRABLE (never abortable), so recovery of a
288
+ * committed decision only ever completes its projections (r4 §3).
289
+ */
290
+ export function abortReservation(turnId, reason, cwd, agentId = 'system') {
291
+ return withReservationLock(turnId, agentId, (fence) => {
292
+ const record = readReservation(turnId, cwd);
293
+ if (!record) {
294
+ throw new ReservationStateError(turnId, 'reservation_not_found', `abortReservation: unknown turn_id ${turnId}`);
295
+ }
296
+ if (record.decision === 'aborted')
297
+ return record; // idempotent
298
+ if (record.decision === 'committed') {
299
+ throw new ReservationStateError(turnId, 'committed_not_abortable', `abortReservation: turn_id ${turnId} is committed and cannot be aborted`);
300
+ }
301
+ const next = { ...record, decision: 'aborted', decided_at: nowISO(), abort_reason: reason };
302
+ fence();
303
+ writeReservation(next, cwd);
304
+ return next;
305
+ }, cwd);
306
+ }
307
+ /**
308
+ * Dispatch guard (spec §2 T2 / §3): dispatch may proceed ONLY for a `committed`
309
+ * reservation. Throws `not_dispatchable` for prepared/aborted/missing — this is
310
+ * the single choke point that prevents dispatching an uncommitted attempt.
311
+ */
312
+ export function assertDispatchable(turnId, cwd) {
313
+ const record = readReservation(turnId, cwd);
314
+ if (!record) {
315
+ throw new ReservationStateError(turnId, 'reservation_not_found', `assertDispatchable: unknown turn_id ${turnId}`);
316
+ }
317
+ if (record.decision !== 'committed') {
318
+ throw new ReservationStateError(turnId, 'not_dispatchable', `assertDispatchable: turn_id ${turnId} is ${record.decision}, not committed — dispatch refused`);
319
+ }
320
+ return record;
321
+ }
322
+ export function isDispatchable(turnId, cwd) {
323
+ const record = readReservation(turnId, cwd);
324
+ return record?.decision === 'committed';
325
+ }
326
+ export function listReservations(filter = {}, cwd) {
327
+ const dir = reservationsDir(cwd);
328
+ if (!fs.existsSync(dir))
329
+ return [];
330
+ const out = [];
331
+ for (const file of fs.readdirSync(dir)) {
332
+ if (!file.endsWith('.json'))
333
+ continue;
334
+ try {
335
+ const record = TurnReservationSchema.parse(JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')));
336
+ if (filter.decision && record.decision !== filter.decision)
337
+ continue;
338
+ out.push(record);
339
+ }
340
+ catch {
341
+ // Skip malformed files; recovery diagnostics surface elsewhere.
342
+ }
343
+ }
344
+ return out.sort((a, b) => a.created_at.localeCompare(b.created_at));
345
+ }
346
+ /**
347
+ * Find the turn-attempt reservation that OWNS a given agent_run, if any
348
+ * (pln#630 PR2b-c). The link is the deterministic `deriveChildIds(turn_id)` —
349
+ * a reservation owns `run_id` iff `child_ids.run_id === runId`. Used by the
350
+ * reconciler to decide whether a run is turn-owned (→ read-strict acceptance)
351
+ * or legacy (→ presence-based acceptance). Returns undefined for legacy runs.
352
+ */
353
+ export function findReservationByRunId(runId, cwd) {
354
+ return listReservations({}, cwd).find((r) => r.child_ids.run_id === runId);
355
+ }
356
+ /**
357
+ * Find the turn-attempt reservation that OWNS a given assignment, if any
358
+ * (pln#630 PR3a). Mirror of {@link findReservationByRunId} keyed on the
359
+ * deterministic `child_ids.assignment_id`. The harvest path uses this to decide
360
+ * whether a completed LANE-RESULT is turn-owned — the lane always carries its
361
+ * `assignment_id`, whereas a real reviewer lane does NOT echo run_id/turn_id/nonce
362
+ * (the review brief never asks for them), so assignment_id is the reliable
363
+ * discriminator. Returns undefined for legacy assignments.
364
+ */
365
+ export function findReservationByAssignmentId(assignmentId, cwd) {
366
+ // decision:'committed' is load-bearing (review #5): only a COMMITTED reservation ever
367
+ // coexists with a real LANE-RESULT (dispatch commits before spawn). Filtering here makes
368
+ // the turn-owned discriminator explicit — a `prepared`/`aborted` reservation must never
369
+ // route a lane to reconcileTurn (it has no live launch generation to accept evidence for).
370
+ return listReservations({ decision: 'committed' }, cwd).find((r) => r.child_ids.assignment_id === assignmentId);
371
+ }
372
+ /**
373
+ * Arm the launch grant on a COMMITTED reservation. The pre-exec supervisor
374
+ * later CONSUMES it (armed→crossed) immediately before invoking the worker;
375
+ * advance/close/reroute (or the expiry sweep) REVOKE it (armed→revoked).
376
+ * Re-arming is allowed only after a prior grant was revoked, and only with a
377
+ * strictly higher epoch (a fresh attempt generation).
378
+ */
379
+ export function armLaunch(turnId, input, cwd, agentId = 'system') {
380
+ return withReservationLock(turnId, agentId, (fence) => {
381
+ const record = readReservation(turnId, cwd);
382
+ if (!record)
383
+ throw new ReservationStateError(turnId, 'reservation_not_found', `armLaunch: unknown turn_id ${turnId}`);
384
+ if (record.decision !== 'committed') {
385
+ throw new LaunchFenceError(turnId, 'not_committed', `armLaunch: turn_id ${turnId} is ${record.decision}, not committed`);
386
+ }
387
+ if (record.launch && record.launch.status !== 'revoked') {
388
+ throw new LaunchFenceError(turnId, 'already_armed', `armLaunch: turn_id ${turnId} already has a ${record.launch.status} grant (epoch ${record.launch.epoch})`);
389
+ }
390
+ if (record.launch && input.epoch <= record.launch.epoch) {
391
+ throw new LaunchFenceError(turnId, 'epoch_mismatch', `armLaunch: re-arm epoch ${input.epoch} must exceed prior ${record.launch.epoch}`);
392
+ }
393
+ // PR2b-b (§13 R5 gap 2): enforce the DISPATCH lease. A committed reservation
394
+ // is never abortable (repairable-only), so a stale one can't be swept away —
395
+ // instead we refuse to arm it once its dispatch lease has passed. Without
396
+ // this, a supervisor arriving long after the lease could arm a fresh grant
397
+ // and spawn (phantom-spawn-after-lease). The reservation stays committed but
398
+ // reserved_never_launched: it simply never spawns.
399
+ // Fail-CLOSED (review PR2b-b #1): a non-parseable dispatch lease must refuse
400
+ // arm, not skip the gate — otherwise a garbage lease reopens the very
401
+ // phantom-spawn-after-lease this guard closes (the launch-lease check below
402
+ // is already fail-closed; the two must be symmetric). reserve() also
403
+ // validates the lease at the boundary, so this is defense-in-depth.
404
+ const dispatchLeaseMs = Date.parse(record.lease_deadline);
405
+ if (!Number.isFinite(dispatchLeaseMs) || Date.parse(nowISO()) >= dispatchLeaseMs) {
406
+ throw new LaunchFenceError(turnId, 'dispatch_lease_expired', `armLaunch: dispatch lease ${record.lease_deadline} for ${turnId} is unparseable or has passed — reserved_never_launched, must not spawn`);
407
+ }
408
+ // PR2a review (BLOCKING): reject a non-parseable lease at arm time — an
409
+ // invalid string makes Date.parse NaN and `now > NaN` false, so the grant
410
+ // would never expire and a matching supervisor could cross it unbounded.
411
+ if (!Number.isFinite(Date.parse(input.lease_deadline))) {
412
+ throw new LaunchFenceError(turnId, 'lease_invalid', `armLaunch: lease_deadline "${input.lease_deadline}" is not a parseable timestamp`);
413
+ }
414
+ // Nonce = fence token + evidence key (§13 R2). Auto-generate a random,
415
+ // generation-unique value unless the caller supplied one — a distinct token
416
+ // per generation is what lets evidenceMatchesAttempt reject stale
417
+ // prior-generation evidence (review PR2b-b #2).
418
+ const token = input.token ?? crypto.randomUUID();
419
+ const next = {
420
+ ...record,
421
+ launch: { status: 'armed', token, epoch: input.epoch, lease_deadline: input.lease_deadline, armed_at: nowISO() },
422
+ };
423
+ fence();
424
+ writeReservation(next, cwd);
425
+ return next;
426
+ }, cwd);
427
+ }
428
+ export function consumeLaunchGrant(turnId, token, epoch, cwd, agentId = 'system') {
429
+ return withReservationLock(turnId, agentId, (fence) => {
430
+ const record = readReservation(turnId, cwd);
431
+ if (!record)
432
+ throw new ReservationStateError(turnId, 'reservation_not_found', `consumeLaunchGrant: unknown turn_id ${turnId}`);
433
+ const g = record.launch;
434
+ if (!g)
435
+ throw new LaunchFenceError(turnId, 'not_armed', `consumeLaunchGrant: turn_id ${turnId} has no launch grant`);
436
+ // Epoch/token/lease validated against the immutable grant fields (set once
437
+ // at arm). Epoch BEFORE token so a stale generation reports epoch_mismatch.
438
+ if (g.epoch !== epoch)
439
+ throw new LaunchFenceError(turnId, 'epoch_mismatch', `consumeLaunchGrant: epoch ${epoch} != grant epoch ${g.epoch}`);
440
+ if (g.token !== token)
441
+ throw new LaunchFenceError(turnId, 'token_mismatch', `consumeLaunchGrant: token mismatch for ${turnId}`);
442
+ // Expiry is inclusive (now >= deadline) — the SAME rule the sweep uses.
443
+ if (Date.parse(nowISO()) >= Date.parse(g.lease_deadline)) {
444
+ throw new LaunchFenceError(turnId, 'lease_expired', `consumeLaunchGrant: grant for ${turnId} expired at ${g.lease_deadline}`);
445
+ }
446
+ // ATOMIC XOR — claim the decision via exclusive-create. If a revoke already
447
+ // won (even from a newer holder after this one was reaped), we LOSE here and
448
+ // must not spawn. No TOCTOU: the create, not a prior check, is the commit.
449
+ const { decision: committed, won } = claimLaunchDecision(turnId, { decision: 'crossed', token, epoch, at: nowISO() }, cwd);
450
+ if (committed.decision === 'revoked') {
451
+ throw new LaunchFenceError(turnId, 'revoked', `consumeLaunchGrant: grant for ${turnId} was revoked — MUST NOT spawn`);
452
+ }
453
+ // Won → this call crossed (may spawn). Adopted (won=false) → already crossed
454
+ // by another invocation: launch_attempted_unknown, caller MUST NOT spawn.
455
+ const next = { ...record, launch: { ...g, status: 'crossed', crossed_at: committed.at } };
456
+ fence();
457
+ writeReservation(next, cwd);
458
+ return { reservation: next, wonTransition: won };
459
+ }, cwd);
460
+ }
461
+ /**
462
+ * REVOKE the grant (armed→revoked) — prevents a still-armed token from ever
463
+ * crossing. Idempotent when already revoked (same epoch). Refused once CROSSED:
464
+ * a crossed grant means the worker launched, so the caller must treat the
465
+ * attempt as launch_attempted_unknown and never re-spawn.
466
+ */
467
+ export function revokeLaunchGrant(turnId, epoch, reason, cwd, agentId = 'system') {
468
+ return withReservationLock(turnId, agentId, (fence) => {
469
+ const record = readReservation(turnId, cwd);
470
+ if (!record)
471
+ throw new ReservationStateError(turnId, 'reservation_not_found', `revokeLaunchGrant: unknown turn_id ${turnId}`);
472
+ const g = record.launch;
473
+ if (!g)
474
+ throw new LaunchFenceError(turnId, 'not_armed', `revokeLaunchGrant: turn_id ${turnId} has no launch grant`);
475
+ if (g.epoch !== epoch)
476
+ throw new LaunchFenceError(turnId, 'epoch_mismatch', `revokeLaunchGrant: epoch ${epoch} != grant epoch ${g.epoch}`);
477
+ // ATOMIC XOR — claim the decision. If a consume already crossed (even from a
478
+ // newer holder), we LOSE: the worker launched, so the attempt is
479
+ // launch_attempted_unknown and must never be treated as re-spawnable.
480
+ const { decision: committed } = claimLaunchDecision(turnId, { decision: 'revoked', token: g.token, epoch, at: nowISO(), reason }, cwd);
481
+ if (committed.decision === 'crossed') {
482
+ throw new LaunchFenceError(turnId, 'crossed_not_revocable', `revokeLaunchGrant: grant for ${turnId} already crossed — worker launched, cannot revoke`);
483
+ }
484
+ const next = { ...record, launch: { ...g, status: 'revoked', revoked_at: committed.at, revoke_reason: committed.reason ?? reason } };
485
+ fence();
486
+ writeReservation(next, cwd);
487
+ return next;
488
+ }, cwd);
489
+ }
490
+ export function launchGrant(turnId, cwd) {
491
+ const g = readReservation(turnId, cwd)?.launch;
492
+ if (!g)
493
+ return undefined;
494
+ // The decision file is AUTHORITATIVE — a winner may have crashed after the
495
+ // atomic exclusive-create but before updating the record projection, so
496
+ // reconcile the status from the decision file when one exists.
497
+ const d = readLaunchDecision(turnId, g.epoch, cwd);
498
+ if (!d)
499
+ return g;
500
+ return d.decision === 'crossed'
501
+ ? { ...g, status: 'crossed', crossed_at: d.at }
502
+ : { ...g, status: 'revoked', revoked_at: d.at, revoke_reason: d.reason };
503
+ }
504
+ /**
505
+ * The evidence nonce for the CURRENT launch generation (§13 R2). Because
506
+ * `deriveChildIds` is epoch-invariant, only the consumed launch token uniquely
507
+ * identifies the generation that actually spawned — so THIS is what the worker
508
+ * must echo (in LANE-RESULT / signals / artifact metadata) and what the
509
+ * read-strict acceptance path (PR2b-c) matches on. `undefined` until armed.
510
+ */
511
+ export function currentNonce(reservation) {
512
+ // Only a LIVE generation (armed or crossed) has a current nonce. A revoked
513
+ // grant means the worker never crossed → never spawned, so its token is a
514
+ // dead generation and must not be reported as current (review PR2b-a #1).
515
+ const l = reservation.launch;
516
+ return (l?.status === 'armed' || l?.status === 'crossed') ? l.token : undefined;
517
+ }
518
+ /**
519
+ * Read-strict evidence predicate (§13 R3) — the foundation the acceptance path
520
+ * (PR2b-c) builds on. Evidence is accepted for a turn-owned attempt ONLY when it
521
+ * carries the matching `turn_id`, the attempt's derived `run_id`, AND the
522
+ * current launch-generation `nonce` (the consumed token). Returns false when the
523
+ * generation is not live (revoked / never armed → `currentNonce` undefined), so
524
+ * a stale prior-generation or bare assignment-keyed signal can never match. The
525
+ * `run.status === 'completed'` gate is applied separately by the caller.
526
+ */
527
+ export function evidenceMatchesAttempt(reservation, evidence) {
528
+ const nonce = currentNonce(reservation);
529
+ if (!nonce)
530
+ return false;
531
+ return (evidence.turn_id === reservation.turn_id &&
532
+ evidence.run_id === reservation.child_ids.run_id &&
533
+ evidence.nonce === nonce);
534
+ }
535
+ export function attemptStatus(reservation, runStatus) {
536
+ if (reservation.decision === 'aborted')
537
+ return 'cancelled';
538
+ const launch = reservation.launch;
539
+ if (launch?.status === 'revoked')
540
+ return 'cancelled';
541
+ if (launch?.status === 'crossed') {
542
+ if (runStatus === 'completed')
543
+ return 'completed';
544
+ if (runStatus === 'failed')
545
+ return 'failed';
546
+ if (runStatus === 'waiting_input')
547
+ return 'waiting_input';
548
+ return 'running';
549
+ }
550
+ // no grant yet, or armed-but-not-crossed
551
+ return reservation.decision === 'committed' ? 'launching' : 'reserved';
552
+ }
553
+ /**
554
+ * Revoke every armed grant whose lease has expired (reserved_never_launched).
555
+ * The single non-GET sweep owner (dec#138). Skips crossed/revoked grants.
556
+ * Returns the turn_ids revoked.
557
+ */
558
+ export function sweepExpiredLaunchGrants(cwd, agentId = 'system') {
559
+ const now = Date.parse(nowISO());
560
+ const revoked = [];
561
+ for (const r of listReservations({}, cwd)) {
562
+ const g = launchGrant(r.turn_id, cwd); // authoritative status (decision-file reconciled)
563
+ if (!g || g.status !== 'armed')
564
+ continue;
565
+ // Expired ⟺ now >= deadline (inclusive), matching consumeLaunchGrant.
566
+ if (Date.parse(g.lease_deadline) > now)
567
+ continue;
568
+ try {
569
+ revokeLaunchGrant(r.turn_id, g.epoch, 'reserved_never_launched', cwd, agentId);
570
+ revoked.push(r.turn_id);
571
+ }
572
+ catch { /* raced to crossed/revoked — skip */ }
573
+ }
574
+ return revoked;
575
+ }
576
+ //# sourceMappingURL=attempt-reservation.js.map