brainclaw 1.28.0 → 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.
Files changed (39) hide show
  1. package/dist/brainclaw-vscode.vsix +0 -0
  2. package/dist/cli/register-coordination.js +12 -0
  3. package/dist/commands/code-map.js +2 -0
  4. package/dist/commands/doctor.js +1 -0
  5. package/dist/commands/harvest.js +32 -43
  6. package/dist/commands/loop.js +12 -0
  7. package/dist/commands/loops-handlers.js +284 -17
  8. package/dist/commands/mcp-catalog.js +6 -3
  9. package/dist/commands/mcp-write-claims.js +55 -8
  10. package/dist/commands/mcp-write-coordination.js +413 -137
  11. package/dist/commands/mcp.js +32 -4
  12. package/dist/core/actions.js +17 -3
  13. package/dist/core/agentrun-reconciler.js +138 -4
  14. package/dist/core/claims.js +4 -1
  15. package/dist/core/code-map/backend.js +8 -0
  16. package/dist/core/execution-adapters.js +15 -7
  17. package/dist/core/hygiene-policy.js +2 -1
  18. package/dist/core/loop-turn-dispatch.js +18 -1
  19. package/dist/core/loops/attempt-authority.js +22 -4
  20. package/dist/core/loops/attempt-generations.js +17 -4
  21. package/dist/core/loops/attempt-reservation.js +14 -1
  22. package/dist/core/loops/attempt-takeover.js +173 -76
  23. package/dist/core/loops/continuation.js +337 -0
  24. package/dist/core/loops/facade-schema.js +15 -0
  25. package/dist/core/loops/index.js +1 -0
  26. package/dist/core/loops/reconcile-turn.js +224 -26
  27. package/dist/core/loops/result-reducers.js +8 -8
  28. package/dist/core/loops/turn-execution.js +38 -19
  29. package/dist/core/loops/types.js +9 -0
  30. package/dist/core/loops/verbs.js +1 -1
  31. package/dist/core/reviewer-policy.js +39 -0
  32. package/dist/core/schema.js +16 -1
  33. package/dist/facts.js +8 -8
  34. package/dist/facts.json +7 -7
  35. package/docs/cli.md +4 -2
  36. package/docs/code-map.md +10 -0
  37. package/docs/concepts/loop-engine.md +30 -0
  38. package/docs/mcp-schema-changelog.md +6 -1
  39. package/package.json +1 -1
@@ -234,6 +234,19 @@ export const BclawLoopBindSchema = z.object({
234
234
  // No expected_version: bind is idempotent by loop phase (past `bind` → noop), not CAS.
235
235
  ...CallerEnvelopeFields,
236
236
  });
237
+ /**
238
+ * Evaluate and apply one persisted cross-loop continuation. This is an
239
+ * orchestration intent: the downstream mutation still traverses the public
240
+ * `open` and `bind` handlers, never a private Loop-store shortcut.
241
+ */
242
+ export const BclawLoopContinueSchema = z.object({
243
+ intent: z.literal('continue'),
244
+ loop_id: z.string().regex(/^lop_[0-9a-z]+$/),
245
+ action_index: z.number().int().nonnegative().default(0),
246
+ autonomy_mode: z.enum(['autonomous', 'require_approval', 'deny']).default('autonomous'),
247
+ risk: z.enum(['normal', 'protected']).default('normal'),
248
+ ...CallerEnvelopeFields,
249
+ });
237
250
  /**
238
251
  * pln#508 step 2 — `bclaw_loop(intent='request_input')`.
239
252
  *
@@ -306,6 +319,7 @@ export const BclawLoopRequestSchema = z.discriminatedUnion('intent', [
306
319
  BclawLoopCloseSchema,
307
320
  BclawLoopVerifySchema,
308
321
  BclawLoopBindSchema,
322
+ BclawLoopContinueSchema,
309
323
  BclawLoopRequestInputSchema,
310
324
  BclawLoopProvideInputSchema,
311
325
  ]);
@@ -323,6 +337,7 @@ export const BCLAW_LOOP_INTENTS = [
323
337
  'close',
324
338
  'verify',
325
339
  'bind',
340
+ 'continue',
326
341
  'request_input',
327
342
  'provide_input',
328
343
  ];
@@ -21,4 +21,5 @@ export { deriveWorkerReplyContract, renderWorkerReplyProse, workerReplyNextActio
21
21
  export { abortAttempt, inspectAttempt, matchEvidence, prepareAttempt, projectAndCross, revokeAttempt, } from './attempt-authority.js';
22
22
  export { LOOP_KIND_POLICIES, assertLoopKindPoliciesComplete, isWorkerPhase, phasePolicy, policyForKind, } from './kind-policies.js';
23
23
  export { ensureTurnExecutionProjections, prepareTurnExecution, } from './turn-execution.js';
24
+ export { CONTINUATION_POLICY_VERSION, ContinuationDecisionSchema, ContinuationRecordSchema, ContinuationStateSchema, attachContinuationActionRequired, denyContinuation, ensureContinuation, evaluateContinuation, listContinuations, loadContinuation, resumeApprovedContinuation, } from './continuation.js';
24
25
  //# sourceMappingURL=index.js.map
@@ -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
  };