brainclaw 1.28.1 → 1.28.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/brainclaw-vscode.vsix +0 -0
  2. package/dist/commands/code-map.js +2 -0
  3. package/dist/commands/doctor.js +1 -0
  4. package/dist/commands/harvest.js +32 -43
  5. package/dist/commands/loops-handlers.js +66 -3
  6. package/dist/commands/mcp-catalog.js +2 -2
  7. package/dist/commands/mcp-write-coordination.js +426 -141
  8. package/dist/commands/mcp-write-entities.js +5 -2
  9. package/dist/commands/mcp.js +57 -11
  10. package/dist/core/agentrun-reconciler.js +138 -4
  11. package/dist/core/claims.js +4 -1
  12. package/dist/core/code-map/aggregate.js +20 -7
  13. package/dist/core/code-map/backend.js +25 -7
  14. package/dist/core/code-map/cascade-jobs.js +174 -0
  15. package/dist/core/code-map/cascade-worker.js +15 -0
  16. package/dist/core/code-map/cascade.js +63 -26
  17. package/dist/core/code-map/query.js +6 -3
  18. package/dist/core/entity-operations.js +18 -4
  19. package/dist/core/execution-adapters.js +23 -8
  20. package/dist/core/hygiene-policy.js +2 -1
  21. package/dist/core/loop-turn-dispatch.js +18 -1
  22. package/dist/core/loops/attempt-authority.js +22 -4
  23. package/dist/core/loops/attempt-generations.js +17 -4
  24. package/dist/core/loops/attempt-reservation.js +14 -1
  25. package/dist/core/loops/attempt-takeover.js +173 -76
  26. package/dist/core/loops/reconcile-turn.js +224 -26
  27. package/dist/core/loops/result-reducers.js +8 -8
  28. package/dist/core/loops/turn-execution.js +38 -19
  29. package/dist/core/loops/types.js +3 -0
  30. package/dist/core/loops/verbs.js +1 -1
  31. package/dist/core/spawn-check.js +9 -1
  32. package/dist/facts.js +8 -8
  33. package/dist/facts.json +7 -7
  34. package/docs/cli.md +2 -2
  35. package/docs/code-map.md +30 -9
  36. package/docs/concepts/loop-engine.md +5 -0
  37. package/docs/integrations/mcp.md +2 -2
  38. package/docs/mcp-schema-changelog.md +30 -0
  39. package/package.json +1 -1
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { z } from 'zod';
5
+ import { CapabilitySnapshotSchema } from '../execution-contract.js';
5
6
  import { memoryDir, writeFileAtomic } from '../io.js';
6
7
  import { nowISO } from '../ids.js';
7
8
  /**
@@ -31,9 +32,17 @@ export const GenerationFenceSchema = z.object({
31
32
  workspace_digest: z.string().min(1),
32
33
  authority_home: AuthorityHomeSchema,
33
34
  }).strict();
35
+ export const GenerationExecutorSchema = z.object({
36
+ agent: z.string().min(1),
37
+ agent_id: z.string().min(1).optional(),
38
+ claim_id: z.string().min(1),
39
+ capability_snapshot: CapabilitySnapshotSchema,
40
+ }).strict();
34
41
  export const AttemptGenerationSchema = GenerationFenceSchema.extend({
35
42
  schema_version: z.literal(ATTEMPT_AUTHORITY_VERSION),
36
43
  generation_kind: z.literal('attempt_generation'),
44
+ /** Executor ownership is generation-scoped so a reroute never inherits stale provenance. */
45
+ executor: GenerationExecutorSchema.optional(),
37
46
  created_at: z.string().min(1),
38
47
  }).strict();
39
48
  export const LaunchDecisionCellSchema = z.object({
@@ -145,8 +154,9 @@ function stableId(prefix, material) {
145
154
  return `${prefix}_${digest}`;
146
155
  }
147
156
  /** Compatible with the existing logical Assignment derivation. */
148
- export function deriveGenerationAssignmentId(turnId) {
149
- return stableId('asgn', `${turnId}:assignment`);
157
+ export function deriveGenerationAssignmentId(turnId, attemptEpoch = 0) {
158
+ const salt = attemptEpoch === 0 ? 'assignment' : `assignment:${attemptEpoch}`;
159
+ return stableId('asgn', `${turnId}:${salt}`);
150
160
  }
151
161
  /** Epoch zero preserves the existing run id; every later epoch is fresh. */
152
162
  export function deriveGenerationRunId(turnId, attemptEpoch) {
@@ -161,7 +171,7 @@ export function prepareInitialGeneration(input) {
161
171
  schema_version: ATTEMPT_AUTHORITY_VERSION,
162
172
  generation_kind: 'attempt_generation',
163
173
  turn_id: input.turn_id,
164
- assignment_id: deriveGenerationAssignmentId(input.turn_id),
174
+ assignment_id: deriveGenerationAssignmentId(input.turn_id, 0),
165
175
  attempt_epoch: 0,
166
176
  run_id: deriveGenerationRunId(input.turn_id, 0),
167
177
  launch_nonce: input.launch_nonce ?? crypto.randomUUID(),
@@ -170,6 +180,7 @@ export function prepareInitialGeneration(input) {
170
180
  workspace_path: input.workspace_path,
171
181
  workspace_digest: input.workspace_digest,
172
182
  authority_home: AuthorityHomeSchema.parse(input.authority_home),
183
+ executor: input.executor,
173
184
  created_at: input.created_at ?? nowISO(),
174
185
  });
175
186
  }
@@ -178,6 +189,7 @@ export function prepareNextGeneration(current, input) {
178
189
  const attemptEpoch = parsed.attempt_epoch + 1;
179
190
  return AttemptGenerationSchema.parse({
180
191
  ...parsed,
192
+ assignment_id: deriveGenerationAssignmentId(parsed.turn_id, attemptEpoch),
181
193
  attempt_epoch: attemptEpoch,
182
194
  run_id: deriveGenerationRunId(parsed.turn_id, attemptEpoch),
183
195
  launch_nonce: input.launch_nonce ?? crypto.randomUUID(),
@@ -185,6 +197,7 @@ export function prepareNextGeneration(current, input) {
185
197
  workspace_id: deriveGenerationWorkspaceId(parsed.turn_id, attemptEpoch),
186
198
  workspace_path: input.workspace_path,
187
199
  workspace_digest: input.workspace_digest,
200
+ executor: input.executor ?? parsed.executor,
188
201
  created_at: input.created_at ?? nowISO(),
189
202
  });
190
203
  }
@@ -231,7 +244,7 @@ function assertValidSuccessor(current, next) {
231
244
  }
232
245
  const invalid = [
233
246
  current.turn_id !== next.turn_id && 'turn_id',
234
- current.assignment_id !== next.assignment_id && 'assignment_id',
247
+ next.assignment_id === current.assignment_id && 'assignment_id',
235
248
  next.attempt_epoch !== current.attempt_epoch + 1 && 'attempt_epoch',
236
249
  next.run_id === current.run_id && 'run_id',
237
250
  next.launch_nonce === current.launch_nonce && 'launch_nonce',
@@ -446,7 +446,20 @@ export function findReservationByAssignmentId(assignmentId, cwd) {
446
446
  // coexists with a real LANE-RESULT (dispatch commits before spawn). Filtering here makes
447
447
  // the turn-owned discriminator explicit — a `prepared`/`aborted` reservation must never
448
448
  // route a lane to reconcileTurn (it has no live launch generation to accept evidence for).
449
- return listReservations({ decision: 'committed' }, cwd).find((r) => r.child_ids.assignment_id === assignmentId);
449
+ return listReservations({ decision: 'committed' }, cwd).find((reservation) => {
450
+ if (reservation.child_ids.assignment_id === assignmentId)
451
+ return true;
452
+ try {
453
+ const root = cwd ?? reservation.store_root;
454
+ const initial = readInitialGeneration(root, reservation.turn_id);
455
+ return initial
456
+ ? listAttemptGenerations(root, initial).some((generation) => generation.assignment_id === assignmentId)
457
+ : false;
458
+ }
459
+ catch {
460
+ return false;
461
+ }
462
+ });
450
463
  }
451
464
  /** Run-scoping key for runtime evidence; undefined preserves legacy assignment-scoped paths. */
452
465
  export function currentAttemptRunIdForAssignment(assignmentId, cwd) {
@@ -1,16 +1,36 @@
1
1
  import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import { ensureAgentRunProjection, loadAgentRun, transitionAgentRun } from '../agentruns.js';
4
- import { loadAssignment } from '../assignments.js';
4
+ import { convergeAssignmentToTerminal, loadAssignment, transitionAssignment } from '../assignments.js';
5
5
  import { createRuntimeEvent } from '../events.js';
6
6
  import { nowISO } from '../ids.js';
7
7
  import { prepareAttemptTakeoverV2 } from './attempt-authority.js';
8
- import { generationDigest } from './attempt-generations.js';
8
+ import { generationDigest, resolveTurnGenerationChain } from './attempt-generations.js';
9
9
  import { commitViaIntent } from './commit-intent.js';
10
10
  import { evidenceDigest } from './evidence.js';
11
11
  import { withLoopLock } from './lock.js';
12
12
  import { getReservation } from './attempt-reservation.js';
13
13
  import { getLoop, listLoopEvents } from './store.js';
14
+ /**
15
+ * Signals that the immutable successor generation already won even though a
16
+ * replayable projection failed afterward. Callers must never roll claims back
17
+ * on this error; replaying the same takeover input repairs the projections.
18
+ */
19
+ export class AttemptTakeoverCommittedError extends Error {
20
+ turn_id;
21
+ assignment_id;
22
+ run_id;
23
+ attempt_epoch;
24
+ constructor(takeover, cause) {
25
+ super(`takeover generation ${takeover.next_generation.attempt_epoch} committed; projection repair required: ${cause instanceof Error ? cause.message : String(cause)}`);
26
+ this.name = 'AttemptTakeoverCommittedError';
27
+ this.turn_id = takeover.next_generation.turn_id;
28
+ this.assignment_id = takeover.next_generation.assignment_id;
29
+ this.run_id = takeover.next_generation.run_id;
30
+ this.attempt_epoch = takeover.next_generation.attempt_epoch;
31
+ this.cause = cause;
32
+ }
33
+ }
14
34
  /**
15
35
  * Operator/engine takeover transaction.
16
36
  *
@@ -25,66 +45,137 @@ export function takeoverLoopAttempt(input) {
25
45
  throw new Error(`takeover workspace must already exist as an isolated directory: ${input.next_workspace_path}`);
26
46
  }
27
47
  const authorityActor = input.actor_id ?? input.actor;
28
- const transaction = withLoopLock({
29
- cwd: input.cwd,
30
- intent: 'attempt-takeover',
31
- agentId: authorityActor,
32
- scope: { kind: 'loop', loopId: input.loop_id },
33
- work: () => {
34
- const loop = getLoop(input.loop_id, input.cwd);
35
- if (!loop || loop.status !== 'open')
36
- throw new Error(`loop ${input.loop_id} is not open`);
37
- if (loop.created_by !== authorityActor) {
38
- throw new Error(`attempt takeover requires loop coordinator ${loop.created_by}; caller is ${authorityActor}`);
48
+ let committed;
49
+ let transaction;
50
+ try {
51
+ transaction = withLoopLock({
52
+ cwd: input.cwd,
53
+ intent: 'attempt-takeover',
54
+ agentId: authorityActor,
55
+ scope: { kind: 'loop', loopId: input.loop_id },
56
+ work: () => {
57
+ const loop = getLoop(input.loop_id, input.cwd);
58
+ if (!loop || loop.status !== 'open')
59
+ throw new Error(`loop ${input.loop_id} is not open`);
60
+ if (loop.created_by !== authorityActor) {
61
+ throw new Error(`attempt takeover requires loop coordinator ${loop.created_by}; caller is ${authorityActor}`);
62
+ }
63
+ const slot = loop.slots.find((candidate) => candidate.slot_id === input.slot_id);
64
+ if (!slot)
65
+ throw new Error(`slot ${input.slot_id} not found in loop ${input.loop_id}`);
66
+ const reservation = getReservation(input.turn_id, input.cwd);
67
+ if (!reservation || reservation.loop_id !== loop.id || reservation.slot_id !== slot.slot_id) {
68
+ throw new Error(`turn ${input.turn_id} does not own ${loop.id}/${slot.slot_id}`);
69
+ }
70
+ const activeGeneration = resolveTurnGenerationChain(input.cwd, input.turn_id)?.latest_generation;
71
+ const activeAssignmentId = activeGeneration?.assignment_id ?? reservation.child_ids.assignment_id;
72
+ if (slot.current_turn_id !== input.turn_id || slot.assignment_id !== activeAssignmentId) {
73
+ throw new Error(`slot ${slot.slot_id} is no longer bound to turn ${input.turn_id}`);
74
+ }
75
+ const takeover = prepareAttemptTakeoverV2({ ...input, actor: authorityActor });
76
+ committed = takeover;
77
+ input.on_stage?.('authority_committed');
78
+ const duplicate = listLoopEvents(loop.id, input.cwd).some((event) => event.kind === 'attempt_generation_changed'
79
+ && event.turn_id === input.turn_id
80
+ && event.to_epoch === takeover.next_generation.attempt_epoch
81
+ && event.to_run_id === takeover.next_generation.run_id);
82
+ if (duplicate)
83
+ return { loop, takeover, reservation };
84
+ const now = nowISO();
85
+ const mutationId = crypto.randomUUID();
86
+ const executor = takeover.next_generation.executor ?? {
87
+ agent: reservation.agent,
88
+ agent_id: reservation.agent_id,
89
+ claim_id: reservation.claim_id,
90
+ capability_snapshot: reservation.capability_snapshot,
91
+ };
92
+ const event = {
93
+ event_id: crypto.randomUUID(),
94
+ loop_id: loop.id,
95
+ seq: loop.version + 1,
96
+ at: now,
97
+ by: authorityActor,
98
+ mutation_id: mutationId,
99
+ kind: 'attempt_generation_changed',
100
+ slot_id: slot.slot_id,
101
+ turn_id: input.turn_id,
102
+ assignment_id: takeover.next_generation.assignment_id,
103
+ claim_id: executor.claim_id,
104
+ agent: executor.agent,
105
+ agent_id: executor.agent_id,
106
+ from_epoch: takeover.previous_generation.attempt_epoch,
107
+ to_epoch: takeover.next_generation.attempt_epoch,
108
+ from_run_id: takeover.previous_generation.run_id,
109
+ to_run_id: takeover.next_generation.run_id,
110
+ close_digest: evidenceDigest(takeover.close_cell),
111
+ cause: input.cause,
112
+ };
113
+ const next = {
114
+ ...loop,
115
+ version: loop.version + 1,
116
+ mutation_id: mutationId,
117
+ slots: loop.slots.map((candidate) => candidate.slot_id === slot.slot_id
118
+ ? {
119
+ ...candidate,
120
+ agent: executor.agent,
121
+ agent_id: executor.agent_id,
122
+ assignment_id: takeover.next_generation.assignment_id,
123
+ claim_id: executor.claim_id,
124
+ current_turn_id: input.turn_id,
125
+ status: 'assigned',
126
+ }
127
+ : candidate),
128
+ updated_at: now,
129
+ };
130
+ commitViaIntent({ loop_id: loop.id, base_version: loop.version, events: [event], thread_snapshot: next }, input.cwd);
131
+ input.on_stage?.('loop_committed');
132
+ return { loop: next, takeover, reservation };
133
+ },
134
+ });
135
+ }
136
+ catch (error) {
137
+ if (committed)
138
+ throw new AttemptTakeoverCommittedError(committed, error);
139
+ throw error;
140
+ }
141
+ const { takeover, reservation } = transaction;
142
+ const executor = takeover.next_generation.executor ?? {
143
+ agent: reservation.agent,
144
+ agent_id: reservation.agent_id,
145
+ claim_id: reservation.claim_id,
146
+ capability_snapshot: reservation.capability_snapshot,
147
+ };
148
+ if (takeover.previous_generation.assignment_id !== takeover.next_generation.assignment_id) {
149
+ try {
150
+ const previousAssignmentId = takeover.previous_generation.assignment_id;
151
+ const previousAssignment = loadAssignment(previousAssignmentId, input.cwd);
152
+ const predecessorTerminal = input.predecessor_assignment_terminal ?? 'cancelled';
153
+ const statusReason = `fenced by ${input.mode ?? 'takeover'} to epoch ${takeover.next_generation.attempt_epoch}`;
154
+ // System convergence intentionally covers only file-worker states
155
+ // (offered/accepted/started). A takeover can win before launch while the
156
+ // predecessor is still created, or during retry setup; both states have
157
+ // a legal direct cancellation edge and must be terminal before the stable
158
+ // claim is rebound to the successor generation.
159
+ if (predecessorTerminal === 'rerouted' && previousAssignment && previousAssignment.status !== 'rerouted') {
160
+ transitionAssignment(previousAssignmentId, 'rerouted', {
161
+ actor: input.actor,
162
+ syncAgentRun: false,
163
+ status_reason: statusReason,
164
+ }, input.cwd);
39
165
  }
40
- const slot = loop.slots.find((candidate) => candidate.slot_id === input.slot_id);
41
- if (!slot)
42
- throw new Error(`slot ${input.slot_id} not found in loop ${input.loop_id}`);
43
- const reservation = getReservation(input.turn_id, input.cwd);
44
- if (!reservation || reservation.loop_id !== loop.id || reservation.slot_id !== slot.slot_id) {
45
- throw new Error(`turn ${input.turn_id} does not own ${loop.id}/${slot.slot_id}`);
166
+ else if (previousAssignment?.status === 'created' || previousAssignment?.status === 'retrying') {
167
+ transitionAssignment(previousAssignmentId, 'cancelled', {
168
+ actor: input.actor,
169
+ syncAgentRun: false,
170
+ status_reason: statusReason,
171
+ }, input.cwd);
46
172
  }
47
- if (slot.current_turn_id !== input.turn_id || slot.assignment_id !== reservation.child_ids.assignment_id) {
48
- throw new Error(`slot ${slot.slot_id} is no longer bound to turn ${input.turn_id}`);
173
+ else {
174
+ convergeAssignmentToTerminal(previousAssignmentId, 'cancelled', statusReason, input.cwd);
49
175
  }
50
- const takeover = prepareAttemptTakeoverV2({ ...input, actor: authorityActor });
51
- const duplicate = listLoopEvents(loop.id, input.cwd).some((event) => event.kind === 'attempt_generation_changed'
52
- && event.turn_id === input.turn_id
53
- && event.to_epoch === takeover.next_generation.attempt_epoch
54
- && event.to_run_id === takeover.next_generation.run_id);
55
- if (duplicate)
56
- return { loop, takeover, reservation };
57
- const now = nowISO();
58
- const mutationId = crypto.randomUUID();
59
- const event = {
60
- event_id: crypto.randomUUID(),
61
- loop_id: loop.id,
62
- seq: loop.version + 1,
63
- at: now,
64
- by: authorityActor,
65
- mutation_id: mutationId,
66
- kind: 'attempt_generation_changed',
67
- slot_id: slot.slot_id,
68
- turn_id: input.turn_id,
69
- assignment_id: takeover.next_generation.assignment_id,
70
- from_epoch: takeover.previous_generation.attempt_epoch,
71
- to_epoch: takeover.next_generation.attempt_epoch,
72
- from_run_id: takeover.previous_generation.run_id,
73
- to_run_id: takeover.next_generation.run_id,
74
- close_digest: evidenceDigest(takeover.close_cell),
75
- cause: input.cause,
76
- };
77
- const next = {
78
- ...loop,
79
- version: loop.version + 1,
80
- mutation_id: mutationId,
81
- updated_at: now,
82
- };
83
- commitViaIntent({ loop_id: loop.id, base_version: loop.version, events: [event], thread_snapshot: next }, input.cwd);
84
- return { loop: next, takeover, reservation };
85
- },
86
- });
87
- const { takeover, reservation } = transaction;
176
+ }
177
+ catch { /* immutable close cell already fences the old assignment */ }
178
+ }
88
179
  const assignment = loadAssignment(takeover.next_generation.assignment_id, input.cwd);
89
180
  const previousRun = loadAgentRun(takeover.previous_generation.run_id, input.cwd);
90
181
  if (previousRun && !['completed', 'failed', 'cancelled', 'timed_out', 'interrupted'].includes(previousRun.status)) {
@@ -97,23 +188,29 @@ export function takeoverLoopAttempt(input) {
97
188
  }
98
189
  catch { /* immutable close cell already fences the old run */ }
99
190
  }
100
- ensureAgentRunProjection({
101
- id: takeover.next_generation.run_id,
102
- short_label: takeover.next_generation.run_id,
103
- assignment_id: takeover.next_generation.assignment_id,
104
- claim_id: reservation.claim_id,
105
- attempt_index: takeover.next_generation.attempt_epoch + 1,
106
- agent: reservation.agent,
107
- agent_id: reservation.agent_id,
108
- transport: 'cli_spawn',
109
- status: 'created',
110
- scope: assignment?.scope ?? reservation.execution_contract?.workspace_policy.scope ?? reservation.cwd,
111
- description: assignment?.description ?? `Attempt generation ${takeover.next_generation.attempt_epoch} for ${input.turn_id}`,
112
- worktree_path: takeover.next_generation.workspace_path,
113
- execution_contract_ref: takeover.execution_contract_ref,
114
- capability_snapshot: reservation.capability_snapshot,
115
- tags: ['turn-owned', 'loop', 'attempt-takeover', `attempt-generation:${takeover.next_generation.attempt_epoch}`],
116
- }, input.cwd);
191
+ try {
192
+ ensureAgentRunProjection({
193
+ id: takeover.next_generation.run_id,
194
+ short_label: takeover.next_generation.run_id,
195
+ assignment_id: takeover.next_generation.assignment_id,
196
+ claim_id: executor.claim_id,
197
+ attempt_index: takeover.next_generation.attempt_epoch + 1,
198
+ agent: executor.agent,
199
+ agent_id: executor.agent_id,
200
+ transport: 'cli_spawn',
201
+ status: 'created',
202
+ scope: assignment?.scope ?? reservation.execution_contract?.workspace_policy.scope ?? reservation.cwd,
203
+ description: assignment?.description ?? `Attempt generation ${takeover.next_generation.attempt_epoch} for ${input.turn_id}`,
204
+ worktree_path: takeover.next_generation.workspace_path,
205
+ execution_contract_ref: takeover.execution_contract_ref,
206
+ capability_snapshot: executor.capability_snapshot,
207
+ tags: ['turn-owned', 'loop', 'attempt-takeover', `attempt-generation:${takeover.next_generation.attempt_epoch}`],
208
+ }, input.cwd);
209
+ input.on_stage?.('run_projected');
210
+ }
211
+ catch (error) {
212
+ throw new AttemptTakeoverCommittedError(takeover, error);
213
+ }
117
214
  try {
118
215
  createRuntimeEvent({
119
216
  agent: input.actor,