brainclaw 1.26.2 → 1.28.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 (88) 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 +143 -15
  8. package/dist/commands/mcp-catalog.js +52 -18
  9. package/dist/commands/mcp-schemas.generated.js +64 -0
  10. package/dist/commands/mcp-write-claims.js +128 -1
  11. package/dist/commands/mcp-write-coordination.js +149 -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 +189 -14
  25. package/dist/core/execution-contract.js +345 -0
  26. package/dist/core/execution.js +130 -16
  27. package/dist/core/facade-schema.js +3 -0
  28. package/dist/core/harness-adapters/base.js +150 -0
  29. package/dist/core/harness-adapters/claude.js +39 -0
  30. package/dist/core/harness-adapters/codex.js +57 -0
  31. package/dist/core/harness-adapters/harvest.js +109 -0
  32. package/dist/core/harness-adapters/index.js +8 -0
  33. package/dist/core/harness-adapters/prompt-only.js +13 -0
  34. package/dist/core/harness-adapters/registry.js +48 -0
  35. package/dist/core/harness-adapters/result.js +33 -0
  36. package/dist/core/harness-adapters/types.js +2 -0
  37. package/dist/core/ideation-loop-close.js +25 -2
  38. package/dist/core/instruction-templates.js +3 -2
  39. package/dist/core/loop-turn-dispatch.js +235 -0
  40. package/dist/core/loops/artifact-contract.js +11 -0
  41. package/dist/core/loops/attempt-authority.js +496 -0
  42. package/dist/core/loops/attempt-generations.js +509 -0
  43. package/dist/core/loops/attempt-reservation.js +197 -35
  44. package/dist/core/loops/attempt-rollout.js +404 -0
  45. package/dist/core/loops/attempt-takeover.js +155 -0
  46. package/dist/core/loops/bootstrap-acquire.js +7 -3
  47. package/dist/core/loops/brief-assembly.js +21 -4
  48. package/dist/core/loops/evidence.js +188 -0
  49. package/dist/core/loops/facade-schema.js +75 -11
  50. package/dist/core/loops/gate-policy.js +533 -0
  51. package/dist/core/loops/impl-bind.js +91 -81
  52. package/dist/core/loops/index.js +9 -0
  53. package/dist/core/loops/iteration-engine.js +31 -19
  54. package/dist/core/loops/kind-policies.js +90 -0
  55. package/dist/core/loops/lock.js +71 -13
  56. package/dist/core/loops/reconcile-turn.js +237 -18
  57. package/dist/core/loops/result-reducers.js +113 -10
  58. package/dist/core/loops/store.js +34 -3
  59. package/dist/core/loops/turn-execution.js +480 -0
  60. package/dist/core/loops/types.js +127 -3
  61. package/dist/core/loops/verbs.js +335 -99
  62. package/dist/core/loops/verify-command.js +105 -20
  63. package/dist/core/loops/workspace-digest.js +54 -0
  64. package/dist/core/review-loop-close.js +25 -3
  65. package/dist/core/review-loop-turn-dispatch.js +210 -161
  66. package/dist/core/runtime-signals.js +62 -25
  67. package/dist/core/schema.js +40 -0
  68. package/dist/core/spawn-check.js +3 -2
  69. package/dist/core/upgrades/backup.js +27 -4
  70. package/dist/facts.js +9 -8
  71. package/dist/facts.json +8 -7
  72. package/docs/cli.md +49 -1
  73. package/docs/concepts/attempt-authority.md +407 -0
  74. package/docs/concepts/evidence-attestations.md +135 -0
  75. package/docs/concepts/execution-contract.md +166 -0
  76. package/docs/concepts/harness-adapters.md +166 -0
  77. package/docs/concepts/ideation-loop.md +5 -4
  78. package/docs/concepts/loop-engine.md +302 -113
  79. package/docs/index.md +4 -1
  80. package/docs/integrations/codex.md +3 -3
  81. package/docs/integrations/mcp.md +59 -5
  82. package/docs/loops/debug.md +144 -0
  83. package/docs/loops/ideation.md +158 -0
  84. package/docs/loops/implementation.md +174 -0
  85. package/docs/loops/research.md +136 -0
  86. package/docs/loops/review.md +200 -0
  87. package/docs/mcp-schema-changelog.md +18 -5
  88. package/package.json +1 -1
@@ -4,21 +4,17 @@ import path from 'node:path';
4
4
  import { z } from 'zod';
5
5
  import { memoryDir, writeFileAtomic } from '../io.js';
6
6
  import { nowISO } from '../ids.js';
7
+ import { CapabilitySnapshotSchema, assertExecutionContractIntegrity, ExecutionContractRefSchema, ExecutionContractSchema, } from '../execution-contract.js';
8
+ import { ExpectedArtifactSchema } from './artifact-contract.js';
7
9
  import { acquireLock } from './lock.js';
10
+ import { readLaunchDecision as readV2LaunchDecision, readInitialGeneration, listAttemptGenerations, resolveTurnGenerationChain, } from './attempt-generations.js';
11
+ export { ExpectedArtifactSchema } from './artifact-contract.js';
8
12
  /**
9
13
  * An artifact the attempt's worker is expected to produce (spec §2 / §13 R1).
10
14
  * Brainclaw generates the canonical target; `worker_path` is worker-relative and
11
15
  * MUST be realpath-containment-validated before any read (invariant #7, wired in
12
16
  * a later PR). `sha256` is filled at harvest and validated before state mutation.
13
17
  */
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
18
  export const TurnReservationSchema = z.object({
23
19
  turn_id: z.string().min(1),
24
20
  epoch: z.number().int().nonnegative(),
@@ -42,6 +38,10 @@ export const TurnReservationSchema = z.object({
42
38
  // pln#630 PR2b-a (§13 R1): artifacts this attempt's worker must produce.
43
39
  // Default [] so PR1 on-disk records (which predate the field) still parse.
44
40
  expected_artifacts: z.array(ExpectedArtifactSchema).default([]),
41
+ /** P1: complete immutable launch contract. Optional for pre-P1 reservations. */
42
+ execution_contract: ExecutionContractSchema.optional(),
43
+ execution_contract_ref: ExecutionContractRefSchema.optional(),
44
+ capability_snapshot: CapabilitySnapshotSchema.optional(),
45
45
  store_root: z.string().min(1),
46
46
  cwd: z.string().min(1),
47
47
  lease_deadline: z.string().min(1),
@@ -174,6 +174,54 @@ export function deriveTurnId(loopId, slotId, iteration) {
174
174
  const h = crypto.createHash('sha256').update(`${loopId}:${slotId}:${iteration}`).digest('hex').slice(0, 16);
175
175
  return `tat_${h}`;
176
176
  }
177
+ /**
178
+ * Versioned identity used only when the legacy `(loop, slot, iteration)` cell
179
+ * is already owned by another phase. Keeping this a separate derivation makes
180
+ * existing turn ids and crash-replay fixtures stable while allowing one slot
181
+ * to execute several worker phases in the same protocol iteration.
182
+ */
183
+ export function derivePhaseQualifiedTurnId(loopId, slotId, phase, iteration) {
184
+ const identity = JSON.stringify(['brainclaw-turn-identity-v2', loopId, slotId, phase, iteration]);
185
+ const h = crypto.createHash('sha256').update(identity).digest('hex').slice(0, 16);
186
+ return `tat_${h}`;
187
+ }
188
+ function reservationMatchesTurnIdentity(reservation, input) {
189
+ return reservation !== undefined
190
+ && reservation.loop_id === input.loop_id
191
+ && reservation.slot_id === input.slot_id
192
+ && reservation.phase === input.phase
193
+ && reservation.iteration === input.iteration;
194
+ }
195
+ /**
196
+ * Resolve the durable turn cell without breaking pre-phase-qualified stores.
197
+ *
198
+ * Resolution order is deliberately conservative:
199
+ * 1. adopt the slot's current compatible reservation (projection/crash replay),
200
+ * 2. preserve the compatible legacy three-tuple reservation,
201
+ * 3. adopt an existing compatible phase-qualified reservation,
202
+ * 4. use the legacy id while that cell is absent,
203
+ * 5. only then fall back to the phase-qualified id for a real phase collision.
204
+ *
205
+ * Compatibility intentionally covers identity fields only. Agent/claim/contract
206
+ * mismatches remain the responsibility of prepareAttempt's fail-closed checks;
207
+ * they must never cause this resolver to mint a parallel authority cell.
208
+ */
209
+ export function resolveTurnId(input, cwd) {
210
+ const legacyTurnId = deriveTurnId(input.loop_id, input.slot_id, input.iteration);
211
+ const phaseTurnId = derivePhaseQualifiedTurnId(input.loop_id, input.slot_id, input.phase, input.iteration);
212
+ if (input.current_turn_id) {
213
+ const current = getReservation(input.current_turn_id, cwd);
214
+ if (reservationMatchesTurnIdentity(current, input))
215
+ return input.current_turn_id;
216
+ }
217
+ const legacy = getReservation(legacyTurnId, cwd);
218
+ if (reservationMatchesTurnIdentity(legacy, input))
219
+ return legacyTurnId;
220
+ const phaseQualified = getReservation(phaseTurnId, cwd);
221
+ if (reservationMatchesTurnIdentity(phaseQualified, input))
222
+ return phaseTurnId;
223
+ return legacy === undefined ? legacyTurnId : phaseTurnId;
224
+ }
177
225
  /* ============================ persistence ================================= */
178
226
  function readReservation(turnId, cwd) {
179
227
  const filePath = reservationPath(turnId, cwd);
@@ -188,8 +236,9 @@ function writeReservation(record, cwd) {
188
236
  }
189
237
  /**
190
238
  * 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.
239
+ * primitive (generation-fenced dead-owner reaping, exclusive hard-link
240
+ * creation, fencing) so the decision CAS is atomic across processes — two
241
+ * racing writers cannot both mutate the decision.
193
242
  */
194
243
  function withReservationLock(turnId, agentId, fn, cwd) {
195
244
  ensureDirs(cwd);
@@ -201,10 +250,10 @@ function withReservationLock(turnId, agentId, fn, cwd) {
201
250
  });
202
251
  try {
203
252
  // 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.
253
+ // before any durable write. If a proven-dead owner's generation was reaped
254
+ // and re-acquired by another writer, fenceCheck throws LockLostError so a
255
+ // stale holder can never overwrite the other terminal transition — the
256
+ // consume-XOR-revoke fence stays durable across recovery.
208
257
  return fn(lock.fenceCheck);
209
258
  }
210
259
  finally {
@@ -227,6 +276,22 @@ export function reserve(input, cwd) {
227
276
  if (!Number.isFinite(Date.parse(input.lease_deadline))) {
228
277
  throw new ReservationStateError(input.turn_id, 'invalid_lease_deadline', `reserve: lease_deadline "${input.lease_deadline}" is not a parseable timestamp`);
229
278
  }
279
+ const contractFieldCount = [
280
+ input.execution_contract,
281
+ input.execution_contract_ref,
282
+ input.capability_snapshot,
283
+ ].filter((value) => value !== undefined).length;
284
+ if (contractFieldCount !== 0 && contractFieldCount !== 3) {
285
+ throw new ReservationStateError(input.turn_id, 'invalid_execution_contract', 'reserve: execution_contract, execution_contract_ref and capability_snapshot must be supplied together');
286
+ }
287
+ if (input.execution_contract && input.execution_contract_ref && input.capability_snapshot) {
288
+ try {
289
+ assertExecutionContractIntegrity(input.execution_contract, input.execution_contract_ref, input.capability_snapshot, { agent: input.agent, agent_id: input.agent_id });
290
+ }
291
+ catch (error) {
292
+ throw new ReservationStateError(input.turn_id, 'invalid_execution_contract', `reserve: ${error instanceof Error ? error.message : String(error)}`);
293
+ }
294
+ }
230
295
  return withReservationLock(input.turn_id, agentId, (fence) => {
231
296
  const existing = readReservation(input.turn_id, cwd);
232
297
  if (existing) {
@@ -247,6 +312,9 @@ export function reserve(input, cwd) {
247
312
  iteration: input.iteration,
248
313
  completion_mode: input.completion_mode ?? 'file',
249
314
  expected_artifacts: input.expected_artifacts ?? [],
315
+ execution_contract: input.execution_contract,
316
+ execution_contract_ref: input.execution_contract_ref,
317
+ capability_snapshot: input.capability_snapshot,
250
318
  store_root: input.store_root,
251
319
  cwd: input.cwd,
252
320
  lease_deadline: input.lease_deadline,
@@ -351,7 +419,18 @@ export function listReservations(filter = {}, cwd) {
351
419
  * or legacy (→ presence-based acceptance). Returns undefined for legacy runs.
352
420
  */
353
421
  export function findReservationByRunId(runId, cwd) {
354
- return listReservations({}, cwd).find((r) => r.child_ids.run_id === runId);
422
+ return listReservations({}, cwd).find((reservation) => {
423
+ if (reservation.child_ids.run_id === runId)
424
+ return true;
425
+ try {
426
+ const root = cwd ?? reservation.store_root;
427
+ const initial = readInitialGeneration(root, reservation.turn_id);
428
+ return initial ? listAttemptGenerations(root, initial).some((generation) => generation.run_id === runId) : false;
429
+ }
430
+ catch {
431
+ return false;
432
+ }
433
+ });
355
434
  }
356
435
  /**
357
436
  * Find the turn-attempt reservation that OWNS a given assignment, if any
@@ -369,6 +448,13 @@ export function findReservationByAssignmentId(assignmentId, cwd) {
369
448
  // route a lane to reconcileTurn (it has no live launch generation to accept evidence for).
370
449
  return listReservations({ decision: 'committed' }, cwd).find((r) => r.child_ids.assignment_id === assignmentId);
371
450
  }
451
+ /** Run-scoping key for runtime evidence; undefined preserves legacy assignment-scoped paths. */
452
+ export function currentAttemptRunIdForAssignment(assignmentId, cwd) {
453
+ const reservation = findReservationByAssignmentId(assignmentId, cwd);
454
+ if (!reservation)
455
+ return undefined;
456
+ return resolveTurnGenerationChain(reservation.store_root, reservation.turn_id)?.latest_generation.run_id;
457
+ }
372
458
  /**
373
459
  * Arm the launch grant on a COMMITTED reservation. The pre-exec supervisor
374
460
  * later CONSUMES it (armed→crossed) immediately before invoking the worker;
@@ -425,37 +511,81 @@ export function armLaunch(turnId, input, cwd, agentId = 'system') {
425
511
  return next;
426
512
  }, cwd);
427
513
  }
514
+ function consumeLaunchGrantLocked(turnId, record, token, epoch, fence, cwd) {
515
+ const g = record.launch;
516
+ if (!g)
517
+ throw new LaunchFenceError(turnId, 'not_armed', `consumeLaunchGrant: turn_id ${turnId} has no launch grant`);
518
+ // Epoch/token/lease validated against the immutable grant fields (set once
519
+ // at arm). Epoch BEFORE token so a stale generation reports epoch_mismatch.
520
+ if (g.epoch !== epoch)
521
+ throw new LaunchFenceError(turnId, 'epoch_mismatch', `consumeLaunchGrant: epoch ${epoch} != grant epoch ${g.epoch}`);
522
+ if (g.token !== token)
523
+ throw new LaunchFenceError(turnId, 'token_mismatch', `consumeLaunchGrant: token mismatch for ${turnId}`);
524
+ // Expiry is inclusive (now >= deadline) — the SAME rule the sweep uses. This
525
+ // is intentionally evaluated after projection callbacks as well: a slow repair
526
+ // cannot cross a generation whose lease expired while it was materializing.
527
+ if (Date.parse(nowISO()) >= Date.parse(g.lease_deadline)) {
528
+ throw new LaunchFenceError(turnId, 'lease_expired', `consumeLaunchGrant: grant for ${turnId} expired at ${g.lease_deadline}`);
529
+ }
530
+ if (g.status === 'revoked') {
531
+ throw new LaunchFenceError(turnId, 'revoked', `consumeLaunchGrant: grant for ${turnId} was revoked — MUST NOT spawn`);
532
+ }
533
+ // Validate that this reservation-lock holder was not reaped while a projection
534
+ // callback performed local I/O. The fence check MUST precede the irreversible
535
+ // decision-cell create, not merely the record projection write that follows it.
536
+ fence();
537
+ // ATOMIC XOR — claim the decision via exclusive-create. If a revoke already
538
+ // won (even from a newer holder after this one was reaped), we LOSE here and
539
+ // must not spawn. No TOCTOU: the create, not a prior check, is the commit.
540
+ const { decision: committed, won } = claimLaunchDecision(turnId, { decision: 'crossed', token, epoch, at: nowISO() }, cwd);
541
+ if (committed.decision === 'revoked') {
542
+ throw new LaunchFenceError(turnId, 'revoked', `consumeLaunchGrant: grant for ${turnId} was revoked — MUST NOT spawn`);
543
+ }
544
+ // Won → this call crossed (may spawn). Adopted (won=false) → already crossed
545
+ // by another invocation: launch_attempted_unknown, caller MUST NOT spawn.
546
+ const next = { ...record, launch: { ...g, status: 'crossed', crossed_at: committed.at } };
547
+ fence();
548
+ writeReservation(next, cwd);
549
+ return { reservation: next, wonTransition: won };
550
+ }
428
551
  export function consumeLaunchGrant(turnId, token, epoch, cwd, agentId = 'system') {
429
552
  return withReservationLock(turnId, agentId, (fence) => {
430
553
  const record = readReservation(turnId, cwd);
431
554
  if (!record)
432
555
  throw new ReservationStateError(turnId, 'reservation_not_found', `consumeLaunchGrant: unknown turn_id ${turnId}`);
556
+ return consumeLaunchGrantLocked(turnId, record, token, epoch, fence, cwd);
557
+ }, cwd);
558
+ }
559
+ /**
560
+ * Consume a launch generation only after its deterministic local projections are durable.
561
+ *
562
+ * Lock order is intentionally reservation -> store -> loop. `project` must remain a short,
563
+ * synchronous, local-I/O callback and this API must never be entered while the caller already
564
+ * holds the store or loop lock. If projection throws, the crossed decision is not created and
565
+ * an identical replay can repair/adopt the same Assignment, AgentRun and slot binding.
566
+ */
567
+ export function consumeLaunchGrantWithProjection(turnId, token, epoch, project, cwd, agentId = 'system') {
568
+ return withReservationLock(turnId, agentId, (fence) => {
569
+ const record = readReservation(turnId, cwd);
570
+ if (!record)
571
+ throw new ReservationStateError(turnId, 'reservation_not_found', `consumeLaunchGrantWithProjection: unknown turn_id ${turnId}`);
572
+ // Validate the immutable generation before allowing it to materialize anything.
433
573
  const g = record.launch;
434
574
  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.
575
+ throw new LaunchFenceError(turnId, 'not_armed', `consumeLaunchGrantWithProjection: turn_id ${turnId} has no launch grant`);
438
576
  if (g.epoch !== epoch)
439
- throw new LaunchFenceError(turnId, 'epoch_mismatch', `consumeLaunchGrant: epoch ${epoch} != grant epoch ${g.epoch}`);
577
+ throw new LaunchFenceError(turnId, 'epoch_mismatch', `consumeLaunchGrantWithProjection: epoch ${epoch} != grant epoch ${g.epoch}`);
440
578
  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.
579
+ throw new LaunchFenceError(turnId, 'token_mismatch', `consumeLaunchGrantWithProjection: token mismatch for ${turnId}`);
580
+ if (g.status === 'revoked')
581
+ throw new LaunchFenceError(turnId, 'revoked', `consumeLaunchGrantWithProjection: grant for ${turnId} was revoked — MUST NOT project or spawn`);
582
+ if (g.status === 'crossed')
583
+ return { reservation: record, wonTransition: false };
443
584
  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}`);
585
+ throw new LaunchFenceError(turnId, 'lease_expired', `consumeLaunchGrantWithProjection: grant for ${turnId} expired at ${g.lease_deadline}`);
445
586
  }
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 };
587
+ project(record);
588
+ return consumeLaunchGrantLocked(turnId, record, token, epoch, fence, cwd);
459
589
  }, cwd);
460
590
  }
461
591
  /**
@@ -509,6 +639,17 @@ export function launchGrant(turnId, cwd) {
509
639
  * read-strict acceptance path (PR2b-c) matches on. `undefined` until armed.
510
640
  */
511
641
  export function currentNonce(reservation) {
642
+ try {
643
+ const resolved = resolveTurnGenerationChain(reservation.store_root, reservation.turn_id);
644
+ if (resolved && (resolved.status === 'active' || resolved.status === 'settled')) {
645
+ const generation = resolved.latest_generation;
646
+ const launch = readV2LaunchDecision(reservation.store_root, reservation.turn_id, generation.attempt_epoch);
647
+ return launch?.decision === 'crossed' ? generation.launch_nonce : undefined;
648
+ }
649
+ }
650
+ catch {
651
+ return undefined;
652
+ }
512
653
  // Only a LIVE generation (armed or crossed) has a current nonce. A revoked
513
654
  // grant means the worker never crossed → never spawned, so its token is a
514
655
  // dead generation and must not be reported as current (review PR2b-a #1).
@@ -525,6 +666,27 @@ export function currentNonce(reservation) {
525
666
  * `run.status === 'completed'` gate is applied separately by the caller.
526
667
  */
527
668
  export function evidenceMatchesAttempt(reservation, evidence) {
669
+ try {
670
+ const resolved = resolveTurnGenerationChain(reservation.store_root, reservation.turn_id);
671
+ if (resolved && (resolved.status === 'active' || resolved.status === 'settled')) {
672
+ const generation = resolved.latest_generation;
673
+ const launch = readV2LaunchDecision(reservation.store_root, reservation.turn_id, generation.attempt_epoch);
674
+ if (launch?.decision !== 'crossed')
675
+ return false;
676
+ return (evidence.assignment_id === generation.assignment_id
677
+ && evidence.turn_id === generation.turn_id
678
+ && evidence.run_id === generation.run_id
679
+ && evidence.nonce === generation.launch_nonce
680
+ && evidence.attempt_epoch === generation.attempt_epoch
681
+ && evidence.contract_hash === generation.contract_hash
682
+ && evidence.workspace_digest === generation.workspace_digest);
683
+ }
684
+ if (resolved)
685
+ return false;
686
+ }
687
+ catch {
688
+ return false;
689
+ }
528
690
  const nonce = currentNonce(reservation);
529
691
  if (!nonce)
530
692
  return false;