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
@@ -33,13 +33,17 @@
33
33
  * @module
34
34
  */
35
35
  import { spawnSync } from 'node:child_process';
36
- import { loadAgentRun, transitionAgentRun, listAgentRuns } from './agentruns.js';
36
+ import fs from 'node:fs';
37
+ import { loadAgentRun, recordExecutionContractAnomaly, transitionAgentRun, listAgentRuns } from './agentruns.js';
37
38
  import { loadClaim, releaseClaim } from './claims.js';
38
39
  import { loadAssignment } from './assignments.js';
39
40
  import { createRuntimeEvent } from './events.js';
40
41
  import { nowISO } from './ids.js';
41
- import { readHeartbeat, readLogTail, signalExists, latestActivityMs, readCompletionSignals } from './runtime-signals.js';
42
+ import { readContractAck, readHeartbeat, readLogTail, signalExists, latestActivityMs, readCompletionSignals } from './runtime-signals.js';
43
+ import { validateWorkerContractAcceptance } from './execution-contract.js';
42
44
  import { findReservationByRunId, evidenceMatchesAttempt, launchGrant, revokeLaunchGrant } from './loops/attempt-reservation.js';
45
+ import { executionContractForGeneration } from './loops/attempt-authority.js';
46
+ import { readLaunchDecision, resolveTurnGenerationChain } from './loops/attempt-generations.js';
43
47
  import { reconcileFailedTurn } from './loops/reconcile-turn.js';
44
48
  // ── Constants ──────────────────────────────────────────────────────────────
45
49
  /**
@@ -55,6 +59,15 @@ export const DEFAULT_HEALTH_CHECK_GRACE_MS = 60_000;
55
59
  export const DEFAULT_STALE_AFTER_MS = 30 * 60_000;
56
60
  export const DEFAULT_DEAD_PID_READ_SWEEP_AGE_MS = 5 * 60_000;
57
61
  export const DEFAULT_DEAD_PID_READ_SWEEP_LIMIT = 50;
62
+ function normalizedWorkspace(value) {
63
+ try {
64
+ const resolved = fs.realpathSync.native(value);
65
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
66
+ }
67
+ catch {
68
+ return undefined;
69
+ }
70
+ }
58
71
  /**
59
72
  * pln#520 step 1 — a heartbeat older than this (with no completion signal) means
60
73
  * the worker reached its loop then went silent: `stalled`. Default 10 min.
@@ -164,17 +177,25 @@ export function collectEvidence(run, cwd, options) {
164
177
  // coordination dir (the dispatcher's ackRoot), which is `cwd` for the
165
178
  // reconciler. Keyed by assignment_id.
166
179
  const signalRoot = cwd ?? process.cwd();
180
+ const signalReservation = findReservationByRunId(run.id, cwd);
181
+ let v2SignalRunId;
182
+ try {
183
+ if (signalReservation && resolveTurnGenerationChain(signalReservation.store_root, signalReservation.turn_id)) {
184
+ v2SignalRunId = run.id;
185
+ }
186
+ }
187
+ catch { /* strict evidence handling below remains fail-closed */ }
167
188
  let completed_signal = false;
168
189
  let failed_signal = false;
169
190
  let heartbeat_exists = false;
170
191
  let heartbeat_age_ms;
171
192
  try {
172
- completed_signal = signalExists(signalRoot, run.assignment_id, 'completed');
173
- failed_signal = signalExists(signalRoot, run.assignment_id, 'failed');
193
+ completed_signal = signalExists(signalRoot, run.assignment_id, 'completed', v2SignalRunId);
194
+ failed_signal = signalExists(signalRoot, run.assignment_id, 'failed', v2SignalRunId);
174
195
  // sprint 1.5: also read the worktree-local heartbeat — the only location a
175
196
  // sandboxed worker can write (the project-root signal dir is outside its
176
197
  // writable roots).
177
- const hb = readHeartbeat(signalRoot, run.assignment_id, run.worktree_path);
198
+ const hb = readHeartbeat(signalRoot, run.assignment_id, run.worktree_path, v2SignalRunId);
178
199
  heartbeat_exists = hb.exists;
179
200
  if (hb.exists && hb.mtimeMs !== undefined)
180
201
  heartbeat_age_ms = now - hb.mtimeMs;
@@ -185,7 +206,7 @@ export function collectEvidence(run, cwd, options) {
185
206
  // its heartbeat is frozen (written once at step 0).
186
207
  let fs_activity_age_ms;
187
208
  try {
188
- const lastFs = latestActivityMs(signalRoot, run.assignment_id, run.worktree_path);
209
+ const lastFs = latestActivityMs(signalRoot, run.assignment_id, run.worktree_path, v2SignalRunId);
189
210
  if (lastFs !== undefined)
190
211
  fs_activity_age_ms = now - lastFs;
191
212
  }
@@ -198,20 +219,63 @@ export function collectEvidence(run, cwd, options) {
198
219
  // (no owning reservation) keep presence-based acceptance.
199
220
  let turn_owned = false;
200
221
  let turn_keyed_completed = false;
222
+ let contract_acceptance_anomaly = Boolean(run.execution_contract_anomaly);
201
223
  try {
202
224
  const reservation = findReservationByRunId(run.id, cwd);
203
225
  if (reservation) {
204
226
  turn_owned = true;
205
- const bodies = readCompletionSignals(signalRoot, run.assignment_id);
227
+ const bodies = readCompletionSignals(signalRoot, run.assignment_id, v2SignalRunId);
228
+ const bootstrapAck = readContractAck(signalRoot, run.assignment_id, v2SignalRunId);
229
+ const hasTerminalBody = bodies.completed !== undefined || bodies.failed !== undefined;
230
+ const generationChain = resolveTurnGenerationChain(reservation.store_root, reservation.turn_id);
231
+ const generation = generationChain?.latest_generation;
232
+ const generationApplies = generation !== undefined && generation.run_id === run.id
233
+ && (generationChain?.status === 'active' || generationChain?.status === 'settled');
234
+ const generationLaunch = generationApplies
235
+ ? readLaunchDecision(reservation.store_root, reservation.turn_id, generation.attempt_epoch)
236
+ : undefined;
237
+ const expectedContractRef = generationApplies
238
+ ? executionContractForGeneration(reservation, generation).ref
239
+ : reservation.execution_contract_ref;
240
+ const bootstrapAccepted = !expectedContractRef || (bootstrapAck?.status === 'accepted'
241
+ && bootstrapAck.turn_id === reservation.turn_id
242
+ && bootstrapAck.run_id === (generationApplies ? generation.run_id : reservation.child_ids.run_id)
243
+ && bootstrapAck.nonce === (generationApplies ? generation.launch_nonce : reservation.launch?.token)
244
+ && (!generationApplies || bootstrapAck.cwd === normalizedWorkspace(generation.workspace_path))
245
+ && (!generationApplies || bootstrapAck.attempt_epoch === generation.attempt_epoch)
246
+ && (!generationApplies || bootstrapAck.workspace_digest === generation.workspace_digest)
247
+ && validateWorkerContractAcceptance(expectedContractRef, {
248
+ contract_hash: bootstrapAck.contract_hash,
249
+ capability_snapshot_hash: bootstrapAck.capability_snapshot_hash,
250
+ }, generationApplies ? generationLaunch?.decision : reservation.launch?.status).kind === 'accepted');
251
+ if (expectedContractRef && ((bootstrapAck && !bootstrapAccepted) || (hasTerminalBody && !bootstrapAccepted))) {
252
+ contract_acceptance_anomaly = true;
253
+ }
254
+ const contractAccepted = (body) => {
255
+ if (!expectedContractRef)
256
+ return true;
257
+ if (!bootstrapAccepted)
258
+ return false;
259
+ if (!body?.contract_hash || !body.capability_snapshot_hash) {
260
+ contract_acceptance_anomaly = Boolean(body);
261
+ return false;
262
+ }
263
+ const verdict = validateWorkerContractAcceptance(expectedContractRef, { contract_hash: body.contract_hash, capability_snapshot_hash: body.capability_snapshot_hash }, generationApplies ? generationLaunch?.decision : reservation.launch?.status);
264
+ if (verdict.kind !== 'accepted')
265
+ contract_acceptance_anomaly = true;
266
+ return verdict.kind === 'accepted';
267
+ };
206
268
  // Evidence must be turn-keyed AND carry the RIGHT status (a `.completed`
207
269
  // file whose body says status:'failed' is not completion evidence —
208
270
  // read-strict trusts the body, not the filename, review PR2b-c #C2).
209
271
  const matchedCompleted = bodies.completed !== undefined
210
272
  && bodies.completed.status === 'completed'
211
- && evidenceMatchesAttempt(reservation, bodies.completed);
273
+ && evidenceMatchesAttempt(reservation, { ...bodies.completed, assignment_id: run.assignment_id })
274
+ && contractAccepted(bodies.completed);
212
275
  const matchedFailed = bodies.failed !== undefined
213
276
  && bodies.failed.status === 'failed'
214
- && evidenceMatchesAttempt(reservation, bodies.failed);
277
+ && evidenceMatchesAttempt(reservation, { ...bodies.failed, assignment_id: run.assignment_id })
278
+ && contractAccepted(bodies.failed);
215
279
  if (matchedCompleted && matchedFailed) {
216
280
  // §13 R4 — a completed+failed contradiction WITHHOLDS both (never a
217
281
  // silent accept). Conflict-event journaling is deferred to
@@ -234,7 +298,7 @@ export function collectEvidence(run, cwd, options) {
234
298
  return {
235
299
  age_ms, has_post_start_commit, claim_released, assignment_completed, process_alive,
236
300
  completed_signal, failed_signal, heartbeat_exists, heartbeat_age_ms, fs_activity_age_ms,
237
- turn_owned, turn_keyed_completed,
301
+ turn_owned, turn_keyed_completed, contract_acceptance_anomaly,
238
302
  };
239
303
  }
240
304
  /**
@@ -322,6 +386,16 @@ export function reconcileStrandedFailureClaimAtRead(run, cwd, options = {}) {
322
386
  if (!run.claim_id)
323
387
  return false;
324
388
  const now = options.nowMs ?? Date.now();
389
+ if (run.status_reason?.startsWith('reserved_never_launched:')) {
390
+ const reservation = findReservationByRunId(run.id, cwd);
391
+ const dispatchDeadline = reservation ? Date.parse(reservation.lease_deadline) : Number.NaN;
392
+ if (reservation?.decision === 'committed'
393
+ && reservation.launch?.status === 'revoked'
394
+ && Number.isFinite(dispatchDeadline)
395
+ && now < dispatchDeadline) {
396
+ return false;
397
+ }
398
+ }
325
399
  const anchor = Date.parse(run.completed_at ?? run.updated_at ?? run.created_at);
326
400
  if (Number.isFinite(anchor) && now - anchor > STRANDED_RELEASE_RETRY_WINDOW_MS)
327
401
  return false;
@@ -366,6 +440,8 @@ function describeEvidence(evidence) {
366
440
  const reasons = [];
367
441
  if (evidence.completed_signal)
368
442
  reasons.push('wrapper wrote completed sentinel');
443
+ if (evidence.contract_acceptance_anomaly)
444
+ reasons.push('post-crossing contract acceptance anomaly (respawn forbidden)');
369
445
  if (evidence.has_post_start_commit)
370
446
  reasons.push('post-start commit on worktree branch');
371
447
  if (evidence.claim_released)
@@ -457,7 +533,7 @@ export function reconcileAgentRun(runId, cwd, options = {}) {
457
533
  const evidence = {
458
534
  age_ms: 0, has_post_start_commit: false, claim_released: false,
459
535
  assignment_completed: false, process_alive: undefined,
460
- completed_signal: false, failed_signal: false, heartbeat_exists: false, turn_owned: false, turn_keyed_completed: false,
536
+ completed_signal: false, failed_signal: false, heartbeat_exists: false, turn_owned: false, turn_keyed_completed: false, contract_acceptance_anomaly: false,
461
537
  };
462
538
  return {
463
539
  run_id: runId, action: 'no_op', reason: 'run not found', evidence,
@@ -473,6 +549,23 @@ export function reconcileAgentRun(runId, cwd, options = {}) {
473
549
  evidence, previous_status, current_status: run.status,
474
550
  };
475
551
  }
552
+ if (evidence.contract_acceptance_anomaly) {
553
+ try {
554
+ recordExecutionContractAnomaly(runId, {
555
+ source: 'reconciler',
556
+ reason: 'bootstrap or terminal evidence did not accept the immutable execution contract',
557
+ }, cwd);
558
+ }
559
+ catch { /* ack/sentinel remains a durable fallback fence */ }
560
+ return {
561
+ run_id: runId,
562
+ action: 'health_check_unverified',
563
+ reason: 'post_crossing_contract_anomaly: accepted contract ref differs or is missing; convergence withheld and respawn=false',
564
+ evidence,
565
+ previous_status,
566
+ current_status: run.status,
567
+ };
568
+ }
476
569
  // pln#630 PR2c-lease (§4 + R5): a turn-owned run preallocated `created`/
477
570
  // `launching` converges on its DISPATCH/LAUNCH LEASE, not the pid/heartbeat
478
571
  // heuristics below (which assume a worker already spawned and can emit
@@ -586,8 +679,10 @@ export function reconcileAgentRun(runId, cwd, options = {}) {
586
679
  * - within lease → NO-OP (the worker may yet cross the launch fence / start);
587
680
  * - past lease + launch grant CROSSED → `failed` / `launch_attempted_unknown`
588
681
  * (the worker WAS invoked; its outcome is unknowable, so it must not complete);
589
- * - past lease + grant not crossed `cancelled` / `reserved_never_launched`
590
- * (no launch receipt nothing ran; matches attemptStatus(revoked)='cancelled').
682
+ * - past launch lease + grant not crossed, while dispatch lease is live →
683
+ * revoke and retain the non-terminal run for re-arm;
684
+ * - past dispatch lease + grant not crossed → `cancelled` /
685
+ * `reserved_never_launched` (the recovery window is exhausted).
591
686
  */
592
687
  function reconcileTurnOwnedPreRunLease(run, reservation, evidence, cwd, options) {
593
688
  const now = options.nowMs ?? Date.now();
@@ -620,13 +715,8 @@ function reconcileTurnOwnedPreRunLease(run, reservation, evidence, cwd, options)
620
715
  }
621
716
  // Past lease, no accepted completion — the launch-grant status (authoritative,
622
717
  // decision-file reconciled) decides the terminal.
623
- const grant = launchGrant(reservation.turn_id, cwd);
624
- const crossed = grant?.status === 'crossed';
625
- const targetStatus = crossed ? 'failed' : 'cancelled';
626
- const action = crossed ? 'inferred_failed' : 'inferred_cancelled';
627
- const reason = crossed
628
- ? `launch_attempted_unknown: launch grant crossed but run never reached running by lease ${leaseISO} — outcome unknown, never completed`
629
- : `reserved_never_launched: no launch receipt by lease ${leaseISO} (grant=${grant?.status ?? 'none'})`;
718
+ let grant = launchGrant(reservation.turn_id, cwd);
719
+ let crossed = grant?.status === 'crossed';
630
720
  // pln#630 dec#149 R1 (review Finding 2) — make the reserved_never_launched strand REACHABLE
631
721
  // through the WIRED lazy reconciler: revoke the still-armed grant so its authoritative status
632
722
  // becomes `revoked`. That is what lets reconcileTurn's fix-cycle strand detector see the strand
@@ -638,6 +728,42 @@ function reconcileTurnOwnedPreRunLease(run, reservation, evidence, cwd, options)
638
728
  revokeLaunchGrant(reservation.turn_id, grant.epoch, 'reserved_never_launched', cwd, actor);
639
729
  }
640
730
  catch { /* raced to crossed/revoked — authoritative status governs */ }
731
+ grant = launchGrant(reservation.turn_id, cwd);
732
+ crossed = grant?.status === 'crossed';
733
+ }
734
+ if (evidence.contract_acceptance_anomaly) {
735
+ try {
736
+ recordExecutionContractAnomaly(run.id, {
737
+ source: 'reconciler',
738
+ reason: 'bootstrap or terminal evidence did not accept the immutable execution contract',
739
+ }, cwd);
740
+ }
741
+ catch { /* ack/sentinel remains a durable fallback fence */ }
742
+ return {
743
+ run_id: run.id,
744
+ action: 'health_check_unverified',
745
+ reason: 'post_crossing_contract_anomaly: accepted contract ref differs or is missing; convergence withheld and respawn=false',
746
+ evidence,
747
+ previous_status,
748
+ current_status: run.status,
749
+ };
750
+ }
751
+ const targetStatus = crossed ? 'failed' : 'cancelled';
752
+ const action = crossed ? 'inferred_failed' : 'inferred_cancelled';
753
+ const reason = crossed
754
+ ? `launch_attempted_unknown: launch grant crossed but run never reached running by lease ${leaseISO} — outcome unknown, never completed`
755
+ : `reserved_never_launched: no launch receipt by lease ${leaseISO} (grant=${grant?.status ?? 'none'})`;
756
+ const dispatchLeaseMs = Date.parse(reservation.lease_deadline);
757
+ const dispatchLeaseLive = Number.isFinite(dispatchLeaseMs) && now < dispatchLeaseMs;
758
+ if (!crossed && dispatchLeaseLive) {
759
+ return {
760
+ run_id: run.id,
761
+ action: 'no_op',
762
+ reason: `${reason}; retained non-terminal for re-arm until dispatch lease ${reservation.lease_deadline}`,
763
+ evidence,
764
+ previous_status,
765
+ current_status: run.status,
766
+ };
641
767
  }
642
768
  try {
643
769
  transitionAgentRun(run.id, targetStatus, { actor, status_reason: reason }, cwd);
@@ -680,7 +806,7 @@ export function reconcileDeadPidRunningAgentRunAtRead(runId, cwd, options = {})
680
806
  const evidence = {
681
807
  age_ms: 0, has_post_start_commit: false, claim_released: false,
682
808
  assignment_completed: false, process_alive: undefined,
683
- completed_signal: false, failed_signal: false, heartbeat_exists: false, turn_owned: false, turn_keyed_completed: false,
809
+ completed_signal: false, failed_signal: false, heartbeat_exists: false, turn_owned: false, turn_keyed_completed: false, contract_acceptance_anomaly: false,
684
810
  };
685
811
  return {
686
812
  run_id: runId, action: 'no_op', reason: 'run not found', evidence,
@@ -854,7 +980,7 @@ export function reconcileAllOpenRuns(cwd, filter = {}, options = {}) {
854
980
  catch {
855
981
  results.push({
856
982
  run_id: run.id, action: 'no_op', reason: 'reconcile threw — skipped',
857
- evidence: { age_ms: 0, has_post_start_commit: false, claim_released: false, assignment_completed: false, process_alive: undefined, completed_signal: false, failed_signal: false, heartbeat_exists: false, turn_owned: false, turn_keyed_completed: false },
983
+ evidence: { age_ms: 0, has_post_start_commit: false, claim_released: false, assignment_completed: false, process_alive: undefined, completed_signal: false, failed_signal: false, heartbeat_exists: false, turn_owned: false, turn_keyed_completed: false, contract_acceptance_anomaly: false },
858
984
  previous_status: run.status, current_status: run.status,
859
985
  });
860
986
  }
@@ -9,6 +9,7 @@
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import { AgentRunSchema } from './schema.js';
12
+ import { RuntimeCapabilityObservationSchema } from './execution-contract.js';
12
13
  import { resolveOwnerProjectId } from './config.js';
13
14
  import { entityRecordDirs, resolveEntityDir } from './io.js';
14
15
  import { mutate } from './mutation-pipeline.js';
@@ -18,6 +19,47 @@ import { appendAuditEntry } from './audit.js';
18
19
  import { appendEvent } from './event-log.js';
19
20
  import { createRuntimeEvent } from './events.js';
20
21
  import { emitRegistryPostImage, registryFaultPoint } from './events/registry-post-image.js';
22
+ import { findReservationByRunId } from './loops/attempt-reservation.js';
23
+ import { resolveTurnGenerationChain } from './loops/attempt-generations.js';
24
+ export class AgentRunFencedError extends Error {
25
+ runId;
26
+ activeRunId;
27
+ attemptEpoch;
28
+ authorityStatus;
29
+ constructor(runId, activeRunId, attemptEpoch, authorityStatus) {
30
+ super(`AgentRun ${runId} is fenced by attempt epoch ${attemptEpoch}`
31
+ + `${activeRunId ? ` (authoritative run: ${activeRunId})` : ''}`
32
+ + ` [${authorityStatus}]`);
33
+ this.runId = runId;
34
+ this.activeRunId = activeRunId;
35
+ this.attemptEpoch = attemptEpoch;
36
+ this.authorityStatus = authorityStatus;
37
+ this.name = 'AgentRunFencedError';
38
+ }
39
+ }
40
+ /**
41
+ * Refuse late writes from an AgentRun superseded by attempt-authority v2.
42
+ *
43
+ * The immutable generation chain is authoritative; assignment status and the
44
+ * mutable head projection are deliberately not consulted. A settled latest
45
+ * generation may still finish its replayable projections after publishing its
46
+ * close cell. Controllers may explicitly override this guard only to project a
47
+ * terminal state onto the run that they just fenced.
48
+ */
49
+ function assertAgentRunMutationAllowed(id, cwd, allowFencedProjection = false) {
50
+ if (allowFencedProjection)
51
+ return;
52
+ const reservation = findReservationByRunId(id, cwd);
53
+ if (!reservation)
54
+ return;
55
+ const chain = resolveTurnGenerationChain(reservation.store_root, reservation.turn_id);
56
+ if (!chain)
57
+ return; // Legacy attempt authority remains compatible.
58
+ const latest = chain.latest_generation;
59
+ if (latest.run_id === id && (chain.status === 'active' || chain.status === 'settled'))
60
+ return;
61
+ throw new AgentRunFencedError(id, chain.status === 'active' ? latest.run_id : null, latest.attempt_epoch, chain.status);
62
+ }
21
63
  function agentRunsDir(cwd, mode = 'read') {
22
64
  return resolveEntityDir('runs', cwd, mode);
23
65
  }
@@ -45,33 +87,37 @@ function agentRunStoreForDir(dirPath) {
45
87
  // loader found it; removing it stops the next reader from reintroducing that asymmetry.
46
88
  export function saveAgentRun(run, cwd) {
47
89
  mutate({ cwd }, () => {
48
- ensureAgentRunsDir(cwd);
49
- const store = new JsonStore({
50
- dirPath: agentRunsDir(cwd, 'write'),
51
- documentType: 'agent_run',
52
- getId: (item) => item.id,
53
- sort: (a, b) => a.created_at.localeCompare(b.created_at),
54
- });
55
- const parsed = AgentRunSchema.parse(run);
56
- // pln#568 (I2): journal the post-image BEFORE the projection write.
57
- const created = !store.exists(parsed.id);
58
- emitRegistryPostImage('agent_run', parsed, { created, agent: parsed.agent, agent_id: parsed.agent_id, session_id: parsed.session_id, cwd });
59
- registryFaultPoint('after_registry_journal');
60
- store.save(parsed);
61
- // Converge the other layout (mirrors saveClaim / saveAssignment): leaving a legacy copy
62
- // holding the stale status is what let a deleted record be resurrected by its own zombie.
63
- const writeDir = agentRunsDir(cwd, 'write');
64
- for (const dirPath of entityRecordDirs('runs', cwd ?? process.cwd())) {
65
- if (dirPath === writeDir)
66
- continue;
67
- const legacyPath = path.join(dirPath, `${parsed.id}.json`);
68
- try {
69
- if (fs.existsSync(legacyPath))
70
- fs.unlinkSync(legacyPath);
71
- }
72
- catch { /* best effort — the dual-layout list keeps it visible */ }
73
- }
90
+ saveAgentRunUnlocked(run, cwd);
91
+ });
92
+ }
93
+ /** Store-lock caller variant used by create-or-validate projection repair. */
94
+ function saveAgentRunUnlocked(run, cwd) {
95
+ ensureAgentRunsDir(cwd);
96
+ const store = new JsonStore({
97
+ dirPath: agentRunsDir(cwd, 'write'),
98
+ documentType: 'agent_run',
99
+ getId: (item) => item.id,
100
+ sort: (a, b) => a.created_at.localeCompare(b.created_at),
74
101
  });
102
+ const parsed = AgentRunSchema.parse(run);
103
+ // pln#568 (I2): journal the post-image BEFORE the projection write.
104
+ const created = !store.exists(parsed.id);
105
+ emitRegistryPostImage('agent_run', parsed, { created, agent: parsed.agent, agent_id: parsed.agent_id, session_id: parsed.session_id, cwd });
106
+ registryFaultPoint('after_registry_journal');
107
+ store.save(parsed);
108
+ // Converge the other layout (mirrors saveClaim / saveAssignment): leaving a legacy copy
109
+ // holding the stale status is what let a deleted record be resurrected by its own zombie.
110
+ const writeDir = agentRunsDir(cwd, 'write');
111
+ for (const dirPath of entityRecordDirs('runs', cwd ?? process.cwd())) {
112
+ if (dirPath === writeDir)
113
+ continue;
114
+ const legacyPath = path.join(dirPath, `${parsed.id}.json`);
115
+ try {
116
+ if (fs.existsSync(legacyPath))
117
+ fs.unlinkSync(legacyPath);
118
+ }
119
+ catch { /* best effort — the dual-layout list keeps it visible */ }
120
+ }
75
121
  }
76
122
  export function loadAgentRun(id, cwd) {
77
123
  // Record-specific across both layouts (pln#649, shared io.ts primitive). Resolving
@@ -96,6 +142,92 @@ export function loadAgentRun(id, cwd) {
96
142
  }
97
143
  return undefined;
98
144
  }
145
+ /**
146
+ * Persist the first execution-contract anomaly for a run.
147
+ *
148
+ * The field is deliberately monotone: later correct-looking evidence cannot
149
+ * erase an already-observed post-crossing mismatch and reopen convergence.
150
+ */
151
+ export function recordExecutionContractAnomaly(id, anomaly, cwd) {
152
+ assertAgentRunMutationAllowed(id, cwd);
153
+ return mutate({ cwd }, () => {
154
+ const run = loadAgentRun(id, cwd);
155
+ if (!run)
156
+ throw new Error(`AgentRun not found: ${id}`);
157
+ if (run.execution_contract_anomaly)
158
+ return run;
159
+ const now = nowISO();
160
+ run.execution_contract_anomaly = {
161
+ detected_at: now,
162
+ source: anomaly.source,
163
+ reason: anomaly.reason,
164
+ accepted_contract_hash: anomaly.accepted_contract_hash,
165
+ accepted_capability_snapshot_hash: anomaly.accepted_capability_snapshot_hash,
166
+ };
167
+ run.updated_at = now;
168
+ run.last_event_at = now;
169
+ saveAgentRunUnlocked(run, cwd);
170
+ return run;
171
+ });
172
+ }
173
+ /** Persist a post-start observation without ever rewriting the frozen snapshot. */
174
+ export function recordRuntimeCapabilityObservation(id, observation, diagnostic, cwd) {
175
+ assertAgentRunMutationAllowed(id, cwd);
176
+ return mutate({ cwd }, () => {
177
+ const run = loadAgentRun(id, cwd);
178
+ if (!run)
179
+ throw new Error(`AgentRun not found: ${id}`);
180
+ const parsed = RuntimeCapabilityObservationSchema.parse(observation);
181
+ if (run.runtime_capability_observation) {
182
+ if (JSON.stringify(run.runtime_capability_observation) !== JSON.stringify(parsed)) {
183
+ throw new Error(`AgentRun ${id} already has a different runtime capability observation`);
184
+ }
185
+ return run;
186
+ }
187
+ const now = nowISO();
188
+ run.runtime_capability_observation = parsed;
189
+ if (diagnostic)
190
+ run.harness_exit_diagnostic = diagnostic;
191
+ const frozenRef = run.execution_contract_ref;
192
+ const frozenHarness = run.capability_snapshot?.resolved.harness;
193
+ const resolvedModel = frozenHarness?.resolved_model ?? run.capability_snapshot?.resolved.model;
194
+ const mismatches = [];
195
+ if (frozenRef?.hash !== parsed.contract_hash) {
196
+ mismatches.push(`observed contract hash '${parsed.contract_hash}' differs from frozen '${frozenRef?.hash ?? 'absent'}'`);
197
+ }
198
+ if (frozenRef?.snapshot_hash !== parsed.capability_snapshot_hash) {
199
+ mismatches.push(`observed capability snapshot hash '${parsed.capability_snapshot_hash}' differs from frozen '${frozenRef?.snapshot_hash ?? 'absent'}'`);
200
+ }
201
+ if (parsed.accepted_contract_hash && parsed.accepted_contract_hash !== frozenRef?.hash) {
202
+ mismatches.push(`accepted contract hash '${parsed.accepted_contract_hash}' differs from frozen '${frozenRef?.hash ?? 'absent'}'`);
203
+ }
204
+ if (parsed.accepted_capability_snapshot_hash && parsed.accepted_capability_snapshot_hash !== frozenRef?.snapshot_hash) {
205
+ mismatches.push(`accepted capability snapshot hash '${parsed.accepted_capability_snapshot_hash}' differs from frozen '${frozenRef?.snapshot_hash ?? 'absent'}'`);
206
+ }
207
+ if (frozenHarness && parsed.adapter_id && frozenHarness.adapter_id !== parsed.adapter_id) {
208
+ mismatches.push(`observed adapter '${parsed.adapter_id}' differs from frozen '${frozenHarness.adapter_id}'`);
209
+ }
210
+ if (frozenHarness && parsed.adapter_version && frozenHarness.adapter_version !== parsed.adapter_version) {
211
+ mismatches.push(`observed adapter version '${parsed.adapter_version}' differs from frozen '${frozenHarness.adapter_version}'`);
212
+ }
213
+ if (resolvedModel && parsed.observed_model && resolvedModel !== parsed.observed_model) {
214
+ mismatches.push(`observed model '${parsed.observed_model}' differs from frozen resolved model '${resolvedModel}'`);
215
+ }
216
+ if (mismatches.length > 0 && !run.execution_contract_anomaly) {
217
+ run.execution_contract_anomaly = {
218
+ detected_at: now,
219
+ source: 'reconciler',
220
+ reason: mismatches.join('; '),
221
+ accepted_contract_hash: parsed.accepted_contract_hash,
222
+ accepted_capability_snapshot_hash: parsed.accepted_capability_snapshot_hash,
223
+ };
224
+ }
225
+ run.updated_at = now;
226
+ run.last_event_at = now;
227
+ saveAgentRunUnlocked(run, cwd);
228
+ return run;
229
+ });
230
+ }
99
231
  /**
100
232
  * BOTH LAYOUTS, canonical winning on a duplicate id (mirrors listClaims / listAssignments).
101
233
  *
@@ -171,10 +303,10 @@ const VALID_TRANSITIONS = new Map([
171
303
  ['timed_out', new Set()],
172
304
  ['interrupted', new Set()],
173
305
  ]);
174
- export function createAgentRun(options, cwd) {
306
+ function buildAgentRun(options, cwd) {
175
307
  const generated = options.id ? undefined : generateAgentRunId(cwd);
176
308
  const now = nowISO();
177
- const run = AgentRunSchema.parse({
309
+ return AgentRunSchema.parse({
178
310
  schema_version: 1,
179
311
  id: options.id ?? generated.id,
180
312
  // Same landmine as createAssignment: `generated` is undefined when the caller
@@ -202,6 +334,8 @@ export function createAgentRun(options, cwd) {
202
334
  shell: options.shell,
203
335
  pid: options.pid,
204
336
  provider_run_id: options.provider_run_id,
337
+ execution_contract_ref: options.execution_contract_ref,
338
+ capability_snapshot: options.capability_snapshot,
205
339
  created_at: now,
206
340
  updated_at: now,
207
341
  last_event_at: now,
@@ -211,7 +345,8 @@ export function createAgentRun(options, cwd) {
211
345
  artifacts: [],
212
346
  tags: options.tags ?? [],
213
347
  });
214
- saveAgentRun(run, cwd);
348
+ }
349
+ function emitAgentRunCreatedSideEffects(run, options, cwd) {
215
350
  emitAgentRunEvent(run, 'run_created', options.agent, cwd);
216
351
  if (run.status !== 'created') {
217
352
  emitAgentRunEvent(run, `run_${run.status}`, options.agent, cwd);
@@ -226,9 +361,98 @@ export function createAgentRun(options, cwd) {
226
361
  scope: run.scope,
227
362
  session_id: run.session_id,
228
363
  }, cwd);
364
+ }
365
+ export function createAgentRun(options, cwd) {
366
+ const run = buildAgentRun(options, cwd);
367
+ saveAgentRun(run, cwd);
368
+ emitAgentRunCreatedSideEffects(run, options, cwd);
229
369
  return run;
230
370
  }
371
+ export class AgentRunProjectionConflictError extends Error {
372
+ runId;
373
+ constructor(runId, detail) {
374
+ super(`AgentRun projection conflict for ${runId}: ${detail}`);
375
+ this.runId = runId;
376
+ this.name = 'AgentRunProjectionConflictError';
377
+ }
378
+ }
379
+ const RECOVERABLE_PROJECTION_RUN_STATUSES = new Set([
380
+ 'created', 'launching', 'waiting_input', 'running',
381
+ ]);
382
+ function assertAgentRunProjectionMatches(existing, expected) {
383
+ const fields = [
384
+ 'id', 'assignment_id', 'claim_id', 'agent', 'agent_id', 'transport', 'scope',
385
+ 'worktree_path', 'attempt_index',
386
+ ];
387
+ for (const field of fields) {
388
+ if (existing[field] !== expected[field]) {
389
+ throw new AgentRunProjectionConflictError(expected.id, `${String(field)} differs (existing=${String(existing[field])}, expected=${String(expected[field])})`);
390
+ }
391
+ }
392
+ if (existing.project_id !== undefined && existing.project_id !== expected.project_id) {
393
+ throw new AgentRunProjectionConflictError(expected.id, 'project_id differs');
394
+ }
395
+ if (existing.execution_contract_ref !== undefined
396
+ && expected.execution_contract_ref !== undefined
397
+ && JSON.stringify(existing.execution_contract_ref) !== JSON.stringify(expected.execution_contract_ref)) {
398
+ throw new AgentRunProjectionConflictError(expected.id, 'execution_contract_ref differs');
399
+ }
400
+ if (existing.capability_snapshot !== undefined
401
+ && expected.capability_snapshot !== undefined
402
+ && JSON.stringify(existing.capability_snapshot) !== JSON.stringify(expected.capability_snapshot)) {
403
+ throw new AgentRunProjectionConflictError(expected.id, 'capability_snapshot differs');
404
+ }
405
+ if (!RECOVERABLE_PROJECTION_RUN_STATUSES.has(existing.status)) {
406
+ throw new AgentRunProjectionConflictError(expected.id, `existing status is terminal (${existing.status})`);
407
+ }
408
+ }
409
+ /**
410
+ * Create-or-validate a deterministic AgentRun projection for one physical
411
+ * generation of a logical turn. P0 callers omit `attempt_index` and therefore
412
+ * keep the legacy value 1; AttemptAuthority v2 supplies the immutable
413
+ * generation index. Recovery never changes an existing run's generation and
414
+ * never resets an existing live run to `created`.
415
+ */
416
+ export function ensureAgentRunProjection(options, cwd) {
417
+ const normalized = {
418
+ ...options,
419
+ attempt_index: options.attempt_index ?? 1,
420
+ };
421
+ const expected = buildAgentRun(normalized, cwd);
422
+ let created = false;
423
+ let repaired = false;
424
+ const run = mutate({ cwd }, () => {
425
+ const existing = loadAgentRun(options.id, cwd);
426
+ if (existing) {
427
+ const requiredTags = normalized.tags ?? [];
428
+ const missingContractRef = existing.execution_contract_ref === undefined && expected.execution_contract_ref !== undefined;
429
+ const missingCapabilitySnapshot = existing.capability_snapshot === undefined && expected.capability_snapshot !== undefined;
430
+ const missingTags = !requiredTags.every((tag) => existing.tags.includes(tag));
431
+ const enriched = !missingContractRef && !missingCapabilitySnapshot && !missingTags
432
+ ? existing
433
+ : {
434
+ ...existing,
435
+ ...(missingContractRef ? { execution_contract_ref: expected.execution_contract_ref } : {}),
436
+ ...(missingCapabilitySnapshot ? { capability_snapshot: expected.capability_snapshot } : {}),
437
+ tags: [...new Set([...existing.tags, ...requiredTags])],
438
+ };
439
+ assertAgentRunProjectionMatches(enriched, expected);
440
+ if (enriched !== existing) {
441
+ saveAgentRunUnlocked(enriched, cwd);
442
+ repaired = true;
443
+ }
444
+ return enriched;
445
+ }
446
+ saveAgentRunUnlocked(expected, cwd);
447
+ created = true;
448
+ return expected;
449
+ });
450
+ if (created)
451
+ emitAgentRunCreatedSideEffects(run, normalized, cwd);
452
+ return { run, created, ...(repaired ? { repaired: true } : {}) };
453
+ }
231
454
  export function transitionAgentRun(id, newStatus, options = {}, cwd) {
455
+ assertAgentRunMutationAllowed(id, cwd, options.allow_fenced_projection);
232
456
  const run = loadAgentRun(id, cwd);
233
457
  if (!run)
234
458
  throw new Error(`AgentRun not found: ${id}`);
@@ -316,6 +540,7 @@ export function transitionAgentRun(id, newStatus, options = {}, cwd) {
316
540
  return { run, previous_status };
317
541
  }
318
542
  export function recordAgentRunProgress(id, options = {}, cwd) {
543
+ assertAgentRunMutationAllowed(id, cwd);
319
544
  const run = loadAgentRun(id, cwd);
320
545
  if (!run)
321
546
  throw new Error(`AgentRun not found: ${id}`);
@@ -80,6 +80,13 @@ export const AssignmentUpdateRequestSchema = z.object({
80
80
  error_message: z.string().describe('Error details (for failed status).').optional(),
81
81
  blocker: z.string().describe('Blocker description (for blocked status).').optional(),
82
82
  action_required: ActionRequiredSchema.optional(),
83
+ /** AttemptAuthority v2 fence. Required together once the Assignment has a v2 generation chain. */
84
+ turn_id: z.string().optional(),
85
+ run_id: z.string().optional(),
86
+ nonce: z.string().optional(),
87
+ attempt_epoch: z.number().int().nonnegative().optional(),
88
+ execution_contract_hash: z.string().regex(/^[a-f0-9]{64}$/).optional(),
89
+ workspace_digest: z.string().regex(/^[a-f0-9]{64}$/).optional(),
83
90
  ...CallerIdentity,
84
91
  });
85
92
  export const AssignmentActionRequestSchema = z.object({