brainclaw 1.28.1 → 1.28.3

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