brainclaw 1.26.2 → 1.28.0
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/README.md +13 -0
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli/register-coordination.js +65 -1
- package/dist/commands/attempt-authority.js +80 -0
- package/dist/commands/harvest.js +140 -61
- package/dist/commands/loop.js +34 -0
- package/dist/commands/loops-handlers.js +143 -15
- package/dist/commands/mcp-catalog.js +52 -18
- package/dist/commands/mcp-schemas.generated.js +64 -0
- package/dist/commands/mcp-write-claims.js +128 -1
- package/dist/commands/mcp-write-coordination.js +149 -76
- package/dist/core/agent-capability.js +1 -1
- package/dist/core/agentrun-reconciler.js +148 -22
- package/dist/core/agentruns.js +254 -29
- package/dist/core/assignment-request-schema.js +7 -0
- package/dist/core/assignment-sweeper.js +5 -3
- package/dist/core/assignments.js +131 -33
- package/dist/core/claim-request-schema.js +7 -0
- package/dist/core/claims.js +53 -2
- package/dist/core/dispatch-status.js +16 -6
- package/dist/core/dispatcher.js +51 -51
- package/dist/core/entity-operations.js +20 -0
- package/dist/core/events.js +4 -0
- package/dist/core/execution-adapters.js +189 -14
- package/dist/core/execution-contract.js +345 -0
- package/dist/core/execution.js +130 -16
- package/dist/core/facade-schema.js +3 -0
- package/dist/core/harness-adapters/base.js +150 -0
- package/dist/core/harness-adapters/claude.js +39 -0
- package/dist/core/harness-adapters/codex.js +57 -0
- package/dist/core/harness-adapters/harvest.js +109 -0
- package/dist/core/harness-adapters/index.js +8 -0
- package/dist/core/harness-adapters/prompt-only.js +13 -0
- package/dist/core/harness-adapters/registry.js +48 -0
- package/dist/core/harness-adapters/result.js +33 -0
- package/dist/core/harness-adapters/types.js +2 -0
- package/dist/core/ideation-loop-close.js +25 -2
- package/dist/core/instruction-templates.js +3 -2
- package/dist/core/loop-turn-dispatch.js +235 -0
- package/dist/core/loops/artifact-contract.js +11 -0
- package/dist/core/loops/attempt-authority.js +496 -0
- package/dist/core/loops/attempt-generations.js +509 -0
- package/dist/core/loops/attempt-reservation.js +197 -35
- package/dist/core/loops/attempt-rollout.js +404 -0
- package/dist/core/loops/attempt-takeover.js +155 -0
- package/dist/core/loops/bootstrap-acquire.js +7 -3
- package/dist/core/loops/brief-assembly.js +21 -4
- package/dist/core/loops/evidence.js +188 -0
- package/dist/core/loops/facade-schema.js +75 -11
- package/dist/core/loops/gate-policy.js +533 -0
- package/dist/core/loops/impl-bind.js +91 -81
- package/dist/core/loops/index.js +9 -0
- package/dist/core/loops/iteration-engine.js +31 -19
- package/dist/core/loops/kind-policies.js +90 -0
- package/dist/core/loops/lock.js +71 -13
- package/dist/core/loops/reconcile-turn.js +237 -18
- package/dist/core/loops/result-reducers.js +113 -10
- package/dist/core/loops/store.js +34 -3
- package/dist/core/loops/turn-execution.js +480 -0
- package/dist/core/loops/types.js +127 -3
- package/dist/core/loops/verbs.js +335 -99
- package/dist/core/loops/verify-command.js +105 -20
- package/dist/core/loops/workspace-digest.js +54 -0
- package/dist/core/review-loop-close.js +25 -3
- package/dist/core/review-loop-turn-dispatch.js +210 -161
- package/dist/core/runtime-signals.js +62 -25
- package/dist/core/schema.js +40 -0
- package/dist/core/spawn-check.js +3 -2
- package/dist/core/upgrades/backup.js +27 -4
- package/dist/facts.js +9 -8
- package/dist/facts.json +8 -7
- package/docs/cli.md +49 -1
- package/docs/concepts/attempt-authority.md +407 -0
- package/docs/concepts/evidence-attestations.md +135 -0
- package/docs/concepts/execution-contract.md +166 -0
- package/docs/concepts/harness-adapters.md +166 -0
- package/docs/concepts/ideation-loop.md +5 -4
- package/docs/concepts/loop-engine.md +302 -113
- package/docs/index.md +4 -1
- package/docs/integrations/codex.md +3 -3
- package/docs/integrations/mcp.md +59 -5
- package/docs/loops/debug.md +144 -0
- package/docs/loops/ideation.md +158 -0
- package/docs/loops/implementation.md +174 -0
- package/docs/loops/research.md +136 -0
- package/docs/loops/review.md +200 -0
- package/docs/mcp-schema-changelog.md +18 -5
- package/package.json +1 -1
|
@@ -17,6 +17,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
17
17
|
import { listAssignments, transitionAssignment } from './assignments.js';
|
|
18
18
|
import { signalExists, readHeartbeat, latestActivityMs } from './runtime-signals.js';
|
|
19
19
|
import { DEFAULT_HYGIENE_POLICY } from './hygiene-policy.js';
|
|
20
|
+
import { currentAttemptRunIdForAssignment } from './loops/attempt-reservation.js';
|
|
20
21
|
function lastCommitAgeMs(worktreePath, nowMs) {
|
|
21
22
|
if (!worktreePath)
|
|
22
23
|
return undefined;
|
|
@@ -46,6 +47,7 @@ function lastCommitAgeMs(worktreePath, nowMs) {
|
|
|
46
47
|
*/
|
|
47
48
|
function collectImplicitEvidence(assignment, cwd, nowMs, sinceMs, freshTtlMs) {
|
|
48
49
|
const root = cwd ?? process.cwd();
|
|
50
|
+
const runId = currentAttemptRunIdForAssignment(assignment.id, cwd);
|
|
49
51
|
const parts = [];
|
|
50
52
|
let freshest;
|
|
51
53
|
const bump = (ageMs) => {
|
|
@@ -55,12 +57,12 @@ function collectImplicitEvidence(assignment, cwd, nowMs, sinceMs, freshTtlMs) {
|
|
|
55
57
|
freshest = ageMs;
|
|
56
58
|
};
|
|
57
59
|
try {
|
|
58
|
-
if (signalExists(root, assignment.id, 'ack'))
|
|
60
|
+
if (signalExists(root, assignment.id, 'ack', runId))
|
|
59
61
|
parts.push('ack sentinel');
|
|
60
62
|
}
|
|
61
63
|
catch { /* defensive */ }
|
|
62
64
|
try {
|
|
63
|
-
const hb = readHeartbeat(root, assignment.id, assignment.worktree_path);
|
|
65
|
+
const hb = readHeartbeat(root, assignment.id, assignment.worktree_path, runId);
|
|
64
66
|
if (hb.exists && hb.mtimeMs !== undefined) {
|
|
65
67
|
const age = nowMs - hb.mtimeMs;
|
|
66
68
|
parts.push(`heartbeat ${Math.round(age / 1000)}s old`);
|
|
@@ -69,7 +71,7 @@ function collectImplicitEvidence(assignment, cwd, nowMs, sinceMs, freshTtlMs) {
|
|
|
69
71
|
}
|
|
70
72
|
catch { /* defensive */ }
|
|
71
73
|
try {
|
|
72
|
-
const lastFs = latestActivityMs(root, assignment.id, assignment.worktree_path);
|
|
74
|
+
const lastFs = latestActivityMs(root, assignment.id, assignment.worktree_path, runId);
|
|
73
75
|
if (lastFs !== undefined) {
|
|
74
76
|
const age = nowMs - lastFs;
|
|
75
77
|
parts.push(`fs activity ${Math.round(age / 1000)}s old`);
|
package/dist/core/assignments.js
CHANGED
|
@@ -48,37 +48,41 @@ function assignmentStoreForDir(dirPath) {
|
|
|
48
48
|
// ── CRUD ─────────────────────────────────────────────────────
|
|
49
49
|
export function saveAssignment(assignment, cwd) {
|
|
50
50
|
mutate({ cwd }, () => {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
registryFaultPoint('after_registry_journal');
|
|
63
|
-
store.save(parsed);
|
|
64
|
-
// CONVERGE THE OTHER LAYOUT, exactly as saveClaim does. Without this, a save wrote
|
|
65
|
-
// canonical and LEFT a legacy copy holding the stale status: `loadAssignment` reads
|
|
66
|
-
// canonical first so the record looked right, but `deleteAssignment` removed only the
|
|
67
|
-
// canonical one and the stale copy became the record again — a zombie resurrection.
|
|
68
|
-
// Best effort on purpose: `listAssignments` reads both dirs, so a missed cleanup stays
|
|
69
|
-
// visible rather than silently dropping data. (Fable audit; claims.ts already had it.)
|
|
70
|
-
const writeDir = assignmentsDir(cwd, 'write');
|
|
71
|
-
for (const dirPath of entityRecordDirs('assignments', cwd ?? process.cwd())) {
|
|
72
|
-
if (dirPath === writeDir)
|
|
73
|
-
continue;
|
|
74
|
-
const legacyPath = path.join(dirPath, `${parsed.id}.json`);
|
|
75
|
-
try {
|
|
76
|
-
if (fs.existsSync(legacyPath))
|
|
77
|
-
fs.unlinkSync(legacyPath);
|
|
78
|
-
}
|
|
79
|
-
catch { /* best effort — the dual-layout list keeps it visible */ }
|
|
80
|
-
}
|
|
51
|
+
saveAssignmentUnlocked(assignment, cwd);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/** Store-lock caller variant used by create-or-validate projection repair. */
|
|
55
|
+
function saveAssignmentUnlocked(assignment, cwd) {
|
|
56
|
+
ensureAssignmentsDir(cwd);
|
|
57
|
+
const store = new JsonStore({
|
|
58
|
+
dirPath: assignmentsDir(cwd, 'write'),
|
|
59
|
+
documentType: 'assignment',
|
|
60
|
+
getId: (a) => a.id,
|
|
61
|
+
sort: (a, b) => a.created_at.localeCompare(b.created_at),
|
|
81
62
|
});
|
|
63
|
+
const parsed = AssignmentSchema.parse(assignment);
|
|
64
|
+
// pln#568 (I2): journal the post-image BEFORE the projection write.
|
|
65
|
+
const created = !store.exists(parsed.id);
|
|
66
|
+
emitRegistryPostImage('assignment', parsed, { created, agent: parsed.agent, agent_id: parsed.agent_id, session_id: parsed.session_id, cwd });
|
|
67
|
+
registryFaultPoint('after_registry_journal');
|
|
68
|
+
store.save(parsed);
|
|
69
|
+
// CONVERGE THE OTHER LAYOUT, exactly as saveClaim does. Without this, a save wrote
|
|
70
|
+
// canonical and LEFT a legacy copy holding the stale status: `loadAssignment` reads
|
|
71
|
+
// canonical first so the record looked right, but `deleteAssignment` removed only the
|
|
72
|
+
// canonical one and the stale copy became the record again — a zombie resurrection.
|
|
73
|
+
// Best effort on purpose: `listAssignments` reads both dirs, so a missed cleanup stays
|
|
74
|
+
// visible rather than silently dropping data. (Fable audit; claims.ts already had it.)
|
|
75
|
+
const writeDir = assignmentsDir(cwd, 'write');
|
|
76
|
+
for (const dirPath of entityRecordDirs('assignments', cwd ?? process.cwd())) {
|
|
77
|
+
if (dirPath === writeDir)
|
|
78
|
+
continue;
|
|
79
|
+
const legacyPath = path.join(dirPath, `${parsed.id}.json`);
|
|
80
|
+
try {
|
|
81
|
+
if (fs.existsSync(legacyPath))
|
|
82
|
+
fs.unlinkSync(legacyPath);
|
|
83
|
+
}
|
|
84
|
+
catch { /* best effort — the dual-layout list keeps it visible */ }
|
|
85
|
+
}
|
|
82
86
|
}
|
|
83
87
|
export function loadAssignment(id, cwd) {
|
|
84
88
|
// JsonStore.load throws when the id is missing; honor the declared
|
|
@@ -357,14 +361,14 @@ export function recordProgress(id, options, cwd) {
|
|
|
357
361
|
* Create a new assignment. Called by the dispatcher after creating a claim
|
|
358
362
|
* and sending an inbox message.
|
|
359
363
|
*/
|
|
360
|
-
|
|
364
|
+
function buildAssignment(options, cwd) {
|
|
361
365
|
const generated = options.id ? undefined : generateAssignmentId(cwd);
|
|
362
366
|
const id = options.id ?? generated.id;
|
|
363
367
|
// `generated` is undefined whenever the caller supplied an id, so the old
|
|
364
368
|
// `generated!.short_label` threw a TypeError on the perfectly reasonable call
|
|
365
369
|
// "give me this id, derive the rest". Found while pinning the layout primitive.
|
|
366
370
|
const short_label = options.short_label ?? generated?.short_label ?? id;
|
|
367
|
-
|
|
371
|
+
return AssignmentSchema.parse({
|
|
368
372
|
schema_version: 1,
|
|
369
373
|
id,
|
|
370
374
|
short_label,
|
|
@@ -388,6 +392,8 @@ export function createAssignment(options, cwd) {
|
|
|
388
392
|
description: options.description,
|
|
389
393
|
lane: options.lane,
|
|
390
394
|
worktree_path: options.worktree_path,
|
|
395
|
+
execution_contract_ref: options.execution_contract_ref,
|
|
396
|
+
capability_snapshot: options.capability_snapshot,
|
|
391
397
|
status: 'created',
|
|
392
398
|
created_at: nowISO(),
|
|
393
399
|
heartbeat_ttl_ms: options.heartbeat_ttl_ms,
|
|
@@ -397,7 +403,8 @@ export function createAssignment(options, cwd) {
|
|
|
397
403
|
artifacts: [],
|
|
398
404
|
tags: options.tags ?? [],
|
|
399
405
|
});
|
|
400
|
-
|
|
406
|
+
}
|
|
407
|
+
function emitAssignmentCreatedSideEffects(assignment, options, cwd) {
|
|
401
408
|
emitAssignmentEvent(assignment, 'assignment_created', options.dispatcher_agent, cwd);
|
|
402
409
|
appendAuditEntry({
|
|
403
410
|
actor: options.dispatcher_agent,
|
|
@@ -406,8 +413,99 @@ export function createAssignment(options, cwd) {
|
|
|
406
413
|
item_type: 'assignment',
|
|
407
414
|
after: { agent: options.agent, scope: options.scope, claim_id: options.claim_id },
|
|
408
415
|
}, cwd);
|
|
416
|
+
}
|
|
417
|
+
export function createAssignment(options, cwd) {
|
|
418
|
+
const assignment = buildAssignment(options, cwd);
|
|
419
|
+
saveAssignment(assignment, cwd);
|
|
420
|
+
emitAssignmentCreatedSideEffects(assignment, options, cwd);
|
|
409
421
|
return assignment;
|
|
410
422
|
}
|
|
423
|
+
export class AssignmentProjectionConflictError extends Error {
|
|
424
|
+
assignmentId;
|
|
425
|
+
constructor(assignmentId, detail) {
|
|
426
|
+
super(`Assignment projection conflict for ${assignmentId}: ${detail}`);
|
|
427
|
+
this.assignmentId = assignmentId;
|
|
428
|
+
this.name = 'AssignmentProjectionConflictError';
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const TERMINAL_PROJECTION_ASSIGNMENT_STATUSES = new Set([
|
|
432
|
+
'completed', 'cancelled', 'failed', 'blocked', 'timed_out', 'expired', 'rerouted',
|
|
433
|
+
]);
|
|
434
|
+
function assertAssignmentProjectionMatches(existing, expected) {
|
|
435
|
+
const fields = [
|
|
436
|
+
'id', 'claim_id', 'agent', 'dispatcher_agent', 'scope', 'description',
|
|
437
|
+
];
|
|
438
|
+
for (const field of fields) {
|
|
439
|
+
if (existing[field] !== expected[field]) {
|
|
440
|
+
throw new AssignmentProjectionConflictError(expected.id, `${String(field)} differs (existing=${String(existing[field])}, expected=${String(expected[field])})`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
// project_id was added after the entity shipped. An absent legacy owner remains readable;
|
|
444
|
+
// when present it must identify the same authoritative store.
|
|
445
|
+
if (existing.project_id !== undefined && existing.project_id !== expected.project_id) {
|
|
446
|
+
throw new AssignmentProjectionConflictError(expected.id, 'project_id differs');
|
|
447
|
+
}
|
|
448
|
+
// Turn-owned Assignments created before P0B did not persist agent_id. Accept and
|
|
449
|
+
// enrich that legacy omission when the named agent still matches; a present,
|
|
450
|
+
// divergent identity remains a hard conflict.
|
|
451
|
+
if (existing.agent_id !== undefined && existing.agent_id !== expected.agent_id) {
|
|
452
|
+
throw new AssignmentProjectionConflictError(expected.id, 'agent_id differs');
|
|
453
|
+
}
|
|
454
|
+
if (existing.execution_contract_ref !== undefined
|
|
455
|
+
&& expected.execution_contract_ref !== undefined
|
|
456
|
+
&& JSON.stringify(existing.execution_contract_ref) !== JSON.stringify(expected.execution_contract_ref)) {
|
|
457
|
+
throw new AssignmentProjectionConflictError(expected.id, 'execution_contract_ref differs');
|
|
458
|
+
}
|
|
459
|
+
if (existing.capability_snapshot !== undefined
|
|
460
|
+
&& expected.capability_snapshot !== undefined
|
|
461
|
+
&& JSON.stringify(existing.capability_snapshot) !== JSON.stringify(expected.capability_snapshot)) {
|
|
462
|
+
throw new AssignmentProjectionConflictError(expected.id, 'capability_snapshot differs');
|
|
463
|
+
}
|
|
464
|
+
if (TERMINAL_PROJECTION_ASSIGNMENT_STATUSES.has(existing.status)) {
|
|
465
|
+
throw new AssignmentProjectionConflictError(expected.id, `existing status is terminal (${existing.status})`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Create-or-validate the deterministic Assignment projection for one logical turn.
|
|
470
|
+
*
|
|
471
|
+
* The read/decision/write sequence is serialized by the store lock. Identical recovery is
|
|
472
|
+
* a strict no-op (no registry/runtime/audit duplicate); any divergent projection fails closed.
|
|
473
|
+
*/
|
|
474
|
+
export function ensureAssignmentProjection(options, cwd) {
|
|
475
|
+
let created = false;
|
|
476
|
+
let repaired = false;
|
|
477
|
+
const expected = buildAssignment(options, cwd);
|
|
478
|
+
const assignment = mutate({ cwd }, () => {
|
|
479
|
+
const existing = loadAssignment(options.id, cwd);
|
|
480
|
+
if (existing) {
|
|
481
|
+
assertAssignmentProjectionMatches(existing, expected);
|
|
482
|
+
const requiredTags = options.tags ?? [];
|
|
483
|
+
const missingAgentId = existing.agent_id === undefined && expected.agent_id !== undefined;
|
|
484
|
+
const missingContractRef = existing.execution_contract_ref === undefined && expected.execution_contract_ref !== undefined;
|
|
485
|
+
const missingCapabilitySnapshot = existing.capability_snapshot === undefined && expected.capability_snapshot !== undefined;
|
|
486
|
+
const missingTags = !requiredTags.every((tag) => existing.tags.includes(tag));
|
|
487
|
+
if (!missingAgentId && !missingContractRef && !missingCapabilitySnapshot && !missingTags)
|
|
488
|
+
return existing;
|
|
489
|
+
const enriched = AssignmentSchema.parse({
|
|
490
|
+
...existing,
|
|
491
|
+
...(missingAgentId ? { agent_id: expected.agent_id } : {}),
|
|
492
|
+
...(missingContractRef ? { execution_contract_ref: expected.execution_contract_ref } : {}),
|
|
493
|
+
...(missingCapabilitySnapshot ? { capability_snapshot: expected.capability_snapshot } : {}),
|
|
494
|
+
tags: [...new Set([...existing.tags, ...requiredTags])],
|
|
495
|
+
updated_at: nowISO(),
|
|
496
|
+
});
|
|
497
|
+
saveAssignmentUnlocked(enriched, cwd);
|
|
498
|
+
repaired = true;
|
|
499
|
+
return enriched;
|
|
500
|
+
}
|
|
501
|
+
saveAssignmentUnlocked(expected, cwd);
|
|
502
|
+
created = true;
|
|
503
|
+
return expected;
|
|
504
|
+
});
|
|
505
|
+
if (created)
|
|
506
|
+
emitAssignmentCreatedSideEffects(assignment, options, cwd);
|
|
507
|
+
return { assignment, created, ...(repaired ? { repaired: true } : {}) };
|
|
508
|
+
}
|
|
411
509
|
// ── Active Assignment Lookup ─────────────────────────────────
|
|
412
510
|
/** Statuses that indicate a finished assignment (no longer active). */
|
|
413
511
|
const TERMINAL_STATUSES = new Set(['completed', 'cancelled', 'expired', 'rerouted']);
|
|
@@ -68,5 +68,12 @@ export const ReleaseClaimRequestSchema = z.object({
|
|
|
68
68
|
.boolean()
|
|
69
69
|
.describe('Opt-in override for a trusted+ caller releasing a claim they do NOT own (cross-agent teardown, ghost-claim cleanup). Rejected for contributor-level callers; audited when used. trp#928.')
|
|
70
70
|
.optional(),
|
|
71
|
+
/** AttemptAuthority v2 fence; mandatory for a worker-owned claim linked to a v2 Assignment. */
|
|
72
|
+
turn_id: z.string().optional(),
|
|
73
|
+
run_id: z.string().optional(),
|
|
74
|
+
nonce: z.string().optional(),
|
|
75
|
+
attempt_epoch: z.number().int().nonnegative().optional(),
|
|
76
|
+
execution_contract_hash: z.string().regex(/^[a-f0-9]{64}$/).optional(),
|
|
77
|
+
workspace_digest: z.string().regex(/^[a-f0-9]{64}$/).optional(),
|
|
71
78
|
});
|
|
72
79
|
//# sourceMappingURL=claim-request-schema.js.map
|
package/dist/core/claims.js
CHANGED
|
@@ -16,6 +16,8 @@ import { loadState, persistState } from './state.js';
|
|
|
16
16
|
import { createRuntimeEvent } from './events.js';
|
|
17
17
|
import { latestActivityMs, readHeartbeat } from './runtime-signals.js';
|
|
18
18
|
import { emitRegistryPostImage, registryFaultPoint } from './events/registry-post-image.js';
|
|
19
|
+
import { loadAssignment } from './assignments.js';
|
|
20
|
+
import { currentAttemptRunIdForAssignment } from './loops/attempt-reservation.js';
|
|
19
21
|
/** Parse duration string like '4h', '30m' to ms. */
|
|
20
22
|
function parseTtl(value) {
|
|
21
23
|
const match = /^(\d+)([mhd])$/i.exec(value.trim());
|
|
@@ -632,6 +634,7 @@ function freshestEvidenceAgeMs(claim, nowMs, cwd) {
|
|
|
632
634
|
if (!claim.assignment_id)
|
|
633
635
|
return undefined;
|
|
634
636
|
const root = cwd ?? process.cwd();
|
|
637
|
+
const runId = currentAttemptRunIdForAssignment(claim.assignment_id, cwd);
|
|
635
638
|
let freshest;
|
|
636
639
|
const consider = (ms) => {
|
|
637
640
|
if (ms === undefined)
|
|
@@ -647,13 +650,13 @@ function freshestEvidenceAgeMs(claim, nowMs, cwd) {
|
|
|
647
650
|
freshest = normalised;
|
|
648
651
|
};
|
|
649
652
|
try {
|
|
650
|
-
const hb = readHeartbeat(root, claim.assignment_id, claim.worktree_path);
|
|
653
|
+
const hb = readHeartbeat(root, claim.assignment_id, claim.worktree_path, runId);
|
|
651
654
|
if (hb.exists)
|
|
652
655
|
consider(hb.mtimeMs);
|
|
653
656
|
}
|
|
654
657
|
catch { /* evidence is best-effort */ }
|
|
655
658
|
try {
|
|
656
|
-
consider(latestActivityMs(root, claim.assignment_id, claim.worktree_path));
|
|
659
|
+
consider(latestActivityMs(root, claim.assignment_id, claim.worktree_path, runId));
|
|
657
660
|
}
|
|
658
661
|
catch { /* evidence is best-effort */ }
|
|
659
662
|
return freshest;
|
|
@@ -1063,6 +1066,54 @@ export function linkClaimToAssignment(claimId, assignmentId, cwd) {
|
|
|
1063
1066
|
saveClaimUnlocked(claim, cwd);
|
|
1064
1067
|
});
|
|
1065
1068
|
}
|
|
1069
|
+
export class ClaimAssignmentConflictError extends Error {
|
|
1070
|
+
claimId;
|
|
1071
|
+
constructor(claimId, detail) {
|
|
1072
|
+
super(`Claim/Assignment projection conflict for ${claimId}: ${detail}`);
|
|
1073
|
+
this.claimId = claimId;
|
|
1074
|
+
this.name = 'ClaimAssignmentConflictError';
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* Idempotently bind an active claim to the deterministic Assignment of its turn.
|
|
1079
|
+
* Unlike the legacy patch helper, this never overwrites a divergent binding.
|
|
1080
|
+
*/
|
|
1081
|
+
export function ensureClaimAssignmentBinding(claimId, assignmentId, cwd, options = {}) {
|
|
1082
|
+
return mutate({ cwd }, () => {
|
|
1083
|
+
const claim = loadClaim(claimId, cwd);
|
|
1084
|
+
if (claim.status !== 'active') {
|
|
1085
|
+
throw new ClaimAssignmentConflictError(claimId, `claim is ${claim.status}, expected active`);
|
|
1086
|
+
}
|
|
1087
|
+
if (claim.assignment_id !== undefined && claim.assignment_id !== assignmentId) {
|
|
1088
|
+
// Symmetric review/fix cycles intentionally retain one active coordinator
|
|
1089
|
+
// claim across rounds. A later deterministic turn may advance that pointer,
|
|
1090
|
+
// but only after the previous Assignment is durably terminal.
|
|
1091
|
+
const prior = loadAssignment(claim.assignment_id, cwd);
|
|
1092
|
+
const priorTerminal = prior && [
|
|
1093
|
+
'completed', 'cancelled', 'expired', 'rerouted',
|
|
1094
|
+
].includes(prior.status);
|
|
1095
|
+
if (!priorTerminal) {
|
|
1096
|
+
throw new ClaimAssignmentConflictError(claimId, `already bound to non-terminal ${claim.assignment_id}, cannot bind ${assignmentId}`);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
let changed = false;
|
|
1100
|
+
if (claim.assignment_id !== assignmentId) {
|
|
1101
|
+
claim.assignment_id = assignmentId;
|
|
1102
|
+
changed = true;
|
|
1103
|
+
}
|
|
1104
|
+
// AttemptAuthority keeps one logical Assignment/claim across physical
|
|
1105
|
+
// generations. Rebind the claim's liveness and GC root to the current
|
|
1106
|
+
// generation workspace in the SAME mutation as its Assignment pointer.
|
|
1107
|
+
if (options.worktreePath !== undefined && claim.worktree_path !== options.worktreePath) {
|
|
1108
|
+
claim.worktree_path = options.worktreePath;
|
|
1109
|
+
changed = true;
|
|
1110
|
+
}
|
|
1111
|
+
if (!changed)
|
|
1112
|
+
return claim;
|
|
1113
|
+
saveClaimUnlocked(claim, cwd);
|
|
1114
|
+
return claim;
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1066
1117
|
/**
|
|
1067
1118
|
* Adopt a claim from a spawned instance's session.
|
|
1068
1119
|
* Sets session_id + adopted_at on the claim. Refuses if the claim is already
|
|
@@ -28,7 +28,8 @@ import { loadClaim } from './claims.js';
|
|
|
28
28
|
import { getLoop, listLoops } from './loops/store.js';
|
|
29
29
|
import { isProcessAlive } from './agentrun-reconciler.js';
|
|
30
30
|
import { findRuntimeNoteById } from './runtime.js';
|
|
31
|
-
import { latestActivityMs, decodeOemAwareBuffer } from './runtime-signals.js';
|
|
31
|
+
import { latestActivityMs, decodeOemAwareBuffer, getRuntimeLogPath, getRuntimeSignalPath } from './runtime-signals.js';
|
|
32
|
+
import { currentAttemptRunIdForAssignment } from './loops/attempt-reservation.js';
|
|
32
33
|
import { LaneResultSchema } from './schema.js';
|
|
33
34
|
const DEFAULT_TAIL = 20;
|
|
34
35
|
const DEFAULT_STALL_MS = 5 * 60_000;
|
|
@@ -422,10 +423,19 @@ export function getDispatchStatus(options) {
|
|
|
422
423
|
// coordination root. Use the cwd or the runtime cwd as the anchor; the
|
|
423
424
|
// dispatcher writes them under cwd/.brainclaw/coordination/runtime/...
|
|
424
425
|
const projectRoot = cwd ?? process.cwd();
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
const
|
|
426
|
+
const currentAttemptRunId = assignmentId
|
|
427
|
+
? currentAttemptRunIdForAssignment(assignmentId, cwd)
|
|
428
|
+
: undefined;
|
|
429
|
+
const runtimeRunId = currentAttemptRunId ? (agentRun?.id ?? currentAttemptRunId) : undefined;
|
|
430
|
+
const ackPath = assignmentId
|
|
431
|
+
? getRuntimeSignalPath(projectRoot, assignmentId, 'ack', runtimeRunId)
|
|
432
|
+
: undefined;
|
|
433
|
+
const stdoutPath = assignmentId
|
|
434
|
+
? getRuntimeLogPath(projectRoot, assignmentId, 'stdout', runtimeRunId)
|
|
435
|
+
: undefined;
|
|
436
|
+
const stderrPath = assignmentId
|
|
437
|
+
? getRuntimeLogPath(projectRoot, assignmentId, 'stderr', runtimeRunId)
|
|
438
|
+
: undefined;
|
|
429
439
|
// pln#527 — filesystem-activity age: max mtime across the captured logs + the
|
|
430
440
|
// run's worktree files (skipping junctions). The truer liveness signal when
|
|
431
441
|
// the heartbeat / last_event_at is stale during a long single operation.
|
|
@@ -436,7 +446,7 @@ export function getDispatchStatus(options) {
|
|
|
436
446
|
const worktreeForFs = agentRun?.worktree_path ?? claim?.worktree_path ?? assignment?.worktree_path;
|
|
437
447
|
let lastFsActivityMs;
|
|
438
448
|
if (assignmentId) {
|
|
439
|
-
const lastFs = latestActivityMs(projectRoot, assignmentId, worktreeForFs);
|
|
449
|
+
const lastFs = latestActivityMs(projectRoot, assignmentId, worktreeForFs, runtimeRunId);
|
|
440
450
|
if (lastFs !== undefined)
|
|
441
451
|
lastFsActivityMs = nowMs - lastFs;
|
|
442
452
|
}
|
package/dist/core/dispatcher.js
CHANGED
|
@@ -44,7 +44,8 @@ import { memoryDir } from './io.js';
|
|
|
44
44
|
import { loadVersionedJsonFile } from './migration.js';
|
|
45
45
|
import fs from 'node:fs';
|
|
46
46
|
import path from 'node:path';
|
|
47
|
-
import {
|
|
47
|
+
import { resolveBriefMode, getCapabilityProfile, dispatchHasMcp, dispatchCanCommit, isSandboxedSpawn, resolveConcurrencyLimit, resolveResourceKey, resolveModel, serializeConcurrencyLimit } from './agent-capability.js';
|
|
48
|
+
import { buildHarnessInvocation } from './harness-adapters/index.js';
|
|
48
49
|
import { getRuntimeSignalPath, getWorktreeHeartbeatPath } from './runtime-signals.js';
|
|
49
50
|
import { attemptExecution } from './execution.js';
|
|
50
51
|
import { createAssignment, transitionAssignment, generateAssignmentId, patchAssignmentMessageId } from './assignments.js';
|
|
@@ -67,9 +68,8 @@ function buildEnvPrefix(claimId) {
|
|
|
67
68
|
/**
|
|
68
69
|
* Analyze a sequence and categorize each item as ready, active, blocked, or done.
|
|
69
70
|
*
|
|
70
|
-
* `sequenceId`
|
|
71
|
-
*
|
|
72
|
-
* sequence without hijacking the global active-sequence pointer. Omitted → the active
|
|
71
|
+
* `sequenceId` targets a SPECIFIC sequence by id instead of the project's
|
|
72
|
+
* active one, without hijacking the global active-sequence pointer. Omitted → the active
|
|
73
73
|
* sequence (byte-identical to the historical behaviour; the resolver is non-throwing,
|
|
74
74
|
* so an unknown id yields `null` exactly like "no active sequence").
|
|
75
75
|
*/
|
|
@@ -283,9 +283,11 @@ export function buildLivenessSection(cwd, assignmentId, worktreePath, opts) {
|
|
|
283
283
|
// cwd is the worktree root) — same file, sandbox-proof spelling.
|
|
284
284
|
const sandboxRelative = opts?.sandboxed === true && !!worktreePath;
|
|
285
285
|
const hbPath = worktreePath
|
|
286
|
-
? getWorktreeHeartbeatPath(worktreePath, assignmentId)
|
|
287
|
-
: getRuntimeSignalPath(cwd, assignmentId, 'heartbeat');
|
|
288
|
-
const targetPath = sandboxRelative
|
|
286
|
+
? getWorktreeHeartbeatPath(worktreePath, assignmentId, opts?.runId)
|
|
287
|
+
: getRuntimeSignalPath(cwd, assignmentId, 'heartbeat', opts?.runId);
|
|
288
|
+
const targetPath = sandboxRelative
|
|
289
|
+
? `.brainclaw-heartbeat-${assignmentId}${opts?.runId ? `-${opts.runId}` : ''}`
|
|
290
|
+
: hbPath;
|
|
289
291
|
const isWin = process.platform === 'win32';
|
|
290
292
|
const writeCmd = isWin
|
|
291
293
|
? `echo work_loop_reached ${assignmentId} > "${targetPath}"`
|
|
@@ -326,50 +328,35 @@ export function buildWorkingDefaultsSection(opts) {
|
|
|
326
328
|
'',
|
|
327
329
|
].join('\n');
|
|
328
330
|
}
|
|
329
|
-
|
|
330
|
-
* pln#638 PR-4 — the transport section, for BOTH declared-MCP and MCP-less agents.
|
|
331
|
-
*
|
|
332
|
-
* WHY THIS EXISTS AS ONE FUNCTION. The MCP-less block was duplicated verbatim in
|
|
333
|
-
* `generateBrief` and `generateDispatchBrief`; that duplication is exactly what
|
|
334
|
-
* lets two brief paths drift, which is the class of bug PR-3 just fixed one layer
|
|
335
|
-
* up. One function, two callers.
|
|
336
|
-
*
|
|
337
|
-
* WHY A DECLARED-MCP AGENT ALSO GETS A SECTION — the part that was missing. A
|
|
338
|
-
* profile's `runtime.mcp_direct` is a STATIC flag: it asserts nothing about
|
|
339
|
-
* whether the config exists on this machine, whether the server started, or
|
|
340
|
-
* whether stdio came up. Proven in production during pln#638's own ideation: a
|
|
341
|
-
* codex critic ran with `mcp_direct=true` and NO reachable MCP. Its 3654-character
|
|
342
|
-
* critique survived only because the brief happened to spell out a file fallback
|
|
343
|
-
* by hand. So the brief must never ASSERT the capability — it states the
|
|
344
|
-
* expectation and names the fallback, and the worker decides from what it
|
|
345
|
-
* actually observes.
|
|
346
|
-
*
|
|
347
|
-
* The store path is deliberately NOT mentioned. `.brainclaw/` is gitignored
|
|
348
|
-
* (.gitignore:10), so it does not exist in a worker's worktree — the previous
|
|
349
|
-
* wording told workers to write candidates into a directory they cannot see.
|
|
350
|
-
*/
|
|
351
|
-
/**
|
|
352
|
-
* The ONE LANE-RESULT shape every brief quotes.
|
|
353
|
-
*
|
|
354
|
-
* There used to be two. `buildProtocolSection` has emitted a fallback since
|
|
355
|
-
* pln#526 with `{summary, files_changed, artifacts}` and NO `body`, while the
|
|
356
|
-
* transport section below asked for `body`. A full-mode worker with an assignment
|
|
357
|
-
* id received both and had to pick — and a worker that followed the older one
|
|
358
|
-
* recreated trp_8efdbf9d (a substantial review collapsed into a one-line summary
|
|
359
|
-
* because the contract had nowhere to put the reasoning). Caught in review by
|
|
360
|
-
* Fable before this shipped.
|
|
361
|
-
*/
|
|
362
|
-
export function laneResultShape(assignmentId) {
|
|
331
|
+
export function laneResultShape(assignmentId, contractRef, fence) {
|
|
363
332
|
const asgn = assignmentId ?? '<assignment_id>';
|
|
364
|
-
|
|
333
|
+
const generation = fence
|
|
334
|
+
? `,"turn_id":"${fence.turn_id}","run_id":"${fence.run_id}","nonce":"${fence.nonce}","attempt_epoch":${fence.attempt_epoch},"workspace_digest":"${fence.workspace_digest}"`
|
|
335
|
+
: '';
|
|
336
|
+
const contract = contractRef
|
|
337
|
+
? `,"execution_contract_hash":"${contractRef.hash}","capability_snapshot_hash":"${contractRef.snapshot_hash}"`
|
|
338
|
+
: '';
|
|
339
|
+
return `{"assignment_id":"${asgn}"${generation}${contract},"status":"completed|blocked|failed","summary":"<one line>","body":"<your full output — the reasoning, not just a label>","files_changed":["..."],"artifacts":["..."]}`;
|
|
365
340
|
}
|
|
366
341
|
export function buildTransportSection(opts) {
|
|
367
|
-
const laneResult = `write LANE-RESULT.json at the worktree ROOT: ${laneResultShape(opts.assignmentId)}`;
|
|
342
|
+
const laneResult = `write LANE-RESULT.json at the worktree ROOT: ${laneResultShape(opts.assignmentId, opts.executionContractRef, opts.attemptFence)}`;
|
|
343
|
+
const acceptance = opts.executionContractRef
|
|
344
|
+
? [
|
|
345
|
+
`Execution contract: ${opts.executionContractRef.hash}`,
|
|
346
|
+
`Capability snapshot: ${opts.executionContractRef.snapshot_hash}`,
|
|
347
|
+
'These values are also available as BRAINCLAW_EXECUTION_CONTRACT_HASH and BRAINCLAW_CAPABILITY_SNAPSHOT_HASH. Echo both unchanged in LANE-RESULT.json. If either differs from what your runtime accepted, report blocked immediately; Brainclaw withholds convergence and never respawns this crossed generation.',
|
|
348
|
+
...(opts.attemptFence ? [
|
|
349
|
+
`Attempt generation fence: turn=${opts.attemptFence.turn_id}, run=${opts.attemptFence.run_id}, nonce=${opts.attemptFence.nonce}, epoch=${opts.attemptFence.attempt_epoch}, workspace_digest=${opts.attemptFence.workspace_digest}.`,
|
|
350
|
+
'Include every fence field in bclaw_assignment_update as well as LANE-RESULT.json; an incomplete or stale update is rejected before mutating Assignment, AgentRun, or Claim.',
|
|
351
|
+
] : []),
|
|
352
|
+
]
|
|
353
|
+
: [];
|
|
368
354
|
if (!opts.hasMcp) {
|
|
369
355
|
return [
|
|
370
356
|
'## ⚠ Transport: no MCP (file protocol only)',
|
|
371
357
|
'Your runtime has no brainclaw MCP access — any `bclaw_*` instruction above does NOT apply to you. Report your outcome via the FILE protocol only; it is authoritative for this run:',
|
|
372
358
|
`- When done, ${laneResult}.`,
|
|
359
|
+
...acceptance,
|
|
373
360
|
// RESTORED after review. Removing this orphaned a real, shipped consumer:
|
|
374
361
|
// `collectWorktreeCandidateFiles` (harvest.ts:191-205) scans exactly this
|
|
375
362
|
// directory inside WORKER worktrees, and `bclaw_harvest_candidates` exposes
|
|
@@ -386,6 +373,7 @@ export function buildTransportSection(opts) {
|
|
|
386
373
|
'Your profile declares brainclaw MCP access, but that is a DECLARATION, not a verified fact — the config may be absent on this machine or the server may not have started. Decide from what you actually observe:',
|
|
387
374
|
'- If `bclaw_*` tools respond: use them, as instructed above.',
|
|
388
375
|
`- If they are unavailable or error: do not stop and do not discard your work — ${laneResult}. The coordinator harvests it.`,
|
|
376
|
+
...acceptance,
|
|
389
377
|
'',
|
|
390
378
|
].join('\n');
|
|
391
379
|
}
|
|
@@ -463,11 +451,17 @@ export function buildProtocolSection(options) {
|
|
|
463
451
|
parts.push(`${options.worktreePath ? '3' : '2'}. Call bclaw_assignment_update(assignment_id: "${options.assignmentId}", status: "started")`);
|
|
464
452
|
parts.push(`${options.worktreePath ? '4' : '3'}. Work on the assigned scope`);
|
|
465
453
|
parts.push(`${options.worktreePath ? '5' : '4'}. Periodically call bclaw_assignment_update(status: "progress", message: "...") as heartbeat`);
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
454
|
+
if (options.attemptFence) {
|
|
455
|
+
parts.push(`${options.worktreePath ? '6' : '5'}. Report the terminal outcome in full-fence LANE-RESULT.json; do NOT terminalize the logical Assignment or release its Claim. Brainclaw settles close(epoch) first, then replays those projections.`);
|
|
456
|
+
parts.push(`${options.worktreePath ? '7' : '6'}. If blocked or failed, encode that status and explanation in LANE-RESULT.json with the same complete generation fence.`);
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
parts.push(`${options.worktreePath ? '6' : '5'}. When done: bclaw_assignment_update(status: "completed", artifacts: [...])`);
|
|
460
|
+
const claimRef = options?.claimId ? `id: "${options.claimId}"` : 'id: "<claim_id>"';
|
|
461
|
+
parts.push(`${options.worktreePath ? '7' : '6'}. Release the claim: bclaw_release_claim(${claimRef}, planStatus: "done") — required for hard_after gating to unblock downstream tasks`);
|
|
462
|
+
parts.push(`${options.worktreePath ? '8' : '7'}. If blocked: bclaw_assignment_update(status: "blocked", blocker: "...")`);
|
|
463
|
+
parts.push(`${options.worktreePath ? '9' : '8'}. If failed: bclaw_assignment_update(status: "failed", error_message: "...")`);
|
|
464
|
+
}
|
|
471
465
|
// pln#479: compile-check contract for code workers — a per-worktree
|
|
472
466
|
// pre-commit gate may HARD-block a commit that fails tsc (opt-in).
|
|
473
467
|
if (options.worktreePath) {
|
|
@@ -763,7 +757,10 @@ export function generateDispatchBrief(options) {
|
|
|
763
757
|
// sprint 1.5 — task-based briefs get the same step-0 liveness contract as
|
|
764
758
|
// plan-based briefs (worktree-local heartbeat, writable from any sandbox).
|
|
765
759
|
if (options.assignmentId && options.worktreePath) {
|
|
766
|
-
parts.push(buildLivenessSection(options.worktreePath, options.assignmentId, options.worktreePath, {
|
|
760
|
+
parts.push(buildLivenessSection(options.worktreePath, options.assignmentId, options.worktreePath, {
|
|
761
|
+
sandboxed: taskSandboxed,
|
|
762
|
+
runId: options.attemptFence?.run_id,
|
|
763
|
+
}));
|
|
767
764
|
}
|
|
768
765
|
// pln#554 step 4 — working defaults (incremental commits + validation bar).
|
|
769
766
|
parts.push(buildWorkingDefaultsSection({ canCommit: taskBriefProfile ? dispatchCanCommit(taskBriefProfile) : true }));
|
|
@@ -779,6 +776,7 @@ export function generateDispatchBrief(options) {
|
|
|
779
776
|
claimId: options.claimId,
|
|
780
777
|
worktreePath: options.worktreePath,
|
|
781
778
|
assignmentId: options.assignmentId,
|
|
779
|
+
attemptFence: options.attemptFence,
|
|
782
780
|
}));
|
|
783
781
|
}
|
|
784
782
|
// pln#628 Focus 4A — transport addendum keyed to the ACTUAL missing capability
|
|
@@ -792,6 +790,8 @@ export function generateDispatchBrief(options) {
|
|
|
792
790
|
parts.push(buildTransportSection({
|
|
793
791
|
hasMcp: taskBriefProfile ? dispatchHasMcp(taskBriefProfile) : true,
|
|
794
792
|
assignmentId: options.assignmentId,
|
|
793
|
+
executionContractRef: options.executionContractRef,
|
|
794
|
+
attemptFence: options.attemptFence,
|
|
795
795
|
}));
|
|
796
796
|
}
|
|
797
797
|
// Codex-specific constraints: focus and speed guidance for sandboxed runs
|
|
@@ -1087,7 +1087,7 @@ export async function dispatch(options, cwd) {
|
|
|
1087
1087
|
// `sandboxed` flag. A --dry-run that previews a DIFFERENT brief than the one
|
|
1088
1088
|
// that ships is worse than no preview.
|
|
1089
1089
|
const brief = generateBrief(readyItem.plan, readyItem.item, cwd, briefMode, { claimId, worktreePath, agent: targetAgent });
|
|
1090
|
-
const invokeCmd =
|
|
1090
|
+
const invokeCmd = buildHarnessInvocation(targetAgent, brief, { model: resolveModel(targetAgent, { override: options.model }) })?.invoke;
|
|
1091
1091
|
if (invokeCmd) {
|
|
1092
1092
|
const cmdPrefix = buildEnvPrefix(claimId);
|
|
1093
1093
|
result.commands.push({ agent: targetAgent, lane: readyItem.lane, plan_id: readyItem.plan.id, command: `${cmdPrefix}${invokeCmd.bashCommand}`, shell: process.platform === 'win32' ? 'cmd' : (invokeCmd.shell ? 'bash' : 'sh') });
|
|
@@ -1143,7 +1143,7 @@ export async function dispatch(options, cwd) {
|
|
|
1143
1143
|
agent: targetAgent,
|
|
1144
1144
|
});
|
|
1145
1145
|
// Step 3: Build invoke command
|
|
1146
|
-
const invokeCmd =
|
|
1146
|
+
const invokeCmd = buildHarnessInvocation(targetAgent, brief, { model: resolveModel(targetAgent, { override: options.model }) })?.invoke;
|
|
1147
1147
|
if (invokeCmd) {
|
|
1148
1148
|
const cmdPrefix = buildEnvPrefix(claimId);
|
|
1149
1149
|
result.commands.push({
|
|
@@ -26,6 +26,9 @@ import { loadAllSessions } from './identity.js';
|
|
|
26
26
|
import { loadInstructions } from './instructions.js';
|
|
27
27
|
import { deleteAssignment, listAssignments, loadAssignment, saveAssignment, transitionAssignment } from './assignments.js';
|
|
28
28
|
import { listAgentRuns } from './agentruns.js';
|
|
29
|
+
import { currentAttemptRunIdForAssignment } from './loops/attempt-reservation.js';
|
|
30
|
+
import { findReservationByAssignmentId } from './loops/attempt-reservation.js';
|
|
31
|
+
import { getLoop } from './loops/store.js';
|
|
29
32
|
import { reconcileAgentRun, reconcileDeadPidRunningAgentRunAtRead, reconcileStrandedFailureClaimAtRead, TERMINAL_STATUSES } from './agentrun-reconciler.js';
|
|
30
33
|
import { isObserverMode } from './observer-mode.js';
|
|
31
34
|
import { deleteRuntimeNote, listRuntimeNotes, parkRuntimeNoteBackup, saveRuntimeNote, } from './runtime.js';
|
|
@@ -709,6 +712,9 @@ export function updateEntity(name, id, patch, cwd) {
|
|
|
709
712
|
const assignment = loadAssignment(id, cwd);
|
|
710
713
|
if (!assignment)
|
|
711
714
|
throw new EntityNotFoundError(name, id);
|
|
715
|
+
if (currentAttemptRunIdForAssignment(id, cwd)) {
|
|
716
|
+
throw new Error(`assignment '${id}' is managed by AttemptAuthority v2; generic update is fenced until authoritative loop settlement/recovery`);
|
|
717
|
+
}
|
|
712
718
|
const patched = { ...assignment, ...patch };
|
|
713
719
|
saveAssignment(patched, cwd);
|
|
714
720
|
return { entity: name, id };
|
|
@@ -894,6 +900,9 @@ export function removeEntity(name, id, cwd, purge = false) {
|
|
|
894
900
|
const assignment = loadAssignment(id, cwd);
|
|
895
901
|
if (!assignment)
|
|
896
902
|
throw new EntityNotFoundError(name, id);
|
|
903
|
+
if (currentAttemptRunIdForAssignment(id, cwd)) {
|
|
904
|
+
throw new Error(`assignment '${id}' is managed by AttemptAuthority v2; remove is fenced until authoritative loop settlement/recovery`);
|
|
905
|
+
}
|
|
897
906
|
if (purge) {
|
|
898
907
|
const deleted = deleteAssignment(id, cwd);
|
|
899
908
|
if (!deleted)
|
|
@@ -987,6 +996,9 @@ export function transitionEntity(name, id, to, cwd, _reason, auth) {
|
|
|
987
996
|
throw new InvalidTransitionError(name, from, to);
|
|
988
997
|
}
|
|
989
998
|
case 'assignment': {
|
|
999
|
+
if (currentAttemptRunIdForAssignment(id, cwd)) {
|
|
1000
|
+
throw new Error(`assignment '${id}' is managed by AttemptAuthority v2; use full-fence bclaw_assignment_update or loop reconciliation`);
|
|
1001
|
+
}
|
|
990
1002
|
transitionAssignment(id, to, {
|
|
991
1003
|
actor: 'brainclaw',
|
|
992
1004
|
status_reason: _reason,
|
|
@@ -1000,6 +1012,14 @@ export function transitionEntity(name, id, to, cwd, _reason, auth) {
|
|
|
1000
1012
|
return { entity: name, id, from, to, side_effects: sideEffects };
|
|
1001
1013
|
}
|
|
1002
1014
|
case 'claim': {
|
|
1015
|
+
const claim = loadClaim(id, cwd);
|
|
1016
|
+
if (claim.assignment_id && currentAttemptRunIdForAssignment(claim.assignment_id, cwd)) {
|
|
1017
|
+
const reservation = findReservationByAssignmentId(claim.assignment_id, cwd);
|
|
1018
|
+
const loop = reservation ? getLoop(reservation.loop_id, cwd) : undefined;
|
|
1019
|
+
if (!auth?.override || !loop || auth.agent_id !== loop.created_by) {
|
|
1020
|
+
throw new Error(`claim '${id}' belongs to an AttemptAuthority v2 assignment; override requires authenticated loop creator ${loop?.created_by ?? 'unknown'}`);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1003
1023
|
// trp#928 — the entity registry advertised `active → released|stale` but
|
|
1004
1024
|
// transitionEntity never routed for entity=claim. The isValidTransition
|
|
1005
1025
|
// check above passed for anyone calling `bclaw_transition(entity='claim',
|
package/dist/core/events.js
CHANGED
|
@@ -121,6 +121,10 @@ export function createRuntimeEvent(options, cwd) {
|
|
|
121
121
|
tags: options.tags ?? [],
|
|
122
122
|
assignment_id: options.assignment_id,
|
|
123
123
|
run_id: options.run_id,
|
|
124
|
+
turn_id: options.turn_id,
|
|
125
|
+
nonce: options.nonce,
|
|
126
|
+
attempt_epoch: options.attempt_epoch,
|
|
127
|
+
workspace_digest: options.workspace_digest,
|
|
124
128
|
claim_id: options.claim_id,
|
|
125
129
|
message_id: options.message_id,
|
|
126
130
|
plan_id: options.plan_id,
|