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
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Functional authority boundary for one physical loop-turn attempt.
|
|
3
|
+
*
|
|
4
|
+
* This facade deliberately stores nothing of its own. TurnReservation remains
|
|
5
|
+
* the canonical record and the Loop Engine remains responsible for phases,
|
|
6
|
+
* gates, retries and convergence. The only decision made here is whether one
|
|
7
|
+
* caller won the irreversible launch crossing.
|
|
8
|
+
*/
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { ExecutionContractSchema, executionContractRef, } from '../execution-contract.js';
|
|
14
|
+
import { abortReservation, armLaunch, attemptStatus, commitReservation, consumeLaunchGrantWithProjection, currentNonce, evidenceMatchesAttempt, getReservation, launchGrant, reserve, revokeLaunchGrant, LaunchFenceError, ReservationStateError, } from './attempt-reservation.js';
|
|
15
|
+
import { AttemptGenerationError, fenceForGeneration, generationDigest, prepareCloseDecision, prepareInitialGeneration, prepareLaunchDecision, prepareNextGeneration, publishInitialGeneration, publishAttemptResultEvidence, publishPreparedCloseDecision, publishPreparedLaunchDecision, readCloseDecision, readInitialGeneration, readAttemptResultEvidence, readLaunchDecision, rebuildAttemptGenerationHead, listAttemptGenerations, resolveTurnGenerationChain, } from './attempt-generations.js';
|
|
16
|
+
import { assertAttemptAuthorityV2Writable, } from './attempt-rollout.js';
|
|
17
|
+
function sameExpectedArtifacts(a, b) {
|
|
18
|
+
const canonical = (items) => JSON.stringify(items.map((item) => ({
|
|
19
|
+
logical_name: item.logical_name,
|
|
20
|
+
worker_path: item.worker_path,
|
|
21
|
+
loop_artifact_type: item.loop_artifact_type,
|
|
22
|
+
schema_id: item.schema_id,
|
|
23
|
+
completion_policy: item.completion_policy,
|
|
24
|
+
sha256: item.sha256,
|
|
25
|
+
})));
|
|
26
|
+
return canonical(a) === canonical(b ?? []);
|
|
27
|
+
}
|
|
28
|
+
function assertCompatible(existing, input) {
|
|
29
|
+
const mismatch = [
|
|
30
|
+
existing.loop_id !== input.loop_id && 'loop_id',
|
|
31
|
+
existing.slot_id !== input.slot_id && 'slot_id',
|
|
32
|
+
existing.target_slot_generation !== input.target_slot_generation && 'target_slot_generation',
|
|
33
|
+
existing.agent !== input.agent && 'agent',
|
|
34
|
+
existing.agent_id !== input.agent_id && 'agent_id',
|
|
35
|
+
existing.claim_id !== input.claim_id && 'claim_id',
|
|
36
|
+
existing.phase !== input.phase && 'phase',
|
|
37
|
+
existing.iteration !== input.iteration && 'iteration',
|
|
38
|
+
existing.completion_mode !== (input.completion_mode ?? 'file') && 'completion_mode',
|
|
39
|
+
!sameExpectedArtifacts(existing.expected_artifacts, input.expected_artifacts) && 'expected_artifacts',
|
|
40
|
+
existing.execution_contract_ref !== undefined
|
|
41
|
+
&& input.execution_contract_ref !== undefined
|
|
42
|
+
&& JSON.stringify(existing.execution_contract_ref) !== JSON.stringify(input.execution_contract_ref)
|
|
43
|
+
&& 'execution_contract_ref',
|
|
44
|
+
existing.capability_snapshot !== undefined
|
|
45
|
+
&& input.capability_snapshot !== undefined
|
|
46
|
+
&& JSON.stringify(existing.capability_snapshot) !== JSON.stringify(input.capability_snapshot)
|
|
47
|
+
&& 'capability_snapshot',
|
|
48
|
+
].find(Boolean);
|
|
49
|
+
if (mismatch) {
|
|
50
|
+
throw new ReservationStateError(input.turn_id, 'reservation_exists', `prepareAttempt: existing reservation ${input.turn_id} has incompatible ${mismatch}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Reserve-or-adopt, commit and arm one attempt generation.
|
|
55
|
+
*
|
|
56
|
+
* Adoption is allowed only for an identical immutable contract. A crossed
|
|
57
|
+
* generation is returned as adopted so callers can repair/read projections,
|
|
58
|
+
* but projectAndCross will never award launch authority twice.
|
|
59
|
+
*/
|
|
60
|
+
export function prepareAttempt(input, cwd) {
|
|
61
|
+
const actor = input.authority_actor ?? input.agent_id ?? input.agent;
|
|
62
|
+
let adopted = false;
|
|
63
|
+
try {
|
|
64
|
+
reserve(input, cwd);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
const existing = getReservation(input.turn_id, cwd);
|
|
68
|
+
if (!(error instanceof ReservationStateError) || error.code !== 'reservation_exists' || !existing) {
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
assertCompatible(existing, input);
|
|
72
|
+
adopted = true;
|
|
73
|
+
}
|
|
74
|
+
input.on_stage?.('reserved');
|
|
75
|
+
const committed = commitReservation(input.turn_id, cwd, actor);
|
|
76
|
+
input.on_stage?.('committed');
|
|
77
|
+
let grant = launchGrant(input.turn_id, cwd);
|
|
78
|
+
if (!grant || grant.status === 'revoked') {
|
|
79
|
+
const nextEpoch = (grant?.epoch ?? -1) + 1;
|
|
80
|
+
try {
|
|
81
|
+
armLaunch(input.turn_id, {
|
|
82
|
+
epoch: nextEpoch,
|
|
83
|
+
lease_deadline: input.grant_lease_deadline,
|
|
84
|
+
}, cwd, actor);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
// A concurrent preparer may have armed the same committed reservation.
|
|
88
|
+
if (!(error instanceof LaunchFenceError) || error.code !== 'already_armed')
|
|
89
|
+
throw error;
|
|
90
|
+
adopted = true;
|
|
91
|
+
}
|
|
92
|
+
grant = launchGrant(input.turn_id, cwd);
|
|
93
|
+
}
|
|
94
|
+
input.on_stage?.('armed');
|
|
95
|
+
if (!grant || (grant.status !== 'armed' && grant.status !== 'crossed')) {
|
|
96
|
+
throw new LaunchFenceError(input.turn_id, grant?.status === 'revoked' ? 'revoked' : 'not_armed', `prepareAttempt: launch generation for ${input.turn_id} is ${grant?.status ?? 'absent'}`);
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
turn_id: input.turn_id,
|
|
100
|
+
reservation: { ...committed, launch: grant },
|
|
101
|
+
token: grant.token,
|
|
102
|
+
epoch: grant.epoch,
|
|
103
|
+
adopted: adopted || grant.status === 'crossed',
|
|
104
|
+
launch_status: grant.status,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/** Materialize deterministic projections and atomically cross the launch fence. */
|
|
108
|
+
export function projectAndCross(prepared, projectProjections, cwd, authorityActor = 'attempt-authority') {
|
|
109
|
+
const crossed = consumeLaunchGrantWithProjection(prepared.turn_id, prepared.token, prepared.epoch, projectProjections, cwd, authorityActor);
|
|
110
|
+
return {
|
|
111
|
+
kind: crossed.wonTransition ? 'won' : 'adopted',
|
|
112
|
+
reservation: crossed.reservation,
|
|
113
|
+
turn_id: prepared.turn_id,
|
|
114
|
+
token: prepared.token,
|
|
115
|
+
epoch: prepared.epoch,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
export function inspectAttempt(turnId, cwd, runStatus) {
|
|
119
|
+
const reservation = getReservation(turnId, cwd);
|
|
120
|
+
if (!reservation)
|
|
121
|
+
return undefined;
|
|
122
|
+
return {
|
|
123
|
+
reservation,
|
|
124
|
+
status: attemptStatus(reservation, runStatus),
|
|
125
|
+
nonce: currentNonce(reservation),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
export function matchEvidence(attempt, evidence) {
|
|
129
|
+
const reservation = 'reservation' in attempt ? attempt.reservation : attempt;
|
|
130
|
+
return evidenceMatchesAttempt(reservation, evidence);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Revoke a not-yet-crossed launch generation. A merely prepared reservation is
|
|
134
|
+
* aborted; a committed-but-unarmed reservation is left repairable by design.
|
|
135
|
+
*/
|
|
136
|
+
export function revokeAttempt(turnId, reason, cwd, actor = 'attempt-authority') {
|
|
137
|
+
const reservation = getReservation(turnId, cwd);
|
|
138
|
+
if (!reservation) {
|
|
139
|
+
throw new ReservationStateError(turnId, 'reservation_not_found', `revokeAttempt: unknown turn_id ${turnId}`);
|
|
140
|
+
}
|
|
141
|
+
let next = reservation;
|
|
142
|
+
if (reservation.decision === 'prepared') {
|
|
143
|
+
next = abortReservation(turnId, reason, cwd, actor);
|
|
144
|
+
}
|
|
145
|
+
else if (reservation.launch?.status === 'armed') {
|
|
146
|
+
next = revokeLaunchGrant(turnId, reservation.launch.epoch, reason, cwd, actor);
|
|
147
|
+
}
|
|
148
|
+
else if (reservation.launch?.status === 'crossed') {
|
|
149
|
+
throw new LaunchFenceError(turnId, 'crossed_not_revocable', `revokeAttempt: ${turnId} already crossed`);
|
|
150
|
+
}
|
|
151
|
+
else if (!reservation.launch) {
|
|
152
|
+
throw new LaunchFenceError(turnId, 'not_armed', `revokeAttempt: ${turnId} has no launch generation`);
|
|
153
|
+
}
|
|
154
|
+
return { reservation: next, status: attemptStatus(next), nonce: currentNonce(next) };
|
|
155
|
+
}
|
|
156
|
+
/** Explicit prepared→aborted helper for recovery code that knows no commit occurred. */
|
|
157
|
+
export function abortAttempt(turnId, reason, cwd, actor = 'attempt-authority') {
|
|
158
|
+
const reservation = abortReservation(turnId, reason, cwd, actor);
|
|
159
|
+
return { reservation, status: attemptStatus(reservation), nonce: currentNonce(reservation) };
|
|
160
|
+
}
|
|
161
|
+
/* ======================= AttemptAuthority v2 ============================ */
|
|
162
|
+
export function attemptWorkspaceDigest(workspacePath, turnId, attemptEpoch) {
|
|
163
|
+
let resolved;
|
|
164
|
+
try {
|
|
165
|
+
resolved = fs.realpathSync.native(workspacePath);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
resolved = path.resolve(workspacePath);
|
|
169
|
+
}
|
|
170
|
+
if (process.platform === 'win32')
|
|
171
|
+
resolved = resolved.toLowerCase();
|
|
172
|
+
return crypto.createHash('sha256')
|
|
173
|
+
.update(`${resolved}\0${turnId}\0${attemptEpoch}`, 'utf8')
|
|
174
|
+
.digest('hex');
|
|
175
|
+
}
|
|
176
|
+
function canonicalWorkspacePath(workspacePath) {
|
|
177
|
+
let resolved;
|
|
178
|
+
try {
|
|
179
|
+
resolved = fs.realpathSync.native(workspacePath);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
resolved = path.resolve(workspacePath);
|
|
183
|
+
}
|
|
184
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
185
|
+
}
|
|
186
|
+
function gitWorkspaceIdentity(workspacePath) {
|
|
187
|
+
const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir', '--show-toplevel'], { cwd: workspacePath, encoding: 'utf8', windowsHide: true });
|
|
188
|
+
const lines = result.status === 0 ? (result.stdout ?? '').trim().split(/\r?\n/) : [];
|
|
189
|
+
if (lines.length < 3) {
|
|
190
|
+
throw new AttemptGenerationError('invalid_transition', `workspace is not an attestable Git worktree: ${workspacePath}`);
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
git_dir: canonicalWorkspacePath(lines[0]),
|
|
194
|
+
common_dir: canonicalWorkspacePath(lines[1]),
|
|
195
|
+
top_level: canonicalWorkspacePath(lines[2]),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
export function executionContractForGeneration(reservation, generation) {
|
|
199
|
+
if (!reservation.execution_contract || !reservation.capability_snapshot) {
|
|
200
|
+
throw new AttemptGenerationError('invalid_transition', `turn ${reservation.turn_id} has no immutable execution contract`);
|
|
201
|
+
}
|
|
202
|
+
// Generation zero anchors the already-crossed immutable reservation. Keep
|
|
203
|
+
// its original serialized contract: on Windows the generation cell stores a
|
|
204
|
+
// canonicalized (case-folded) workspace path, and rebuilding the contract
|
|
205
|
+
// from that path changes its hash even though it names the same checkout.
|
|
206
|
+
// Successor generations still derive a new contract below because their
|
|
207
|
+
// epoch, run id, and workspace are intentionally different.
|
|
208
|
+
if (generation.attempt_epoch === 0) {
|
|
209
|
+
const contract = reservation.execution_contract;
|
|
210
|
+
if (contract.identity.assignment_id !== generation.assignment_id
|
|
211
|
+
|| contract.identity.run_id !== generation.run_id
|
|
212
|
+
|| canonicalWorkspacePath(contract.workspace_policy.worktree_path ?? contract.workspace_policy.cwd)
|
|
213
|
+
!== canonicalWorkspacePath(generation.workspace_path)) {
|
|
214
|
+
throw new AttemptGenerationError('fenced', 'generation zero diverges from its immutable reservation contract');
|
|
215
|
+
}
|
|
216
|
+
const ref = executionContractRef(contract, reservation.capability_snapshot);
|
|
217
|
+
if (ref.hash !== generation.contract_hash) {
|
|
218
|
+
throw new AttemptGenerationError('fenced', `generation zero contract hash ${generation.contract_hash} does not match reservation ${ref.hash}`);
|
|
219
|
+
}
|
|
220
|
+
return { contract, ref };
|
|
221
|
+
}
|
|
222
|
+
const contract = ExecutionContractSchema.parse({
|
|
223
|
+
...reservation.execution_contract,
|
|
224
|
+
identity: {
|
|
225
|
+
...reservation.execution_contract.identity,
|
|
226
|
+
logical_attempt_epoch: generation.attempt_epoch,
|
|
227
|
+
assignment_id: generation.assignment_id,
|
|
228
|
+
run_id: generation.run_id,
|
|
229
|
+
},
|
|
230
|
+
workspace_policy: {
|
|
231
|
+
...reservation.execution_contract.workspace_policy,
|
|
232
|
+
cwd: generation.workspace_path,
|
|
233
|
+
worktree_path: generation.workspace_path,
|
|
234
|
+
isolation: 'worktree',
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
const ref = executionContractRef(contract, reservation.capability_snapshot);
|
|
238
|
+
if (ref.hash !== generation.contract_hash) {
|
|
239
|
+
throw new AttemptGenerationError('fenced', `generation ${generation.attempt_epoch} contract hash ${generation.contract_hash} does not match derived ${ref.hash}`);
|
|
240
|
+
}
|
|
241
|
+
return { contract, ref };
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Anchor a crossed legacy generation into the v2 immutable chain. This is an
|
|
245
|
+
* explicit Release-B write and therefore refuses unless the signed writer
|
|
246
|
+
* guard is active on the local authority home.
|
|
247
|
+
*/
|
|
248
|
+
export function bootstrapAttemptAuthorityV2(input) {
|
|
249
|
+
assertAttemptAuthorityV2Writable(input.cwd, input.authority_home, undefined, input.writer_id);
|
|
250
|
+
const reservation = getReservation(input.turn_id, input.cwd);
|
|
251
|
+
if (!reservation || reservation.decision !== 'committed') {
|
|
252
|
+
throw new AttemptGenerationError('invalid_transition', `turn ${input.turn_id} is not a committed reservation`);
|
|
253
|
+
}
|
|
254
|
+
const grant = launchGrant(input.turn_id, input.cwd);
|
|
255
|
+
if (!grant || grant.status !== 'crossed') {
|
|
256
|
+
throw new AttemptGenerationError('invalid_transition', `turn ${input.turn_id} is not a crossed generation`);
|
|
257
|
+
}
|
|
258
|
+
if (!reservation.execution_contract_ref) {
|
|
259
|
+
throw new AttemptGenerationError('invalid_transition', `turn ${input.turn_id} is legacy-uncontracted and cannot enter v2`);
|
|
260
|
+
}
|
|
261
|
+
const workspacePath = path.resolve(input.workspace_path
|
|
262
|
+
?? reservation.execution_contract?.workspace_policy.worktree_path
|
|
263
|
+
?? reservation.cwd);
|
|
264
|
+
const existingAnchor = readInitialGeneration(input.cwd, input.turn_id);
|
|
265
|
+
const initial = existingAnchor ?? prepareInitialGeneration({
|
|
266
|
+
turn_id: reservation.turn_id,
|
|
267
|
+
authority_home: input.authority_home,
|
|
268
|
+
contract_hash: reservation.execution_contract_ref.hash,
|
|
269
|
+
workspace_path: workspacePath,
|
|
270
|
+
workspace_digest: attemptWorkspaceDigest(workspacePath, reservation.turn_id, 0),
|
|
271
|
+
launch_nonce: grant.token,
|
|
272
|
+
});
|
|
273
|
+
if (initial.assignment_id !== reservation.child_ids.assignment_id
|
|
274
|
+
|| initial.run_id !== reservation.child_ids.run_id
|
|
275
|
+
|| initial.launch_nonce !== grant.token
|
|
276
|
+
|| initial.contract_hash !== reservation.execution_contract_ref.hash
|
|
277
|
+
|| canonicalWorkspacePath(initial.workspace_path) !== canonicalWorkspacePath(workspacePath)
|
|
278
|
+
|| initial.workspace_digest !== attemptWorkspaceDigest(workspacePath, reservation.turn_id, 0)
|
|
279
|
+
|| JSON.stringify(initial.authority_home) !== JSON.stringify(input.authority_home)) {
|
|
280
|
+
throw new AttemptGenerationError('invalid_transition', 'generation-zero anchor diverges from the existing reservation');
|
|
281
|
+
}
|
|
282
|
+
const anchored = publishInitialGeneration(input.cwd, initial).cell;
|
|
283
|
+
input.on_stage?.('initial_anchored');
|
|
284
|
+
const launch = prepareLaunchDecision(anchored, {
|
|
285
|
+
decision: 'crossed',
|
|
286
|
+
actor: input.actor,
|
|
287
|
+
cause: 'release-b bootstrap of already-crossed generation',
|
|
288
|
+
});
|
|
289
|
+
const publishedLaunch = publishPreparedLaunchDecision(input.cwd, anchored, launch).cell;
|
|
290
|
+
if (publishedLaunch.decision !== 'crossed') {
|
|
291
|
+
throw new AttemptGenerationError('fenced', `generation zero was already revoked for ${input.turn_id}`);
|
|
292
|
+
}
|
|
293
|
+
input.on_stage?.('launch_crossed');
|
|
294
|
+
rebuildAttemptGenerationHead(input.cwd, anchored);
|
|
295
|
+
return anchored;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Close the active epoch and activate its successor with ONE close(epoch) CAS.
|
|
299
|
+
* No projection is authoritative: callers may replay AgentRun/head/event
|
|
300
|
+
* materialization after this function returns.
|
|
301
|
+
*/
|
|
302
|
+
export function prepareAttemptTakeoverV2(input) {
|
|
303
|
+
const rollout = assertAttemptAuthorityV2Writable(input.cwd, input.authority_home, undefined, input.writer_id);
|
|
304
|
+
if (input.external_effect_policy !== 'none'
|
|
305
|
+
&& input.external_effect_policy !== 'idempotent'
|
|
306
|
+
&& input.external_effect_policy !== 'externally_fenced') {
|
|
307
|
+
throw new AttemptGenerationError('invalid_transition', 'automatic takeover requires idempotent or externally fenced effects');
|
|
308
|
+
}
|
|
309
|
+
if (!input.cause.trim() || !input.liveness_evidence.trim()) {
|
|
310
|
+
throw new AttemptGenerationError('invalid_transition', 'takeover requires a cause and liveness evidence');
|
|
311
|
+
}
|
|
312
|
+
const reservation = getReservation(input.turn_id, input.cwd);
|
|
313
|
+
const resolved = resolveTurnGenerationChain(input.cwd, input.turn_id);
|
|
314
|
+
if (!reservation || !resolved) {
|
|
315
|
+
throw new AttemptGenerationError('invalid_transition', `turn ${input.turn_id} has no active v2 generation`);
|
|
316
|
+
}
|
|
317
|
+
const requestedCause = `${input.cause}; liveness=${input.liveness_evidence}; effects=${input.external_effect_policy}`;
|
|
318
|
+
// Crash replay: close(epoch) may have won before the LoopEvent/AgentRun/head
|
|
319
|
+
// projections were written. Adopt that exact immutable successor so the
|
|
320
|
+
// caller can repair projections; never mint another generation on replay.
|
|
321
|
+
if (resolved.latest_generation.attempt_epoch > input.expected_epoch) {
|
|
322
|
+
const initial = readInitialGeneration(input.cwd, input.turn_id);
|
|
323
|
+
const previous = initial
|
|
324
|
+
? listAttemptGenerations(input.cwd, initial).find((generation) => generation.attempt_epoch === input.expected_epoch)
|
|
325
|
+
: undefined;
|
|
326
|
+
const close = previous ? readCloseDecision(input.cwd, input.turn_id, input.expected_epoch) : undefined;
|
|
327
|
+
const expectedMode = input.mode ?? 'takeover';
|
|
328
|
+
if (previous
|
|
329
|
+
&& close
|
|
330
|
+
&& (close.decision === 'takeover' || close.decision === 'retry')
|
|
331
|
+
&& close.decision === expectedMode
|
|
332
|
+
&& close.cause === requestedCause
|
|
333
|
+
&& canonicalWorkspacePath(close.next_generation.workspace_path) === canonicalWorkspacePath(input.next_workspace_path)
|
|
334
|
+
&& JSON.stringify(close.next_generation.authority_home) === JSON.stringify(input.authority_home)) {
|
|
335
|
+
const generationContract = executionContractForGeneration(reservation, close.next_generation);
|
|
336
|
+
return {
|
|
337
|
+
won: false,
|
|
338
|
+
rollout,
|
|
339
|
+
previous_generation: previous,
|
|
340
|
+
next_generation: close.next_generation,
|
|
341
|
+
close_cell: close,
|
|
342
|
+
execution_contract: generationContract.contract,
|
|
343
|
+
execution_contract_ref: generationContract.ref,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
throw new AttemptGenerationError('fenced', `expected epoch ${input.expected_epoch}, active epoch is ${resolved.latest_generation.attempt_epoch}`);
|
|
347
|
+
}
|
|
348
|
+
if (resolved.status !== 'active') {
|
|
349
|
+
throw new AttemptGenerationError('invalid_transition', `turn ${input.turn_id} has no active v2 generation`);
|
|
350
|
+
}
|
|
351
|
+
const current = resolved.latest_generation;
|
|
352
|
+
if (current.attempt_epoch !== input.expected_epoch) {
|
|
353
|
+
throw new AttemptGenerationError('fenced', `expected epoch ${input.expected_epoch}, active epoch is ${current.attempt_epoch}`);
|
|
354
|
+
}
|
|
355
|
+
if (JSON.stringify(current.authority_home) !== JSON.stringify(input.authority_home)) {
|
|
356
|
+
throw new AttemptGenerationError('authority_home_mismatch', 'takeover caller is not authority_home');
|
|
357
|
+
}
|
|
358
|
+
const currentLaunch = readLaunchDecision(input.cwd, input.turn_id, current.attempt_epoch);
|
|
359
|
+
if (!currentLaunch || currentLaunch.decision !== 'crossed') {
|
|
360
|
+
throw new AttemptGenerationError('invalid_transition', `generation ${current.attempt_epoch} never crossed`);
|
|
361
|
+
}
|
|
362
|
+
const nextEpoch = current.attempt_epoch + 1;
|
|
363
|
+
const nextWorkspacePath = path.resolve(input.next_workspace_path);
|
|
364
|
+
if (canonicalWorkspacePath(nextWorkspacePath) === canonicalWorkspacePath(current.workspace_path)) {
|
|
365
|
+
throw new AttemptGenerationError('invalid_transition', 'next generation workspace resolves to the current workspace (alias/junction reuse refused)');
|
|
366
|
+
}
|
|
367
|
+
const currentWorkspace = gitWorkspaceIdentity(current.workspace_path);
|
|
368
|
+
const nextWorkspace = gitWorkspaceIdentity(nextWorkspacePath);
|
|
369
|
+
if (currentWorkspace.common_dir !== nextWorkspace.common_dir) {
|
|
370
|
+
throw new AttemptGenerationError('invalid_transition', 'next generation workspace is not a worktree of the same Git repository');
|
|
371
|
+
}
|
|
372
|
+
if (currentWorkspace.git_dir === nextWorkspace.git_dir || currentWorkspace.top_level === nextWorkspace.top_level) {
|
|
373
|
+
throw new AttemptGenerationError('invalid_transition', 'next generation must use a distinct Git worktree/gitdir');
|
|
374
|
+
}
|
|
375
|
+
const provisional = prepareNextGeneration(current, {
|
|
376
|
+
contract_hash: '0'.repeat(64),
|
|
377
|
+
workspace_path: nextWorkspacePath,
|
|
378
|
+
workspace_digest: attemptWorkspaceDigest(nextWorkspacePath, input.turn_id, nextEpoch),
|
|
379
|
+
});
|
|
380
|
+
const baseContract = reservation.execution_contract;
|
|
381
|
+
const snapshot = reservation.capability_snapshot;
|
|
382
|
+
if (!baseContract || !snapshot) {
|
|
383
|
+
throw new AttemptGenerationError('invalid_transition', `turn ${input.turn_id} lacks a contracted capability snapshot`);
|
|
384
|
+
}
|
|
385
|
+
const nextContract = ExecutionContractSchema.parse({
|
|
386
|
+
...baseContract,
|
|
387
|
+
identity: {
|
|
388
|
+
...baseContract.identity,
|
|
389
|
+
logical_attempt_epoch: nextEpoch,
|
|
390
|
+
assignment_id: provisional.assignment_id,
|
|
391
|
+
run_id: provisional.run_id,
|
|
392
|
+
},
|
|
393
|
+
workspace_policy: {
|
|
394
|
+
...baseContract.workspace_policy,
|
|
395
|
+
cwd: nextWorkspacePath,
|
|
396
|
+
worktree_path: nextWorkspacePath,
|
|
397
|
+
isolation: 'worktree',
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
const nextRef = executionContractRef(nextContract, snapshot);
|
|
401
|
+
const next = prepareNextGeneration(current, {
|
|
402
|
+
contract_hash: nextRef.hash,
|
|
403
|
+
workspace_path: nextWorkspacePath,
|
|
404
|
+
workspace_digest: attemptWorkspaceDigest(nextWorkspacePath, input.turn_id, nextEpoch),
|
|
405
|
+
launch_nonce: provisional.launch_nonce,
|
|
406
|
+
created_at: provisional.created_at,
|
|
407
|
+
});
|
|
408
|
+
const proposed = prepareCloseDecision(current, {
|
|
409
|
+
decision: input.mode ?? 'takeover',
|
|
410
|
+
actor: input.actor,
|
|
411
|
+
cause: requestedCause,
|
|
412
|
+
next_generation: next,
|
|
413
|
+
});
|
|
414
|
+
const published = publishPreparedCloseDecision(input.cwd, current, proposed);
|
|
415
|
+
const incumbent = published.cell;
|
|
416
|
+
if (incumbent.decision !== 'takeover' && incumbent.decision !== 'retry') {
|
|
417
|
+
throw new AttemptGenerationError('fenced', `generation ${current.attempt_epoch} already closed as ${incumbent.decision}`);
|
|
418
|
+
}
|
|
419
|
+
if (generationDigest(incumbent.next_generation) !== generationDigest(next)) {
|
|
420
|
+
throw new AttemptGenerationError('fenced', `a different successor already won generation ${current.attempt_epoch}`);
|
|
421
|
+
}
|
|
422
|
+
rebuildAttemptGenerationHead(input.cwd, current);
|
|
423
|
+
return {
|
|
424
|
+
won: published.won,
|
|
425
|
+
rollout,
|
|
426
|
+
previous_generation: current,
|
|
427
|
+
next_generation: incumbent.next_generation,
|
|
428
|
+
close_cell: incumbent,
|
|
429
|
+
execution_contract: nextContract,
|
|
430
|
+
execution_contract_ref: nextRef,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
export function crossActiveAttemptGenerationV2(turnId, expectedEpoch, authorityHome, actor, writerId, cwd) {
|
|
434
|
+
assertAttemptAuthorityV2Writable(cwd, authorityHome, undefined, writerId);
|
|
435
|
+
const resolved = resolveTurnGenerationChain(cwd, turnId);
|
|
436
|
+
if (!resolved || resolved.status !== 'active' || resolved.latest_generation.attempt_epoch !== expectedEpoch) {
|
|
437
|
+
throw new AttemptGenerationError('fenced', `turn ${turnId} epoch ${expectedEpoch} is not active`);
|
|
438
|
+
}
|
|
439
|
+
const generation = resolved.latest_generation;
|
|
440
|
+
if (JSON.stringify(generation.authority_home) !== JSON.stringify(authorityHome)) {
|
|
441
|
+
throw new AttemptGenerationError('authority_home_mismatch', 'cross caller is not authority_home');
|
|
442
|
+
}
|
|
443
|
+
const published = publishPreparedLaunchDecision(cwd, generation, prepareLaunchDecision(generation, {
|
|
444
|
+
decision: 'crossed', actor, cause: 'projection-complete launch crossing',
|
|
445
|
+
}));
|
|
446
|
+
if (published.cell.decision !== 'crossed') {
|
|
447
|
+
throw new AttemptGenerationError('fenced', `generation ${expectedEpoch} was revoked before crossing`);
|
|
448
|
+
}
|
|
449
|
+
return { won: published.won, generation, fence: fenceForGeneration(generation) };
|
|
450
|
+
}
|
|
451
|
+
export function settleActiveAttemptGenerationV2(turnId, expectedFence, result, authorityHome, actor, writerId, cwd) {
|
|
452
|
+
assertAttemptAuthorityV2Writable(cwd, authorityHome, undefined, writerId);
|
|
453
|
+
const resolved = resolveTurnGenerationChain(cwd, turnId);
|
|
454
|
+
if (!resolved)
|
|
455
|
+
return undefined;
|
|
456
|
+
if (resolved.status !== 'active') {
|
|
457
|
+
const terminal = resolved.terminal_cell;
|
|
458
|
+
if (!terminal || terminal.decision !== 'settled' || !terminal.result_digest)
|
|
459
|
+
return undefined;
|
|
460
|
+
if (JSON.stringify(terminal.fence) !== JSON.stringify(expectedFence)) {
|
|
461
|
+
throw new AttemptGenerationError('fenced', `settled generation fence does not match ${turnId}`);
|
|
462
|
+
}
|
|
463
|
+
const evidence = readAttemptResultEvidence(cwd, turnId, terminal.fence.attempt_epoch, terminal.result_digest);
|
|
464
|
+
if (!evidence)
|
|
465
|
+
throw new AttemptGenerationError('fenced', `settled evidence ${terminal.result_digest} is missing`);
|
|
466
|
+
return { won: false, cell: terminal, evidence };
|
|
467
|
+
}
|
|
468
|
+
const generation = resolved.latest_generation;
|
|
469
|
+
if (JSON.stringify(fenceForGeneration(generation)) !== JSON.stringify(expectedFence)) {
|
|
470
|
+
throw new AttemptGenerationError('fenced', 'settlement fence no longer matches the active generation');
|
|
471
|
+
}
|
|
472
|
+
if (JSON.stringify(generation.authority_home) !== JSON.stringify(authorityHome)) {
|
|
473
|
+
throw new AttemptGenerationError('authority_home_mismatch', 'settlement caller is not authority_home');
|
|
474
|
+
}
|
|
475
|
+
const launch = readLaunchDecision(cwd, turnId, generation.attempt_epoch);
|
|
476
|
+
if (!launch || launch.decision !== 'crossed') {
|
|
477
|
+
throw new AttemptGenerationError('invalid_transition', `generation ${generation.attempt_epoch} never crossed`);
|
|
478
|
+
}
|
|
479
|
+
const publishedEvidence = publishAttemptResultEvidence(cwd, generation, result);
|
|
480
|
+
const proposed = prepareCloseDecision(generation, {
|
|
481
|
+
decision: 'settled', actor, cause: 'turn evidence accepted', result_digest: publishedEvidence.digest,
|
|
482
|
+
});
|
|
483
|
+
const published = publishPreparedCloseDecision(cwd, generation, proposed);
|
|
484
|
+
if (published.cell.decision !== 'settled') {
|
|
485
|
+
return { ...published, evidence: publishedEvidence.cell };
|
|
486
|
+
}
|
|
487
|
+
if (published.cell.result_digest !== publishedEvidence.digest) {
|
|
488
|
+
throw new AttemptGenerationError('fenced', `generation ${generation.attempt_epoch} already settled with different evidence`);
|
|
489
|
+
}
|
|
490
|
+
const authoritativeEvidence = readAttemptResultEvidence(cwd, turnId, generation.attempt_epoch, published.cell.result_digest);
|
|
491
|
+
if (!authoritativeEvidence)
|
|
492
|
+
throw new AttemptGenerationError('fenced', 'authoritative settlement evidence is missing');
|
|
493
|
+
rebuildAttemptGenerationHead(cwd, generation);
|
|
494
|
+
return { ...published, evidence: authoritativeEvidence };
|
|
495
|
+
}
|
|
496
|
+
//# sourceMappingURL=attempt-authority.js.map
|