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
@@ -12,6 +12,7 @@
12
12
  * @module
13
13
  */
14
14
  import crypto from 'node:crypto';
15
+ import { spawnSync } from 'node:child_process';
15
16
  import { buildClaimEnvPrefix } from '../core/execution-profile.js';
16
17
  import { resolveProjectCwd } from '../core/cross-project.js';
17
18
  import { attachAssignmentMessageToClaim, createCoordinatorClaim, linkClaimToAssignment, listClaims, releaseClaimIfActive, saveClaim, } from '../core/claims.js';
@@ -20,6 +21,8 @@ import { appendAuditEntry } from '../core/audit.js';
20
21
  import { nowISO } from '../core/ids.js';
21
22
  import { validateMcpField } from '../core/input-validation.js';
22
23
  import { generateCandidateIdWithLabel, saveCandidate } from '../core/candidates.js';
24
+ import { DEFAULT_PROTOCOLS } from '../core/loops/types.js';
25
+ import { capLoopArtifactBody } from '../core/loops/result-reducers.js';
23
26
  import { validateLoopProjectResolution } from '../core/loops/project-resolution.js';
24
27
  import { coordinateNextActions, dispatchNextActions } from '../core/next-actions.js';
25
28
  import { agentValidationFailedWarning, consultAutoExecuteNoOpWarning, planAlreadyAssignedWarning, pushStructuredWarning, scopeAlreadyClaimedWarning, } from '../core/warnings.js';
@@ -32,8 +35,14 @@ import { attemptExecution } from '../core/execution.js';
32
35
  import { createAgentRun, transitionAgentRun } from '../core/agentruns.js';
33
36
  import { prepareTurnOwnedReviewDispatch, turnOwnedReviewEnabled } from '../core/review-loop-turn-dispatch.js';
34
37
  import { prepareTurnExecution } from '../core/loops/turn-execution.js';
38
+ import { findReservationByAssignmentId } from '../core/loops/attempt-reservation.js';
39
+ import { resolveTurnGenerationChain } from '../core/loops/attempt-generations.js';
40
+ import { readLocalAuthorityHome } from '../core/loops/attempt-rollout.js';
41
+ import { AttemptTakeoverCommittedError, takeoverLoopAttempt } from '../core/loops/attempt-takeover.js';
42
+ import { getLoop as getLoopThread } from '../core/loops/store.js';
35
43
  import { removeWorktree } from '../core/worktree.js';
36
- import { createAssignment, generateAssignmentId, patchAssignmentMessageId, transitionAssignment, } from '../core/assignments.js';
44
+ import { resolveCapabilitySnapshot } from '../core/execution-contract.js';
45
+ import { createAssignment, generateAssignmentId, listAssignments, patchAssignmentMessageId, transitionAssignment, } from '../core/assignments.js';
37
46
  import { createToolErrorResponse, toolResponse, } from './mcp-contract.js';
38
47
  import { handleMcpReadToolCall } from './mcp-read-handlers.js';
39
48
  import { ensureTrust } from './mcp-write-support.js';
@@ -383,6 +392,88 @@ export async function handleBclawCoordinate(args, ctx) {
383
392
  warnings.push(`cross-project dispatch (project='${req.project}') — auto-spawn disabled; the target agent picks up the brief async via its own bclaw_work.`);
384
393
  }
385
394
  const effectiveAutoExecute = isCrossProject ? false : req.autoExecute;
395
+ // pln#692 P0 — an explicit checkout ref is part of admission, not worktree
396
+ // creation. Validate it before releasing a reroute predecessor (and before
397
+ // every other worktree-producing mutation). A claim id is not a Git ref.
398
+ const refCreatesWorktree = ['assign', 'review', 'reroute'].includes(req.intent)
399
+ || (req.intent === 'ideate' && Array.isArray(req.targetAgents) && req.targetAgents.length > 0 && req.preset !== 'bootstrap');
400
+ if (req.ref && refCreatesWorktree && !isCrossProject) {
401
+ const refCheck = spawnSync('git', ['rev-parse', '--verify', '--quiet', `${req.ref}^{commit}`], {
402
+ cwd: dispatchCwd, encoding: 'utf8', windowsHide: true,
403
+ });
404
+ if (refCheck.status !== 0) {
405
+ return {
406
+ response: createToolErrorResponse('invalid_dispatch_ref', `dispatch admission refused before mutation: ref "${req.ref}" does not resolve to a commit`, {
407
+ ref: req.ref,
408
+ blocker: 'worktree base must be a valid commit, branch, or tag',
409
+ next_actions: [{
410
+ tool: 'bclaw_coordinate',
411
+ args: { intent: req.intent, task: req.task, scope: req.scope, targetAgents: req.targetAgents },
412
+ when: 'retry without ref to use HEAD, or supply a Git ref that resolves to a commit',
413
+ }],
414
+ }),
415
+ };
416
+ }
417
+ }
418
+ // pln#692 P0 — admission must prove that a multi-agent ideation request can
419
+ // satisfy the first worker-produced phase gate BEFORE openLoop (or any
420
+ // identity/claim/assignment mutation). The default critique phase requires
421
+ // three distinct critique artifacts, so accepting one executable critic
422
+ // creates a loop that cannot converge without manual artifact injection.
423
+ if (req.intent === 'ideate'
424
+ && req.preset !== 'bootstrap'
425
+ && Array.isArray(req.targetAgents)
426
+ && req.targetAgents.length > 0) {
427
+ const critiquePhase = DEFAULT_PROTOCOLS.ideation.phases.find((phase) => phase.name === 'critique');
428
+ const gate = critiquePhase?.advance_gate;
429
+ const requiredCritics = gate?.kind === 'min_artifacts_by_type' ? gate.n : 0;
430
+ const uniqueTargets = [...new Set(req.targetAgents)];
431
+ const checks = uniqueTargets.map((agent) => ({
432
+ agent,
433
+ check: validateAgentForDispatch(agent, { requireSpawnable: true }),
434
+ }));
435
+ const executableTargets = checks.filter(({ check }) => check.valid).map(({ agent }) => agent);
436
+ const invalidTargets = checks.filter(({ check }) => !check.valid).map(({ agent, check }) => ({
437
+ agent,
438
+ code: check.code,
439
+ reason: check.reason,
440
+ }));
441
+ if (requiredCritics > 0 && executableTargets.length < requiredCritics) {
442
+ const availableTargets = getSpawnableAgents()
443
+ .map((profile) => profile.name)
444
+ .filter((agent, index, all) => agent !== senderAgent && all.indexOf(agent) === index)
445
+ .filter((agent) => validateAgentForDispatch(agent, { requireSpawnable: true }).valid);
446
+ const recoveryTargets = availableTargets.slice(0, requiredCritics);
447
+ const nextActions = recoveryTargets.length >= requiredCritics
448
+ ? [{
449
+ tool: 'bclaw_coordinate',
450
+ args: {
451
+ intent: 'ideate', task: req.task, scope: req.scope,
452
+ targetAgents: recoveryTargets, autoExecute: effectiveAutoExecute !== false,
453
+ },
454
+ when: `retry with at least ${requiredCritics} distinct executable critics`,
455
+ }]
456
+ : [{
457
+ tool: 'bclaw_context',
458
+ args: { kind: 'execution', includeAgentTooling: true },
459
+ when: `configure at least ${requiredCritics} spawnable critic identities before retrying`,
460
+ }];
461
+ return {
462
+ response: createToolErrorResponse('ideate_gate_capacity_unavailable', `ideation admission refused before mutation: critique gate requires ${requiredCritics} distinct executable critic(s), observed ${executableTargets.length}`, {
463
+ gate: { phase: 'critique', kind: gate?.kind, expected: requiredCritics, observed: executableTargets.length },
464
+ requested_targets: req.targetAgents,
465
+ executable_targets: executableTargets,
466
+ invalid_targets: invalidTargets,
467
+ blockers: [
468
+ ...(uniqueTargets.length < req.targetAgents.length ? ['duplicate target identities do not add executable capacity'] : []),
469
+ ...(invalidTargets.length > 0 ? ['one or more requested targets are not spawnable'] : []),
470
+ `missing executable critic capacity: ${requiredCritics - executableTargets.length}`,
471
+ ],
472
+ next_actions: nextActions,
473
+ }),
474
+ };
475
+ }
476
+ }
386
477
  // pln#521 P1 — project resolution gate. A review loop written into the wrong
387
478
  // store is worse than one that never opened: candidate, claim, assignment and
388
479
  // loop all persist where nobody is watching, and the reviewer spawns against
@@ -1279,166 +1370,351 @@ export async function handleBclawCoordinate(args, ctx) {
1279
1370
  ...(reviewExecStatus ? { execution_status: reviewExecStatus } : {}),
1280
1371
  };
1281
1372
  }
1282
- else if (req.intent === 'reroute') {
1283
- const activeClaims = listClaims(dispatchCwd).filter((c) => c.status === 'active' && (req.scope ? c.scope === req.scope : true));
1284
- if (activeClaims.length === 0) {
1285
- return { response: createToolErrorResponse('not_found', `No active claim found for scope: ${req.scope ?? '(any)'}`) };
1286
- }
1287
- const oldClaim = activeClaims[0];
1288
- saveClaim({ ...oldClaim, status: 'released', released_at: nowISO() }, dispatchCwd);
1289
- appendAuditEntry({ actor: oldClaim.agent, action: 'release_claim', item_id: oldClaim.id, item_type: 'claim', scope: oldClaim.scope }, dispatchCwd);
1290
- side_effects.push({ action: 'release', entity: 'claim', id: oldClaim.id });
1291
- // trp#61: supersede assignments attached to the old claim so they
1292
- // don't linger in `created`/`offered`/etc. Prior behaviour only
1293
- // released the claim, leaving the assignment FSM stuck and confusing
1294
- // dispatch analysis / review.
1295
- const { listAssignments: listAsgn } = await import('../core/assignments.js');
1296
- const predecessors = listAsgn(dispatchCwd, { claim_id: oldClaim.id })
1297
- .filter((a) => a.status !== 'completed' && a.status !== 'cancelled' && a.status !== 'expired' && a.status !== 'rerouted');
1298
- for (const predecessor of predecessors) {
1299
- try {
1300
- transitionAssignment(predecessor.id, 'rerouted', {
1301
- actor: senderAgent,
1302
- status_reason: `reroute: claim ${oldClaim.id} reassigned`,
1303
- }, dispatchCwd);
1304
- side_effects.push({ action: 'update', entity: 'assignment', id: predecessor.id });
1373
+ else if (req.intent === 'reroute')
1374
+ reroute: {
1375
+ const activeClaims = listClaims(dispatchCwd).filter((c) => c.status === 'active' && (req.scope ? c.scope === req.scope : true));
1376
+ if (activeClaims.length === 0) {
1377
+ return { response: createToolErrorResponse('not_found', `No active claim found for scope: ${req.scope ?? '(any)'}`) };
1305
1378
  }
1306
- catch (err) {
1307
- warnings.push(`Failed to close predecessor assignment ${predecessor.id}: ${err instanceof Error ? err.message : String(err)}`);
1379
+ const oldClaim = activeClaims[0];
1380
+ const newAgentName = resolvedAgents.find((a) => a !== oldClaim.agent) ?? resolvedAgents[0];
1381
+ if (!newAgentName) {
1382
+ return { response: createToolErrorResponse('reroute_target_unavailable', 'reroute requires a replacement target agent') };
1308
1383
  }
1309
- }
1310
- const newAgentName = resolvedAgents.find((a) => a !== oldClaim.agent) ?? resolvedAgents[0];
1311
- let newClaimId;
1312
- if (newAgentName) {
1313
- // trp#51: validate target agent before creating a new claim.
1314
- const check = validateAgentForDispatch(newAgentName, { requireSpawnable: true });
1315
- if (!check.valid) {
1316
- pushStructuredWarning(warnings, warningDetails, agentValidationFailedWarning({
1317
- agent: newAgentName,
1318
- code: check.code,
1319
- reason: check.reason,
1320
- }));
1384
+ // pln#692 P0 — validate the successor before releasing the predecessor.
1385
+ // Previously an invalid target left the old claim released and the scope
1386
+ // unowned, even though no replacement claim/assignment could be created.
1387
+ const rerouteCheck = validateAgentForDispatch(newAgentName, { requireSpawnable: true });
1388
+ if (!rerouteCheck.valid || !rerouteCheck.profile) {
1389
+ return {
1390
+ response: createToolErrorResponse('reroute_target_unavailable', `reroute admission refused before mutation: ${newAgentName} is not executable (${rerouteCheck.code}: ${rerouteCheck.reason})`, {
1391
+ released_claim: null,
1392
+ active_claim: oldClaim.id,
1393
+ target: newAgentName,
1394
+ blocker: { code: rerouteCheck.code, reason: rerouteCheck.reason },
1395
+ next_actions: [{
1396
+ tool: 'bclaw_coordinate',
1397
+ args: { intent: 'reroute', task: req.task, scope: oldClaim.scope, targetAgents: getSpawnableAgents().map((agent) => agent.name) },
1398
+ when: 'retry with a validated spawnable target',
1399
+ }],
1400
+ }),
1401
+ };
1321
1402
  }
1322
- const profile = check.profile;
1323
- if (check.valid && profile) {
1324
- ensureAgentRegisteredForDispatch(newAgentName, dispatchCwd);
1325
- const rerouteClaimResult = createCoordinatorClaim({
1326
- agent: newAgentName,
1327
- scope: oldClaim.scope,
1328
- description: req.task,
1329
- dispatcherAgent: senderAgent,
1330
- sessionId: connectionSessionId,
1331
- cwd: dispatchCwd,
1332
- worktreeBaseRef: req.ref,
1333
- });
1334
- newClaimId = rerouteClaimResult.claimId;
1335
- if (rerouteClaimResult.worktreeWarning) {
1336
- warnings.push(rerouteClaimResult.worktreeWarning);
1403
+ {
1404
+ const activePredecessors = listAssignments(dispatchCwd, { claim_id: oldClaim.id })
1405
+ .filter((a) => !['completed', 'cancelled', 'expired', 'rerouted'].includes(a.status));
1406
+ const owned = activePredecessors
1407
+ .map((assignment) => ({ assignment, reservation: findReservationByAssignmentId(assignment.id, dispatchCwd) }))
1408
+ .find((candidate) => candidate.reservation !== undefined);
1409
+ if (owned?.reservation) {
1410
+ const reservation = owned.reservation;
1411
+ const loop = getLoopThread(reservation.loop_id, dispatchCwd);
1412
+ const generation = resolveTurnGenerationChain(dispatchCwd, reservation.turn_id)?.latest_generation;
1413
+ const authorityHome = readLocalAuthorityHome(dispatchCwd);
1414
+ if (!loop || loop.status !== 'open' || !generation || !authorityHome || !reservation.execution_contract) {
1415
+ return { response: createToolErrorResponse('reroute_authority_unavailable', `loop-owned reroute cannot establish AttemptAuthority for ${owned.assignment.id}`, {
1416
+ loop_id: reservation.loop_id, turn_id: reservation.turn_id,
1417
+ has_loop: Boolean(loop), has_generation: Boolean(generation),
1418
+ has_authority_home: Boolean(authorityHome),
1419
+ has_execution_contract: Boolean(reservation.execution_contract),
1420
+ released_claim: null, active_claim: oldClaim.id,
1421
+ }) };
1422
+ }
1423
+ const newIdentity = ensureAgentRegisteredForDispatch(newAgentName, dispatchCwd);
1424
+ const rerouteHarness = resolveHarnessBinding(newAgentName, resolveModel(newAgentName, { override: req.model }));
1425
+ const successorSnapshot = resolveCapabilitySnapshot(newAgentName, reservation.execution_contract.capability_requirement, newIdentity?.agent_id, rerouteHarness);
1426
+ if (!successorSnapshot.accepted) {
1427
+ return { response: createToolErrorResponse('reroute_target_incompatible', `reroute target ${newAgentName} does not satisfy the immutable execution contract`, {
1428
+ reasons: successorSnapshot.reasons, released_claim: null,
1429
+ active_claim: oldClaim.id, assignment_id: owned.assignment.id,
1430
+ }) };
1431
+ }
1432
+ saveClaim({ ...oldClaim, status: 'released', released_at: nowISO() }, dispatchCwd);
1433
+ appendAuditEntry({ actor: oldClaim.agent, action: 'release_claim', item_id: oldClaim.id, item_type: 'claim', scope: oldClaim.scope }, dispatchCwd);
1434
+ let successorClaim;
1435
+ let takeoverCommitted = false;
1436
+ try {
1437
+ successorClaim = createCoordinatorClaim({
1438
+ agent: newAgentName, scope: oldClaim.scope, description: req.task,
1439
+ dispatcherAgent: senderAgent, sessionId: connectionSessionId,
1440
+ cwd: dispatchCwd, worktreeBaseRef: req.ref,
1441
+ worktreeBranchSuffix: `attempt-${generation.attempt_epoch + 1}`,
1442
+ });
1443
+ if (successorClaim.scopeConflict || !successorClaim.worktreePath) {
1444
+ throw new Error(successorClaim.scopeConflict
1445
+ ? `scope remained owned by ${successorClaim.conflictAgent ?? 'another agent'}`
1446
+ : successorClaim.worktreeWarning ?? 'successor worktree is unavailable');
1447
+ }
1448
+ const takeoverInput = {
1449
+ loop_id: reservation.loop_id, slot_id: reservation.slot_id, turn_id: reservation.turn_id,
1450
+ expected_epoch: generation.attempt_epoch, authority_home: authorityHome,
1451
+ actor: senderAgent, actor_id: senderAgentId, writer_id: senderAgentId ?? senderAgent,
1452
+ cause: `coordinate reroute from claim ${oldClaim.id} to ${successorClaim.claimId}`,
1453
+ liveness_evidence: `operator-requested reroute of assignment ${owned.assignment.id}`,
1454
+ external_effect_policy: 'none', next_workspace_path: successorClaim.worktreePath,
1455
+ predecessor_assignment_terminal: 'rerouted',
1456
+ next_executor: {
1457
+ agent: newAgentName, agent_id: newIdentity?.agent_id, claim_id: successorClaim.claimId,
1458
+ capability_snapshot: successorSnapshot,
1459
+ }, cwd: dispatchCwd,
1460
+ };
1461
+ let taken;
1462
+ try {
1463
+ taken = takeoverLoopAttempt(takeoverInput);
1464
+ }
1465
+ catch (error) {
1466
+ if (!(error instanceof AttemptTakeoverCommittedError))
1467
+ throw error;
1468
+ // The close(epoch) CAS already won. Never reactivate/release claims
1469
+ // from the predecessor generation; replay the identical transaction
1470
+ // once to repair the loop/run projections before continuing.
1471
+ takeoverCommitted = true;
1472
+ taken = takeoverLoopAttempt(takeoverInput);
1473
+ }
1474
+ takeoverCommitted = true;
1475
+ const prepared = prepareTurnExecution({
1476
+ kind: loop.kind, loop_id: loop.id, slot_id: reservation.slot_id, phase: reservation.phase,
1477
+ agent: newAgentName, agent_id: newIdentity?.agent_id, claim_id: successorClaim.claimId,
1478
+ dispatcher_agent: senderAgent, dispatcher_agent_id: senderAgentId,
1479
+ dispatcher_session_id: connectionSessionId, scope: oldClaim.scope,
1480
+ description: req.task, task: req.task, cwd: dispatchCwd,
1481
+ worktree_path: successorClaim.worktreePath,
1482
+ assignment_tags: ['coordinate', 'assign', 'reroute', 'turn-owned', 'loop'],
1483
+ run_tags: ['turn-owned', 'loop', 'reroute'],
1484
+ capability_requirement: reservation.execution_contract.capability_requirement,
1485
+ harness_binding: rerouteHarness,
1486
+ });
1487
+ if (prepared.kind !== 'won')
1488
+ throw new Error(`successor generation did not win launch: ${prepared.reason}`);
1489
+ if (prepared.assignment_id !== taken.assignment_id || prepared.run_id !== taken.run_id) {
1490
+ throw new Error('successor projections diverged from immutable takeover generation');
1491
+ }
1492
+ artifacts.push({ type: 'claim', id: successorClaim.claimId }, { type: 'assignment', id: prepared.assignment_id });
1493
+ side_effects.push({ action: 'release', entity: 'claim', id: oldClaim.id }, { action: 'create', entity: 'claim', id: successorClaim.claimId }, { action: 'create', entity: 'assignment', id: prepared.assignment_id });
1494
+ const fence = {
1495
+ turn_id: prepared.turn_id, run_id: prepared.run_id, nonce: prepared.nonce,
1496
+ attempt_epoch: prepared.attempt_epoch, workspace_digest: prepared.workspace_digest,
1497
+ };
1498
+ const queued = queueCoordinateMessage({
1499
+ agent: newAgentName,
1500
+ text: buildCoordinateBrief(newAgentName, req.task, {
1501
+ claimId: successorClaim.claimId, scope: oldClaim.scope,
1502
+ worktreePath: successorClaim.worktreePath, assignmentId: prepared.assignment_id,
1503
+ executionContractRef: prepared.execution_contract_ref, attemptFence: fence,
1504
+ }),
1505
+ messageType: 'assign', ref: oldClaim.scope, scope: oldClaim.scope, requiresAck: true,
1506
+ claimId: successorClaim.claimId, assignmentId: prepared.assignment_id,
1507
+ releasedClaimId: oldClaim.id, tags: ['coordinate', 'assign', 'reroute', 'turn-owned', 'loop'],
1508
+ payload: {
1509
+ intent: req.intent, scope: oldClaim.scope, claim_id: successorClaim.claimId,
1510
+ assignment_id: prepared.assignment_id, worktree_path: successorClaim.worktreePath,
1511
+ released_claim_id: oldClaim.id, previous_agent: oldClaim.agent,
1512
+ turn_id: prepared.turn_id, run_id: prepared.run_id, attempt_epoch: prepared.attempt_epoch,
1513
+ }, commandMode: 'worker', harnessBinding: rerouteHarness,
1514
+ });
1515
+ attachAssignmentMessageToClaim(successorClaim.claimId, queued.entry.message_id, dispatchCwd);
1516
+ linkClaimToAssignment(successorClaim.claimId, prepared.assignment_id, dispatchCwd);
1517
+ transitionAssignment(prepared.assignment_id, 'offered', { actor: senderAgent }, dispatchCwd);
1518
+ patchAssignmentMessageId(prepared.assignment_id, queued.entry.message_id, dispatchCwd);
1519
+ transitionAssignment(owned.assignment.id, 'rerouted', {
1520
+ actor: senderAgent, status_reason: `reroute: superseded by generation ${prepared.attempt_epoch}`,
1521
+ }, dispatchCwd);
1522
+ side_effects.push({ action: 'update', entity: 'assignment', id: owned.assignment.id });
1523
+ const turnEcho = {
1524
+ ...fence, contract_hash: prepared.execution_contract_ref.hash,
1525
+ capability_snapshot_hash: prepared.execution_contract_ref.snapshot_hash,
1526
+ };
1527
+ const delivery_plan = [queued.entry];
1528
+ const execution_status = await runCoordinateExecution([
1529
+ { entry: queued.entry, invoke: queued.invoke, worktreePath: successorClaim.worktreePath, turnEcho },
1530
+ ], { autoExecute: effectiveAutoExecute !== false, senderAgent, senderAgentId, cwd: dispatchCwd, warnings });
1531
+ result = {
1532
+ released_claim: oldClaim.id, old_agent: oldClaim.agent, new_agent: newAgentName,
1533
+ new_claim_id: successorClaim.claimId, loop_id: loop.id, turn_id: prepared.turn_id,
1534
+ assignment_id: prepared.assignment_id, run_id: prepared.run_id,
1535
+ attempt_epoch: prepared.attempt_epoch, selected_targets: resolvedAgents,
1536
+ delivery_plan, messages_sent: toMessageSummary(delivery_plan),
1537
+ commands: commandHints, execution_status,
1538
+ };
1539
+ break reroute;
1540
+ }
1541
+ catch (error) {
1542
+ if (!takeoverCommitted) {
1543
+ if (successorClaim && successorClaim.claimId !== oldClaim.id) {
1544
+ releaseClaimIfActive(successorClaim.claimId, dispatchCwd, {
1545
+ agent: senderAgent, agent_id: senderAgentId, session_id: connectionSessionId, override: true,
1546
+ });
1547
+ if (successorClaim.worktreePath) {
1548
+ try {
1549
+ removeWorktree(dispatchCwd, successorClaim.worktreePath, { force: true });
1550
+ }
1551
+ catch (cleanupError) {
1552
+ warnings.push(`reroute rollback worktree cleanup failed for ${successorClaim.claimId}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
1553
+ }
1554
+ }
1555
+ }
1556
+ saveClaim({ ...oldClaim, status: 'active', released_at: undefined }, dispatchCwd);
1557
+ appendAuditEntry({
1558
+ actor: senderAgent,
1559
+ action: 'rollback',
1560
+ item_id: oldClaim.id,
1561
+ item_type: 'claim',
1562
+ scope: oldClaim.scope,
1563
+ reason: 'reroute aborted before immutable takeover commit; predecessor claim restored',
1564
+ }, dispatchCwd);
1565
+ }
1566
+ return { response: createToolErrorResponse(takeoverCommitted ? 'reroute_successor_incomplete' : 'reroute_transaction_rolled_back', error instanceof Error ? error.message : String(error), {
1567
+ takeover_committed: takeoverCommitted,
1568
+ active_claim: takeoverCommitted ? successorClaim?.claimId : oldClaim.id,
1569
+ previous_claim: oldClaim.id, loop_id: loop.id, turn_id: reservation.turn_id,
1570
+ }) };
1571
+ }
1337
1572
  }
1338
- artifacts.push({ type: 'claim', id: newClaimId });
1339
- side_effects.push({
1340
- action: rerouteClaimResult.reusedExisting ? 'reuse' : 'create',
1341
- entity: 'claim',
1342
- id: newClaimId,
1343
- });
1344
- let rerouteAssignmentId;
1573
+ }
1574
+ saveClaim({ ...oldClaim, status: 'released', released_at: nowISO() }, dispatchCwd);
1575
+ appendAuditEntry({ actor: oldClaim.agent, action: 'release_claim', item_id: oldClaim.id, item_type: 'claim', scope: oldClaim.scope }, dispatchCwd);
1576
+ side_effects.push({ action: 'release', entity: 'claim', id: oldClaim.id });
1577
+ // trp#61: supersede assignments attached to the old claim so they
1578
+ // don't linger in `created`/`offered`/etc. Prior behaviour only
1579
+ // released the claim, leaving the assignment FSM stuck and confusing
1580
+ // dispatch analysis / review.
1581
+ const { listAssignments: listAsgn } = await import('../core/assignments.js');
1582
+ const predecessors = listAsgn(dispatchCwd, { claim_id: oldClaim.id })
1583
+ .filter((a) => a.status !== 'completed' && a.status !== 'cancelled' && a.status !== 'expired' && a.status !== 'rerouted');
1584
+ for (const predecessor of predecessors) {
1345
1585
  try {
1346
- const preId = generateAssignmentId(dispatchCwd);
1347
- const assignment = createAssignment({
1348
- id: preId.id,
1349
- short_label: preId.short_label,
1350
- claim_id: newClaimId,
1351
- agent: newAgentName,
1352
- dispatcher_agent: senderAgent,
1353
- dispatcher_session_id: connectionSessionId,
1354
- scope: oldClaim.scope,
1355
- description: req.task,
1356
- tags: ['coordinate', 'assign', 'reroute'],
1586
+ transitionAssignment(predecessor.id, 'rerouted', {
1587
+ actor: senderAgent,
1588
+ status_reason: `reroute: claim ${oldClaim.id} reassigned`,
1357
1589
  }, dispatchCwd);
1358
- rerouteAssignmentId = assignment.id;
1359
- artifacts.push({ type: 'assignment', id: assignment.id });
1590
+ side_effects.push({ action: 'update', entity: 'assignment', id: predecessor.id });
1360
1591
  }
1361
1592
  catch (err) {
1362
- warnings.push(`Assignment creation failed for ${newAgentName}: ${err instanceof Error ? err.message : String(err)}`);
1593
+ warnings.push(`Failed to close predecessor assignment ${predecessor.id}: ${err instanceof Error ? err.message : String(err)}`);
1363
1594
  }
1364
- const rerouteBrief = buildCoordinateBrief(newAgentName, req.task, {
1365
- claimId: newClaimId,
1366
- scope: oldClaim.scope,
1367
- worktreePath: rerouteClaimResult.worktreePath,
1368
- assignmentId: rerouteAssignmentId,
1369
- });
1370
- const delivery_plan = [];
1371
- const reroutePrepared = [];
1372
- const queued = queueCoordinateMessage({
1373
- agent: newAgentName,
1374
- text: rerouteBrief,
1375
- messageType: 'assign',
1376
- ref: oldClaim.scope,
1377
- scope: oldClaim.scope,
1378
- requiresAck: true,
1379
- claimId: newClaimId,
1380
- assignmentId: rerouteAssignmentId,
1381
- releasedClaimId: oldClaim.id,
1382
- tags: ['coordinate', 'assign', 'reroute'],
1383
- payload: {
1384
- intent: req.intent,
1595
+ }
1596
+ let newClaimId;
1597
+ if (newAgentName) {
1598
+ const profile = rerouteCheck.profile;
1599
+ if (profile) {
1600
+ ensureAgentRegisteredForDispatch(newAgentName, dispatchCwd);
1601
+ const rerouteClaimResult = createCoordinatorClaim({
1602
+ agent: newAgentName,
1385
1603
  scope: oldClaim.scope,
1386
- claim_id: newClaimId,
1387
- ...(rerouteAssignmentId ? { assignment_id: rerouteAssignmentId } : {}),
1388
- worktree_path: rerouteClaimResult.worktreePath,
1389
- released_claim_id: oldClaim.id,
1390
- previous_agent: oldClaim.agent,
1391
- constraints: req.constraints,
1392
- },
1393
- commandMode: 'worker',
1394
- });
1395
- if (rerouteAssignmentId) {
1604
+ description: req.task,
1605
+ dispatcherAgent: senderAgent,
1606
+ sessionId: connectionSessionId,
1607
+ cwd: dispatchCwd,
1608
+ worktreeBaseRef: req.ref,
1609
+ });
1610
+ newClaimId = rerouteClaimResult.claimId;
1611
+ if (rerouteClaimResult.worktreeWarning) {
1612
+ warnings.push(rerouteClaimResult.worktreeWarning);
1613
+ }
1614
+ artifacts.push({ type: 'claim', id: newClaimId });
1615
+ side_effects.push({
1616
+ action: rerouteClaimResult.reusedExisting ? 'reuse' : 'create',
1617
+ entity: 'claim',
1618
+ id: newClaimId,
1619
+ });
1620
+ let rerouteAssignmentId;
1396
1621
  try {
1397
- attachAssignmentMessageToClaim(newClaimId, queued.entry.message_id, dispatchCwd);
1398
- linkClaimToAssignment(newClaimId, rerouteAssignmentId, dispatchCwd);
1399
- transitionAssignment(rerouteAssignmentId, 'offered', { actor: senderAgent }, dispatchCwd);
1400
- patchAssignmentMessageId(rerouteAssignmentId, queued.entry.message_id, dispatchCwd);
1401
- queued.entry.assignment_id = rerouteAssignmentId;
1622
+ const preId = generateAssignmentId(dispatchCwd);
1623
+ const assignment = createAssignment({
1624
+ id: preId.id,
1625
+ short_label: preId.short_label,
1626
+ claim_id: newClaimId,
1627
+ agent: newAgentName,
1628
+ dispatcher_agent: senderAgent,
1629
+ dispatcher_session_id: connectionSessionId,
1630
+ scope: oldClaim.scope,
1631
+ description: req.task,
1632
+ tags: ['coordinate', 'assign', 'reroute'],
1633
+ }, dispatchCwd);
1634
+ rerouteAssignmentId = assignment.id;
1635
+ artifacts.push({ type: 'assignment', id: assignment.id });
1402
1636
  }
1403
1637
  catch (err) {
1404
- warnings.push(`Assignment linkage failed for ${newAgentName}: ${err instanceof Error ? err.message : String(err)}`);
1638
+ warnings.push(`Assignment creation failed for ${newAgentName}: ${err instanceof Error ? err.message : String(err)}`);
1639
+ }
1640
+ const rerouteBrief = buildCoordinateBrief(newAgentName, req.task, {
1641
+ claimId: newClaimId,
1642
+ scope: oldClaim.scope,
1643
+ worktreePath: rerouteClaimResult.worktreePath,
1644
+ assignmentId: rerouteAssignmentId,
1645
+ });
1646
+ const delivery_plan = [];
1647
+ const reroutePrepared = [];
1648
+ const queued = queueCoordinateMessage({
1649
+ agent: newAgentName,
1650
+ text: rerouteBrief,
1651
+ messageType: 'assign',
1652
+ ref: oldClaim.scope,
1653
+ scope: oldClaim.scope,
1654
+ requiresAck: true,
1655
+ claimId: newClaimId,
1656
+ assignmentId: rerouteAssignmentId,
1657
+ releasedClaimId: oldClaim.id,
1658
+ tags: ['coordinate', 'assign', 'reroute'],
1659
+ payload: {
1660
+ intent: req.intent,
1661
+ scope: oldClaim.scope,
1662
+ claim_id: newClaimId,
1663
+ ...(rerouteAssignmentId ? { assignment_id: rerouteAssignmentId } : {}),
1664
+ worktree_path: rerouteClaimResult.worktreePath,
1665
+ released_claim_id: oldClaim.id,
1666
+ previous_agent: oldClaim.agent,
1667
+ constraints: req.constraints,
1668
+ },
1669
+ commandMode: 'worker',
1670
+ });
1671
+ if (rerouteAssignmentId) {
1672
+ try {
1673
+ attachAssignmentMessageToClaim(newClaimId, queued.entry.message_id, dispatchCwd);
1674
+ linkClaimToAssignment(newClaimId, rerouteAssignmentId, dispatchCwd);
1675
+ transitionAssignment(rerouteAssignmentId, 'offered', { actor: senderAgent }, dispatchCwd);
1676
+ patchAssignmentMessageId(rerouteAssignmentId, queued.entry.message_id, dispatchCwd);
1677
+ queued.entry.assignment_id = rerouteAssignmentId;
1678
+ }
1679
+ catch (err) {
1680
+ warnings.push(`Assignment linkage failed for ${newAgentName}: ${err instanceof Error ? err.message : String(err)}`);
1681
+ }
1405
1682
  }
1683
+ delivery_plan.push(queued.entry);
1684
+ reroutePrepared.push({ entry: queued.entry, invoke: queued.invoke, worktreePath: rerouteClaimResult.worktreePath });
1685
+ const rerouteExecStatus = await runCoordinateExecution(reroutePrepared, {
1686
+ autoExecute: effectiveAutoExecute !== false,
1687
+ senderAgent, senderAgentId, cwd: dispatchCwd, warnings,
1688
+ });
1689
+ result = {
1690
+ released_claim: oldClaim.id,
1691
+ old_agent: oldClaim.agent,
1692
+ new_agent: newAgentName,
1693
+ new_claim_id: newClaimId,
1694
+ selected_targets: resolvedAgents,
1695
+ delivery_plan,
1696
+ messages_sent: toMessageSummary(delivery_plan),
1697
+ commands: commandHints,
1698
+ execution_status: rerouteExecStatus,
1699
+ };
1406
1700
  }
1407
- delivery_plan.push(queued.entry);
1408
- reroutePrepared.push({ entry: queued.entry, invoke: queued.invoke, worktreePath: rerouteClaimResult.worktreePath });
1409
- const rerouteExecStatus = await runCoordinateExecution(reroutePrepared, {
1410
- autoExecute: effectiveAutoExecute !== false,
1411
- senderAgent, senderAgentId, cwd: dispatchCwd, warnings,
1412
- });
1701
+ else {
1702
+ warnings.push(`Unknown agent profile: ${newAgentName}`);
1703
+ }
1704
+ }
1705
+ if (!('released_claim' in result)) {
1413
1706
  result = {
1414
1707
  released_claim: oldClaim.id,
1415
1708
  old_agent: oldClaim.agent,
1416
1709
  new_agent: newAgentName,
1417
1710
  new_claim_id: newClaimId,
1418
1711
  selected_targets: resolvedAgents,
1419
- delivery_plan,
1420
- messages_sent: toMessageSummary(delivery_plan),
1712
+ delivery_plan: [],
1713
+ messages_sent: [],
1421
1714
  commands: commandHints,
1422
- execution_status: rerouteExecStatus,
1423
1715
  };
1424
1716
  }
1425
- else {
1426
- warnings.push(`Unknown agent profile: ${newAgentName}`);
1427
- }
1428
- }
1429
- if (!('released_claim' in result)) {
1430
- result = {
1431
- released_claim: oldClaim.id,
1432
- old_agent: oldClaim.agent,
1433
- new_agent: newAgentName,
1434
- new_claim_id: newClaimId,
1435
- selected_targets: resolvedAgents,
1436
- delivery_plan: [],
1437
- messages_sent: [],
1438
- commands: commandHints,
1439
- };
1440
1717
  }
1441
- }
1442
1718
  else if (req.intent === 'summarize') {
1443
1719
  const threadId = req.threadId ?? req.scope;
1444
1720
  if (!threadId) {
@@ -1607,7 +1883,7 @@ export async function handleBclawCoordinate(args, ctx) {
1607
1883
  // loop doesn't contain. The task text is already captured on
1608
1884
  // the thread (title + goal).
1609
1885
  if (!presetSelected) {
1610
- const proposalBody = req.task.slice(0, 4000);
1886
+ const proposalBody = capLoopArtifactBody(req.task);
1611
1887
  const updated = add_artifact({
1612
1888
  id: loop.id,
1613
1889
  actor: creatorActor,