brainclaw 1.28.1 → 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.
@@ -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,