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.
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/commands/code-map.js +2 -0
- package/dist/commands/doctor.js +1 -0
- package/dist/commands/harvest.js +32 -43
- package/dist/commands/loops-handlers.js +66 -3
- package/dist/commands/mcp-catalog.js +2 -2
- package/dist/commands/mcp-write-coordination.js +426 -141
- package/dist/commands/mcp-write-entities.js +5 -2
- package/dist/commands/mcp.js +57 -11
- package/dist/core/agentrun-reconciler.js +138 -4
- package/dist/core/claims.js +4 -1
- package/dist/core/code-map/aggregate.js +20 -7
- package/dist/core/code-map/backend.js +25 -7
- package/dist/core/code-map/cascade-jobs.js +174 -0
- package/dist/core/code-map/cascade-worker.js +15 -0
- package/dist/core/code-map/cascade.js +63 -26
- package/dist/core/code-map/query.js +6 -3
- package/dist/core/entity-operations.js +18 -4
- package/dist/core/execution-adapters.js +23 -8
- package/dist/core/hygiene-policy.js +2 -1
- package/dist/core/loop-turn-dispatch.js +18 -1
- package/dist/core/loops/attempt-authority.js +22 -4
- package/dist/core/loops/attempt-generations.js +17 -4
- package/dist/core/loops/attempt-reservation.js +14 -1
- package/dist/core/loops/attempt-takeover.js +173 -76
- package/dist/core/loops/reconcile-turn.js +224 -26
- package/dist/core/loops/result-reducers.js +8 -8
- package/dist/core/loops/turn-execution.js +38 -19
- package/dist/core/loops/types.js +3 -0
- package/dist/core/loops/verbs.js +1 -1
- package/dist/core/spawn-check.js +9 -1
- package/dist/facts.js +8 -8
- package/dist/facts.json +7 -7
- package/docs/cli.md +2 -2
- package/docs/code-map.md +30 -9
- package/docs/concepts/loop-engine.md +5 -0
- package/docs/integrations/mcp.md +2 -2
- package/docs/mcp-schema-changelog.md +30 -0
- 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 {
|
|
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
|
-
|
|
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 && !
|
|
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() && !
|
|
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
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
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
|
-
|
|
1307
|
-
|
|
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
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
const
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
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
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
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
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
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
|
-
|
|
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(`
|
|
1602
|
+
warnings.push(`Failed to close predecessor assignment ${predecessor.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1363
1603
|
}
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
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
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
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
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
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
|
|
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
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
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:
|
|
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
|
|
1895
|
+
const proposalBody = capLoopArtifactBody(req.task);
|
|
1611
1896
|
const updated = add_artifact({
|
|
1612
1897
|
id: loop.id,
|
|
1613
1898
|
actor: creatorActor,
|