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.
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli/register-coordination.js +12 -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/loop.js +12 -0
- package/dist/commands/loops-handlers.js +284 -17
- package/dist/commands/mcp-catalog.js +6 -3
- package/dist/commands/mcp-write-claims.js +55 -8
- package/dist/commands/mcp-write-coordination.js +413 -137
- package/dist/commands/mcp.js +32 -4
- package/dist/core/actions.js +17 -3
- package/dist/core/agentrun-reconciler.js +138 -4
- package/dist/core/claims.js +4 -1
- package/dist/core/code-map/backend.js +8 -0
- package/dist/core/execution-adapters.js +15 -7
- 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/continuation.js +337 -0
- package/dist/core/loops/facade-schema.js +15 -0
- package/dist/core/loops/index.js +1 -0
- 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 +9 -0
- package/dist/core/loops/verbs.js +1 -1
- package/dist/core/reviewer-policy.js +39 -0
- package/dist/core/schema.js +16 -1
- package/dist/facts.js +8 -8
- package/dist/facts.json +7 -7
- package/docs/cli.md +4 -2
- package/docs/code-map.md +10 -0
- package/docs/concepts/loop-engine.md +30 -0
- package/docs/mcp-schema-changelog.md +6 -1
- 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 {
|
|
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
|
-
|
|
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 });
|
|
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
|
-
|
|
1307
|
-
|
|
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
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
const
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
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
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
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
|
-
|
|
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'],
|
|
1586
|
+
transitionAssignment(predecessor.id, 'rerouted', {
|
|
1587
|
+
actor: senderAgent,
|
|
1588
|
+
status_reason: `reroute: claim ${oldClaim.id} reassigned`,
|
|
1357
1589
|
}, dispatchCwd);
|
|
1358
|
-
|
|
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(`
|
|
1593
|
+
warnings.push(`Failed to close predecessor assignment ${predecessor.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1363
1594
|
}
|
|
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,
|
|
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
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
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
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
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
|
|
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
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
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:
|
|
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
|
|
1886
|
+
const proposalBody = capLoopArtifactBody(req.task);
|
|
1611
1887
|
const updated = add_artifact({
|
|
1612
1888
|
id: loop.id,
|
|
1613
1889
|
actor: creatorActor,
|