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
|
@@ -1,15 +1,22 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
1
2
|
import path from 'node:path';
|
|
2
|
-
import { getReservation, evidenceMatchesAttempt, currentNonce,
|
|
3
|
+
import { getReservation, evidenceMatchesAttempt, currentNonce, launchGrant, resolveTurnId } from './attempt-reservation.js';
|
|
3
4
|
import { getLoop } from './store.js';
|
|
4
|
-
import {
|
|
5
|
+
import { completeTurnWithEvidence, addArtifactWithEvidence, complete_turn, advance } from './verbs.js';
|
|
5
6
|
import { reducerForKind } from './result-reducers.js';
|
|
6
|
-
import { loadAgentRun, transitionAgentRun } from '../agentruns.js';
|
|
7
|
+
import { loadAgentRun, recordExecutionContractAnomaly, transitionAgentRun } from '../agentruns.js';
|
|
7
8
|
import { loadAssignment, transitionAssignment } from '../assignments.js';
|
|
8
9
|
import { loadClaim, releaseClaim, releaseClaimIfActive } from '../claims.js';
|
|
9
10
|
import { createRuntimeEvent } from '../events.js';
|
|
10
|
-
import { readCompletionSignals } from '../runtime-signals.js';
|
|
11
|
+
import { readCompletionSignals, readContractAck } from '../runtime-signals.js';
|
|
11
12
|
import { buildFixCycleTask } from '../review-loop-close.js';
|
|
12
13
|
import { withLoopLock, LockTimeoutError, LockLostError } from './lock.js';
|
|
14
|
+
import { validateWorkerContractAcceptance } from '../execution-contract.js';
|
|
15
|
+
import { evidenceDigest } from './evidence.js';
|
|
16
|
+
import { executionContractForGeneration, settleActiveAttemptGenerationV2 } from './attempt-authority.js';
|
|
17
|
+
import { fenceForGeneration, resolveTurnGenerationChain } from './attempt-generations.js';
|
|
18
|
+
import { readLocalAuthorityHome } from './attempt-rollout.js';
|
|
19
|
+
import { LaneResultSchema } from '../schema.js';
|
|
13
20
|
// The terminal loop statuses (LOOP_STATUSES = open|paused|completed|blocked|cancelled).
|
|
14
21
|
// 'blocked' is LOAD-BEARING (pln#630 PR3b): the iteration cap closes a fix cycle to
|
|
15
22
|
// `blocked`, and a blocked loop must be treated as terminal both by the idempotent
|
|
@@ -88,9 +95,35 @@ export function reconcileTurn(input) {
|
|
|
88
95
|
if (!sameStore) {
|
|
89
96
|
return { reconciled: false, reason: `containment: reservation store_root ${reservation.store_root} != operating store ${operatingRoot}` };
|
|
90
97
|
}
|
|
98
|
+
const resolvedGeneration = resolveTurnGenerationChain(cwd ?? reservation.store_root, reservation.turn_id);
|
|
99
|
+
const activeGeneration = resolvedGeneration && (resolvedGeneration.status === 'active' || resolvedGeneration.status === 'settled')
|
|
100
|
+
? resolvedGeneration.latest_generation
|
|
101
|
+
: undefined;
|
|
102
|
+
const activeRunId = activeGeneration?.run_id ?? reservation.child_ids.run_id;
|
|
103
|
+
const activeContractRef = activeGeneration
|
|
104
|
+
? executionContractForGeneration(reservation, activeGeneration).ref
|
|
105
|
+
: reservation.execution_contract_ref;
|
|
106
|
+
const activeLaunchStatus = activeGeneration ? 'crossed' : reservation.launch?.status;
|
|
107
|
+
const owningRun = loadAgentRun(activeRunId, cwd);
|
|
108
|
+
if (owningRun?.execution_contract_anomaly) {
|
|
109
|
+
return {
|
|
110
|
+
reconciled: false,
|
|
111
|
+
contract_anomaly: true,
|
|
112
|
+
respawn: false,
|
|
113
|
+
reason: `persisted post-crossing execution-contract anomaly (${owningRun.execution_contract_anomaly.source}) — convergence withheld; respawn=false`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
91
116
|
// ── §2 read-strict evidence gate: the LANE must be turn-keyed to THIS attempt's
|
|
92
117
|
// current launch generation. A stale/mismatched result never converges the loop. ──
|
|
93
|
-
if (!evidenceMatchesAttempt(reservation, {
|
|
118
|
+
if (!evidenceMatchesAttempt(reservation, {
|
|
119
|
+
assignment_id: lane.assignment_id,
|
|
120
|
+
turn_id: lane.turn_id,
|
|
121
|
+
run_id: lane.run_id,
|
|
122
|
+
nonce: lane.nonce,
|
|
123
|
+
attempt_epoch: lane.attempt_epoch,
|
|
124
|
+
contract_hash: lane.execution_contract_hash,
|
|
125
|
+
workspace_digest: lane.workspace_digest,
|
|
126
|
+
})) {
|
|
94
127
|
const nonce = currentNonce(reservation);
|
|
95
128
|
return {
|
|
96
129
|
reconciled: false,
|
|
@@ -99,6 +132,65 @@ export function reconcileTurn(input) {
|
|
|
99
132
|
: `lane evidence (turn=${lane.turn_id} run=${lane.run_id} nonce=${lane.nonce}) does not match attempt ${turn_id}`,
|
|
100
133
|
};
|
|
101
134
|
}
|
|
135
|
+
if (activeContractRef) {
|
|
136
|
+
const completion = readCompletionSignals(cwd ?? process.cwd(), reservation.child_ids.assignment_id, activeGeneration?.run_id).completed;
|
|
137
|
+
const bootstrapAck = readContractAck(cwd ?? process.cwd(), reservation.child_ids.assignment_id, activeGeneration?.run_id);
|
|
138
|
+
const accepted = {
|
|
139
|
+
contract_hash: lane.execution_contract_hash ?? completion?.contract_hash ?? '',
|
|
140
|
+
capability_snapshot_hash: lane.capability_snapshot_hash ?? completion?.capability_snapshot_hash ?? '',
|
|
141
|
+
};
|
|
142
|
+
const bootstrapVerdict = bootstrapAck?.status === 'accepted'
|
|
143
|
+
&& bootstrapAck.turn_id === reservation.turn_id
|
|
144
|
+
&& bootstrapAck.run_id === activeRunId
|
|
145
|
+
&& bootstrapAck.nonce === (activeGeneration?.launch_nonce ?? reservation.launch?.token)
|
|
146
|
+
&& (!activeGeneration
|
|
147
|
+
|| bootstrapAck.cwd === normalizedWorkspace(activeGeneration.workspace_path))
|
|
148
|
+
&& (!activeGeneration || (bootstrapAck.attempt_epoch === activeGeneration.attempt_epoch
|
|
149
|
+
&& bootstrapAck.workspace_digest === activeGeneration.workspace_digest))
|
|
150
|
+
? validateWorkerContractAcceptance(activeContractRef, {
|
|
151
|
+
contract_hash: bootstrapAck.contract_hash,
|
|
152
|
+
capability_snapshot_hash: bootstrapAck.capability_snapshot_hash,
|
|
153
|
+
}, activeLaunchStatus)
|
|
154
|
+
: undefined;
|
|
155
|
+
const terminalVerdict = validateWorkerContractAcceptance(activeContractRef, accepted, activeLaunchStatus);
|
|
156
|
+
if (bootstrapVerdict?.kind !== 'accepted' || terminalVerdict.kind !== 'accepted') {
|
|
157
|
+
try {
|
|
158
|
+
recordExecutionContractAnomaly(activeRunId, {
|
|
159
|
+
source: bootstrapVerdict?.kind !== 'accepted'
|
|
160
|
+
? 'bootstrap_ack'
|
|
161
|
+
: lane.execution_contract_hash ? 'lane_result' : 'completion_signal',
|
|
162
|
+
reason: bootstrapVerdict?.kind !== 'accepted'
|
|
163
|
+
? 'bootstrap did not accept the immutable execution contract'
|
|
164
|
+
: 'terminal evidence did not match the immutable execution contract',
|
|
165
|
+
accepted_contract_hash: bootstrapVerdict?.kind !== 'accepted'
|
|
166
|
+
? bootstrapAck?.contract_hash
|
|
167
|
+
: accepted.contract_hash,
|
|
168
|
+
accepted_capability_snapshot_hash: bootstrapVerdict?.kind !== 'accepted'
|
|
169
|
+
? bootstrapAck?.capability_snapshot_hash
|
|
170
|
+
: accepted.capability_snapshot_hash,
|
|
171
|
+
}, cwd);
|
|
172
|
+
}
|
|
173
|
+
catch { /* ack/sentinel remains a durable fallback fence */ }
|
|
174
|
+
try {
|
|
175
|
+
createRuntimeEvent({
|
|
176
|
+
agent: actor,
|
|
177
|
+
event_type: 'run_blocked',
|
|
178
|
+
text: `reconcileTurn: post-crossing execution-contract acceptance anomaly for ${turn_id}; convergence WITHHELD and respawn=false`,
|
|
179
|
+
tags: ['loops', 'reconcile', 'contract-anomaly', 'turn-attempt'],
|
|
180
|
+
assignment_id: reservation.child_ids.assignment_id,
|
|
181
|
+
run_id: activeRunId,
|
|
182
|
+
status_reason: 'execution_contract_acceptance_mismatch',
|
|
183
|
+
}, cwd);
|
|
184
|
+
}
|
|
185
|
+
catch { /* anomaly journal best-effort */ }
|
|
186
|
+
return {
|
|
187
|
+
reconciled: false,
|
|
188
|
+
contract_anomaly: true,
|
|
189
|
+
respawn: false,
|
|
190
|
+
reason: 'post-crossing execution-contract acceptance mismatch or missing hash — convergence withheld; respawn=false',
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
102
194
|
// ── §13 R4 contradiction: a turn-keyed FAILED sentinel present alongside a
|
|
103
195
|
// completed result (the lane or a completed sentinel) → WITHHOLD convergence,
|
|
104
196
|
// journal a conflict (never silently accept). Compared against the LANE's
|
|
@@ -106,9 +198,15 @@ export function reconcileTurn(input) {
|
|
|
106
198
|
// LANE-RESULT then exited non-zero (turn-keyed failed sentinel) is a conflict,
|
|
107
199
|
// not a clean close. ──
|
|
108
200
|
try {
|
|
109
|
-
const bodies = readCompletionSignals(cwd ?? process.cwd(), reservation.child_ids.assignment_id);
|
|
110
|
-
const matchedCompleted = bodies.completed?.status === 'completed' && evidenceMatchesAttempt(reservation,
|
|
111
|
-
|
|
201
|
+
const bodies = readCompletionSignals(cwd ?? process.cwd(), reservation.child_ids.assignment_id, activeGeneration?.run_id);
|
|
202
|
+
const matchedCompleted = bodies.completed?.status === 'completed' && evidenceMatchesAttempt(reservation, {
|
|
203
|
+
assignment_id: reservation.child_ids.assignment_id,
|
|
204
|
+
...bodies.completed,
|
|
205
|
+
});
|
|
206
|
+
const matchedFailed = bodies.failed?.status === 'failed' && evidenceMatchesAttempt(reservation, {
|
|
207
|
+
assignment_id: reservation.child_ids.assignment_id,
|
|
208
|
+
...bodies.failed,
|
|
209
|
+
});
|
|
112
210
|
if (matchedFailed && (matchedCompleted || lane.status === 'completed')) {
|
|
113
211
|
try {
|
|
114
212
|
createRuntimeEvent({
|
|
@@ -117,7 +215,7 @@ export function reconcileTurn(input) {
|
|
|
117
215
|
text: `reconcileTurn: turn ${turn_id} has a completed(lane/sentinel)+failed(sentinel) contradiction — auto-stop WITHHELD (§13 R4), escalating to human`,
|
|
118
216
|
tags: ['loops', 'reconcile', 'conflict', 'turn-attempt'],
|
|
119
217
|
assignment_id: reservation.child_ids.assignment_id,
|
|
120
|
-
run_id:
|
|
218
|
+
run_id: activeRunId,
|
|
121
219
|
status_reason: 'turn_evidence_contradiction',
|
|
122
220
|
}, cwd);
|
|
123
221
|
}
|
|
@@ -156,7 +254,8 @@ export function reconcileTurn(input) {
|
|
|
156
254
|
* version (plus the terminal-early-return claim release, review Finding 2).
|
|
157
255
|
*/
|
|
158
256
|
function convergeLockedTurn(reservation, input, actor, cwd) {
|
|
159
|
-
const { turn_id
|
|
257
|
+
const { turn_id } = input;
|
|
258
|
+
let lane = input.lane;
|
|
160
259
|
const loop = getLoop(reservation.loop_id, cwd);
|
|
161
260
|
if (!loop)
|
|
162
261
|
return { reconciled: false, reason: `loop ${reservation.loop_id} not found` };
|
|
@@ -175,6 +274,57 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
|
|
|
175
274
|
if (slot.current_turn_id !== undefined && slot.current_turn_id !== turn_id) {
|
|
176
275
|
return { reconciled: false, reason: `turn ${turn_id} superseded by current turn ${slot.current_turn_id} on slot ${slot.slot_id}` };
|
|
177
276
|
}
|
|
277
|
+
// AttemptAuthority v2 TOCTOU closure: repeat the FULL evidence/fence check
|
|
278
|
+
// while holding the loop lock, then let settlement and takeover contend on
|
|
279
|
+
// the same immutable close(epoch) cell. If takeover won after the optimistic
|
|
280
|
+
// pre-check, settlement observes it here and performs no loop mutation.
|
|
281
|
+
const generationState = resolveTurnGenerationChain(cwd ?? reservation.store_root, reservation.turn_id);
|
|
282
|
+
if (generationState) {
|
|
283
|
+
if (!evidenceMatchesAttempt(reservation, {
|
|
284
|
+
assignment_id: lane.assignment_id,
|
|
285
|
+
turn_id: lane.turn_id,
|
|
286
|
+
run_id: lane.run_id,
|
|
287
|
+
nonce: lane.nonce,
|
|
288
|
+
attempt_epoch: lane.attempt_epoch,
|
|
289
|
+
contract_hash: lane.execution_contract_hash,
|
|
290
|
+
workspace_digest: lane.workspace_digest,
|
|
291
|
+
})) {
|
|
292
|
+
return { reconciled: false, reason: 'attempt generation changed before commit — stale evidence fenced' };
|
|
293
|
+
}
|
|
294
|
+
const generation = generationState.latest_generation;
|
|
295
|
+
const localAuthorityHome = readLocalAuthorityHome(cwd ?? reservation.store_root);
|
|
296
|
+
if (!localAuthorityHome) {
|
|
297
|
+
return { reconciled: false, reason: 'AttemptAuthority v2 settlement requires the activated local authority_home' };
|
|
298
|
+
}
|
|
299
|
+
const settlement = settleActiveAttemptGenerationV2(turn_id, fenceForGeneration(generation), lane, localAuthorityHome, actor, loop.created_by, cwd ?? reservation.store_root);
|
|
300
|
+
if (!settlement || settlement.cell.decision !== 'settled') {
|
|
301
|
+
return {
|
|
302
|
+
reconciled: false,
|
|
303
|
+
reason: `settlement lost close(${generation.attempt_epoch}) to ${settlement?.cell.decision ?? 'unknown'} — evidence is audit-only`,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
lane = LaneResultSchema.parse(settlement.evidence.result);
|
|
307
|
+
}
|
|
308
|
+
const acceptedGeneration = generationState?.latest_generation;
|
|
309
|
+
const acceptedRunId = acceptedGeneration?.run_id ?? reservation.child_ids.run_id;
|
|
310
|
+
const acceptedNonce = acceptedGeneration?.launch_nonce ?? reservation.launch?.token;
|
|
311
|
+
const acceptedEpoch = acceptedGeneration?.attempt_epoch ?? reservation.epoch;
|
|
312
|
+
const acceptedContractHash = acceptedGeneration?.contract_hash
|
|
313
|
+
?? reservation.execution_contract_ref?.hash
|
|
314
|
+
?? evidenceDigest({
|
|
315
|
+
version: 'legacy-uncontracted-reservation-v1',
|
|
316
|
+
turn_id: reservation.turn_id,
|
|
317
|
+
run_id: reservation.child_ids.run_id,
|
|
318
|
+
epoch: reservation.epoch,
|
|
319
|
+
phase: reservation.phase,
|
|
320
|
+
iteration: reservation.iteration,
|
|
321
|
+
cwd: reservation.cwd,
|
|
322
|
+
});
|
|
323
|
+
const acceptedWorkspaceDigest = acceptedGeneration?.workspace_digest ?? evidenceDigest({
|
|
324
|
+
workspace_policy: reservation.execution_contract?.workspace_policy,
|
|
325
|
+
cwd: reservation.cwd,
|
|
326
|
+
store_root: reservation.store_root,
|
|
327
|
+
});
|
|
178
328
|
// A terminal loop already converged → idempotent no-op (any trigger may fire us). Still
|
|
179
329
|
// release the claim (review Finding 2): a cap-blocked loop that crashed AFTER the block
|
|
180
330
|
// transition but BEFORE its own deferred release would otherwise leak the retained claim
|
|
@@ -210,17 +360,68 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
|
|
|
210
360
|
const [primary, ...extras] = reduced.artifacts;
|
|
211
361
|
for (const a of extras) {
|
|
212
362
|
try {
|
|
213
|
-
|
|
363
|
+
addArtifactWithEvidence({
|
|
364
|
+
id: loop.id,
|
|
365
|
+
actor,
|
|
366
|
+
evidence_context: {
|
|
367
|
+
channel: 'reconcile_turn',
|
|
368
|
+
producer_kind: 'slot',
|
|
369
|
+
producer_id: reservation.agent,
|
|
370
|
+
agent_id: reservation.agent_id,
|
|
371
|
+
slot_id: slot.slot_id,
|
|
372
|
+
slot_role: slot.role,
|
|
373
|
+
turn_id,
|
|
374
|
+
assignment_id: reservation.child_ids.assignment_id,
|
|
375
|
+
claim_id: reservation.claim_id,
|
|
376
|
+
run_id: acceptedRunId,
|
|
377
|
+
nonce: acceptedNonce,
|
|
378
|
+
attempt_epoch: acceptedEpoch,
|
|
379
|
+
execution_contract_hash: acceptedContractHash,
|
|
380
|
+
workspace_digest: acceptedWorkspaceDigest,
|
|
381
|
+
},
|
|
382
|
+
artifact: {
|
|
383
|
+
phase: a.phase,
|
|
384
|
+
type: a.type,
|
|
385
|
+
body: a.body,
|
|
386
|
+
produced_by: a.produced_by,
|
|
387
|
+
addresses_critique: a.addresses_critique,
|
|
388
|
+
implementation_verify: a.implementation_verify,
|
|
389
|
+
},
|
|
390
|
+
}, cwd);
|
|
214
391
|
}
|
|
215
392
|
catch { /* an extra artifact failing must not abort convergence */ }
|
|
216
393
|
}
|
|
217
|
-
|
|
394
|
+
completeTurnWithEvidence({
|
|
218
395
|
id: loop.id,
|
|
219
396
|
slot_id: slot.slot_id,
|
|
220
397
|
actor,
|
|
398
|
+
evidence_context: {
|
|
399
|
+
channel: 'reconcile_turn',
|
|
400
|
+
producer_kind: 'slot',
|
|
401
|
+
producer_id: reservation.agent,
|
|
402
|
+
agent_id: reservation.agent_id,
|
|
403
|
+
slot_id: slot.slot_id,
|
|
404
|
+
slot_role: slot.role,
|
|
405
|
+
turn_id,
|
|
406
|
+
assignment_id: reservation.child_ids.assignment_id,
|
|
407
|
+
claim_id: reservation.claim_id,
|
|
408
|
+
run_id: acceptedRunId,
|
|
409
|
+
nonce: acceptedNonce,
|
|
410
|
+
attempt_epoch: acceptedEpoch,
|
|
411
|
+
execution_contract_hash: acceptedContractHash,
|
|
412
|
+
workspace_digest: acceptedWorkspaceDigest,
|
|
413
|
+
},
|
|
221
414
|
outcome: reduced.slot_outcome,
|
|
222
415
|
failure_reason: reduced.failure_reason,
|
|
223
|
-
...(primary ? {
|
|
416
|
+
...(primary ? {
|
|
417
|
+
artifact: {
|
|
418
|
+
phase: primary.phase,
|
|
419
|
+
type: primary.type,
|
|
420
|
+
body: primary.body,
|
|
421
|
+
addresses_critique: primary.addresses_critique,
|
|
422
|
+
implementation_verify: primary.implementation_verify,
|
|
423
|
+
},
|
|
424
|
+
} : {}),
|
|
224
425
|
}, cwd);
|
|
225
426
|
}
|
|
226
427
|
catch (err) {
|
|
@@ -235,7 +436,9 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
|
|
|
235
436
|
text: `reconcileTurn: harvested turn ${turn_id} on slot ${slot.slot_id} → loop ${loop.id} (${slot_outcome}, ${artifacts_added} artifact(s), phase ${reservation.phase})`,
|
|
236
437
|
tags: ['loops', 'reconcile', 'harvest', 'turn-attempt'],
|
|
237
438
|
assignment_id: reservation.child_ids.assignment_id,
|
|
238
|
-
run_id:
|
|
439
|
+
run_id: acceptedRunId,
|
|
440
|
+
attempt_epoch: acceptedGeneration?.attempt_epoch,
|
|
441
|
+
workspace_digest: acceptedGeneration?.workspace_digest,
|
|
239
442
|
status_reason: `harvested_${slot_outcome}`,
|
|
240
443
|
}, cwd);
|
|
241
444
|
}
|
|
@@ -248,7 +451,7 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
|
|
|
248
451
|
// can RETAIN the claim/worktree. Run + assignment settle unconditionally on both paths
|
|
249
452
|
// (a crash that recorded the turn but not the settle still converges on replay; the
|
|
250
453
|
// fix-cycle re-dispatch mints a fresh run/assignment, so completing the old is correct). ──
|
|
251
|
-
settleRunCompleted(
|
|
454
|
+
settleRunCompleted(acceptedRunId, actor, cwd);
|
|
252
455
|
settleAssignment(reservation.child_ids.assignment_id, actor, cwd);
|
|
253
456
|
// ── Advance / stop decision. On a `done` outcome we either drive a deterministic stop
|
|
254
457
|
// (reviewer_green / gate → close), continue a symmetric fix cycle (bump the round + retain
|
|
@@ -276,7 +479,7 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
|
|
|
276
479
|
loop.protocol?.review_mode === 'symmetric';
|
|
277
480
|
if (symmetricRC) {
|
|
278
481
|
// EXACTLY-ONCE bump (the one non-negotiable safety guard): each bump changes
|
|
279
|
-
//
|
|
482
|
+
// resolveTurnId(loop, slot, phase, iteration), so a DOUBLE bump would mint two turn_ids and
|
|
280
483
|
// the launch fence would spawn BOTH rounds. Bump only when this turn's round is still
|
|
281
484
|
// current; a re-reconcile after the bump takes the else-branch (no re-bump, no re-emit).
|
|
282
485
|
if (loop.iteration_count === reservation.iteration) {
|
|
@@ -308,7 +511,14 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
|
|
|
308
511
|
// (reserved_never_launched — crash between arm and consume + the expiry sweep) or an
|
|
309
512
|
// absent reservation is a STRAND (dec#149 R1): re-emit to self-heal. The re-dispatch's
|
|
310
513
|
// prepare re-arms a revoked grant at a higher epoch, so this round can actually relaunch.
|
|
311
|
-
const
|
|
514
|
+
const currentSlot = cur.slots.find((candidate) => candidate.slot_id === slot.slot_id);
|
|
515
|
+
const bumpedTurnId = resolveTurnId({
|
|
516
|
+
loop_id: loop.id,
|
|
517
|
+
slot_id: slot.slot_id,
|
|
518
|
+
phase: cur.current_phase,
|
|
519
|
+
iteration: cur.iteration_count,
|
|
520
|
+
current_turn_id: currentSlot?.current_turn_id,
|
|
521
|
+
}, cwd);
|
|
312
522
|
const bumpedGrant = launchGrant(bumpedTurnId, cwd);
|
|
313
523
|
const bumpedLive = getReservation(bumpedTurnId, cwd) !== undefined &&
|
|
314
524
|
(bumpedGrant?.status === 'armed' || bumpedGrant?.status === 'crossed');
|
|
@@ -321,7 +531,7 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
|
|
|
321
531
|
text: `reconcileTurn: fix-cycle round ${cur.iteration_count} of loop ${loop.id} was bumped but never dispatched (turn ${turn_id} strand) — re-emitting next_turn to self-heal`,
|
|
322
532
|
tags: ['loops', 'reconcile', 'turn-owned', 'strand-recovery'],
|
|
323
533
|
assignment_id: reservation.child_ids.assignment_id,
|
|
324
|
-
run_id:
|
|
534
|
+
run_id: acceptedRunId,
|
|
325
535
|
status_reason: 'fix_cycle_strand_reemit',
|
|
326
536
|
}, cwd);
|
|
327
537
|
}
|
|
@@ -366,6 +576,15 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
|
|
|
366
576
|
...(next_turn ? { next_turn } : {}),
|
|
367
577
|
};
|
|
368
578
|
}
|
|
579
|
+
function normalizedWorkspace(value) {
|
|
580
|
+
try {
|
|
581
|
+
const resolved = fs.realpathSync.native(value);
|
|
582
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
583
|
+
}
|
|
584
|
+
catch {
|
|
585
|
+
return undefined;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
369
588
|
/**
|
|
370
589
|
* Converge a TURN-OWNED lane whose worker died at the TRANSPORT level (no lane
|
|
371
590
|
* result will ever arrive). dec#151, operator-decided option (b): the lane's
|
|
@@ -26,6 +26,22 @@ export const reviewReducer = (input, attempt) => {
|
|
|
26
26
|
if (lane.status !== 'completed') {
|
|
27
27
|
return { artifacts: [], slot_outcome: 'failed', failure_reason: `review lane status is ${lane.status}, not completed` };
|
|
28
28
|
}
|
|
29
|
+
if (phase === 'author_response') {
|
|
30
|
+
if (lane.artifact_type !== 'author_response') {
|
|
31
|
+
return { artifacts: [], slot_outcome: 'failed', failure_reason: "review author_response requires artifact_type 'author_response'" };
|
|
32
|
+
}
|
|
33
|
+
const response = (lane.body ?? '').trim();
|
|
34
|
+
if (!response) {
|
|
35
|
+
return { artifacts: [], slot_outcome: 'failed', failure_reason: 'review author_response produced no body' };
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
artifacts: [{ phase, type: 'author_response', body: capBody(response), produced_by: attempt.agent }],
|
|
39
|
+
slot_outcome: 'done',
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (phase !== 'findings' && phase !== 'followup_review') {
|
|
43
|
+
return { artifacts: [], slot_outcome: 'failed', failure_reason: `review phase '${phase}' has no worker-result contract` };
|
|
44
|
+
}
|
|
29
45
|
if (!lane.review_verdict) {
|
|
30
46
|
return { artifacts: [], slot_outcome: 'failed', failure_reason: 'review lane completed without a review_verdict — cannot converge the loop' };
|
|
31
47
|
}
|
|
@@ -39,7 +55,7 @@ export const reviewReducer = (input, attempt) => {
|
|
|
39
55
|
};
|
|
40
56
|
};
|
|
41
57
|
/**
|
|
42
|
-
* ideation reducer (§6).
|
|
58
|
+
* ideation reducer (§6). An explicitly typed critique → `critique` artifacts (so
|
|
43
59
|
* `min_artifacts_by_type` can open the next phase). A bare summary with no
|
|
44
60
|
* critique body → slot `failed`, gate stays shut (correct: no fake progress from
|
|
45
61
|
* a lane that produced no critiques).
|
|
@@ -49,8 +65,52 @@ export const ideationReducer = (input, attempt) => {
|
|
|
49
65
|
if (lane.status !== 'completed') {
|
|
50
66
|
return { artifacts: [], slot_outcome: 'failed', failure_reason: `ideation lane status is ${lane.status}, not completed` };
|
|
51
67
|
}
|
|
68
|
+
if (phase !== 'critique') {
|
|
69
|
+
const artifactType = phase === 'proposal' ? 'proposal' : phase === 'revision' ? 'revision' : phase === 'synthesis' ? 'plan_draft' : undefined;
|
|
70
|
+
if (!artifactType) {
|
|
71
|
+
return { artifacts: [], slot_outcome: 'failed', failure_reason: `ideation phase '${phase}' has no result contract` };
|
|
72
|
+
}
|
|
73
|
+
if (lane.artifact_type !== artifactType) {
|
|
74
|
+
return { artifacts: [], slot_outcome: 'failed', failure_reason: `ideation phase '${phase}' expected artifact_type '${artifactType}', got '${lane.artifact_type}'` };
|
|
75
|
+
}
|
|
76
|
+
const body = (lane.body ?? lane.summary).trim();
|
|
77
|
+
if (!body)
|
|
78
|
+
return { artifacts: [], slot_outcome: 'failed', failure_reason: `ideation ${phase} produced no body` };
|
|
79
|
+
if (artifactType === 'plan_draft') {
|
|
80
|
+
const addresses = [
|
|
81
|
+
...(lane.artifacts ?? []).filter((id) => /^art_[0-9a-z]+$/.test(id)),
|
|
82
|
+
...(critiques ?? []).flatMap((c) => c.addresses_critique ?? []),
|
|
83
|
+
];
|
|
84
|
+
const uniqueAddresses = [...new Set(addresses)];
|
|
85
|
+
if (uniqueAddresses.length === 0) {
|
|
86
|
+
return { artifacts: [], slot_outcome: 'failed', failure_reason: 'ideation synthesis must cite critique artifact ids in lane.artifacts' };
|
|
87
|
+
}
|
|
88
|
+
if (!lane.implementation_verify) {
|
|
89
|
+
return {
|
|
90
|
+
artifacts: [],
|
|
91
|
+
slot_outcome: 'failed',
|
|
92
|
+
failure_reason: 'ideation synthesis must declare implementation_verify for deterministic downstream verification',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
artifacts: [{
|
|
97
|
+
phase,
|
|
98
|
+
type: artifactType,
|
|
99
|
+
body: capBody(body),
|
|
100
|
+
produced_by: attempt.agent,
|
|
101
|
+
addresses_critique: uniqueAddresses,
|
|
102
|
+
implementation_verify: lane.implementation_verify,
|
|
103
|
+
}],
|
|
104
|
+
slot_outcome: 'done',
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return { artifacts: [{ phase, type: artifactType, body: capBody(body), produced_by: attempt.agent }], slot_outcome: 'done' };
|
|
108
|
+
}
|
|
109
|
+
if (lane.artifact_type !== 'critique') {
|
|
110
|
+
return { artifacts: [], slot_outcome: 'failed', failure_reason: "ideation critique requires artifact_type 'critique'" };
|
|
111
|
+
}
|
|
52
112
|
if (!critiques || critiques.length === 0) {
|
|
53
|
-
return { artifacts: [], slot_outcome: 'failed', failure_reason: 'ideation lane produced no critiques (bare summary) — gate stays shut' };
|
|
113
|
+
return { artifacts: [], slot_outcome: 'failed', failure_reason: 'ideation critique lane produced no critiques (bare summary) — gate stays shut' };
|
|
54
114
|
}
|
|
55
115
|
return {
|
|
56
116
|
artifacts: critiques.map((c) => ({
|
|
@@ -61,11 +121,9 @@ export const ideationReducer = (input, attempt) => {
|
|
|
61
121
|
};
|
|
62
122
|
};
|
|
63
123
|
/**
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* convergence sensible without fabricating structured artifacts a kind never
|
|
68
|
-
* declared.
|
|
124
|
+
* Explicit legacy helper retained for callers/tests that intentionally want a
|
|
125
|
+
* generic lane_result. The exhaustive LoopKind registry below never falls back
|
|
126
|
+
* to it: every shipped kind has a phase-aware reducer.
|
|
69
127
|
*/
|
|
70
128
|
export const defaultReducer = (input, attempt) => {
|
|
71
129
|
const { lane, phase } = input;
|
|
@@ -77,12 +135,57 @@ export const defaultReducer = (input, attempt) => {
|
|
|
77
135
|
slot_outcome: 'done',
|
|
78
136
|
};
|
|
79
137
|
};
|
|
80
|
-
|
|
138
|
+
function typedPhaseReducer(kind, artifactByPhase) {
|
|
139
|
+
return (input, attempt) => {
|
|
140
|
+
const { lane, phase } = input;
|
|
141
|
+
if (lane.status !== 'completed') {
|
|
142
|
+
return { artifacts: [], slot_outcome: 'failed', failure_reason: `${kind} lane status is ${lane.status}, not completed` };
|
|
143
|
+
}
|
|
144
|
+
const expectedType = artifactByPhase[phase];
|
|
145
|
+
if (!expectedType) {
|
|
146
|
+
return { artifacts: [], slot_outcome: 'failed', failure_reason: `${kind} phase '${phase}' has no worker-result contract` };
|
|
147
|
+
}
|
|
148
|
+
if (lane.artifact_type !== expectedType) {
|
|
149
|
+
return {
|
|
150
|
+
artifacts: [],
|
|
151
|
+
slot_outcome: 'failed',
|
|
152
|
+
failure_reason: `${kind} phase '${phase}' expected artifact_type '${expectedType}', got '${lane.artifact_type}'`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
// Every worker artifact is explicitly attested. In particular a narrative
|
|
156
|
+
// summary can never masquerade as a gate-driving repro or verify report.
|
|
157
|
+
const body = (lane.body ?? lane.summary).trim();
|
|
158
|
+
if (!body) {
|
|
159
|
+
return { artifacts: [], slot_outcome: 'failed', failure_reason: `${kind} phase '${phase}' produced no artifact body` };
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
artifacts: [{ phase, type: expectedType, body: capBody(body), produced_by: attempt.agent }],
|
|
163
|
+
slot_outcome: 'done',
|
|
164
|
+
};
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
export const implementationReducer = typedPhaseReducer('implementation', {
|
|
168
|
+
execute: 'execute_report',
|
|
169
|
+
});
|
|
170
|
+
export const researchReducer = typedPhaseReducer('research', {
|
|
171
|
+
investigate: 'finding',
|
|
172
|
+
synthesize: 'synthesis',
|
|
173
|
+
});
|
|
174
|
+
export const debugReducer = typedPhaseReducer('debug', {
|
|
175
|
+
reproduce: 'repro',
|
|
176
|
+
hypothesize: 'hypothesis',
|
|
177
|
+
isolate: 'isolation_report',
|
|
178
|
+
fix: 'verify_report',
|
|
179
|
+
});
|
|
180
|
+
export const RESULT_REDUCERS = {
|
|
81
181
|
review: reviewReducer,
|
|
82
182
|
ideation: ideationReducer,
|
|
183
|
+
implementation: implementationReducer,
|
|
184
|
+
research: researchReducer,
|
|
185
|
+
debug: debugReducer,
|
|
83
186
|
};
|
|
84
|
-
/** The reducer for a loop kind
|
|
187
|
+
/** The reducer for a loop kind. Exhaustive by construction: no silent fallback. */
|
|
85
188
|
export function reducerForKind(kind) {
|
|
86
|
-
return RESULT_REDUCERS[kind]
|
|
189
|
+
return RESULT_REDUCERS[kind];
|
|
87
190
|
}
|
|
88
191
|
//# sourceMappingURL=result-reducers.js.map
|
package/dist/core/loops/store.js
CHANGED
|
@@ -10,6 +10,7 @@ import { gcWorktreeIfHarvested } from '../worktree.js';
|
|
|
10
10
|
import { writeProjectMdSafe } from './hooks/bootstrap-write.js';
|
|
11
11
|
import { notifyOperatorOnInputRequested } from './hooks/notify-operator.js';
|
|
12
12
|
import { reconstructConsistentThread } from './commit-intent.js';
|
|
13
|
+
import { evidencePolicyForNewLoop, evidenceWriterEnabled, sealArtifactEvidence, validateThreadEvidence } from './evidence.js';
|
|
13
14
|
import { DEFAULT_PROTOCOLS, LoopArtifactSchema, LoopEventSchema, LoopThreadSchema, } from './types.js';
|
|
14
15
|
function loopsDir(cwd) {
|
|
15
16
|
return path.join(memoryDir(cwd ?? process.cwd()), 'loops');
|
|
@@ -70,6 +71,10 @@ function buildSlot(partial) {
|
|
|
70
71
|
assignment_id: partial.assignment_id,
|
|
71
72
|
claim_id: partial.claim_id,
|
|
72
73
|
phase: partial.phase,
|
|
74
|
+
lane: partial.lane,
|
|
75
|
+
scope_hint: partial.scope_hint,
|
|
76
|
+
plan_ids: partial.plan_ids,
|
|
77
|
+
step_ids: partial.step_ids,
|
|
73
78
|
status: partial.status ?? 'open',
|
|
74
79
|
};
|
|
75
80
|
}
|
|
@@ -166,6 +171,7 @@ export function openLoop(input, cwd) {
|
|
|
166
171
|
open_questions: [],
|
|
167
172
|
linked: input.linked,
|
|
168
173
|
stop_condition: input.stop_condition ?? protocolDefaults.stop_condition,
|
|
174
|
+
evidence_policy: evidencePolicyForNewLoop(),
|
|
169
175
|
created_at: now,
|
|
170
176
|
updated_at: now,
|
|
171
177
|
created_by: input.created_by,
|
|
@@ -193,7 +199,13 @@ export function getLoop(id, cwd) {
|
|
|
193
199
|
// the on-disk thread, return the reconstructed consistent view. The mutation
|
|
194
200
|
// is durable in the intent; persistence catches up at the next lock-entry
|
|
195
201
|
// recovery (never a write on this read path — cf. trp_fdf3e590 / dec#137).
|
|
196
|
-
|
|
202
|
+
const thread = reconstructConsistentThread(id, onDisk, cwd);
|
|
203
|
+
if (thread) {
|
|
204
|
+
const diagnostics = validateThreadEvidence(thread);
|
|
205
|
+
if (diagnostics.length > 0)
|
|
206
|
+
logger.warn(`loop ${id}: rejected evidence envelopes ${JSON.stringify(diagnostics)}`);
|
|
207
|
+
}
|
|
208
|
+
return thread;
|
|
197
209
|
}
|
|
198
210
|
export function listLoops(filters = {}, cwd) {
|
|
199
211
|
const dir = threadsDir(cwd);
|
|
@@ -206,6 +218,9 @@ export function listLoops(filters = {}, cwd) {
|
|
|
206
218
|
try {
|
|
207
219
|
const raw = fs.readFileSync(path.join(dir, file), 'utf8');
|
|
208
220
|
const loop = LoopThreadSchema.parse(JSON.parse(raw));
|
|
221
|
+
const diagnostics = validateThreadEvidence(loop);
|
|
222
|
+
if (diagnostics.length > 0)
|
|
223
|
+
logger.warn(`loop ${loop.id}: rejected evidence envelopes ${JSON.stringify(diagnostics)}`);
|
|
209
224
|
if (filters.kind && loop.kind !== filters.kind)
|
|
210
225
|
continue;
|
|
211
226
|
if (filters.status && loop.status !== filters.status)
|
|
@@ -361,6 +376,22 @@ export function closeLoop(input, cwd) {
|
|
|
361
376
|
project_md_final_id,
|
|
362
377
|
now: pauseNow,
|
|
363
378
|
});
|
|
379
|
+
const persistedDiffArtifact = (current.evidence_policy !== undefined || evidenceWriterEnabled())
|
|
380
|
+
? LoopArtifactSchema.parse(sealArtifactEvidence(current, writeResult.diff_artifact, {
|
|
381
|
+
channel: 'system_hook',
|
|
382
|
+
producer_kind: 'engine',
|
|
383
|
+
producer_id: 'brainclaw:bootstrap-write',
|
|
384
|
+
}))
|
|
385
|
+
: writeResult.diff_artifact;
|
|
386
|
+
const persistedQuestionArtifact = (current.evidence_policy !== undefined || evidenceWriterEnabled())
|
|
387
|
+
? LoopArtifactSchema.parse(sealArtifactEvidence(current, questionArtifact, {
|
|
388
|
+
channel: 'system_hook',
|
|
389
|
+
producer_kind: 'engine',
|
|
390
|
+
producer_id: 'brainclaw:bootstrap-write',
|
|
391
|
+
slot_id: slot.slot_id,
|
|
392
|
+
slot_role: slot.role,
|
|
393
|
+
}))
|
|
394
|
+
: questionArtifact;
|
|
364
395
|
const pausedThread = {
|
|
365
396
|
...current,
|
|
366
397
|
version: pauseVersion,
|
|
@@ -370,9 +401,9 @@ export function closeLoop(input, cwd) {
|
|
|
370
401
|
pending_file_apply: {
|
|
371
402
|
artifact_id: project_md_final_id,
|
|
372
403
|
target_path: writeResult.target_path,
|
|
373
|
-
diff_artifact_id:
|
|
404
|
+
diff_artifact_id: persistedDiffArtifact.artifact_id,
|
|
374
405
|
},
|
|
375
|
-
artifacts: [...current.artifacts,
|
|
406
|
+
artifacts: [...current.artifacts, persistedDiffArtifact, persistedQuestionArtifact],
|
|
376
407
|
open_questions: [...current.open_questions, question_id],
|
|
377
408
|
updated_at: pauseNow,
|
|
378
409
|
};
|