brainclaw 1.26.2 → 1.27.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 +87 -14
- package/dist/commands/mcp-catalog.js +42 -18
- package/dist/commands/mcp-schemas.generated.js +44 -0
- package/dist/commands/mcp-write-claims.js +128 -1
- package/dist/commands/mcp-write-coordination.js +146 -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 +160 -14
- package/dist/core/execution-contract.js +345 -0
- package/dist/core/execution.js +130 -16
- 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 +207 -0
- package/dist/core/loops/artifact-contract.js +11 -0
- package/dist/core/loops/attempt-authority.js +476 -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/evidence.js +187 -0
- package/dist/core/loops/facade-schema.js +41 -10
- package/dist/core/loops/gate-policy.js +485 -0
- package/dist/core/loops/impl-bind.js +37 -79
- 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 +235 -18
- package/dist/core/loops/result-reducers.js +99 -10
- package/dist/core/loops/store.js +30 -3
- package/dist/core/loops/turn-execution.js +480 -0
- package/dist/core/loops/types.js +113 -2
- package/dist/core/loops/verbs.js +332 -99
- package/dist/core/loops/verify-command.js +31 -8
- 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 +35 -0
- package/dist/core/spawn-check.js +3 -2
- package/dist/core/upgrades/backup.js +27 -4
- package/dist/facts.js +7 -6
- package/dist/facts.json +6 -5
- 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 +154 -0
- package/docs/loops/research.md +136 -0
- package/docs/loops/review.md +200 -0
- package/docs/mcp-schema-changelog.md +14 -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,66 @@ 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
|
+
},
|
|
389
|
+
}, cwd);
|
|
214
390
|
}
|
|
215
391
|
catch { /* an extra artifact failing must not abort convergence */ }
|
|
216
392
|
}
|
|
217
|
-
|
|
393
|
+
completeTurnWithEvidence({
|
|
218
394
|
id: loop.id,
|
|
219
395
|
slot_id: slot.slot_id,
|
|
220
396
|
actor,
|
|
397
|
+
evidence_context: {
|
|
398
|
+
channel: 'reconcile_turn',
|
|
399
|
+
producer_kind: 'slot',
|
|
400
|
+
producer_id: reservation.agent,
|
|
401
|
+
agent_id: reservation.agent_id,
|
|
402
|
+
slot_id: slot.slot_id,
|
|
403
|
+
slot_role: slot.role,
|
|
404
|
+
turn_id,
|
|
405
|
+
assignment_id: reservation.child_ids.assignment_id,
|
|
406
|
+
claim_id: reservation.claim_id,
|
|
407
|
+
run_id: acceptedRunId,
|
|
408
|
+
nonce: acceptedNonce,
|
|
409
|
+
attempt_epoch: acceptedEpoch,
|
|
410
|
+
execution_contract_hash: acceptedContractHash,
|
|
411
|
+
workspace_digest: acceptedWorkspaceDigest,
|
|
412
|
+
},
|
|
221
413
|
outcome: reduced.slot_outcome,
|
|
222
414
|
failure_reason: reduced.failure_reason,
|
|
223
|
-
...(primary ? {
|
|
415
|
+
...(primary ? {
|
|
416
|
+
artifact: {
|
|
417
|
+
phase: primary.phase,
|
|
418
|
+
type: primary.type,
|
|
419
|
+
body: primary.body,
|
|
420
|
+
addresses_critique: primary.addresses_critique,
|
|
421
|
+
},
|
|
422
|
+
} : {}),
|
|
224
423
|
}, cwd);
|
|
225
424
|
}
|
|
226
425
|
catch (err) {
|
|
@@ -235,7 +434,9 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
|
|
|
235
434
|
text: `reconcileTurn: harvested turn ${turn_id} on slot ${slot.slot_id} → loop ${loop.id} (${slot_outcome}, ${artifacts_added} artifact(s), phase ${reservation.phase})`,
|
|
236
435
|
tags: ['loops', 'reconcile', 'harvest', 'turn-attempt'],
|
|
237
436
|
assignment_id: reservation.child_ids.assignment_id,
|
|
238
|
-
run_id:
|
|
437
|
+
run_id: acceptedRunId,
|
|
438
|
+
attempt_epoch: acceptedGeneration?.attempt_epoch,
|
|
439
|
+
workspace_digest: acceptedGeneration?.workspace_digest,
|
|
239
440
|
status_reason: `harvested_${slot_outcome}`,
|
|
240
441
|
}, cwd);
|
|
241
442
|
}
|
|
@@ -248,7 +449,7 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
|
|
|
248
449
|
// can RETAIN the claim/worktree. Run + assignment settle unconditionally on both paths
|
|
249
450
|
// (a crash that recorded the turn but not the settle still converges on replay; the
|
|
250
451
|
// fix-cycle re-dispatch mints a fresh run/assignment, so completing the old is correct). ──
|
|
251
|
-
settleRunCompleted(
|
|
452
|
+
settleRunCompleted(acceptedRunId, actor, cwd);
|
|
252
453
|
settleAssignment(reservation.child_ids.assignment_id, actor, cwd);
|
|
253
454
|
// ── Advance / stop decision. On a `done` outcome we either drive a deterministic stop
|
|
254
455
|
// (reviewer_green / gate → close), continue a symmetric fix cycle (bump the round + retain
|
|
@@ -276,7 +477,7 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
|
|
|
276
477
|
loop.protocol?.review_mode === 'symmetric';
|
|
277
478
|
if (symmetricRC) {
|
|
278
479
|
// EXACTLY-ONCE bump (the one non-negotiable safety guard): each bump changes
|
|
279
|
-
//
|
|
480
|
+
// resolveTurnId(loop, slot, phase, iteration), so a DOUBLE bump would mint two turn_ids and
|
|
280
481
|
// the launch fence would spawn BOTH rounds. Bump only when this turn's round is still
|
|
281
482
|
// current; a re-reconcile after the bump takes the else-branch (no re-bump, no re-emit).
|
|
282
483
|
if (loop.iteration_count === reservation.iteration) {
|
|
@@ -308,7 +509,14 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
|
|
|
308
509
|
// (reserved_never_launched — crash between arm and consume + the expiry sweep) or an
|
|
309
510
|
// absent reservation is a STRAND (dec#149 R1): re-emit to self-heal. The re-dispatch's
|
|
310
511
|
// prepare re-arms a revoked grant at a higher epoch, so this round can actually relaunch.
|
|
311
|
-
const
|
|
512
|
+
const currentSlot = cur.slots.find((candidate) => candidate.slot_id === slot.slot_id);
|
|
513
|
+
const bumpedTurnId = resolveTurnId({
|
|
514
|
+
loop_id: loop.id,
|
|
515
|
+
slot_id: slot.slot_id,
|
|
516
|
+
phase: cur.current_phase,
|
|
517
|
+
iteration: cur.iteration_count,
|
|
518
|
+
current_turn_id: currentSlot?.current_turn_id,
|
|
519
|
+
}, cwd);
|
|
312
520
|
const bumpedGrant = launchGrant(bumpedTurnId, cwd);
|
|
313
521
|
const bumpedLive = getReservation(bumpedTurnId, cwd) !== undefined &&
|
|
314
522
|
(bumpedGrant?.status === 'armed' || bumpedGrant?.status === 'crossed');
|
|
@@ -321,7 +529,7 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
|
|
|
321
529
|
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
530
|
tags: ['loops', 'reconcile', 'turn-owned', 'strand-recovery'],
|
|
323
531
|
assignment_id: reservation.child_ids.assignment_id,
|
|
324
|
-
run_id:
|
|
532
|
+
run_id: acceptedRunId,
|
|
325
533
|
status_reason: 'fix_cycle_strand_reemit',
|
|
326
534
|
}, cwd);
|
|
327
535
|
}
|
|
@@ -366,6 +574,15 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
|
|
|
366
574
|
...(next_turn ? { next_turn } : {}),
|
|
367
575
|
};
|
|
368
576
|
}
|
|
577
|
+
function normalizedWorkspace(value) {
|
|
578
|
+
try {
|
|
579
|
+
const resolved = fs.realpathSync.native(value);
|
|
580
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
581
|
+
}
|
|
582
|
+
catch {
|
|
583
|
+
return undefined;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
369
586
|
/**
|
|
370
587
|
* Converge a TURN-OWNED lane whose worker died at the TRANSPORT level (no lane
|
|
371
588
|
* 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,38 @@ 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
|
+
return {
|
|
89
|
+
artifacts: [{ phase, type: artifactType, body: capBody(body), produced_by: attempt.agent, addresses_critique: uniqueAddresses }],
|
|
90
|
+
slot_outcome: 'done',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return { artifacts: [{ phase, type: artifactType, body: capBody(body), produced_by: attempt.agent }], slot_outcome: 'done' };
|
|
94
|
+
}
|
|
95
|
+
if (lane.artifact_type !== 'critique') {
|
|
96
|
+
return { artifacts: [], slot_outcome: 'failed', failure_reason: "ideation critique requires artifact_type 'critique'" };
|
|
97
|
+
}
|
|
52
98
|
if (!critiques || critiques.length === 0) {
|
|
53
|
-
return { artifacts: [], slot_outcome: 'failed', failure_reason: 'ideation lane produced no critiques (bare summary) — gate stays shut' };
|
|
99
|
+
return { artifacts: [], slot_outcome: 'failed', failure_reason: 'ideation critique lane produced no critiques (bare summary) — gate stays shut' };
|
|
54
100
|
}
|
|
55
101
|
return {
|
|
56
102
|
artifacts: critiques.map((c) => ({
|
|
@@ -61,11 +107,9 @@ export const ideationReducer = (input, attempt) => {
|
|
|
61
107
|
};
|
|
62
108
|
};
|
|
63
109
|
/**
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* convergence sensible without fabricating structured artifacts a kind never
|
|
68
|
-
* declared.
|
|
110
|
+
* Explicit legacy helper retained for callers/tests that intentionally want a
|
|
111
|
+
* generic lane_result. The exhaustive LoopKind registry below never falls back
|
|
112
|
+
* to it: every shipped kind has a phase-aware reducer.
|
|
69
113
|
*/
|
|
70
114
|
export const defaultReducer = (input, attempt) => {
|
|
71
115
|
const { lane, phase } = input;
|
|
@@ -77,12 +121,57 @@ export const defaultReducer = (input, attempt) => {
|
|
|
77
121
|
slot_outcome: 'done',
|
|
78
122
|
};
|
|
79
123
|
};
|
|
80
|
-
|
|
124
|
+
function typedPhaseReducer(kind, artifactByPhase) {
|
|
125
|
+
return (input, attempt) => {
|
|
126
|
+
const { lane, phase } = input;
|
|
127
|
+
if (lane.status !== 'completed') {
|
|
128
|
+
return { artifacts: [], slot_outcome: 'failed', failure_reason: `${kind} lane status is ${lane.status}, not completed` };
|
|
129
|
+
}
|
|
130
|
+
const expectedType = artifactByPhase[phase];
|
|
131
|
+
if (!expectedType) {
|
|
132
|
+
return { artifacts: [], slot_outcome: 'failed', failure_reason: `${kind} phase '${phase}' has no worker-result contract` };
|
|
133
|
+
}
|
|
134
|
+
if (lane.artifact_type !== expectedType) {
|
|
135
|
+
return {
|
|
136
|
+
artifacts: [],
|
|
137
|
+
slot_outcome: 'failed',
|
|
138
|
+
failure_reason: `${kind} phase '${phase}' expected artifact_type '${expectedType}', got '${lane.artifact_type}'`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
// Every worker artifact is explicitly attested. In particular a narrative
|
|
142
|
+
// summary can never masquerade as a gate-driving repro or verify report.
|
|
143
|
+
const body = (lane.body ?? lane.summary).trim();
|
|
144
|
+
if (!body) {
|
|
145
|
+
return { artifacts: [], slot_outcome: 'failed', failure_reason: `${kind} phase '${phase}' produced no artifact body` };
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
artifacts: [{ phase, type: expectedType, body: capBody(body), produced_by: attempt.agent }],
|
|
149
|
+
slot_outcome: 'done',
|
|
150
|
+
};
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
export const implementationReducer = typedPhaseReducer('implementation', {
|
|
154
|
+
execute: 'execute_report',
|
|
155
|
+
});
|
|
156
|
+
export const researchReducer = typedPhaseReducer('research', {
|
|
157
|
+
investigate: 'finding',
|
|
158
|
+
synthesize: 'synthesis',
|
|
159
|
+
});
|
|
160
|
+
export const debugReducer = typedPhaseReducer('debug', {
|
|
161
|
+
reproduce: 'repro',
|
|
162
|
+
hypothesize: 'hypothesis',
|
|
163
|
+
isolate: 'isolation_report',
|
|
164
|
+
fix: 'verify_report',
|
|
165
|
+
});
|
|
166
|
+
export const RESULT_REDUCERS = {
|
|
81
167
|
review: reviewReducer,
|
|
82
168
|
ideation: ideationReducer,
|
|
169
|
+
implementation: implementationReducer,
|
|
170
|
+
research: researchReducer,
|
|
171
|
+
debug: debugReducer,
|
|
83
172
|
};
|
|
84
|
-
/** The reducer for a loop kind
|
|
173
|
+
/** The reducer for a loop kind. Exhaustive by construction: no silent fallback. */
|
|
85
174
|
export function reducerForKind(kind) {
|
|
86
|
-
return RESULT_REDUCERS[kind]
|
|
175
|
+
return RESULT_REDUCERS[kind];
|
|
87
176
|
}
|
|
88
177
|
//# 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');
|
|
@@ -166,6 +167,7 @@ export function openLoop(input, cwd) {
|
|
|
166
167
|
open_questions: [],
|
|
167
168
|
linked: input.linked,
|
|
168
169
|
stop_condition: input.stop_condition ?? protocolDefaults.stop_condition,
|
|
170
|
+
evidence_policy: evidencePolicyForNewLoop(),
|
|
169
171
|
created_at: now,
|
|
170
172
|
updated_at: now,
|
|
171
173
|
created_by: input.created_by,
|
|
@@ -193,7 +195,13 @@ export function getLoop(id, cwd) {
|
|
|
193
195
|
// the on-disk thread, return the reconstructed consistent view. The mutation
|
|
194
196
|
// is durable in the intent; persistence catches up at the next lock-entry
|
|
195
197
|
// recovery (never a write on this read path — cf. trp_fdf3e590 / dec#137).
|
|
196
|
-
|
|
198
|
+
const thread = reconstructConsistentThread(id, onDisk, cwd);
|
|
199
|
+
if (thread) {
|
|
200
|
+
const diagnostics = validateThreadEvidence(thread);
|
|
201
|
+
if (diagnostics.length > 0)
|
|
202
|
+
logger.warn(`loop ${id}: rejected evidence envelopes ${JSON.stringify(diagnostics)}`);
|
|
203
|
+
}
|
|
204
|
+
return thread;
|
|
197
205
|
}
|
|
198
206
|
export function listLoops(filters = {}, cwd) {
|
|
199
207
|
const dir = threadsDir(cwd);
|
|
@@ -206,6 +214,9 @@ export function listLoops(filters = {}, cwd) {
|
|
|
206
214
|
try {
|
|
207
215
|
const raw = fs.readFileSync(path.join(dir, file), 'utf8');
|
|
208
216
|
const loop = LoopThreadSchema.parse(JSON.parse(raw));
|
|
217
|
+
const diagnostics = validateThreadEvidence(loop);
|
|
218
|
+
if (diagnostics.length > 0)
|
|
219
|
+
logger.warn(`loop ${loop.id}: rejected evidence envelopes ${JSON.stringify(diagnostics)}`);
|
|
209
220
|
if (filters.kind && loop.kind !== filters.kind)
|
|
210
221
|
continue;
|
|
211
222
|
if (filters.status && loop.status !== filters.status)
|
|
@@ -361,6 +372,22 @@ export function closeLoop(input, cwd) {
|
|
|
361
372
|
project_md_final_id,
|
|
362
373
|
now: pauseNow,
|
|
363
374
|
});
|
|
375
|
+
const persistedDiffArtifact = (current.evidence_policy !== undefined || evidenceWriterEnabled())
|
|
376
|
+
? LoopArtifactSchema.parse(sealArtifactEvidence(current, writeResult.diff_artifact, {
|
|
377
|
+
channel: 'system_hook',
|
|
378
|
+
producer_kind: 'engine',
|
|
379
|
+
producer_id: 'brainclaw:bootstrap-write',
|
|
380
|
+
}))
|
|
381
|
+
: writeResult.diff_artifact;
|
|
382
|
+
const persistedQuestionArtifact = (current.evidence_policy !== undefined || evidenceWriterEnabled())
|
|
383
|
+
? LoopArtifactSchema.parse(sealArtifactEvidence(current, questionArtifact, {
|
|
384
|
+
channel: 'system_hook',
|
|
385
|
+
producer_kind: 'engine',
|
|
386
|
+
producer_id: 'brainclaw:bootstrap-write',
|
|
387
|
+
slot_id: slot.slot_id,
|
|
388
|
+
slot_role: slot.role,
|
|
389
|
+
}))
|
|
390
|
+
: questionArtifact;
|
|
364
391
|
const pausedThread = {
|
|
365
392
|
...current,
|
|
366
393
|
version: pauseVersion,
|
|
@@ -370,9 +397,9 @@ export function closeLoop(input, cwd) {
|
|
|
370
397
|
pending_file_apply: {
|
|
371
398
|
artifact_id: project_md_final_id,
|
|
372
399
|
target_path: writeResult.target_path,
|
|
373
|
-
diff_artifact_id:
|
|
400
|
+
diff_artifact_id: persistedDiffArtifact.artifact_id,
|
|
374
401
|
},
|
|
375
|
-
artifacts: [...current.artifacts,
|
|
402
|
+
artifacts: [...current.artifacts, persistedDiffArtifact, persistedQuestionArtifact],
|
|
376
403
|
open_questions: [...current.open_questions, question_id],
|
|
377
404
|
updated_at: pauseNow,
|
|
378
405
|
};
|