brainclaw 1.28.1 → 1.28.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,11 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { getReservation, evidenceMatchesAttempt, currentNonce, launchGrant, resolveTurnId } from './attempt-reservation.js';
3
+ import { getReservation, evidenceMatchesAttempt, currentNonce, findReservationByAssignmentId, findReservationByRunId, launchGrant, resolveTurnId } from './attempt-reservation.js';
4
4
  import { getLoop } from './store.js';
5
5
  import { completeTurnWithEvidence, addArtifactWithEvidence, complete_turn, advance } from './verbs.js';
6
6
  import { reducerForKind } from './result-reducers.js';
7
7
  import { loadAgentRun, recordExecutionContractAnomaly, transitionAgentRun } from '../agentruns.js';
8
- import { loadAssignment, transitionAssignment } from '../assignments.js';
8
+ import { convergeAssignmentToTerminal, loadAssignment, transitionAssignment } from '../assignments.js';
9
9
  import { loadClaim, releaseClaim, releaseClaimIfActive } from '../claims.js';
10
10
  import { createRuntimeEvent } from '../events.js';
11
11
  import { readCompletionSignals, readContractAck } from '../runtime-signals.js';
@@ -17,6 +17,143 @@ import { executionContractForGeneration, settleActiveAttemptGenerationV2 } from
17
17
  import { fenceForGeneration, resolveTurnGenerationChain } from './attempt-generations.js';
18
18
  import { readLocalAuthorityHome } from './attempt-rollout.js';
19
19
  import { LaneResultSchema } from '../schema.js';
20
+ /** Prefer the current run-scoped runtime channel, with assignment-scoped files
21
+ * retained only as a compatibility fallback for pre-run-keyed dispatches. */
22
+ function readAttemptCompletionSignals(root, assignmentId, runId) {
23
+ const scoped = readCompletionSignals(root, assignmentId, runId);
24
+ const legacy = readCompletionSignals(root, assignmentId);
25
+ return {
26
+ completed: scoped.completed ?? legacy.completed,
27
+ failed: scoped.failed ?? legacy.failed,
28
+ };
29
+ }
30
+ function readAttemptContractAck(root, assignmentId, runId) {
31
+ return readContractAck(root, assignmentId, runId) ?? readContractAck(root, assignmentId);
32
+ }
33
+ /**
34
+ * Resolve the authoritative generation coordinates for a worker result.
35
+ *
36
+ * A file-fallback LANE-RESULT may omit the mechanical fence fields because the
37
+ * wrapper already wrote them to its completion signal. The worker-controlled
38
+ * fields always win when present, so stale/mismatched evidence is still rejected
39
+ * by reconcileTurn. Missing fields are filled only from the reservation's active
40
+ * generation and its run-keyed completion signal.
41
+ */
42
+ export function turnOwnedLaneEvidence(lane, cwd, reservationOverride) {
43
+ const reservation = reservationOverride ?? findReservationByAssignmentId(lane.assignment_id, cwd);
44
+ if (!reservation)
45
+ return undefined;
46
+ const chain = resolveTurnGenerationChain(cwd, reservation.turn_id);
47
+ const generation = chain?.latest_generation;
48
+ const runId = generation?.run_id ?? reservation.child_ids.run_id;
49
+ const completion = readAttemptCompletionSignals(cwd, generation?.assignment_id ?? reservation.child_ids.assignment_id, runId).completed;
50
+ const bootstrapAck = readAttemptContractAck(cwd, generation?.assignment_id ?? reservation.child_ids.assignment_id, runId);
51
+ // The pre-exec bootstrap ACK is the first durable, coordinator-authored proof
52
+ // that this exact launch generation crossed into the worker. Real file-fallback
53
+ // workers commonly write LANE-RESULT before the wrapper can emit its terminal
54
+ // sentinel, and they do not echo the mechanical turn/run/nonce tuple. In that
55
+ // window, enrich only from an ACCEPTED run-keyed ACK; reconcileTurn still checks
56
+ // every coordinate against the active generation, so a rejected/foreign/stale
57
+ // ACK cannot authorize convergence and an explicit lane value always wins.
58
+ const acceptedAck = bootstrapAck?.status === 'accepted' ? bootstrapAck : undefined;
59
+ const nonce = lane.nonce ?? completion?.nonce ?? acceptedAck?.nonce;
60
+ if (!nonce && !reservation.execution_contract_ref)
61
+ return undefined;
62
+ return {
63
+ reservation,
64
+ nonce,
65
+ run_id: runId,
66
+ attempt_epoch: lane.attempt_epoch ?? completion?.attempt_epoch ?? acceptedAck?.attempt_epoch ?? generation?.attempt_epoch,
67
+ workspace_digest: lane.workspace_digest ?? completion?.workspace_digest ?? acceptedAck?.workspace_digest ?? generation?.workspace_digest,
68
+ contract_hash: lane.execution_contract_hash
69
+ ?? completion?.contract_hash
70
+ ?? acceptedAck?.contract_hash
71
+ ?? generation?.contract_hash
72
+ ?? reservation.execution_contract_ref?.hash,
73
+ capability_snapshot_hash: lane.capability_snapshot_hash
74
+ ?? completion?.capability_snapshot_hash
75
+ ?? acceptedAck?.capability_snapshot_hash
76
+ ?? reservation.execution_contract_ref?.snapshot_hash,
77
+ };
78
+ }
79
+ /** Reconcile one parsed worker result through the single AttemptAuthority path. */
80
+ export function reconcileTurnOwnedLane(lane, cwd, evidence, actor) {
81
+ const ev = evidence ?? turnOwnedLaneEvidence(lane, cwd);
82
+ if (!ev)
83
+ return undefined;
84
+ const { reservation } = ev;
85
+ const enrichedLane = {
86
+ ...lane,
87
+ turn_id: lane.turn_id ?? reservation.turn_id,
88
+ run_id: lane.run_id ?? ev.run_id,
89
+ nonce: lane.nonce ?? ev.nonce,
90
+ attempt_epoch: lane.attempt_epoch ?? ev.attempt_epoch,
91
+ workspace_digest: lane.workspace_digest ?? ev.workspace_digest,
92
+ execution_contract_hash: lane.execution_contract_hash ?? ev.contract_hash,
93
+ capability_snapshot_hash: lane.capability_snapshot_hash ?? ev.capability_snapshot_hash,
94
+ };
95
+ const loop = getLoop(reservation.loop_id, cwd);
96
+ const critiques = loop?.kind === 'ideation'
97
+ && reservation.phase === 'critique'
98
+ && lane.artifact_type === 'critique'
99
+ && (lane.body ?? '').trim().length > 0
100
+ ? [{ body: lane.body.trim() }]
101
+ : undefined;
102
+ const result = reconcileTurn({ turn_id: reservation.turn_id, lane: enrichedLane, cwd, critiques, actor });
103
+ return { reservation, result };
104
+ }
105
+ /**
106
+ * Lazy read-path bridge: consume the exact LANE-RESULT owned by one AgentRun.
107
+ * Invalid JSON, foreign assignment ids, stale generation workspaces, or missing
108
+ * turn fences are reported without mutating slot/assignment/run/claim state.
109
+ */
110
+ export function reconcileLaneResultForRun(run, cwd, actor = 'reconciler') {
111
+ if (!run.worktree_path)
112
+ return { found: false, valid: false, reason: 'run has no worktree_path' };
113
+ const resultPath = path.join(run.worktree_path, 'LANE-RESULT.json');
114
+ if (!fs.existsSync(resultPath))
115
+ return { found: false, valid: false, reason: 'LANE-RESULT.json not found' };
116
+ let lane;
117
+ try {
118
+ lane = LaneResultSchema.parse(JSON.parse(fs.readFileSync(resultPath, 'utf8')));
119
+ }
120
+ catch (err) {
121
+ return { found: true, valid: false, reason: `invalid LANE-RESULT.json: ${err instanceof Error ? err.message : String(err)}` };
122
+ }
123
+ if (lane.assignment_id !== run.assignment_id) {
124
+ return { found: true, valid: false, lane, reason: `foreign assignment_id ${lane.assignment_id}; expected ${run.assignment_id}` };
125
+ }
126
+ const reservation = findReservationByRunId(run.id, cwd);
127
+ if (!reservation)
128
+ return { found: true, valid: false, lane, reason: `run ${run.id} has no owning turn reservation` };
129
+ const chain = resolveTurnGenerationChain(cwd, reservation.turn_id);
130
+ const generation = chain?.latest_generation;
131
+ if (generation && generation.run_id !== run.id) {
132
+ return { found: true, valid: false, lane, reservation, reason: `run ${run.id} is not the active generation ${generation.run_id}` };
133
+ }
134
+ if (generation) {
135
+ const actualWorkspace = normalizedWorkspace(run.worktree_path);
136
+ const expectedWorkspace = normalizedWorkspace(generation.workspace_path);
137
+ if (!actualWorkspace || !expectedWorkspace || actualWorkspace !== expectedWorkspace) {
138
+ return { found: true, valid: false, lane, reservation, reason: 'run worktree does not match the active attempt generation workspace' };
139
+ }
140
+ }
141
+ const evidence = turnOwnedLaneEvidence(lane, cwd, reservation);
142
+ if (!evidence) {
143
+ return { found: true, valid: false, lane, reservation, reason: 'LANE-RESULT lacks a run-keyed launch fence and no completion signal supplies one' };
144
+ }
145
+ const reconciled = reconcileTurnOwnedLane(lane, cwd, evidence, actor);
146
+ if (!reconciled)
147
+ return { found: true, valid: false, lane, reservation, reason: 'turn-owned reconciliation unavailable' };
148
+ return {
149
+ found: true,
150
+ valid: reconciled.result.reconciled,
151
+ lane,
152
+ reservation,
153
+ result: reconciled.result,
154
+ reason: reconciled.result.reason,
155
+ };
156
+ }
20
157
  // The terminal loop statuses (LOOP_STATUSES = open|paused|completed|blocked|cancelled).
21
158
  // 'blocked' is LOAD-BEARING (pln#630 PR3b): the iteration cap closes a fix cycle to
22
159
  // `blocked`, and a blocked loop must be treated as terminal both by the idempotent
@@ -49,12 +186,22 @@ function settleRunCompleted(runId, actor, cwd) {
49
186
  function settleAssignment(assignmentId, actor, cwd) {
50
187
  try {
51
188
  const asg = loadAssignment(assignmentId, cwd);
52
- if (asg && asg.status !== 'completed' && asg.status !== 'cancelled') {
189
+ if (!asg || asg.status === 'completed' || asg.status === 'cancelled')
190
+ return;
191
+ if (asg.status === 'expired') {
53
192
  try {
54
193
  transitionAssignment(assignmentId, 'completed', { actor }, cwd);
55
194
  }
56
- catch { /* transition may be illegal from current state — best-effort */ }
195
+ catch { /* concurrent terminal transition */ }
196
+ return;
197
+ }
198
+ if (asg.status === 'created') {
199
+ try {
200
+ transitionAssignment(assignmentId, 'offered', { actor }, cwd);
201
+ }
202
+ catch { /* concurrent transition */ }
57
203
  }
204
+ convergeAssignmentToTerminal(assignmentId, 'completed', 'reconcileTurn: turn-keyed worker result accepted', cwd);
58
205
  }
59
206
  catch { /* best-effort */ }
60
207
  }
@@ -100,6 +247,7 @@ export function reconcileTurn(input) {
100
247
  ? resolvedGeneration.latest_generation
101
248
  : undefined;
102
249
  const activeRunId = activeGeneration?.run_id ?? reservation.child_ids.run_id;
250
+ const activeAssignmentId = activeGeneration?.assignment_id ?? reservation.child_ids.assignment_id;
103
251
  const activeContractRef = activeGeneration
104
252
  ? executionContractForGeneration(reservation, activeGeneration).ref
105
253
  : reservation.execution_contract_ref;
@@ -133,8 +281,8 @@ export function reconcileTurn(input) {
133
281
  };
134
282
  }
135
283
  if (activeContractRef) {
136
- const completion = readCompletionSignals(cwd ?? process.cwd(), reservation.child_ids.assignment_id, activeGeneration?.run_id).completed;
137
- const bootstrapAck = readContractAck(cwd ?? process.cwd(), reservation.child_ids.assignment_id, activeGeneration?.run_id);
284
+ const completion = readAttemptCompletionSignals(cwd ?? process.cwd(), activeAssignmentId, activeRunId).completed;
285
+ const bootstrapAck = readAttemptContractAck(cwd ?? process.cwd(), activeAssignmentId, activeRunId);
138
286
  const accepted = {
139
287
  contract_hash: lane.execution_contract_hash ?? completion?.contract_hash ?? '',
140
288
  capability_snapshot_hash: lane.capability_snapshot_hash ?? completion?.capability_snapshot_hash ?? '',
@@ -177,7 +325,7 @@ export function reconcileTurn(input) {
177
325
  event_type: 'run_blocked',
178
326
  text: `reconcileTurn: post-crossing execution-contract acceptance anomaly for ${turn_id}; convergence WITHHELD and respawn=false`,
179
327
  tags: ['loops', 'reconcile', 'contract-anomaly', 'turn-attempt'],
180
- assignment_id: reservation.child_ids.assignment_id,
328
+ assignment_id: activeAssignmentId,
181
329
  run_id: activeRunId,
182
330
  status_reason: 'execution_contract_acceptance_mismatch',
183
331
  }, cwd);
@@ -198,13 +346,13 @@ export function reconcileTurn(input) {
198
346
  // LANE-RESULT then exited non-zero (turn-keyed failed sentinel) is a conflict,
199
347
  // not a clean close. ──
200
348
  try {
201
- const bodies = readCompletionSignals(cwd ?? process.cwd(), reservation.child_ids.assignment_id, activeGeneration?.run_id);
349
+ const bodies = readAttemptCompletionSignals(cwd ?? process.cwd(), activeAssignmentId, activeRunId);
202
350
  const matchedCompleted = bodies.completed?.status === 'completed' && evidenceMatchesAttempt(reservation, {
203
- assignment_id: reservation.child_ids.assignment_id,
351
+ assignment_id: activeAssignmentId,
204
352
  ...bodies.completed,
205
353
  });
206
354
  const matchedFailed = bodies.failed?.status === 'failed' && evidenceMatchesAttempt(reservation, {
207
- assignment_id: reservation.child_ids.assignment_id,
355
+ assignment_id: activeAssignmentId,
208
356
  ...bodies.failed,
209
357
  });
210
358
  if (matchedFailed && (matchedCompleted || lane.status === 'completed')) {
@@ -214,7 +362,7 @@ export function reconcileTurn(input) {
214
362
  event_type: 'run_blocked',
215
363
  text: `reconcileTurn: turn ${turn_id} has a completed(lane/sentinel)+failed(sentinel) contradiction — auto-stop WITHHELD (§13 R4), escalating to human`,
216
364
  tags: ['loops', 'reconcile', 'conflict', 'turn-attempt'],
217
- assignment_id: reservation.child_ids.assignment_id,
365
+ assignment_id: activeAssignmentId,
218
366
  run_id: activeRunId,
219
367
  status_reason: 'turn_evidence_contradiction',
220
368
  }, cwd);
@@ -307,6 +455,13 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
307
455
  }
308
456
  const acceptedGeneration = generationState?.latest_generation;
309
457
  const acceptedRunId = acceptedGeneration?.run_id ?? reservation.child_ids.run_id;
458
+ const acceptedAssignmentId = acceptedGeneration?.assignment_id ?? reservation.child_ids.assignment_id;
459
+ const acceptedExecutor = acceptedGeneration?.executor ?? {
460
+ agent: reservation.agent,
461
+ agent_id: reservation.agent_id,
462
+ claim_id: reservation.claim_id,
463
+ capability_snapshot: reservation.capability_snapshot,
464
+ };
310
465
  const acceptedNonce = acceptedGeneration?.launch_nonce ?? reservation.launch?.token;
311
466
  const acceptedEpoch = acceptedGeneration?.attempt_epoch ?? reservation.epoch;
312
467
  const acceptedContractHash = acceptedGeneration?.contract_hash
@@ -330,7 +485,7 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
330
485
  // transition but BEFORE its own deferred release would otherwise leak the retained claim
331
486
  // until the staleness sweep — releaseCoordinatorClaim is idempotent (no-op if not active).
332
487
  if (LOOP_TERMINAL.has(loop.status)) {
333
- releaseCoordinatorClaim(loadAssignment(reservation.child_ids.assignment_id, cwd)?.claim_id ?? reservation.claim_id, cwd);
488
+ releaseCoordinatorClaim(loadAssignment(acceptedAssignmentId, cwd)?.claim_id ?? acceptedExecutor.claim_id, cwd);
334
489
  return { reconciled: true, reason: `loop already ${loop.status} (idempotent no-op)`, artifacts_added: 0, loop_status: loop.status };
335
490
  }
336
491
  // ── Idempotency (review Findings 1+2): a TERMINAL slot durably means this turn's
@@ -366,13 +521,13 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
366
521
  evidence_context: {
367
522
  channel: 'reconcile_turn',
368
523
  producer_kind: 'slot',
369
- producer_id: reservation.agent,
370
- agent_id: reservation.agent_id,
524
+ producer_id: acceptedExecutor.agent,
525
+ agent_id: acceptedExecutor.agent_id,
371
526
  slot_id: slot.slot_id,
372
527
  slot_role: slot.role,
373
528
  turn_id,
374
- assignment_id: reservation.child_ids.assignment_id,
375
- claim_id: reservation.claim_id,
529
+ assignment_id: acceptedAssignmentId,
530
+ claim_id: acceptedExecutor.claim_id,
376
531
  run_id: acceptedRunId,
377
532
  nonce: acceptedNonce,
378
533
  attempt_epoch: acceptedEpoch,
@@ -398,13 +553,13 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
398
553
  evidence_context: {
399
554
  channel: 'reconcile_turn',
400
555
  producer_kind: 'slot',
401
- producer_id: reservation.agent,
402
- agent_id: reservation.agent_id,
556
+ producer_id: acceptedExecutor.agent,
557
+ agent_id: acceptedExecutor.agent_id,
403
558
  slot_id: slot.slot_id,
404
559
  slot_role: slot.role,
405
560
  turn_id,
406
- assignment_id: reservation.child_ids.assignment_id,
407
- claim_id: reservation.claim_id,
561
+ assignment_id: acceptedAssignmentId,
562
+ claim_id: acceptedExecutor.claim_id,
408
563
  run_id: acceptedRunId,
409
564
  nonce: acceptedNonce,
410
565
  attempt_epoch: acceptedEpoch,
@@ -435,7 +590,7 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
435
590
  event_type: 'loop_artifact_harvested',
436
591
  text: `reconcileTurn: harvested turn ${turn_id} on slot ${slot.slot_id} → loop ${loop.id} (${slot_outcome}, ${artifacts_added} artifact(s), phase ${reservation.phase})`,
437
592
  tags: ['loops', 'reconcile', 'harvest', 'turn-attempt'],
438
- assignment_id: reservation.child_ids.assignment_id,
593
+ assignment_id: acceptedAssignmentId,
439
594
  run_id: acceptedRunId,
440
595
  attempt_epoch: acceptedGeneration?.attempt_epoch,
441
596
  workspace_digest: acceptedGeneration?.workspace_digest,
@@ -452,7 +607,7 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
452
607
  // (a crash that recorded the turn but not the settle still converges on replay; the
453
608
  // fix-cycle re-dispatch mints a fresh run/assignment, so completing the old is correct). ──
454
609
  settleRunCompleted(acceptedRunId, actor, cwd);
455
- settleAssignment(reservation.child_ids.assignment_id, actor, cwd);
610
+ settleAssignment(acceptedAssignmentId, actor, cwd);
456
611
  // ── Advance / stop decision. On a `done` outcome we either drive a deterministic stop
457
612
  // (reviewer_green / gate → close), continue a symmetric fix cycle (bump the round + retain
458
613
  // the claim + emit next_turn), or leave the loop open (asymmetric / no successor). ──
@@ -530,7 +685,7 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
530
685
  event_type: 'run_blocked',
531
686
  text: `reconcileTurn: fix-cycle round ${cur.iteration_count} of loop ${loop.id} was bumped but never dispatched (turn ${turn_id} strand) — re-emitting next_turn to self-heal`,
532
687
  tags: ['loops', 'reconcile', 'turn-owned', 'strand-recovery'],
533
- assignment_id: reservation.child_ids.assignment_id,
688
+ assignment_id: acceptedAssignmentId,
534
689
  run_id: acceptedRunId,
535
690
  status_reason: 'fix_cycle_strand_reemit',
536
691
  }, cwd);
@@ -560,7 +715,7 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
560
715
  // Release the coordinator claim now — UNLESS a fix-cycle round retained it. Target the
561
716
  // authoritative claim the assignment is bound to, not reservation.claim_id (dec#149 #3).
562
717
  if (!retainClaim) {
563
- const authoritativeClaimId = loadAssignment(reservation.child_ids.assignment_id, cwd)?.claim_id ?? reservation.claim_id;
718
+ const authoritativeClaimId = loadAssignment(acceptedAssignmentId, cwd)?.claim_id ?? acceptedExecutor.claim_id;
564
719
  releaseCoordinatorClaim(authoritativeClaimId, cwd);
565
720
  }
566
721
  const loop_status = getLoop(loop.id, cwd)?.status ?? loop.status;
@@ -661,7 +816,7 @@ function convergeFailedLockedTurn(reservation, run, transportReason, actor, cwd)
661
816
  event_type: 'run_failed',
662
817
  text: `Released claim ${claimId} as the BUSINESS convergence of failed turn ${reservation.turn_id}: ${why}`,
663
818
  tags: ['loops', 'reconcile', 'claim-release', 'effects-boundary'],
664
- assignment_id: reservation.child_ids.assignment_id,
819
+ assignment_id: run.assignment_id,
665
820
  run_id: run.id,
666
821
  claim_id: claimId,
667
822
  status_reason: 'turn_failure_business_release',
@@ -676,7 +831,7 @@ function convergeFailedLockedTurn(reservation, run, transportReason, actor, cwd)
676
831
  // A genuinely deleted loop leaves the claim to the staleness sweep.
677
832
  return { converged: false, claim_released: false, reason: `loop ${reservation.loop_id} not found — claim retained for the staleness sweep` };
678
833
  }
679
- const authoritativeClaimId = loadAssignment(reservation.child_ids.assignment_id, cwd)?.claim_id ?? reservation.claim_id;
834
+ const authoritativeClaimId = loadAssignment(run.assignment_id, cwd)?.claim_id ?? run.claim_id ?? reservation.claim_id;
680
835
  // Terminal loop: the business story is already over — releasing is pure
681
836
  // idempotent cleanup, mirroring reconcileTurn's terminal early-return.
682
837
  if (LOOP_TERMINAL.has(loop.status)) {
@@ -711,6 +866,49 @@ function convergeFailedLockedTurn(reservation, run, transportReason, actor, cwd)
711
866
  return { converged: false, claim_released: false, reason: `complete_turn failed: ${err instanceof Error ? err.message : String(err)} — claim retained, next pass retries` };
712
867
  }
713
868
  }
869
+ // Assignment is a business projection of the same failed turn. Leaving it
870
+ // offered/started while the run, slot, and claim are terminal recreates the
871
+ // orphaned-state ambiguity this convergence path exists to remove.
872
+ let assignment = loadAssignment(run.assignment_id, cwd);
873
+ if (assignment && !['completed', 'failed', 'blocked', 'timed_out', 'cancelled', 'expired', 'rerouted'].includes(assignment.status)) {
874
+ const assignmentId = assignment.id;
875
+ try {
876
+ // A running AgentRun is stronger execution evidence than a lagging
877
+ // Assignment projection. Legacy/file workers can leave that projection
878
+ // at created or accepted; walk the legal FSM edges before recording the
879
+ // terminal failure instead of declining forever on created -> failed or
880
+ // accepted -> failed. Each edge is durable, so a crash resumes from the
881
+ // last projection on the next lazy pass.
882
+ if (assignment.status === 'created' || assignment.status === 'retrying') {
883
+ transitionAssignment(assignmentId, 'offered', {
884
+ actor,
885
+ syncAgentRun: false,
886
+ status_reason: `turn ${reservation.turn_id} failure projection catch-up`,
887
+ }, cwd);
888
+ assignment = loadAssignment(assignmentId, cwd);
889
+ }
890
+ if (assignment?.status === 'accepted') {
891
+ transitionAssignment(assignmentId, 'started', {
892
+ actor,
893
+ syncAgentRun: false,
894
+ status_reason: `turn ${reservation.turn_id} failure projection catch-up`,
895
+ }, cwd);
896
+ assignment = loadAssignment(assignmentId, cwd);
897
+ }
898
+ transitionAssignment(assignmentId, 'failed', {
899
+ actor,
900
+ syncAgentRun: false,
901
+ status_reason: `turn ${reservation.turn_id} failed: ${transportReason}`,
902
+ }, cwd);
903
+ }
904
+ catch (err) {
905
+ return {
906
+ converged: false,
907
+ claim_released: false,
908
+ reason: `assignment failure projection failed: ${err instanceof Error ? err.message : String(err)} — claim retained, next pass retries`,
909
+ };
910
+ }
911
+ }
714
912
  const released = releaseAudited(authoritativeClaimId, transportReason);
715
913
  return {
716
914
  converged: true,
@@ -6,7 +6,7 @@ import { LOOP_ARTIFACT_BODY_MAX_BYTES } from './types.js';
6
6
  * would make complete_turn throw and crash reconcileTurn. Byte-aware, drops any
7
7
  * partial trailing multibyte char.
8
8
  */
9
- function capBody(s) {
9
+ export function capLoopArtifactBody(s) {
10
10
  if (Buffer.byteLength(s, 'utf8') <= LOOP_ARTIFACT_BODY_MAX_BYTES)
11
11
  return s;
12
12
  const marker = '…[truncated]';
@@ -35,7 +35,7 @@ export const reviewReducer = (input, attempt) => {
35
35
  return { artifacts: [], slot_outcome: 'failed', failure_reason: 'review author_response produced no body' };
36
36
  }
37
37
  return {
38
- artifacts: [{ phase, type: 'author_response', body: capBody(response), produced_by: attempt.agent }],
38
+ artifacts: [{ phase, type: 'author_response', body: capLoopArtifactBody(response), produced_by: attempt.agent }],
39
39
  slot_outcome: 'done',
40
40
  };
41
41
  }
@@ -46,7 +46,7 @@ export const reviewReducer = (input, attempt) => {
46
46
  return { artifacts: [], slot_outcome: 'failed', failure_reason: 'review lane completed without a review_verdict — cannot converge the loop' };
47
47
  }
48
48
  const summary = (lane.review_summary ?? '').trim();
49
- const body = capBody(lane.review_verdict === 'approve'
49
+ const body = capLoopArtifactBody(lane.review_verdict === 'approve'
50
50
  ? `accepted${summary ? `: ${summary}` : ''}`
51
51
  : `changes-requested${summary ? `: ${summary}` : ''}`);
52
52
  return {
@@ -96,7 +96,7 @@ export const ideationReducer = (input, attempt) => {
96
96
  artifacts: [{
97
97
  phase,
98
98
  type: artifactType,
99
- body: capBody(body),
99
+ body: capLoopArtifactBody(body),
100
100
  produced_by: attempt.agent,
101
101
  addresses_critique: uniqueAddresses,
102
102
  implementation_verify: lane.implementation_verify,
@@ -104,7 +104,7 @@ export const ideationReducer = (input, attempt) => {
104
104
  slot_outcome: 'done',
105
105
  };
106
106
  }
107
- return { artifacts: [{ phase, type: artifactType, body: capBody(body), produced_by: attempt.agent }], slot_outcome: 'done' };
107
+ return { artifacts: [{ phase, type: artifactType, body: capLoopArtifactBody(body), produced_by: attempt.agent }], slot_outcome: 'done' };
108
108
  }
109
109
  if (lane.artifact_type !== 'critique') {
110
110
  return { artifacts: [], slot_outcome: 'failed', failure_reason: "ideation critique requires artifact_type 'critique'" };
@@ -114,7 +114,7 @@ export const ideationReducer = (input, attempt) => {
114
114
  }
115
115
  return {
116
116
  artifacts: critiques.map((c) => ({
117
- phase, type: 'critique', body: capBody(c.body), produced_by: attempt.agent,
117
+ phase, type: 'critique', body: capLoopArtifactBody(c.body), produced_by: attempt.agent,
118
118
  ...(c.addresses_critique ? { addresses_critique: c.addresses_critique } : {}),
119
119
  })),
120
120
  slot_outcome: 'done',
@@ -131,7 +131,7 @@ export const defaultReducer = (input, attempt) => {
131
131
  return { artifacts: [], slot_outcome: 'failed', failure_reason: `lane status is ${lane.status}, not completed` };
132
132
  }
133
133
  return {
134
- artifacts: [{ phase, type: 'lane_result', body: capBody(lane.summary), produced_by: attempt.agent }],
134
+ artifacts: [{ phase, type: 'lane_result', body: capLoopArtifactBody(lane.summary), produced_by: attempt.agent }],
135
135
  slot_outcome: 'done',
136
136
  };
137
137
  };
@@ -159,7 +159,7 @@ function typedPhaseReducer(kind, artifactByPhase) {
159
159
  return { artifacts: [], slot_outcome: 'failed', failure_reason: `${kind} phase '${phase}' produced no artifact body` };
160
160
  }
161
161
  return {
162
- artifacts: [{ phase, type: expectedType, body: capBody(body), produced_by: attempt.agent }],
162
+ artifacts: [{ phase, type: expectedType, body: capLoopArtifactBody(body), produced_by: attempt.agent }],
163
163
  slot_outcome: 'done',
164
164
  };
165
165
  };
@@ -13,22 +13,23 @@ import { getLoop } from './store.js';
13
13
  import { phasePolicy } from './kind-policies.js';
14
14
  /** Idempotently materialize every child projection required before launch. */
15
15
  export function ensureTurnExecutionProjections(reservation, input, cwd) {
16
- if (reservation.claim_id === '')
16
+ const claimId = input.claim_id ?? reservation.claim_id;
17
+ if (claimId === '')
17
18
  throw new Error('attempt reservation has no claim');
18
19
  ensureAssignmentProjection({
19
20
  id: input.assignment_id,
20
21
  short_label: input.assignment_id,
21
- claim_id: reservation.claim_id,
22
+ claim_id: claimId,
22
23
  agent: input.agent,
23
24
  agent_id: input.agent_id,
24
25
  dispatcher_agent: input.dispatcher_agent,
25
26
  dispatcher_session_id: input.dispatcher_session_id,
26
27
  scope: input.scope,
27
28
  description: input.description,
28
- // Assignment is the stable logical attempt. Its contract projection stays
29
- // generation-zero; each physical AgentRun below carries its own contract.
30
- execution_contract_ref: reservation.execution_contract_ref,
31
- capability_snapshot: reservation.capability_snapshot,
29
+ // Assignment is generation-scoped. Generation zero preserves the historic
30
+ // deterministic id; every takeover projects a fresh assignment and contract.
31
+ execution_contract_ref: input.execution_contract_ref ?? reservation.execution_contract_ref,
32
+ capability_snapshot: input.capability_snapshot ?? reservation.capability_snapshot,
32
33
  tags: input.assignment_tags ?? ['coordinate', 'loop', 'turn-owned'],
33
34
  }, cwd);
34
35
  input.on_projection?.('assignment');
@@ -36,7 +37,7 @@ export function ensureTurnExecutionProjections(reservation, input, cwd) {
36
37
  id: input.run_id,
37
38
  short_label: input.run_id,
38
39
  assignment_id: input.assignment_id,
39
- claim_id: reservation.claim_id,
40
+ claim_id: claimId,
40
41
  attempt_index: input.attempt_index ?? 1,
41
42
  agent: input.agent,
42
43
  agent_id: input.agent_id,
@@ -50,7 +51,7 @@ export function ensureTurnExecutionProjections(reservation, input, cwd) {
50
51
  tags: input.run_tags ?? ['turn-owned', 'loop'],
51
52
  }, cwd);
52
53
  input.on_projection?.('run');
53
- ensureClaimAssignmentBinding(reservation.claim_id, input.assignment_id, cwd, {
54
+ ensureClaimAssignmentBinding(claimId, input.assignment_id, cwd, {
54
55
  worktreePath: input.worktree_path,
55
56
  });
56
57
  input.on_projection?.('claim_binding');
@@ -61,7 +62,7 @@ export function ensureTurnExecutionProjections(reservation, input, cwd) {
61
62
  input: input.task,
62
63
  turn_id: input.turn_id,
63
64
  assignment_id: input.assignment_id,
64
- claim_id: reservation.claim_id,
65
+ claim_id: claimId,
65
66
  }, cwd);
66
67
  input.on_projection?.('slot_binding');
67
68
  input.on_projection?.('before_crossing');
@@ -71,7 +72,11 @@ function preconditionDenied(reason) {
71
72
  }
72
73
  function authorityDenied(input, turnId, reason) {
73
74
  const reservation = getReservation(turnId, input.cwd);
74
- const ownsAuthority = reservation?.claim_id === input.claim_id;
75
+ const chain = resolveTurnGenerationChain(input.cwd, turnId);
76
+ const authorityClaimId = chain?.status === 'active'
77
+ ? chain.latest_generation.executor?.claim_id ?? reservation?.claim_id
78
+ : reservation?.claim_id;
79
+ const ownsAuthority = authorityClaimId === input.claim_id;
75
80
  const crossed = reservation?.launch?.status === 'crossed';
76
81
  return {
77
82
  kind: 'denied',
@@ -129,13 +134,19 @@ export function prepareTurnExecution(input) {
129
134
  }, input.cwd);
130
135
  const childIds = deriveChildIds(turnId);
131
136
  const existingReservation = getReservation(turnId, input.cwd);
137
+ const v2 = resolveTurnGenerationChain(input.cwd, turnId);
138
+ const activeExecutor = v2?.status === 'active' && v2.latest_generation.attempt_epoch > 0
139
+ ? v2.latest_generation.executor
140
+ : undefined;
132
141
  if (slot.agent !== undefined && slot.agent !== input.agent) {
133
142
  return preconditionDenied(`slot ${input.slot_id} belongs to agent '${slot.agent}', not '${input.agent}'`);
134
143
  }
135
144
  if (slot.agent_id !== undefined && slot.agent_id !== input.agent_id) {
136
145
  return preconditionDenied(`slot ${input.slot_id} belongs to agent_id '${slot.agent_id}', not '${input.agent_id ?? 'none'}'`);
137
146
  }
138
- if (slot.claim_id !== undefined && slot.claim_id !== input.claim_id) {
147
+ if (slot.claim_id !== undefined
148
+ && slot.claim_id !== input.claim_id
149
+ && ['assigned', 'working', 'waiting_input'].includes(slot.status)) {
139
150
  return preconditionDenied(`slot ${input.slot_id} is bound to claim ${slot.claim_id}, not ${input.claim_id}`);
140
151
  }
141
152
  if (slot.current_turn_id !== undefined
@@ -202,13 +213,15 @@ export function prepareTurnExecution(input) {
202
213
  catch (error) {
203
214
  return preconditionDenied(error instanceof Error ? error.message : String(error));
204
215
  }
205
- const frozenHarnessBinding = existingReservation?.capability_snapshot?.resolved.harness;
216
+ const frozenHarnessBinding = activeExecutor?.capability_snapshot.resolved.harness
217
+ ?? existingReservation?.capability_snapshot?.resolved.harness;
206
218
  if (frozenHarnessBinding && JSON.stringify(frozenHarnessBinding) !== JSON.stringify(requestedHarnessBinding)) {
207
219
  return preconditionDenied(`harness binding differs from immutable capability snapshot: frozen `
208
220
  + `${frozenHarnessBinding.adapter_id}@${frozenHarnessBinding.adapter_version}, requested `
209
221
  + `${requestedHarnessBinding.adapter_id}@${requestedHarnessBinding.adapter_version}`);
210
222
  }
211
- const capabilitySnapshot = existingReservation?.capability_snapshot
223
+ const capabilitySnapshot = activeExecutor?.capability_snapshot
224
+ ?? existingReservation?.capability_snapshot
212
225
  ?? resolveCapabilitySnapshot(input.agent, capabilityRequirement, input.agent_id, requestedHarnessBinding);
213
226
  if (!capabilitySnapshot.accepted) {
214
227
  const reasons = capabilitySnapshot.reasons.map((reason) => reason.code).join(', ');
@@ -256,7 +269,6 @@ export function prepareTurnExecution(input) {
256
269
  const contractRef = contract
257
270
  ? (existingReservation?.execution_contract_ref ?? executionContractRef(contract, capabilitySnapshot))
258
271
  : undefined;
259
- const v2 = resolveTurnGenerationChain(input.cwd, turnId);
260
272
  const activeRollout = resolveActiveAttemptRollout(input.cwd);
261
273
  const localHome = readLocalAuthorityHome(input.cwd);
262
274
  if (activeRollout && (!localHome || !contractRef)) {
@@ -339,10 +351,16 @@ export function prepareTurnExecution(input) {
339
351
  try {
340
352
  const generation = v2.latest_generation;
341
353
  const generationContract = executionContractForGeneration(reservation, generation);
354
+ const executor = generation.executor ?? {
355
+ agent: reservation.agent,
356
+ agent_id: reservation.agent_id,
357
+ claim_id: reservation.claim_id,
358
+ capability_snapshot: reservation.capability_snapshot,
359
+ };
342
360
  let accepted;
343
361
  try {
344
362
  accepted = input.accepted_execution_contract
345
- ?? attestHarnessContractAcceptance(generationContract.ref, reservation.capability_snapshot, requestedHarnessBinding);
363
+ ?? attestHarnessContractAcceptance(generationContract.ref, executor.capability_snapshot, requestedHarnessBinding);
346
364
  }
347
365
  catch (error) {
348
366
  return preconditionDenied(`worker contract acceptance unavailable before crossing: ${error instanceof Error ? error.message : String(error)}`);
@@ -357,8 +375,9 @@ export function prepareTurnExecution(input) {
357
375
  turn_id: turnId,
358
376
  assignment_id: generation.assignment_id,
359
377
  run_id: generation.run_id,
360
- agent: input.agent,
361
- agent_id: input.agent_id,
378
+ claim_id: executor.claim_id,
379
+ agent: executor.agent,
380
+ agent_id: executor.agent_id,
362
381
  dispatcher_agent: input.dispatcher_agent,
363
382
  dispatcher_agent_id: input.dispatcher_agent_id,
364
383
  dispatcher_session_id: input.dispatcher_session_id,
@@ -370,7 +389,7 @@ export function prepareTurnExecution(input) {
370
389
  run_tags: [...(input.run_tags ?? ['turn-owned', 'loop']), `attempt-generation:${generation.attempt_epoch}`],
371
390
  attempt_index: generation.attempt_epoch + 1,
372
391
  execution_contract_ref: generationContract.ref,
373
- capability_snapshot: reservation.capability_snapshot,
392
+ capability_snapshot: executor.capability_snapshot,
374
393
  on_projection: input.on_projection,
375
394
  }, input.cwd);
376
395
  const crossing = crossActiveAttemptGenerationV2(turnId, generation.attempt_epoch, localHome, input.dispatcher_agent_id ?? input.dispatcher_agent, input.dispatcher_agent_id ?? input.dispatcher_agent, input.cwd);
@@ -387,7 +406,7 @@ export function prepareTurnExecution(input) {
387
406
  workspace_path: generation.workspace_path,
388
407
  contract_status: 'contracted',
389
408
  execution_contract_ref: generationContract.ref,
390
- capability_snapshot: reservation.capability_snapshot,
409
+ capability_snapshot: executor.capability_snapshot,
391
410
  };
392
411
  }
393
412
  catch (error) {
@@ -734,6 +734,9 @@ export const LoopEventSchema = z.discriminatedUnion('kind', [
734
734
  slot_id: z.string().min(1),
735
735
  turn_id: z.string().min(1),
736
736
  assignment_id: z.string().min(1),
737
+ claim_id: z.string().min(1).optional(),
738
+ agent: z.string().min(1).optional(),
739
+ agent_id: z.string().min(1).optional(),
737
740
  from_epoch: z.number().int().nonnegative(),
738
741
  to_epoch: z.number().int().positive(),
739
742
  from_run_id: z.string().min(1),
@@ -586,7 +586,7 @@ function authorizeCompleteTurnAttempt(input, slot, cwd) {
586
586
  slot_role: slot.role,
587
587
  turn_id: generation.turn_id,
588
588
  assignment_id: generation.assignment_id,
589
- claim_id: reservation.claim_id ?? slot.claim_id,
589
+ claim_id: generation.executor?.claim_id ?? slot.claim_id ?? reservation.claim_id,
590
590
  run_id: generation.run_id,
591
591
  nonce: generation.launch_nonce,
592
592
  attempt_epoch: generation.attempt_epoch,