brainclaw 1.16.0 → 1.18.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 +22 -8
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli/register-review.js +2 -2
- package/dist/commands/code-map.js +4 -1
- package/dist/commands/codev.js +61 -30
- package/dist/commands/doctor.js +14 -1
- package/dist/commands/harvest.js +241 -25
- package/dist/commands/inbox.js +10 -4
- package/dist/commands/loop.js +2 -2
- package/dist/commands/loops-handlers.js +82 -1
- package/dist/commands/mcp-catalog.js +12 -4
- package/dist/commands/mcp-read-handlers.js +90 -7
- package/dist/commands/mcp-schemas.generated.js +3 -0
- package/dist/commands/mcp-write-coordination.js +159 -40
- package/dist/commands/mcp.js +11 -2
- package/dist/core/agent-capability.js +7 -2
- package/dist/core/agent-files.js +53 -2
- package/dist/core/agent-integrations.js +1 -0
- package/dist/core/agentrun-reconciler.js +171 -7
- package/dist/core/agentruns.js +6 -1
- package/dist/core/code-map/aggregate.js +473 -0
- package/dist/core/code-map/backend.js +36 -10
- package/dist/core/code-map/freshness.js +36 -1
- package/dist/core/code-map/lang/c/imports.scm +12 -0
- package/dist/core/code-map/lang/c/index.js +150 -0
- package/dist/core/code-map/lang/c/tags.scm +68 -0
- package/dist/core/code-map/lang/cpp/imports.scm +14 -0
- package/dist/core/code-map/lang/cpp/index.js +149 -0
- package/dist/core/code-map/lang/cpp/tags.scm +87 -0
- package/dist/core/code-map/lang/csharp/imports.scm +20 -0
- package/dist/core/code-map/lang/csharp/index.js +224 -0
- package/dist/core/code-map/lang/csharp/tags.scm +63 -0
- package/dist/core/code-map/lang/go/imports.scm +13 -0
- package/dist/core/code-map/lang/go/index.js +139 -0
- package/dist/core/code-map/lang/go/tags.scm +36 -0
- package/dist/core/code-map/lang/providers.js +12 -1
- package/dist/core/code-map/lang/ruby/imports.scm +24 -0
- package/dist/core/code-map/lang/ruby/index.js +198 -0
- package/dist/core/code-map/lang/ruby/tags.scm +49 -0
- package/dist/core/code-map/lang/rust/imports.scm +44 -0
- package/dist/core/code-map/lang/rust/index.js +136 -0
- package/dist/core/code-map/lang/rust/tags.scm +47 -0
- package/dist/core/code-map/query.js +229 -80
- package/dist/core/code-map/types.js +18 -0
- package/dist/core/code-map/work-section.js +8 -7
- package/dist/core/codev-responses.js +16 -0
- package/dist/core/dispatcher.js +209 -29
- package/dist/core/execution-adapters.js +29 -3
- package/dist/core/ideation-loop-close.js +124 -0
- package/dist/core/loops/artifact-resolver.js +197 -0
- package/dist/core/loops/attempt-reservation.js +576 -0
- package/dist/core/loops/commit-intent.js +494 -0
- package/dist/core/loops/facade-schema.js +48 -0
- package/dist/core/loops/impl-bind.js +144 -0
- package/dist/core/loops/index.js +1 -1
- package/dist/core/loops/iteration-engine.js +29 -0
- package/dist/core/loops/lock.js +14 -0
- package/dist/core/loops/project-resolution.js +157 -0
- package/dist/core/loops/reconcile-turn.js +369 -0
- package/dist/core/loops/result-reducers.js +88 -0
- package/dist/core/loops/store.js +46 -7
- package/dist/core/loops/types.js +139 -11
- package/dist/core/loops/verbs.js +9 -3
- package/dist/core/loops/verify-command.js +209 -0
- package/dist/core/messaging.js +58 -5
- package/dist/core/review-loop-close.js +106 -34
- package/dist/core/review-loop-turn-dispatch.js +445 -0
- package/dist/core/runtime-signals.js +68 -0
- package/dist/core/schema.js +34 -0
- package/dist/core/worktree.js +240 -22
- package/dist/facts.js +10 -10
- package/dist/facts.json +9 -9
- package/dist/wasm/tree-sitter-c.wasm +0 -0
- package/dist/wasm/tree-sitter-c_sharp.wasm +0 -0
- package/dist/wasm/tree-sitter-cpp.wasm +0 -0
- package/dist/wasm/tree-sitter-go.wasm +0 -0
- package/dist/wasm/tree-sitter-ruby.wasm +0 -0
- package/dist/wasm/tree-sitter-rust.wasm +0 -0
- package/docs/cli.md +1 -1
- package/docs/code-map.md +22 -6
- package/docs/concepts/loop-engine.md +28 -2
- package/docs/concepts/observer-protocol.md +22 -0
- package/docs/integrations/codex.md +19 -3
- package/docs/mcp-schema-changelog.md +43 -1
- package/package.json +1 -1
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { memoryDir, writeFileAtomic } from '../io.js';
|
|
6
|
+
import { nowISO } from '../ids.js';
|
|
7
|
+
import { acquireLock } from './lock.js';
|
|
8
|
+
/**
|
|
9
|
+
* An artifact the attempt's worker is expected to produce (spec §2 / §13 R1).
|
|
10
|
+
* Brainclaw generates the canonical target; `worker_path` is worker-relative and
|
|
11
|
+
* MUST be realpath-containment-validated before any read (invariant #7, wired in
|
|
12
|
+
* a later PR). `sha256` is filled at harvest and validated before state mutation.
|
|
13
|
+
*/
|
|
14
|
+
export const ExpectedArtifactSchema = z.object({
|
|
15
|
+
logical_name: z.string().min(1),
|
|
16
|
+
worker_path: z.string().min(1),
|
|
17
|
+
loop_artifact_type: z.string().min(1),
|
|
18
|
+
schema_id: z.string().optional(),
|
|
19
|
+
completion_policy: z.enum(['required', 'optional']).default('required'),
|
|
20
|
+
sha256: z.string().optional(),
|
|
21
|
+
});
|
|
22
|
+
export const TurnReservationSchema = z.object({
|
|
23
|
+
turn_id: z.string().min(1),
|
|
24
|
+
epoch: z.number().int().nonnegative(),
|
|
25
|
+
loop_id: z.string().min(1),
|
|
26
|
+
slot_id: z.string().min(1),
|
|
27
|
+
target_slot_generation: z.number().int().nonnegative(),
|
|
28
|
+
loop_version_at_reserve: z.number().int().nonnegative(),
|
|
29
|
+
agent: z.string().min(1),
|
|
30
|
+
agent_id: z.string().optional(),
|
|
31
|
+
claim_id: z.string().min(1),
|
|
32
|
+
child_ids: z.object({
|
|
33
|
+
assignment_id: z.string().min(1),
|
|
34
|
+
run_id: z.string().min(1),
|
|
35
|
+
}),
|
|
36
|
+
phase: z.string().min(1),
|
|
37
|
+
iteration: z.number().int().nonnegative(),
|
|
38
|
+
// pln#630 PR2b-a (§13 R1): widened from z.literal('file'). The EFFECTIVE
|
|
39
|
+
// resolved policy for this attempt (§5). Default 'file' keeps PR1 records
|
|
40
|
+
// parsing; mcp/either are wired later.
|
|
41
|
+
completion_mode: z.enum(['file', 'mcp', 'either']).default('file'),
|
|
42
|
+
// pln#630 PR2b-a (§13 R1): artifacts this attempt's worker must produce.
|
|
43
|
+
// Default [] so PR1 on-disk records (which predate the field) still parse.
|
|
44
|
+
expected_artifacts: z.array(ExpectedArtifactSchema).default([]),
|
|
45
|
+
store_root: z.string().min(1),
|
|
46
|
+
cwd: z.string().min(1),
|
|
47
|
+
lease_deadline: z.string().min(1),
|
|
48
|
+
decision: z.enum(['prepared', 'committed', 'aborted']),
|
|
49
|
+
created_at: z.string().min(1),
|
|
50
|
+
decided_at: z.string().optional(),
|
|
51
|
+
abort_reason: z.string().optional(),
|
|
52
|
+
// pln#630 PR2a (dec#138) — the LAUNCH-GRANT fence. The decidable, atomic
|
|
53
|
+
// gate between "a committed attempt may spawn" and "a worker crossed into
|
|
54
|
+
// exec". The pre-exec supervisor CONSUMES the grant (armed→crossed) before
|
|
55
|
+
// invoking the worker; advance/close/reroute REVOKES it (armed→revoked). The
|
|
56
|
+
// two are mutually exclusive CAS transitions on ONE record, so an old token
|
|
57
|
+
// can never spawn after supersession, and a crossed grant is never re-spawned.
|
|
58
|
+
launch: z.object({
|
|
59
|
+
status: z.enum(['armed', 'crossed', 'revoked']),
|
|
60
|
+
token: z.string().min(1),
|
|
61
|
+
epoch: z.number().int().nonnegative(),
|
|
62
|
+
lease_deadline: z.string().min(1),
|
|
63
|
+
armed_at: z.string().min(1),
|
|
64
|
+
crossed_at: z.string().optional(),
|
|
65
|
+
revoked_at: z.string().optional(),
|
|
66
|
+
revoke_reason: z.string().optional(),
|
|
67
|
+
}).optional(),
|
|
68
|
+
});
|
|
69
|
+
/** Raised when a decision CAS is attempted from an incompatible terminal state. */
|
|
70
|
+
export class ReservationStateError extends Error {
|
|
71
|
+
turn_id;
|
|
72
|
+
code;
|
|
73
|
+
constructor(turn_id, code, message) {
|
|
74
|
+
super(message);
|
|
75
|
+
this.turn_id = turn_id;
|
|
76
|
+
this.code = code;
|
|
77
|
+
this.name = 'ReservationStateError';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** Raised when a launch-grant CAS (arm/consume/revoke) is refused. */
|
|
81
|
+
export class LaunchFenceError extends Error {
|
|
82
|
+
turn_id;
|
|
83
|
+
code;
|
|
84
|
+
constructor(turn_id, code, message) {
|
|
85
|
+
super(message);
|
|
86
|
+
this.turn_id = turn_id;
|
|
87
|
+
this.code = code;
|
|
88
|
+
this.name = 'LaunchFenceError';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/* ============================ path resolution ============================= */
|
|
92
|
+
function reservationsDir(cwd) {
|
|
93
|
+
return path.join(memoryDir(cwd ?? process.cwd()), 'loops', 'reservations');
|
|
94
|
+
}
|
|
95
|
+
function reservationLocksDir(cwd) {
|
|
96
|
+
return path.join(reservationsDir(cwd), 'locks');
|
|
97
|
+
}
|
|
98
|
+
function reservationPath(turnId, cwd) {
|
|
99
|
+
return path.join(reservationsDir(cwd), `${turnId}.json`);
|
|
100
|
+
}
|
|
101
|
+
function reservationLockPath(turnId, cwd) {
|
|
102
|
+
return path.join(reservationLocksDir(cwd), `${turnId}.lock`);
|
|
103
|
+
}
|
|
104
|
+
function launchDecisionPath(turnId, epoch, cwd) {
|
|
105
|
+
return path.join(reservationsDir(cwd), `${turnId}.launch-${epoch}.decision.json`);
|
|
106
|
+
}
|
|
107
|
+
function readLaunchDecision(turnId, epoch, cwd) {
|
|
108
|
+
const p = launchDecisionPath(turnId, epoch, cwd);
|
|
109
|
+
if (!fs.existsSync(p))
|
|
110
|
+
return undefined;
|
|
111
|
+
try {
|
|
112
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Atomically claim the decision via exclusive-create. Returns the committed
|
|
119
|
+
* decision (this caller's if it won, or the incumbent's if it lost). */
|
|
120
|
+
function claimLaunchDecision(turnId, decision, cwd) {
|
|
121
|
+
ensureDirs(cwd);
|
|
122
|
+
const p = launchDecisionPath(turnId, decision.epoch, cwd);
|
|
123
|
+
const body = `${JSON.stringify(decision, null, 2)}\n`;
|
|
124
|
+
try {
|
|
125
|
+
// 'wx' = O_CREAT | O_EXCL — atomic; fails with EEXIST if a decision exists.
|
|
126
|
+
const fd = fs.openSync(p, 'wx');
|
|
127
|
+
try {
|
|
128
|
+
const buf = Buffer.from(body, 'utf8');
|
|
129
|
+
let off = 0;
|
|
130
|
+
while (off < buf.length)
|
|
131
|
+
off += fs.writeSync(fd, buf, off, buf.length - off);
|
|
132
|
+
fs.fsyncSync(fd);
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
fs.closeSync(fd);
|
|
136
|
+
}
|
|
137
|
+
return { decision, won: true }; // THIS call performed the atomic create
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
if (err.code !== 'EEXIST')
|
|
141
|
+
throw err;
|
|
142
|
+
const incumbent = readLaunchDecision(turnId, decision.epoch, cwd);
|
|
143
|
+
if (!incumbent)
|
|
144
|
+
throw err; // decision file vanished mid-race — surface it
|
|
145
|
+
return { decision: incumbent, won: false }; // lost — the incumbent decision stands (adopted)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function ensureDirs(cwd) {
|
|
149
|
+
for (const dir of [reservationsDir(cwd), reservationLocksDir(cwd)]) {
|
|
150
|
+
if (!fs.existsSync(dir))
|
|
151
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/* ============================ child id derivation ========================= */
|
|
155
|
+
/**
|
|
156
|
+
* Deterministic child ids from the turn_id. Deterministic so a crashed reserve
|
|
157
|
+
* is repairable idempotently: a recoverer re-derives the exact same ids and can
|
|
158
|
+
* complete the projection without minting a second assignment/run (r3/r4 fix #1).
|
|
159
|
+
*/
|
|
160
|
+
export function deriveChildIds(turnId) {
|
|
161
|
+
const h = (salt) => crypto.createHash('sha256').update(`${turnId}:${salt}`).digest('hex').slice(0, 16);
|
|
162
|
+
return { assignment_id: `asgn_${h('assignment')}`, run_id: `run_${h('run')}` };
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* DETERMINISTIC turn_id from (loop_id, slot_id, iteration) — pln#630 PR2c (§13
|
|
166
|
+
* A2). A duplicate dispatch of the same slot in the same iteration re-derives
|
|
167
|
+
* the SAME turn_id, so reserve() hits `reservation_exists` and the caller adopts
|
|
168
|
+
* the existing attempt instead of minting a second one — the closure for
|
|
169
|
+
* double-spawn-per-slot (a random turn_id would let two concurrent dispatches
|
|
170
|
+
* both reserve+arm+consume+spawn the same slot). `tat_` prefix matches the
|
|
171
|
+
* attempt-id convention.
|
|
172
|
+
*/
|
|
173
|
+
export function deriveTurnId(loopId, slotId, iteration) {
|
|
174
|
+
const h = crypto.createHash('sha256').update(`${loopId}:${slotId}:${iteration}`).digest('hex').slice(0, 16);
|
|
175
|
+
return `tat_${h}`;
|
|
176
|
+
}
|
|
177
|
+
/* ============================ persistence ================================= */
|
|
178
|
+
function readReservation(turnId, cwd) {
|
|
179
|
+
const filePath = reservationPath(turnId, cwd);
|
|
180
|
+
if (!fs.existsSync(filePath))
|
|
181
|
+
return undefined;
|
|
182
|
+
return TurnReservationSchema.parse(JSON.parse(fs.readFileSync(filePath, 'utf8')));
|
|
183
|
+
}
|
|
184
|
+
function writeReservation(record, cwd) {
|
|
185
|
+
const parsed = TurnReservationSchema.parse(record);
|
|
186
|
+
ensureDirs(cwd);
|
|
187
|
+
writeFileAtomic(reservationPath(parsed.turn_id, cwd), `${JSON.stringify(parsed, null, 2)}\n`);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Run `fn` under the per-reservation exclusive lock. Reuses the loop lock
|
|
191
|
+
* primitive (stale-reaping, O_EXCL, fencing) so the decision CAS is atomic
|
|
192
|
+
* across processes — two racing writers cannot both mutate the decision.
|
|
193
|
+
*/
|
|
194
|
+
function withReservationLock(turnId, agentId, fn, cwd) {
|
|
195
|
+
ensureDirs(cwd);
|
|
196
|
+
const lock = acquireLock({
|
|
197
|
+
lockPath: reservationLockPath(turnId, cwd),
|
|
198
|
+
agentId,
|
|
199
|
+
intent: 'reservation',
|
|
200
|
+
maxMutationDurationMs: 30_000,
|
|
201
|
+
});
|
|
202
|
+
try {
|
|
203
|
+
// PR2a review (BLOCKING): the callback MUST invoke `fence()` immediately
|
|
204
|
+
// before any durable write. If this lock was reaped (holder suspended past
|
|
205
|
+
// the hard deadline) and re-acquired by another writer, fenceCheck throws
|
|
206
|
+
// LockLostError so a stale holder can never overwrite the other terminal
|
|
207
|
+
// transition — the consume-XOR-revoke fence stays durable across recovery.
|
|
208
|
+
return fn(lock.fenceCheck);
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
lock.release();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/* ============================ public API ================================== */
|
|
215
|
+
export function getReservation(turnId, cwd) {
|
|
216
|
+
return readReservation(turnId, cwd);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Write a fresh `prepared` reservation. Throws `reservation_exists` if one is
|
|
220
|
+
* already on disk for this turn_id (identity is written once).
|
|
221
|
+
*/
|
|
222
|
+
export function reserve(input, cwd) {
|
|
223
|
+
const agentId = input.agent_id ?? input.agent;
|
|
224
|
+
// Fail-CLOSED at the boundary (review PR2b-b #1): an unparseable dispatch
|
|
225
|
+
// lease must be rejected here, so no committed reservation can ever carry a
|
|
226
|
+
// garbage lease that the armLaunch dispatch-lease gate would then skip.
|
|
227
|
+
if (!Number.isFinite(Date.parse(input.lease_deadline))) {
|
|
228
|
+
throw new ReservationStateError(input.turn_id, 'invalid_lease_deadline', `reserve: lease_deadline "${input.lease_deadline}" is not a parseable timestamp`);
|
|
229
|
+
}
|
|
230
|
+
return withReservationLock(input.turn_id, agentId, (fence) => {
|
|
231
|
+
const existing = readReservation(input.turn_id, cwd);
|
|
232
|
+
if (existing) {
|
|
233
|
+
throw new ReservationStateError(input.turn_id, 'reservation_exists', `reserve: turn_id ${input.turn_id} already reserved (decision=${existing.decision})`);
|
|
234
|
+
}
|
|
235
|
+
const record = {
|
|
236
|
+
turn_id: input.turn_id,
|
|
237
|
+
epoch: input.epoch ?? 0,
|
|
238
|
+
loop_id: input.loop_id,
|
|
239
|
+
slot_id: input.slot_id,
|
|
240
|
+
target_slot_generation: input.target_slot_generation,
|
|
241
|
+
loop_version_at_reserve: input.loop_version_at_reserve,
|
|
242
|
+
agent: input.agent,
|
|
243
|
+
agent_id: input.agent_id,
|
|
244
|
+
claim_id: input.claim_id,
|
|
245
|
+
child_ids: deriveChildIds(input.turn_id),
|
|
246
|
+
phase: input.phase,
|
|
247
|
+
iteration: input.iteration,
|
|
248
|
+
completion_mode: input.completion_mode ?? 'file',
|
|
249
|
+
expected_artifacts: input.expected_artifacts ?? [],
|
|
250
|
+
store_root: input.store_root,
|
|
251
|
+
cwd: input.cwd,
|
|
252
|
+
lease_deadline: input.lease_deadline,
|
|
253
|
+
decision: 'prepared',
|
|
254
|
+
created_at: nowISO(),
|
|
255
|
+
};
|
|
256
|
+
fence();
|
|
257
|
+
writeReservation(record, cwd);
|
|
258
|
+
return record;
|
|
259
|
+
}, cwd);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* CAS `prepared → committed`. Idempotent when already `committed`. Throws
|
|
263
|
+
* `aborted_not_committable` if the reservation was aborted — an aborted
|
|
264
|
+
* reservation can NEVER become dispatchable (closes the "recoverer aborted,
|
|
265
|
+
* stale reserver resumes and commits" split-brain).
|
|
266
|
+
*/
|
|
267
|
+
export function commitReservation(turnId, cwd, agentId = 'system') {
|
|
268
|
+
return withReservationLock(turnId, agentId, (fence) => {
|
|
269
|
+
const record = readReservation(turnId, cwd);
|
|
270
|
+
if (!record) {
|
|
271
|
+
throw new ReservationStateError(turnId, 'reservation_not_found', `commitReservation: unknown turn_id ${turnId}`);
|
|
272
|
+
}
|
|
273
|
+
if (record.decision === 'committed')
|
|
274
|
+
return record; // idempotent
|
|
275
|
+
if (record.decision === 'aborted') {
|
|
276
|
+
throw new ReservationStateError(turnId, 'aborted_not_committable', `commitReservation: turn_id ${turnId} is aborted and cannot be committed`);
|
|
277
|
+
}
|
|
278
|
+
const next = { ...record, decision: 'committed', decided_at: nowISO() };
|
|
279
|
+
fence();
|
|
280
|
+
writeReservation(next, cwd);
|
|
281
|
+
return next;
|
|
282
|
+
}, cwd);
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* CAS `prepared → aborted`. Idempotent when already `aborted`. Throws
|
|
286
|
+
* `committed_not_abortable` if the reservation was committed — a committed
|
|
287
|
+
* reservation is always REPAIRABLE (never abortable), so recovery of a
|
|
288
|
+
* committed decision only ever completes its projections (r4 §3).
|
|
289
|
+
*/
|
|
290
|
+
export function abortReservation(turnId, reason, cwd, agentId = 'system') {
|
|
291
|
+
return withReservationLock(turnId, agentId, (fence) => {
|
|
292
|
+
const record = readReservation(turnId, cwd);
|
|
293
|
+
if (!record) {
|
|
294
|
+
throw new ReservationStateError(turnId, 'reservation_not_found', `abortReservation: unknown turn_id ${turnId}`);
|
|
295
|
+
}
|
|
296
|
+
if (record.decision === 'aborted')
|
|
297
|
+
return record; // idempotent
|
|
298
|
+
if (record.decision === 'committed') {
|
|
299
|
+
throw new ReservationStateError(turnId, 'committed_not_abortable', `abortReservation: turn_id ${turnId} is committed and cannot be aborted`);
|
|
300
|
+
}
|
|
301
|
+
const next = { ...record, decision: 'aborted', decided_at: nowISO(), abort_reason: reason };
|
|
302
|
+
fence();
|
|
303
|
+
writeReservation(next, cwd);
|
|
304
|
+
return next;
|
|
305
|
+
}, cwd);
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Dispatch guard (spec §2 T2 / §3): dispatch may proceed ONLY for a `committed`
|
|
309
|
+
* reservation. Throws `not_dispatchable` for prepared/aborted/missing — this is
|
|
310
|
+
* the single choke point that prevents dispatching an uncommitted attempt.
|
|
311
|
+
*/
|
|
312
|
+
export function assertDispatchable(turnId, cwd) {
|
|
313
|
+
const record = readReservation(turnId, cwd);
|
|
314
|
+
if (!record) {
|
|
315
|
+
throw new ReservationStateError(turnId, 'reservation_not_found', `assertDispatchable: unknown turn_id ${turnId}`);
|
|
316
|
+
}
|
|
317
|
+
if (record.decision !== 'committed') {
|
|
318
|
+
throw new ReservationStateError(turnId, 'not_dispatchable', `assertDispatchable: turn_id ${turnId} is ${record.decision}, not committed — dispatch refused`);
|
|
319
|
+
}
|
|
320
|
+
return record;
|
|
321
|
+
}
|
|
322
|
+
export function isDispatchable(turnId, cwd) {
|
|
323
|
+
const record = readReservation(turnId, cwd);
|
|
324
|
+
return record?.decision === 'committed';
|
|
325
|
+
}
|
|
326
|
+
export function listReservations(filter = {}, cwd) {
|
|
327
|
+
const dir = reservationsDir(cwd);
|
|
328
|
+
if (!fs.existsSync(dir))
|
|
329
|
+
return [];
|
|
330
|
+
const out = [];
|
|
331
|
+
for (const file of fs.readdirSync(dir)) {
|
|
332
|
+
if (!file.endsWith('.json'))
|
|
333
|
+
continue;
|
|
334
|
+
try {
|
|
335
|
+
const record = TurnReservationSchema.parse(JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')));
|
|
336
|
+
if (filter.decision && record.decision !== filter.decision)
|
|
337
|
+
continue;
|
|
338
|
+
out.push(record);
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
// Skip malformed files; recovery diagnostics surface elsewhere.
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return out.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Find the turn-attempt reservation that OWNS a given agent_run, if any
|
|
348
|
+
* (pln#630 PR2b-c). The link is the deterministic `deriveChildIds(turn_id)` —
|
|
349
|
+
* a reservation owns `run_id` iff `child_ids.run_id === runId`. Used by the
|
|
350
|
+
* reconciler to decide whether a run is turn-owned (→ read-strict acceptance)
|
|
351
|
+
* or legacy (→ presence-based acceptance). Returns undefined for legacy runs.
|
|
352
|
+
*/
|
|
353
|
+
export function findReservationByRunId(runId, cwd) {
|
|
354
|
+
return listReservations({}, cwd).find((r) => r.child_ids.run_id === runId);
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Find the turn-attempt reservation that OWNS a given assignment, if any
|
|
358
|
+
* (pln#630 PR3a). Mirror of {@link findReservationByRunId} keyed on the
|
|
359
|
+
* deterministic `child_ids.assignment_id`. The harvest path uses this to decide
|
|
360
|
+
* whether a completed LANE-RESULT is turn-owned — the lane always carries its
|
|
361
|
+
* `assignment_id`, whereas a real reviewer lane does NOT echo run_id/turn_id/nonce
|
|
362
|
+
* (the review brief never asks for them), so assignment_id is the reliable
|
|
363
|
+
* discriminator. Returns undefined for legacy assignments.
|
|
364
|
+
*/
|
|
365
|
+
export function findReservationByAssignmentId(assignmentId, cwd) {
|
|
366
|
+
// decision:'committed' is load-bearing (review #5): only a COMMITTED reservation ever
|
|
367
|
+
// coexists with a real LANE-RESULT (dispatch commits before spawn). Filtering here makes
|
|
368
|
+
// the turn-owned discriminator explicit — a `prepared`/`aborted` reservation must never
|
|
369
|
+
// route a lane to reconcileTurn (it has no live launch generation to accept evidence for).
|
|
370
|
+
return listReservations({ decision: 'committed' }, cwd).find((r) => r.child_ids.assignment_id === assignmentId);
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Arm the launch grant on a COMMITTED reservation. The pre-exec supervisor
|
|
374
|
+
* later CONSUMES it (armed→crossed) immediately before invoking the worker;
|
|
375
|
+
* advance/close/reroute (or the expiry sweep) REVOKE it (armed→revoked).
|
|
376
|
+
* Re-arming is allowed only after a prior grant was revoked, and only with a
|
|
377
|
+
* strictly higher epoch (a fresh attempt generation).
|
|
378
|
+
*/
|
|
379
|
+
export function armLaunch(turnId, input, cwd, agentId = 'system') {
|
|
380
|
+
return withReservationLock(turnId, agentId, (fence) => {
|
|
381
|
+
const record = readReservation(turnId, cwd);
|
|
382
|
+
if (!record)
|
|
383
|
+
throw new ReservationStateError(turnId, 'reservation_not_found', `armLaunch: unknown turn_id ${turnId}`);
|
|
384
|
+
if (record.decision !== 'committed') {
|
|
385
|
+
throw new LaunchFenceError(turnId, 'not_committed', `armLaunch: turn_id ${turnId} is ${record.decision}, not committed`);
|
|
386
|
+
}
|
|
387
|
+
if (record.launch && record.launch.status !== 'revoked') {
|
|
388
|
+
throw new LaunchFenceError(turnId, 'already_armed', `armLaunch: turn_id ${turnId} already has a ${record.launch.status} grant (epoch ${record.launch.epoch})`);
|
|
389
|
+
}
|
|
390
|
+
if (record.launch && input.epoch <= record.launch.epoch) {
|
|
391
|
+
throw new LaunchFenceError(turnId, 'epoch_mismatch', `armLaunch: re-arm epoch ${input.epoch} must exceed prior ${record.launch.epoch}`);
|
|
392
|
+
}
|
|
393
|
+
// PR2b-b (§13 R5 gap 2): enforce the DISPATCH lease. A committed reservation
|
|
394
|
+
// is never abortable (repairable-only), so a stale one can't be swept away —
|
|
395
|
+
// instead we refuse to arm it once its dispatch lease has passed. Without
|
|
396
|
+
// this, a supervisor arriving long after the lease could arm a fresh grant
|
|
397
|
+
// and spawn (phantom-spawn-after-lease). The reservation stays committed but
|
|
398
|
+
// reserved_never_launched: it simply never spawns.
|
|
399
|
+
// Fail-CLOSED (review PR2b-b #1): a non-parseable dispatch lease must refuse
|
|
400
|
+
// arm, not skip the gate — otherwise a garbage lease reopens the very
|
|
401
|
+
// phantom-spawn-after-lease this guard closes (the launch-lease check below
|
|
402
|
+
// is already fail-closed; the two must be symmetric). reserve() also
|
|
403
|
+
// validates the lease at the boundary, so this is defense-in-depth.
|
|
404
|
+
const dispatchLeaseMs = Date.parse(record.lease_deadline);
|
|
405
|
+
if (!Number.isFinite(dispatchLeaseMs) || Date.parse(nowISO()) >= dispatchLeaseMs) {
|
|
406
|
+
throw new LaunchFenceError(turnId, 'dispatch_lease_expired', `armLaunch: dispatch lease ${record.lease_deadline} for ${turnId} is unparseable or has passed — reserved_never_launched, must not spawn`);
|
|
407
|
+
}
|
|
408
|
+
// PR2a review (BLOCKING): reject a non-parseable lease at arm time — an
|
|
409
|
+
// invalid string makes Date.parse NaN and `now > NaN` false, so the grant
|
|
410
|
+
// would never expire and a matching supervisor could cross it unbounded.
|
|
411
|
+
if (!Number.isFinite(Date.parse(input.lease_deadline))) {
|
|
412
|
+
throw new LaunchFenceError(turnId, 'lease_invalid', `armLaunch: lease_deadline "${input.lease_deadline}" is not a parseable timestamp`);
|
|
413
|
+
}
|
|
414
|
+
// Nonce = fence token + evidence key (§13 R2). Auto-generate a random,
|
|
415
|
+
// generation-unique value unless the caller supplied one — a distinct token
|
|
416
|
+
// per generation is what lets evidenceMatchesAttempt reject stale
|
|
417
|
+
// prior-generation evidence (review PR2b-b #2).
|
|
418
|
+
const token = input.token ?? crypto.randomUUID();
|
|
419
|
+
const next = {
|
|
420
|
+
...record,
|
|
421
|
+
launch: { status: 'armed', token, epoch: input.epoch, lease_deadline: input.lease_deadline, armed_at: nowISO() },
|
|
422
|
+
};
|
|
423
|
+
fence();
|
|
424
|
+
writeReservation(next, cwd);
|
|
425
|
+
return next;
|
|
426
|
+
}, cwd);
|
|
427
|
+
}
|
|
428
|
+
export function consumeLaunchGrant(turnId, token, epoch, cwd, agentId = 'system') {
|
|
429
|
+
return withReservationLock(turnId, agentId, (fence) => {
|
|
430
|
+
const record = readReservation(turnId, cwd);
|
|
431
|
+
if (!record)
|
|
432
|
+
throw new ReservationStateError(turnId, 'reservation_not_found', `consumeLaunchGrant: unknown turn_id ${turnId}`);
|
|
433
|
+
const g = record.launch;
|
|
434
|
+
if (!g)
|
|
435
|
+
throw new LaunchFenceError(turnId, 'not_armed', `consumeLaunchGrant: turn_id ${turnId} has no launch grant`);
|
|
436
|
+
// Epoch/token/lease validated against the immutable grant fields (set once
|
|
437
|
+
// at arm). Epoch BEFORE token so a stale generation reports epoch_mismatch.
|
|
438
|
+
if (g.epoch !== epoch)
|
|
439
|
+
throw new LaunchFenceError(turnId, 'epoch_mismatch', `consumeLaunchGrant: epoch ${epoch} != grant epoch ${g.epoch}`);
|
|
440
|
+
if (g.token !== token)
|
|
441
|
+
throw new LaunchFenceError(turnId, 'token_mismatch', `consumeLaunchGrant: token mismatch for ${turnId}`);
|
|
442
|
+
// Expiry is inclusive (now >= deadline) — the SAME rule the sweep uses.
|
|
443
|
+
if (Date.parse(nowISO()) >= Date.parse(g.lease_deadline)) {
|
|
444
|
+
throw new LaunchFenceError(turnId, 'lease_expired', `consumeLaunchGrant: grant for ${turnId} expired at ${g.lease_deadline}`);
|
|
445
|
+
}
|
|
446
|
+
// ATOMIC XOR — claim the decision via exclusive-create. If a revoke already
|
|
447
|
+
// won (even from a newer holder after this one was reaped), we LOSE here and
|
|
448
|
+
// must not spawn. No TOCTOU: the create, not a prior check, is the commit.
|
|
449
|
+
const { decision: committed, won } = claimLaunchDecision(turnId, { decision: 'crossed', token, epoch, at: nowISO() }, cwd);
|
|
450
|
+
if (committed.decision === 'revoked') {
|
|
451
|
+
throw new LaunchFenceError(turnId, 'revoked', `consumeLaunchGrant: grant for ${turnId} was revoked — MUST NOT spawn`);
|
|
452
|
+
}
|
|
453
|
+
// Won → this call crossed (may spawn). Adopted (won=false) → already crossed
|
|
454
|
+
// by another invocation: launch_attempted_unknown, caller MUST NOT spawn.
|
|
455
|
+
const next = { ...record, launch: { ...g, status: 'crossed', crossed_at: committed.at } };
|
|
456
|
+
fence();
|
|
457
|
+
writeReservation(next, cwd);
|
|
458
|
+
return { reservation: next, wonTransition: won };
|
|
459
|
+
}, cwd);
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* REVOKE the grant (armed→revoked) — prevents a still-armed token from ever
|
|
463
|
+
* crossing. Idempotent when already revoked (same epoch). Refused once CROSSED:
|
|
464
|
+
* a crossed grant means the worker launched, so the caller must treat the
|
|
465
|
+
* attempt as launch_attempted_unknown and never re-spawn.
|
|
466
|
+
*/
|
|
467
|
+
export function revokeLaunchGrant(turnId, epoch, reason, cwd, agentId = 'system') {
|
|
468
|
+
return withReservationLock(turnId, agentId, (fence) => {
|
|
469
|
+
const record = readReservation(turnId, cwd);
|
|
470
|
+
if (!record)
|
|
471
|
+
throw new ReservationStateError(turnId, 'reservation_not_found', `revokeLaunchGrant: unknown turn_id ${turnId}`);
|
|
472
|
+
const g = record.launch;
|
|
473
|
+
if (!g)
|
|
474
|
+
throw new LaunchFenceError(turnId, 'not_armed', `revokeLaunchGrant: turn_id ${turnId} has no launch grant`);
|
|
475
|
+
if (g.epoch !== epoch)
|
|
476
|
+
throw new LaunchFenceError(turnId, 'epoch_mismatch', `revokeLaunchGrant: epoch ${epoch} != grant epoch ${g.epoch}`);
|
|
477
|
+
// ATOMIC XOR — claim the decision. If a consume already crossed (even from a
|
|
478
|
+
// newer holder), we LOSE: the worker launched, so the attempt is
|
|
479
|
+
// launch_attempted_unknown and must never be treated as re-spawnable.
|
|
480
|
+
const { decision: committed } = claimLaunchDecision(turnId, { decision: 'revoked', token: g.token, epoch, at: nowISO(), reason }, cwd);
|
|
481
|
+
if (committed.decision === 'crossed') {
|
|
482
|
+
throw new LaunchFenceError(turnId, 'crossed_not_revocable', `revokeLaunchGrant: grant for ${turnId} already crossed — worker launched, cannot revoke`);
|
|
483
|
+
}
|
|
484
|
+
const next = { ...record, launch: { ...g, status: 'revoked', revoked_at: committed.at, revoke_reason: committed.reason ?? reason } };
|
|
485
|
+
fence();
|
|
486
|
+
writeReservation(next, cwd);
|
|
487
|
+
return next;
|
|
488
|
+
}, cwd);
|
|
489
|
+
}
|
|
490
|
+
export function launchGrant(turnId, cwd) {
|
|
491
|
+
const g = readReservation(turnId, cwd)?.launch;
|
|
492
|
+
if (!g)
|
|
493
|
+
return undefined;
|
|
494
|
+
// The decision file is AUTHORITATIVE — a winner may have crashed after the
|
|
495
|
+
// atomic exclusive-create but before updating the record projection, so
|
|
496
|
+
// reconcile the status from the decision file when one exists.
|
|
497
|
+
const d = readLaunchDecision(turnId, g.epoch, cwd);
|
|
498
|
+
if (!d)
|
|
499
|
+
return g;
|
|
500
|
+
return d.decision === 'crossed'
|
|
501
|
+
? { ...g, status: 'crossed', crossed_at: d.at }
|
|
502
|
+
: { ...g, status: 'revoked', revoked_at: d.at, revoke_reason: d.reason };
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* The evidence nonce for the CURRENT launch generation (§13 R2). Because
|
|
506
|
+
* `deriveChildIds` is epoch-invariant, only the consumed launch token uniquely
|
|
507
|
+
* identifies the generation that actually spawned — so THIS is what the worker
|
|
508
|
+
* must echo (in LANE-RESULT / signals / artifact metadata) and what the
|
|
509
|
+
* read-strict acceptance path (PR2b-c) matches on. `undefined` until armed.
|
|
510
|
+
*/
|
|
511
|
+
export function currentNonce(reservation) {
|
|
512
|
+
// Only a LIVE generation (armed or crossed) has a current nonce. A revoked
|
|
513
|
+
// grant means the worker never crossed → never spawned, so its token is a
|
|
514
|
+
// dead generation and must not be reported as current (review PR2b-a #1).
|
|
515
|
+
const l = reservation.launch;
|
|
516
|
+
return (l?.status === 'armed' || l?.status === 'crossed') ? l.token : undefined;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Read-strict evidence predicate (§13 R3) — the foundation the acceptance path
|
|
520
|
+
* (PR2b-c) builds on. Evidence is accepted for a turn-owned attempt ONLY when it
|
|
521
|
+
* carries the matching `turn_id`, the attempt's derived `run_id`, AND the
|
|
522
|
+
* current launch-generation `nonce` (the consumed token). Returns false when the
|
|
523
|
+
* generation is not live (revoked / never armed → `currentNonce` undefined), so
|
|
524
|
+
* a stale prior-generation or bare assignment-keyed signal can never match. The
|
|
525
|
+
* `run.status === 'completed'` gate is applied separately by the caller.
|
|
526
|
+
*/
|
|
527
|
+
export function evidenceMatchesAttempt(reservation, evidence) {
|
|
528
|
+
const nonce = currentNonce(reservation);
|
|
529
|
+
if (!nonce)
|
|
530
|
+
return false;
|
|
531
|
+
return (evidence.turn_id === reservation.turn_id &&
|
|
532
|
+
evidence.run_id === reservation.child_ids.run_id &&
|
|
533
|
+
evidence.nonce === nonce);
|
|
534
|
+
}
|
|
535
|
+
export function attemptStatus(reservation, runStatus) {
|
|
536
|
+
if (reservation.decision === 'aborted')
|
|
537
|
+
return 'cancelled';
|
|
538
|
+
const launch = reservation.launch;
|
|
539
|
+
if (launch?.status === 'revoked')
|
|
540
|
+
return 'cancelled';
|
|
541
|
+
if (launch?.status === 'crossed') {
|
|
542
|
+
if (runStatus === 'completed')
|
|
543
|
+
return 'completed';
|
|
544
|
+
if (runStatus === 'failed')
|
|
545
|
+
return 'failed';
|
|
546
|
+
if (runStatus === 'waiting_input')
|
|
547
|
+
return 'waiting_input';
|
|
548
|
+
return 'running';
|
|
549
|
+
}
|
|
550
|
+
// no grant yet, or armed-but-not-crossed
|
|
551
|
+
return reservation.decision === 'committed' ? 'launching' : 'reserved';
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Revoke every armed grant whose lease has expired (reserved_never_launched).
|
|
555
|
+
* The single non-GET sweep owner (dec#138). Skips crossed/revoked grants.
|
|
556
|
+
* Returns the turn_ids revoked.
|
|
557
|
+
*/
|
|
558
|
+
export function sweepExpiredLaunchGrants(cwd, agentId = 'system') {
|
|
559
|
+
const now = Date.parse(nowISO());
|
|
560
|
+
const revoked = [];
|
|
561
|
+
for (const r of listReservations({}, cwd)) {
|
|
562
|
+
const g = launchGrant(r.turn_id, cwd); // authoritative status (decision-file reconciled)
|
|
563
|
+
if (!g || g.status !== 'armed')
|
|
564
|
+
continue;
|
|
565
|
+
// Expired ⟺ now >= deadline (inclusive), matching consumeLaunchGrant.
|
|
566
|
+
if (Date.parse(g.lease_deadline) > now)
|
|
567
|
+
continue;
|
|
568
|
+
try {
|
|
569
|
+
revokeLaunchGrant(r.turn_id, g.epoch, 'reserved_never_launched', cwd, agentId);
|
|
570
|
+
revoked.push(r.turn_id);
|
|
571
|
+
}
|
|
572
|
+
catch { /* raced to crossed/revoked — skip */ }
|
|
573
|
+
}
|
|
574
|
+
return revoked;
|
|
575
|
+
}
|
|
576
|
+
//# sourceMappingURL=attempt-reservation.js.map
|