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
|
@@ -4,21 +4,17 @@ import path from 'node:path';
|
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { memoryDir, writeFileAtomic } from '../io.js';
|
|
6
6
|
import { nowISO } from '../ids.js';
|
|
7
|
+
import { CapabilitySnapshotSchema, assertExecutionContractIntegrity, ExecutionContractRefSchema, ExecutionContractSchema, } from '../execution-contract.js';
|
|
8
|
+
import { ExpectedArtifactSchema } from './artifact-contract.js';
|
|
7
9
|
import { acquireLock } from './lock.js';
|
|
10
|
+
import { readLaunchDecision as readV2LaunchDecision, readInitialGeneration, listAttemptGenerations, resolveTurnGenerationChain, } from './attempt-generations.js';
|
|
11
|
+
export { ExpectedArtifactSchema } from './artifact-contract.js';
|
|
8
12
|
/**
|
|
9
13
|
* An artifact the attempt's worker is expected to produce (spec §2 / §13 R1).
|
|
10
14
|
* Brainclaw generates the canonical target; `worker_path` is worker-relative and
|
|
11
15
|
* MUST be realpath-containment-validated before any read (invariant #7, wired in
|
|
12
16
|
* a later PR). `sha256` is filled at harvest and validated before state mutation.
|
|
13
17
|
*/
|
|
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
18
|
export const TurnReservationSchema = z.object({
|
|
23
19
|
turn_id: z.string().min(1),
|
|
24
20
|
epoch: z.number().int().nonnegative(),
|
|
@@ -42,6 +38,10 @@ export const TurnReservationSchema = z.object({
|
|
|
42
38
|
// pln#630 PR2b-a (§13 R1): artifacts this attempt's worker must produce.
|
|
43
39
|
// Default [] so PR1 on-disk records (which predate the field) still parse.
|
|
44
40
|
expected_artifacts: z.array(ExpectedArtifactSchema).default([]),
|
|
41
|
+
/** P1: complete immutable launch contract. Optional for pre-P1 reservations. */
|
|
42
|
+
execution_contract: ExecutionContractSchema.optional(),
|
|
43
|
+
execution_contract_ref: ExecutionContractRefSchema.optional(),
|
|
44
|
+
capability_snapshot: CapabilitySnapshotSchema.optional(),
|
|
45
45
|
store_root: z.string().min(1),
|
|
46
46
|
cwd: z.string().min(1),
|
|
47
47
|
lease_deadline: z.string().min(1),
|
|
@@ -174,6 +174,54 @@ export function deriveTurnId(loopId, slotId, iteration) {
|
|
|
174
174
|
const h = crypto.createHash('sha256').update(`${loopId}:${slotId}:${iteration}`).digest('hex').slice(0, 16);
|
|
175
175
|
return `tat_${h}`;
|
|
176
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* Versioned identity used only when the legacy `(loop, slot, iteration)` cell
|
|
179
|
+
* is already owned by another phase. Keeping this a separate derivation makes
|
|
180
|
+
* existing turn ids and crash-replay fixtures stable while allowing one slot
|
|
181
|
+
* to execute several worker phases in the same protocol iteration.
|
|
182
|
+
*/
|
|
183
|
+
export function derivePhaseQualifiedTurnId(loopId, slotId, phase, iteration) {
|
|
184
|
+
const identity = JSON.stringify(['brainclaw-turn-identity-v2', loopId, slotId, phase, iteration]);
|
|
185
|
+
const h = crypto.createHash('sha256').update(identity).digest('hex').slice(0, 16);
|
|
186
|
+
return `tat_${h}`;
|
|
187
|
+
}
|
|
188
|
+
function reservationMatchesTurnIdentity(reservation, input) {
|
|
189
|
+
return reservation !== undefined
|
|
190
|
+
&& reservation.loop_id === input.loop_id
|
|
191
|
+
&& reservation.slot_id === input.slot_id
|
|
192
|
+
&& reservation.phase === input.phase
|
|
193
|
+
&& reservation.iteration === input.iteration;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Resolve the durable turn cell without breaking pre-phase-qualified stores.
|
|
197
|
+
*
|
|
198
|
+
* Resolution order is deliberately conservative:
|
|
199
|
+
* 1. adopt the slot's current compatible reservation (projection/crash replay),
|
|
200
|
+
* 2. preserve the compatible legacy three-tuple reservation,
|
|
201
|
+
* 3. adopt an existing compatible phase-qualified reservation,
|
|
202
|
+
* 4. use the legacy id while that cell is absent,
|
|
203
|
+
* 5. only then fall back to the phase-qualified id for a real phase collision.
|
|
204
|
+
*
|
|
205
|
+
* Compatibility intentionally covers identity fields only. Agent/claim/contract
|
|
206
|
+
* mismatches remain the responsibility of prepareAttempt's fail-closed checks;
|
|
207
|
+
* they must never cause this resolver to mint a parallel authority cell.
|
|
208
|
+
*/
|
|
209
|
+
export function resolveTurnId(input, cwd) {
|
|
210
|
+
const legacyTurnId = deriveTurnId(input.loop_id, input.slot_id, input.iteration);
|
|
211
|
+
const phaseTurnId = derivePhaseQualifiedTurnId(input.loop_id, input.slot_id, input.phase, input.iteration);
|
|
212
|
+
if (input.current_turn_id) {
|
|
213
|
+
const current = getReservation(input.current_turn_id, cwd);
|
|
214
|
+
if (reservationMatchesTurnIdentity(current, input))
|
|
215
|
+
return input.current_turn_id;
|
|
216
|
+
}
|
|
217
|
+
const legacy = getReservation(legacyTurnId, cwd);
|
|
218
|
+
if (reservationMatchesTurnIdentity(legacy, input))
|
|
219
|
+
return legacyTurnId;
|
|
220
|
+
const phaseQualified = getReservation(phaseTurnId, cwd);
|
|
221
|
+
if (reservationMatchesTurnIdentity(phaseQualified, input))
|
|
222
|
+
return phaseTurnId;
|
|
223
|
+
return legacy === undefined ? legacyTurnId : phaseTurnId;
|
|
224
|
+
}
|
|
177
225
|
/* ============================ persistence ================================= */
|
|
178
226
|
function readReservation(turnId, cwd) {
|
|
179
227
|
const filePath = reservationPath(turnId, cwd);
|
|
@@ -188,8 +236,9 @@ function writeReservation(record, cwd) {
|
|
|
188
236
|
}
|
|
189
237
|
/**
|
|
190
238
|
* Run `fn` under the per-reservation exclusive lock. Reuses the loop lock
|
|
191
|
-
* primitive (
|
|
192
|
-
*
|
|
239
|
+
* primitive (generation-fenced dead-owner reaping, exclusive hard-link
|
|
240
|
+
* creation, fencing) so the decision CAS is atomic across processes — two
|
|
241
|
+
* racing writers cannot both mutate the decision.
|
|
193
242
|
*/
|
|
194
243
|
function withReservationLock(turnId, agentId, fn, cwd) {
|
|
195
244
|
ensureDirs(cwd);
|
|
@@ -201,10 +250,10 @@ function withReservationLock(turnId, agentId, fn, cwd) {
|
|
|
201
250
|
});
|
|
202
251
|
try {
|
|
203
252
|
// PR2a review (BLOCKING): the callback MUST invoke `fence()` immediately
|
|
204
|
-
// before any durable write. If
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
253
|
+
// before any durable write. If a proven-dead owner's generation was reaped
|
|
254
|
+
// and re-acquired by another writer, fenceCheck throws LockLostError so a
|
|
255
|
+
// stale holder can never overwrite the other terminal transition — the
|
|
256
|
+
// consume-XOR-revoke fence stays durable across recovery.
|
|
208
257
|
return fn(lock.fenceCheck);
|
|
209
258
|
}
|
|
210
259
|
finally {
|
|
@@ -227,6 +276,22 @@ export function reserve(input, cwd) {
|
|
|
227
276
|
if (!Number.isFinite(Date.parse(input.lease_deadline))) {
|
|
228
277
|
throw new ReservationStateError(input.turn_id, 'invalid_lease_deadline', `reserve: lease_deadline "${input.lease_deadline}" is not a parseable timestamp`);
|
|
229
278
|
}
|
|
279
|
+
const contractFieldCount = [
|
|
280
|
+
input.execution_contract,
|
|
281
|
+
input.execution_contract_ref,
|
|
282
|
+
input.capability_snapshot,
|
|
283
|
+
].filter((value) => value !== undefined).length;
|
|
284
|
+
if (contractFieldCount !== 0 && contractFieldCount !== 3) {
|
|
285
|
+
throw new ReservationStateError(input.turn_id, 'invalid_execution_contract', 'reserve: execution_contract, execution_contract_ref and capability_snapshot must be supplied together');
|
|
286
|
+
}
|
|
287
|
+
if (input.execution_contract && input.execution_contract_ref && input.capability_snapshot) {
|
|
288
|
+
try {
|
|
289
|
+
assertExecutionContractIntegrity(input.execution_contract, input.execution_contract_ref, input.capability_snapshot, { agent: input.agent, agent_id: input.agent_id });
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
throw new ReservationStateError(input.turn_id, 'invalid_execution_contract', `reserve: ${error instanceof Error ? error.message : String(error)}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
230
295
|
return withReservationLock(input.turn_id, agentId, (fence) => {
|
|
231
296
|
const existing = readReservation(input.turn_id, cwd);
|
|
232
297
|
if (existing) {
|
|
@@ -247,6 +312,9 @@ export function reserve(input, cwd) {
|
|
|
247
312
|
iteration: input.iteration,
|
|
248
313
|
completion_mode: input.completion_mode ?? 'file',
|
|
249
314
|
expected_artifacts: input.expected_artifacts ?? [],
|
|
315
|
+
execution_contract: input.execution_contract,
|
|
316
|
+
execution_contract_ref: input.execution_contract_ref,
|
|
317
|
+
capability_snapshot: input.capability_snapshot,
|
|
250
318
|
store_root: input.store_root,
|
|
251
319
|
cwd: input.cwd,
|
|
252
320
|
lease_deadline: input.lease_deadline,
|
|
@@ -351,7 +419,18 @@ export function listReservations(filter = {}, cwd) {
|
|
|
351
419
|
* or legacy (→ presence-based acceptance). Returns undefined for legacy runs.
|
|
352
420
|
*/
|
|
353
421
|
export function findReservationByRunId(runId, cwd) {
|
|
354
|
-
return listReservations({}, cwd).find((
|
|
422
|
+
return listReservations({}, cwd).find((reservation) => {
|
|
423
|
+
if (reservation.child_ids.run_id === runId)
|
|
424
|
+
return true;
|
|
425
|
+
try {
|
|
426
|
+
const root = cwd ?? reservation.store_root;
|
|
427
|
+
const initial = readInitialGeneration(root, reservation.turn_id);
|
|
428
|
+
return initial ? listAttemptGenerations(root, initial).some((generation) => generation.run_id === runId) : false;
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
});
|
|
355
434
|
}
|
|
356
435
|
/**
|
|
357
436
|
* Find the turn-attempt reservation that OWNS a given assignment, if any
|
|
@@ -369,6 +448,13 @@ export function findReservationByAssignmentId(assignmentId, cwd) {
|
|
|
369
448
|
// route a lane to reconcileTurn (it has no live launch generation to accept evidence for).
|
|
370
449
|
return listReservations({ decision: 'committed' }, cwd).find((r) => r.child_ids.assignment_id === assignmentId);
|
|
371
450
|
}
|
|
451
|
+
/** Run-scoping key for runtime evidence; undefined preserves legacy assignment-scoped paths. */
|
|
452
|
+
export function currentAttemptRunIdForAssignment(assignmentId, cwd) {
|
|
453
|
+
const reservation = findReservationByAssignmentId(assignmentId, cwd);
|
|
454
|
+
if (!reservation)
|
|
455
|
+
return undefined;
|
|
456
|
+
return resolveTurnGenerationChain(reservation.store_root, reservation.turn_id)?.latest_generation.run_id;
|
|
457
|
+
}
|
|
372
458
|
/**
|
|
373
459
|
* Arm the launch grant on a COMMITTED reservation. The pre-exec supervisor
|
|
374
460
|
* later CONSUMES it (armed→crossed) immediately before invoking the worker;
|
|
@@ -425,37 +511,81 @@ export function armLaunch(turnId, input, cwd, agentId = 'system') {
|
|
|
425
511
|
return next;
|
|
426
512
|
}, cwd);
|
|
427
513
|
}
|
|
514
|
+
function consumeLaunchGrantLocked(turnId, record, token, epoch, fence, cwd) {
|
|
515
|
+
const g = record.launch;
|
|
516
|
+
if (!g)
|
|
517
|
+
throw new LaunchFenceError(turnId, 'not_armed', `consumeLaunchGrant: turn_id ${turnId} has no launch grant`);
|
|
518
|
+
// Epoch/token/lease validated against the immutable grant fields (set once
|
|
519
|
+
// at arm). Epoch BEFORE token so a stale generation reports epoch_mismatch.
|
|
520
|
+
if (g.epoch !== epoch)
|
|
521
|
+
throw new LaunchFenceError(turnId, 'epoch_mismatch', `consumeLaunchGrant: epoch ${epoch} != grant epoch ${g.epoch}`);
|
|
522
|
+
if (g.token !== token)
|
|
523
|
+
throw new LaunchFenceError(turnId, 'token_mismatch', `consumeLaunchGrant: token mismatch for ${turnId}`);
|
|
524
|
+
// Expiry is inclusive (now >= deadline) — the SAME rule the sweep uses. This
|
|
525
|
+
// is intentionally evaluated after projection callbacks as well: a slow repair
|
|
526
|
+
// cannot cross a generation whose lease expired while it was materializing.
|
|
527
|
+
if (Date.parse(nowISO()) >= Date.parse(g.lease_deadline)) {
|
|
528
|
+
throw new LaunchFenceError(turnId, 'lease_expired', `consumeLaunchGrant: grant for ${turnId} expired at ${g.lease_deadline}`);
|
|
529
|
+
}
|
|
530
|
+
if (g.status === 'revoked') {
|
|
531
|
+
throw new LaunchFenceError(turnId, 'revoked', `consumeLaunchGrant: grant for ${turnId} was revoked — MUST NOT spawn`);
|
|
532
|
+
}
|
|
533
|
+
// Validate that this reservation-lock holder was not reaped while a projection
|
|
534
|
+
// callback performed local I/O. The fence check MUST precede the irreversible
|
|
535
|
+
// decision-cell create, not merely the record projection write that follows it.
|
|
536
|
+
fence();
|
|
537
|
+
// ATOMIC XOR — claim the decision via exclusive-create. If a revoke already
|
|
538
|
+
// won (even from a newer holder after this one was reaped), we LOSE here and
|
|
539
|
+
// must not spawn. No TOCTOU: the create, not a prior check, is the commit.
|
|
540
|
+
const { decision: committed, won } = claimLaunchDecision(turnId, { decision: 'crossed', token, epoch, at: nowISO() }, cwd);
|
|
541
|
+
if (committed.decision === 'revoked') {
|
|
542
|
+
throw new LaunchFenceError(turnId, 'revoked', `consumeLaunchGrant: grant for ${turnId} was revoked — MUST NOT spawn`);
|
|
543
|
+
}
|
|
544
|
+
// Won → this call crossed (may spawn). Adopted (won=false) → already crossed
|
|
545
|
+
// by another invocation: launch_attempted_unknown, caller MUST NOT spawn.
|
|
546
|
+
const next = { ...record, launch: { ...g, status: 'crossed', crossed_at: committed.at } };
|
|
547
|
+
fence();
|
|
548
|
+
writeReservation(next, cwd);
|
|
549
|
+
return { reservation: next, wonTransition: won };
|
|
550
|
+
}
|
|
428
551
|
export function consumeLaunchGrant(turnId, token, epoch, cwd, agentId = 'system') {
|
|
429
552
|
return withReservationLock(turnId, agentId, (fence) => {
|
|
430
553
|
const record = readReservation(turnId, cwd);
|
|
431
554
|
if (!record)
|
|
432
555
|
throw new ReservationStateError(turnId, 'reservation_not_found', `consumeLaunchGrant: unknown turn_id ${turnId}`);
|
|
556
|
+
return consumeLaunchGrantLocked(turnId, record, token, epoch, fence, cwd);
|
|
557
|
+
}, cwd);
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Consume a launch generation only after its deterministic local projections are durable.
|
|
561
|
+
*
|
|
562
|
+
* Lock order is intentionally reservation -> store -> loop. `project` must remain a short,
|
|
563
|
+
* synchronous, local-I/O callback and this API must never be entered while the caller already
|
|
564
|
+
* holds the store or loop lock. If projection throws, the crossed decision is not created and
|
|
565
|
+
* an identical replay can repair/adopt the same Assignment, AgentRun and slot binding.
|
|
566
|
+
*/
|
|
567
|
+
export function consumeLaunchGrantWithProjection(turnId, token, epoch, project, cwd, agentId = 'system') {
|
|
568
|
+
return withReservationLock(turnId, agentId, (fence) => {
|
|
569
|
+
const record = readReservation(turnId, cwd);
|
|
570
|
+
if (!record)
|
|
571
|
+
throw new ReservationStateError(turnId, 'reservation_not_found', `consumeLaunchGrantWithProjection: unknown turn_id ${turnId}`);
|
|
572
|
+
// Validate the immutable generation before allowing it to materialize anything.
|
|
433
573
|
const g = record.launch;
|
|
434
574
|
if (!g)
|
|
435
|
-
throw new LaunchFenceError(turnId, 'not_armed', `
|
|
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.
|
|
575
|
+
throw new LaunchFenceError(turnId, 'not_armed', `consumeLaunchGrantWithProjection: turn_id ${turnId} has no launch grant`);
|
|
438
576
|
if (g.epoch !== epoch)
|
|
439
|
-
throw new LaunchFenceError(turnId, 'epoch_mismatch', `
|
|
577
|
+
throw new LaunchFenceError(turnId, 'epoch_mismatch', `consumeLaunchGrantWithProjection: epoch ${epoch} != grant epoch ${g.epoch}`);
|
|
440
578
|
if (g.token !== token)
|
|
441
|
-
throw new LaunchFenceError(turnId, 'token_mismatch', `
|
|
442
|
-
|
|
579
|
+
throw new LaunchFenceError(turnId, 'token_mismatch', `consumeLaunchGrantWithProjection: token mismatch for ${turnId}`);
|
|
580
|
+
if (g.status === 'revoked')
|
|
581
|
+
throw new LaunchFenceError(turnId, 'revoked', `consumeLaunchGrantWithProjection: grant for ${turnId} was revoked — MUST NOT project or spawn`);
|
|
582
|
+
if (g.status === 'crossed')
|
|
583
|
+
return { reservation: record, wonTransition: false };
|
|
443
584
|
if (Date.parse(nowISO()) >= Date.parse(g.lease_deadline)) {
|
|
444
|
-
throw new LaunchFenceError(turnId, 'lease_expired', `
|
|
585
|
+
throw new LaunchFenceError(turnId, 'lease_expired', `consumeLaunchGrantWithProjection: grant for ${turnId} expired at ${g.lease_deadline}`);
|
|
445
586
|
}
|
|
446
|
-
|
|
447
|
-
|
|
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 };
|
|
587
|
+
project(record);
|
|
588
|
+
return consumeLaunchGrantLocked(turnId, record, token, epoch, fence, cwd);
|
|
459
589
|
}, cwd);
|
|
460
590
|
}
|
|
461
591
|
/**
|
|
@@ -509,6 +639,17 @@ export function launchGrant(turnId, cwd) {
|
|
|
509
639
|
* read-strict acceptance path (PR2b-c) matches on. `undefined` until armed.
|
|
510
640
|
*/
|
|
511
641
|
export function currentNonce(reservation) {
|
|
642
|
+
try {
|
|
643
|
+
const resolved = resolveTurnGenerationChain(reservation.store_root, reservation.turn_id);
|
|
644
|
+
if (resolved && (resolved.status === 'active' || resolved.status === 'settled')) {
|
|
645
|
+
const generation = resolved.latest_generation;
|
|
646
|
+
const launch = readV2LaunchDecision(reservation.store_root, reservation.turn_id, generation.attempt_epoch);
|
|
647
|
+
return launch?.decision === 'crossed' ? generation.launch_nonce : undefined;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
catch {
|
|
651
|
+
return undefined;
|
|
652
|
+
}
|
|
512
653
|
// Only a LIVE generation (armed or crossed) has a current nonce. A revoked
|
|
513
654
|
// grant means the worker never crossed → never spawned, so its token is a
|
|
514
655
|
// dead generation and must not be reported as current (review PR2b-a #1).
|
|
@@ -525,6 +666,27 @@ export function currentNonce(reservation) {
|
|
|
525
666
|
* `run.status === 'completed'` gate is applied separately by the caller.
|
|
526
667
|
*/
|
|
527
668
|
export function evidenceMatchesAttempt(reservation, evidence) {
|
|
669
|
+
try {
|
|
670
|
+
const resolved = resolveTurnGenerationChain(reservation.store_root, reservation.turn_id);
|
|
671
|
+
if (resolved && (resolved.status === 'active' || resolved.status === 'settled')) {
|
|
672
|
+
const generation = resolved.latest_generation;
|
|
673
|
+
const launch = readV2LaunchDecision(reservation.store_root, reservation.turn_id, generation.attempt_epoch);
|
|
674
|
+
if (launch?.decision !== 'crossed')
|
|
675
|
+
return false;
|
|
676
|
+
return (evidence.assignment_id === generation.assignment_id
|
|
677
|
+
&& evidence.turn_id === generation.turn_id
|
|
678
|
+
&& evidence.run_id === generation.run_id
|
|
679
|
+
&& evidence.nonce === generation.launch_nonce
|
|
680
|
+
&& evidence.attempt_epoch === generation.attempt_epoch
|
|
681
|
+
&& evidence.contract_hash === generation.contract_hash
|
|
682
|
+
&& evidence.workspace_digest === generation.workspace_digest);
|
|
683
|
+
}
|
|
684
|
+
if (resolved)
|
|
685
|
+
return false;
|
|
686
|
+
}
|
|
687
|
+
catch {
|
|
688
|
+
return false;
|
|
689
|
+
}
|
|
528
690
|
const nonce = currentNonce(reservation);
|
|
529
691
|
if (!nonce)
|
|
530
692
|
return false;
|