brainclaw 1.28.0 → 1.28.2
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/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli/register-coordination.js +12 -0
- package/dist/commands/code-map.js +2 -0
- package/dist/commands/doctor.js +1 -0
- package/dist/commands/harvest.js +32 -43
- package/dist/commands/loop.js +12 -0
- package/dist/commands/loops-handlers.js +284 -17
- package/dist/commands/mcp-catalog.js +6 -3
- package/dist/commands/mcp-write-claims.js +55 -8
- package/dist/commands/mcp-write-coordination.js +413 -137
- package/dist/commands/mcp.js +32 -4
- package/dist/core/actions.js +17 -3
- package/dist/core/agentrun-reconciler.js +138 -4
- package/dist/core/claims.js +4 -1
- package/dist/core/code-map/backend.js +8 -0
- package/dist/core/execution-adapters.js +15 -7
- package/dist/core/hygiene-policy.js +2 -1
- package/dist/core/loop-turn-dispatch.js +18 -1
- package/dist/core/loops/attempt-authority.js +22 -4
- package/dist/core/loops/attempt-generations.js +17 -4
- package/dist/core/loops/attempt-reservation.js +14 -1
- package/dist/core/loops/attempt-takeover.js +173 -76
- package/dist/core/loops/continuation.js +337 -0
- package/dist/core/loops/facade-schema.js +15 -0
- package/dist/core/loops/index.js +1 -0
- package/dist/core/loops/reconcile-turn.js +224 -26
- package/dist/core/loops/result-reducers.js +8 -8
- package/dist/core/loops/turn-execution.js +38 -19
- package/dist/core/loops/types.js +9 -0
- package/dist/core/loops/verbs.js +1 -1
- package/dist/core/reviewer-policy.js +39 -0
- package/dist/core/schema.js +16 -1
- package/dist/facts.js +8 -8
- package/dist/facts.json +7 -7
- package/docs/cli.md +4 -2
- package/docs/code-map.md +10 -0
- package/docs/concepts/loop-engine.md +30 -0
- package/docs/mcp-schema-changelog.md +6 -1
- package/package.json +1 -1
|
@@ -234,6 +234,19 @@ export const BclawLoopBindSchema = z.object({
|
|
|
234
234
|
// No expected_version: bind is idempotent by loop phase (past `bind` → noop), not CAS.
|
|
235
235
|
...CallerEnvelopeFields,
|
|
236
236
|
});
|
|
237
|
+
/**
|
|
238
|
+
* Evaluate and apply one persisted cross-loop continuation. This is an
|
|
239
|
+
* orchestration intent: the downstream mutation still traverses the public
|
|
240
|
+
* `open` and `bind` handlers, never a private Loop-store shortcut.
|
|
241
|
+
*/
|
|
242
|
+
export const BclawLoopContinueSchema = z.object({
|
|
243
|
+
intent: z.literal('continue'),
|
|
244
|
+
loop_id: z.string().regex(/^lop_[0-9a-z]+$/),
|
|
245
|
+
action_index: z.number().int().nonnegative().default(0),
|
|
246
|
+
autonomy_mode: z.enum(['autonomous', 'require_approval', 'deny']).default('autonomous'),
|
|
247
|
+
risk: z.enum(['normal', 'protected']).default('normal'),
|
|
248
|
+
...CallerEnvelopeFields,
|
|
249
|
+
});
|
|
237
250
|
/**
|
|
238
251
|
* pln#508 step 2 — `bclaw_loop(intent='request_input')`.
|
|
239
252
|
*
|
|
@@ -306,6 +319,7 @@ export const BclawLoopRequestSchema = z.discriminatedUnion('intent', [
|
|
|
306
319
|
BclawLoopCloseSchema,
|
|
307
320
|
BclawLoopVerifySchema,
|
|
308
321
|
BclawLoopBindSchema,
|
|
322
|
+
BclawLoopContinueSchema,
|
|
309
323
|
BclawLoopRequestInputSchema,
|
|
310
324
|
BclawLoopProvideInputSchema,
|
|
311
325
|
]);
|
|
@@ -323,6 +337,7 @@ export const BCLAW_LOOP_INTENTS = [
|
|
|
323
337
|
'close',
|
|
324
338
|
'verify',
|
|
325
339
|
'bind',
|
|
340
|
+
'continue',
|
|
326
341
|
'request_input',
|
|
327
342
|
'provide_input',
|
|
328
343
|
];
|
package/dist/core/loops/index.js
CHANGED
|
@@ -21,4 +21,5 @@ export { deriveWorkerReplyContract, renderWorkerReplyProse, workerReplyNextActio
|
|
|
21
21
|
export { abortAttempt, inspectAttempt, matchEvidence, prepareAttempt, projectAndCross, revokeAttempt, } from './attempt-authority.js';
|
|
22
22
|
export { LOOP_KIND_POLICIES, assertLoopKindPoliciesComplete, isWorkerPhase, phasePolicy, policyForKind, } from './kind-policies.js';
|
|
23
23
|
export { ensureTurnExecutionProjections, prepareTurnExecution, } from './turn-execution.js';
|
|
24
|
+
export { CONTINUATION_POLICY_VERSION, ContinuationDecisionSchema, ContinuationRecordSchema, ContinuationStateSchema, attachContinuationActionRequired, denyContinuation, ensureContinuation, evaluateContinuation, listContinuations, loadContinuation, resumeApprovedContinuation, } from './continuation.js';
|
|
24
25
|
//# sourceMappingURL=index.js.map
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { getReservation, evidenceMatchesAttempt, currentNonce, launchGrant, resolveTurnId } from './attempt-reservation.js';
|
|
3
|
+
import { getReservation, evidenceMatchesAttempt, currentNonce, findReservationByAssignmentId, findReservationByRunId, launchGrant, resolveTurnId } from './attempt-reservation.js';
|
|
4
4
|
import { getLoop } from './store.js';
|
|
5
5
|
import { completeTurnWithEvidence, addArtifactWithEvidence, complete_turn, advance } from './verbs.js';
|
|
6
6
|
import { reducerForKind } from './result-reducers.js';
|
|
7
7
|
import { loadAgentRun, recordExecutionContractAnomaly, transitionAgentRun } from '../agentruns.js';
|
|
8
|
-
import { loadAssignment, transitionAssignment } from '../assignments.js';
|
|
8
|
+
import { convergeAssignmentToTerminal, loadAssignment, transitionAssignment } from '../assignments.js';
|
|
9
9
|
import { loadClaim, releaseClaim, releaseClaimIfActive } from '../claims.js';
|
|
10
10
|
import { createRuntimeEvent } from '../events.js';
|
|
11
11
|
import { readCompletionSignals, readContractAck } from '../runtime-signals.js';
|
|
@@ -17,6 +17,143 @@ import { executionContractForGeneration, settleActiveAttemptGenerationV2 } from
|
|
|
17
17
|
import { fenceForGeneration, resolveTurnGenerationChain } from './attempt-generations.js';
|
|
18
18
|
import { readLocalAuthorityHome } from './attempt-rollout.js';
|
|
19
19
|
import { LaneResultSchema } from '../schema.js';
|
|
20
|
+
/** Prefer the current run-scoped runtime channel, with assignment-scoped files
|
|
21
|
+
* retained only as a compatibility fallback for pre-run-keyed dispatches. */
|
|
22
|
+
function readAttemptCompletionSignals(root, assignmentId, runId) {
|
|
23
|
+
const scoped = readCompletionSignals(root, assignmentId, runId);
|
|
24
|
+
const legacy = readCompletionSignals(root, assignmentId);
|
|
25
|
+
return {
|
|
26
|
+
completed: scoped.completed ?? legacy.completed,
|
|
27
|
+
failed: scoped.failed ?? legacy.failed,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function readAttemptContractAck(root, assignmentId, runId) {
|
|
31
|
+
return readContractAck(root, assignmentId, runId) ?? readContractAck(root, assignmentId);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Resolve the authoritative generation coordinates for a worker result.
|
|
35
|
+
*
|
|
36
|
+
* A file-fallback LANE-RESULT may omit the mechanical fence fields because the
|
|
37
|
+
* wrapper already wrote them to its completion signal. The worker-controlled
|
|
38
|
+
* fields always win when present, so stale/mismatched evidence is still rejected
|
|
39
|
+
* by reconcileTurn. Missing fields are filled only from the reservation's active
|
|
40
|
+
* generation and its run-keyed completion signal.
|
|
41
|
+
*/
|
|
42
|
+
export function turnOwnedLaneEvidence(lane, cwd, reservationOverride) {
|
|
43
|
+
const reservation = reservationOverride ?? findReservationByAssignmentId(lane.assignment_id, cwd);
|
|
44
|
+
if (!reservation)
|
|
45
|
+
return undefined;
|
|
46
|
+
const chain = resolveTurnGenerationChain(cwd, reservation.turn_id);
|
|
47
|
+
const generation = chain?.latest_generation;
|
|
48
|
+
const runId = generation?.run_id ?? reservation.child_ids.run_id;
|
|
49
|
+
const completion = readAttemptCompletionSignals(cwd, generation?.assignment_id ?? reservation.child_ids.assignment_id, runId).completed;
|
|
50
|
+
const bootstrapAck = readAttemptContractAck(cwd, generation?.assignment_id ?? reservation.child_ids.assignment_id, runId);
|
|
51
|
+
// The pre-exec bootstrap ACK is the first durable, coordinator-authored proof
|
|
52
|
+
// that this exact launch generation crossed into the worker. Real file-fallback
|
|
53
|
+
// workers commonly write LANE-RESULT before the wrapper can emit its terminal
|
|
54
|
+
// sentinel, and they do not echo the mechanical turn/run/nonce tuple. In that
|
|
55
|
+
// window, enrich only from an ACCEPTED run-keyed ACK; reconcileTurn still checks
|
|
56
|
+
// every coordinate against the active generation, so a rejected/foreign/stale
|
|
57
|
+
// ACK cannot authorize convergence and an explicit lane value always wins.
|
|
58
|
+
const acceptedAck = bootstrapAck?.status === 'accepted' ? bootstrapAck : undefined;
|
|
59
|
+
const nonce = lane.nonce ?? completion?.nonce ?? acceptedAck?.nonce;
|
|
60
|
+
if (!nonce && !reservation.execution_contract_ref)
|
|
61
|
+
return undefined;
|
|
62
|
+
return {
|
|
63
|
+
reservation,
|
|
64
|
+
nonce,
|
|
65
|
+
run_id: runId,
|
|
66
|
+
attempt_epoch: lane.attempt_epoch ?? completion?.attempt_epoch ?? acceptedAck?.attempt_epoch ?? generation?.attempt_epoch,
|
|
67
|
+
workspace_digest: lane.workspace_digest ?? completion?.workspace_digest ?? acceptedAck?.workspace_digest ?? generation?.workspace_digest,
|
|
68
|
+
contract_hash: lane.execution_contract_hash
|
|
69
|
+
?? completion?.contract_hash
|
|
70
|
+
?? acceptedAck?.contract_hash
|
|
71
|
+
?? generation?.contract_hash
|
|
72
|
+
?? reservation.execution_contract_ref?.hash,
|
|
73
|
+
capability_snapshot_hash: lane.capability_snapshot_hash
|
|
74
|
+
?? completion?.capability_snapshot_hash
|
|
75
|
+
?? acceptedAck?.capability_snapshot_hash
|
|
76
|
+
?? reservation.execution_contract_ref?.snapshot_hash,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/** Reconcile one parsed worker result through the single AttemptAuthority path. */
|
|
80
|
+
export function reconcileTurnOwnedLane(lane, cwd, evidence, actor) {
|
|
81
|
+
const ev = evidence ?? turnOwnedLaneEvidence(lane, cwd);
|
|
82
|
+
if (!ev)
|
|
83
|
+
return undefined;
|
|
84
|
+
const { reservation } = ev;
|
|
85
|
+
const enrichedLane = {
|
|
86
|
+
...lane,
|
|
87
|
+
turn_id: lane.turn_id ?? reservation.turn_id,
|
|
88
|
+
run_id: lane.run_id ?? ev.run_id,
|
|
89
|
+
nonce: lane.nonce ?? ev.nonce,
|
|
90
|
+
attempt_epoch: lane.attempt_epoch ?? ev.attempt_epoch,
|
|
91
|
+
workspace_digest: lane.workspace_digest ?? ev.workspace_digest,
|
|
92
|
+
execution_contract_hash: lane.execution_contract_hash ?? ev.contract_hash,
|
|
93
|
+
capability_snapshot_hash: lane.capability_snapshot_hash ?? ev.capability_snapshot_hash,
|
|
94
|
+
};
|
|
95
|
+
const loop = getLoop(reservation.loop_id, cwd);
|
|
96
|
+
const critiques = loop?.kind === 'ideation'
|
|
97
|
+
&& reservation.phase === 'critique'
|
|
98
|
+
&& lane.artifact_type === 'critique'
|
|
99
|
+
&& (lane.body ?? '').trim().length > 0
|
|
100
|
+
? [{ body: lane.body.trim() }]
|
|
101
|
+
: undefined;
|
|
102
|
+
const result = reconcileTurn({ turn_id: reservation.turn_id, lane: enrichedLane, cwd, critiques, actor });
|
|
103
|
+
return { reservation, result };
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Lazy read-path bridge: consume the exact LANE-RESULT owned by one AgentRun.
|
|
107
|
+
* Invalid JSON, foreign assignment ids, stale generation workspaces, or missing
|
|
108
|
+
* turn fences are reported without mutating slot/assignment/run/claim state.
|
|
109
|
+
*/
|
|
110
|
+
export function reconcileLaneResultForRun(run, cwd, actor = 'reconciler') {
|
|
111
|
+
if (!run.worktree_path)
|
|
112
|
+
return { found: false, valid: false, reason: 'run has no worktree_path' };
|
|
113
|
+
const resultPath = path.join(run.worktree_path, 'LANE-RESULT.json');
|
|
114
|
+
if (!fs.existsSync(resultPath))
|
|
115
|
+
return { found: false, valid: false, reason: 'LANE-RESULT.json not found' };
|
|
116
|
+
let lane;
|
|
117
|
+
try {
|
|
118
|
+
lane = LaneResultSchema.parse(JSON.parse(fs.readFileSync(resultPath, 'utf8')));
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
return { found: true, valid: false, reason: `invalid LANE-RESULT.json: ${err instanceof Error ? err.message : String(err)}` };
|
|
122
|
+
}
|
|
123
|
+
if (lane.assignment_id !== run.assignment_id) {
|
|
124
|
+
return { found: true, valid: false, lane, reason: `foreign assignment_id ${lane.assignment_id}; expected ${run.assignment_id}` };
|
|
125
|
+
}
|
|
126
|
+
const reservation = findReservationByRunId(run.id, cwd);
|
|
127
|
+
if (!reservation)
|
|
128
|
+
return { found: true, valid: false, lane, reason: `run ${run.id} has no owning turn reservation` };
|
|
129
|
+
const chain = resolveTurnGenerationChain(cwd, reservation.turn_id);
|
|
130
|
+
const generation = chain?.latest_generation;
|
|
131
|
+
if (generation && generation.run_id !== run.id) {
|
|
132
|
+
return { found: true, valid: false, lane, reservation, reason: `run ${run.id} is not the active generation ${generation.run_id}` };
|
|
133
|
+
}
|
|
134
|
+
if (generation) {
|
|
135
|
+
const actualWorkspace = normalizedWorkspace(run.worktree_path);
|
|
136
|
+
const expectedWorkspace = normalizedWorkspace(generation.workspace_path);
|
|
137
|
+
if (!actualWorkspace || !expectedWorkspace || actualWorkspace !== expectedWorkspace) {
|
|
138
|
+
return { found: true, valid: false, lane, reservation, reason: 'run worktree does not match the active attempt generation workspace' };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const evidence = turnOwnedLaneEvidence(lane, cwd, reservation);
|
|
142
|
+
if (!evidence) {
|
|
143
|
+
return { found: true, valid: false, lane, reservation, reason: 'LANE-RESULT lacks a run-keyed launch fence and no completion signal supplies one' };
|
|
144
|
+
}
|
|
145
|
+
const reconciled = reconcileTurnOwnedLane(lane, cwd, evidence, actor);
|
|
146
|
+
if (!reconciled)
|
|
147
|
+
return { found: true, valid: false, lane, reservation, reason: 'turn-owned reconciliation unavailable' };
|
|
148
|
+
return {
|
|
149
|
+
found: true,
|
|
150
|
+
valid: reconciled.result.reconciled,
|
|
151
|
+
lane,
|
|
152
|
+
reservation,
|
|
153
|
+
result: reconciled.result,
|
|
154
|
+
reason: reconciled.result.reason,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
20
157
|
// The terminal loop statuses (LOOP_STATUSES = open|paused|completed|blocked|cancelled).
|
|
21
158
|
// 'blocked' is LOAD-BEARING (pln#630 PR3b): the iteration cap closes a fix cycle to
|
|
22
159
|
// `blocked`, and a blocked loop must be treated as terminal both by the idempotent
|
|
@@ -49,12 +186,22 @@ function settleRunCompleted(runId, actor, cwd) {
|
|
|
49
186
|
function settleAssignment(assignmentId, actor, cwd) {
|
|
50
187
|
try {
|
|
51
188
|
const asg = loadAssignment(assignmentId, cwd);
|
|
52
|
-
if (asg
|
|
189
|
+
if (!asg || asg.status === 'completed' || asg.status === 'cancelled')
|
|
190
|
+
return;
|
|
191
|
+
if (asg.status === 'expired') {
|
|
53
192
|
try {
|
|
54
193
|
transitionAssignment(assignmentId, 'completed', { actor }, cwd);
|
|
55
194
|
}
|
|
56
|
-
catch { /*
|
|
195
|
+
catch { /* concurrent terminal transition */ }
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (asg.status === 'created') {
|
|
199
|
+
try {
|
|
200
|
+
transitionAssignment(assignmentId, 'offered', { actor }, cwd);
|
|
201
|
+
}
|
|
202
|
+
catch { /* concurrent transition */ }
|
|
57
203
|
}
|
|
204
|
+
convergeAssignmentToTerminal(assignmentId, 'completed', 'reconcileTurn: turn-keyed worker result accepted', cwd);
|
|
58
205
|
}
|
|
59
206
|
catch { /* best-effort */ }
|
|
60
207
|
}
|
|
@@ -100,6 +247,7 @@ export function reconcileTurn(input) {
|
|
|
100
247
|
? resolvedGeneration.latest_generation
|
|
101
248
|
: undefined;
|
|
102
249
|
const activeRunId = activeGeneration?.run_id ?? reservation.child_ids.run_id;
|
|
250
|
+
const activeAssignmentId = activeGeneration?.assignment_id ?? reservation.child_ids.assignment_id;
|
|
103
251
|
const activeContractRef = activeGeneration
|
|
104
252
|
? executionContractForGeneration(reservation, activeGeneration).ref
|
|
105
253
|
: reservation.execution_contract_ref;
|
|
@@ -133,8 +281,8 @@ export function reconcileTurn(input) {
|
|
|
133
281
|
};
|
|
134
282
|
}
|
|
135
283
|
if (activeContractRef) {
|
|
136
|
-
const completion =
|
|
137
|
-
const bootstrapAck =
|
|
284
|
+
const completion = readAttemptCompletionSignals(cwd ?? process.cwd(), activeAssignmentId, activeRunId).completed;
|
|
285
|
+
const bootstrapAck = readAttemptContractAck(cwd ?? process.cwd(), activeAssignmentId, activeRunId);
|
|
138
286
|
const accepted = {
|
|
139
287
|
contract_hash: lane.execution_contract_hash ?? completion?.contract_hash ?? '',
|
|
140
288
|
capability_snapshot_hash: lane.capability_snapshot_hash ?? completion?.capability_snapshot_hash ?? '',
|
|
@@ -177,7 +325,7 @@ export function reconcileTurn(input) {
|
|
|
177
325
|
event_type: 'run_blocked',
|
|
178
326
|
text: `reconcileTurn: post-crossing execution-contract acceptance anomaly for ${turn_id}; convergence WITHHELD and respawn=false`,
|
|
179
327
|
tags: ['loops', 'reconcile', 'contract-anomaly', 'turn-attempt'],
|
|
180
|
-
assignment_id:
|
|
328
|
+
assignment_id: activeAssignmentId,
|
|
181
329
|
run_id: activeRunId,
|
|
182
330
|
status_reason: 'execution_contract_acceptance_mismatch',
|
|
183
331
|
}, cwd);
|
|
@@ -198,13 +346,13 @@ export function reconcileTurn(input) {
|
|
|
198
346
|
// LANE-RESULT then exited non-zero (turn-keyed failed sentinel) is a conflict,
|
|
199
347
|
// not a clean close. ──
|
|
200
348
|
try {
|
|
201
|
-
const bodies =
|
|
349
|
+
const bodies = readAttemptCompletionSignals(cwd ?? process.cwd(), activeAssignmentId, activeRunId);
|
|
202
350
|
const matchedCompleted = bodies.completed?.status === 'completed' && evidenceMatchesAttempt(reservation, {
|
|
203
|
-
assignment_id:
|
|
351
|
+
assignment_id: activeAssignmentId,
|
|
204
352
|
...bodies.completed,
|
|
205
353
|
});
|
|
206
354
|
const matchedFailed = bodies.failed?.status === 'failed' && evidenceMatchesAttempt(reservation, {
|
|
207
|
-
assignment_id:
|
|
355
|
+
assignment_id: activeAssignmentId,
|
|
208
356
|
...bodies.failed,
|
|
209
357
|
});
|
|
210
358
|
if (matchedFailed && (matchedCompleted || lane.status === 'completed')) {
|
|
@@ -214,7 +362,7 @@ export function reconcileTurn(input) {
|
|
|
214
362
|
event_type: 'run_blocked',
|
|
215
363
|
text: `reconcileTurn: turn ${turn_id} has a completed(lane/sentinel)+failed(sentinel) contradiction — auto-stop WITHHELD (§13 R4), escalating to human`,
|
|
216
364
|
tags: ['loops', 'reconcile', 'conflict', 'turn-attempt'],
|
|
217
|
-
assignment_id:
|
|
365
|
+
assignment_id: activeAssignmentId,
|
|
218
366
|
run_id: activeRunId,
|
|
219
367
|
status_reason: 'turn_evidence_contradiction',
|
|
220
368
|
}, cwd);
|
|
@@ -307,6 +455,13 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
|
|
|
307
455
|
}
|
|
308
456
|
const acceptedGeneration = generationState?.latest_generation;
|
|
309
457
|
const acceptedRunId = acceptedGeneration?.run_id ?? reservation.child_ids.run_id;
|
|
458
|
+
const acceptedAssignmentId = acceptedGeneration?.assignment_id ?? reservation.child_ids.assignment_id;
|
|
459
|
+
const acceptedExecutor = acceptedGeneration?.executor ?? {
|
|
460
|
+
agent: reservation.agent,
|
|
461
|
+
agent_id: reservation.agent_id,
|
|
462
|
+
claim_id: reservation.claim_id,
|
|
463
|
+
capability_snapshot: reservation.capability_snapshot,
|
|
464
|
+
};
|
|
310
465
|
const acceptedNonce = acceptedGeneration?.launch_nonce ?? reservation.launch?.token;
|
|
311
466
|
const acceptedEpoch = acceptedGeneration?.attempt_epoch ?? reservation.epoch;
|
|
312
467
|
const acceptedContractHash = acceptedGeneration?.contract_hash
|
|
@@ -330,7 +485,7 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
|
|
|
330
485
|
// transition but BEFORE its own deferred release would otherwise leak the retained claim
|
|
331
486
|
// until the staleness sweep — releaseCoordinatorClaim is idempotent (no-op if not active).
|
|
332
487
|
if (LOOP_TERMINAL.has(loop.status)) {
|
|
333
|
-
releaseCoordinatorClaim(loadAssignment(
|
|
488
|
+
releaseCoordinatorClaim(loadAssignment(acceptedAssignmentId, cwd)?.claim_id ?? acceptedExecutor.claim_id, cwd);
|
|
334
489
|
return { reconciled: true, reason: `loop already ${loop.status} (idempotent no-op)`, artifacts_added: 0, loop_status: loop.status };
|
|
335
490
|
}
|
|
336
491
|
// ── Idempotency (review Findings 1+2): a TERMINAL slot durably means this turn's
|
|
@@ -366,13 +521,13 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
|
|
|
366
521
|
evidence_context: {
|
|
367
522
|
channel: 'reconcile_turn',
|
|
368
523
|
producer_kind: 'slot',
|
|
369
|
-
producer_id:
|
|
370
|
-
agent_id:
|
|
524
|
+
producer_id: acceptedExecutor.agent,
|
|
525
|
+
agent_id: acceptedExecutor.agent_id,
|
|
371
526
|
slot_id: slot.slot_id,
|
|
372
527
|
slot_role: slot.role,
|
|
373
528
|
turn_id,
|
|
374
|
-
assignment_id:
|
|
375
|
-
claim_id:
|
|
529
|
+
assignment_id: acceptedAssignmentId,
|
|
530
|
+
claim_id: acceptedExecutor.claim_id,
|
|
376
531
|
run_id: acceptedRunId,
|
|
377
532
|
nonce: acceptedNonce,
|
|
378
533
|
attempt_epoch: acceptedEpoch,
|
|
@@ -398,13 +553,13 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
|
|
|
398
553
|
evidence_context: {
|
|
399
554
|
channel: 'reconcile_turn',
|
|
400
555
|
producer_kind: 'slot',
|
|
401
|
-
producer_id:
|
|
402
|
-
agent_id:
|
|
556
|
+
producer_id: acceptedExecutor.agent,
|
|
557
|
+
agent_id: acceptedExecutor.agent_id,
|
|
403
558
|
slot_id: slot.slot_id,
|
|
404
559
|
slot_role: slot.role,
|
|
405
560
|
turn_id,
|
|
406
|
-
assignment_id:
|
|
407
|
-
claim_id:
|
|
561
|
+
assignment_id: acceptedAssignmentId,
|
|
562
|
+
claim_id: acceptedExecutor.claim_id,
|
|
408
563
|
run_id: acceptedRunId,
|
|
409
564
|
nonce: acceptedNonce,
|
|
410
565
|
attempt_epoch: acceptedEpoch,
|
|
@@ -435,7 +590,7 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
|
|
|
435
590
|
event_type: 'loop_artifact_harvested',
|
|
436
591
|
text: `reconcileTurn: harvested turn ${turn_id} on slot ${slot.slot_id} → loop ${loop.id} (${slot_outcome}, ${artifacts_added} artifact(s), phase ${reservation.phase})`,
|
|
437
592
|
tags: ['loops', 'reconcile', 'harvest', 'turn-attempt'],
|
|
438
|
-
assignment_id:
|
|
593
|
+
assignment_id: acceptedAssignmentId,
|
|
439
594
|
run_id: acceptedRunId,
|
|
440
595
|
attempt_epoch: acceptedGeneration?.attempt_epoch,
|
|
441
596
|
workspace_digest: acceptedGeneration?.workspace_digest,
|
|
@@ -452,7 +607,7 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
|
|
|
452
607
|
// (a crash that recorded the turn but not the settle still converges on replay; the
|
|
453
608
|
// fix-cycle re-dispatch mints a fresh run/assignment, so completing the old is correct). ──
|
|
454
609
|
settleRunCompleted(acceptedRunId, actor, cwd);
|
|
455
|
-
settleAssignment(
|
|
610
|
+
settleAssignment(acceptedAssignmentId, actor, cwd);
|
|
456
611
|
// ── Advance / stop decision. On a `done` outcome we either drive a deterministic stop
|
|
457
612
|
// (reviewer_green / gate → close), continue a symmetric fix cycle (bump the round + retain
|
|
458
613
|
// the claim + emit next_turn), or leave the loop open (asymmetric / no successor). ──
|
|
@@ -530,7 +685,7 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
|
|
|
530
685
|
event_type: 'run_blocked',
|
|
531
686
|
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`,
|
|
532
687
|
tags: ['loops', 'reconcile', 'turn-owned', 'strand-recovery'],
|
|
533
|
-
assignment_id:
|
|
688
|
+
assignment_id: acceptedAssignmentId,
|
|
534
689
|
run_id: acceptedRunId,
|
|
535
690
|
status_reason: 'fix_cycle_strand_reemit',
|
|
536
691
|
}, cwd);
|
|
@@ -560,7 +715,7 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
|
|
|
560
715
|
// Release the coordinator claim now — UNLESS a fix-cycle round retained it. Target the
|
|
561
716
|
// authoritative claim the assignment is bound to, not reservation.claim_id (dec#149 #3).
|
|
562
717
|
if (!retainClaim) {
|
|
563
|
-
const authoritativeClaimId = loadAssignment(
|
|
718
|
+
const authoritativeClaimId = loadAssignment(acceptedAssignmentId, cwd)?.claim_id ?? acceptedExecutor.claim_id;
|
|
564
719
|
releaseCoordinatorClaim(authoritativeClaimId, cwd);
|
|
565
720
|
}
|
|
566
721
|
const loop_status = getLoop(loop.id, cwd)?.status ?? loop.status;
|
|
@@ -661,7 +816,7 @@ function convergeFailedLockedTurn(reservation, run, transportReason, actor, cwd)
|
|
|
661
816
|
event_type: 'run_failed',
|
|
662
817
|
text: `Released claim ${claimId} as the BUSINESS convergence of failed turn ${reservation.turn_id}: ${why}`,
|
|
663
818
|
tags: ['loops', 'reconcile', 'claim-release', 'effects-boundary'],
|
|
664
|
-
assignment_id:
|
|
819
|
+
assignment_id: run.assignment_id,
|
|
665
820
|
run_id: run.id,
|
|
666
821
|
claim_id: claimId,
|
|
667
822
|
status_reason: 'turn_failure_business_release',
|
|
@@ -676,7 +831,7 @@ function convergeFailedLockedTurn(reservation, run, transportReason, actor, cwd)
|
|
|
676
831
|
// A genuinely deleted loop leaves the claim to the staleness sweep.
|
|
677
832
|
return { converged: false, claim_released: false, reason: `loop ${reservation.loop_id} not found — claim retained for the staleness sweep` };
|
|
678
833
|
}
|
|
679
|
-
const authoritativeClaimId = loadAssignment(
|
|
834
|
+
const authoritativeClaimId = loadAssignment(run.assignment_id, cwd)?.claim_id ?? run.claim_id ?? reservation.claim_id;
|
|
680
835
|
// Terminal loop: the business story is already over — releasing is pure
|
|
681
836
|
// idempotent cleanup, mirroring reconcileTurn's terminal early-return.
|
|
682
837
|
if (LOOP_TERMINAL.has(loop.status)) {
|
|
@@ -711,6 +866,49 @@ function convergeFailedLockedTurn(reservation, run, transportReason, actor, cwd)
|
|
|
711
866
|
return { converged: false, claim_released: false, reason: `complete_turn failed: ${err instanceof Error ? err.message : String(err)} — claim retained, next pass retries` };
|
|
712
867
|
}
|
|
713
868
|
}
|
|
869
|
+
// Assignment is a business projection of the same failed turn. Leaving it
|
|
870
|
+
// offered/started while the run, slot, and claim are terminal recreates the
|
|
871
|
+
// orphaned-state ambiguity this convergence path exists to remove.
|
|
872
|
+
let assignment = loadAssignment(run.assignment_id, cwd);
|
|
873
|
+
if (assignment && !['completed', 'failed', 'blocked', 'timed_out', 'cancelled', 'expired', 'rerouted'].includes(assignment.status)) {
|
|
874
|
+
const assignmentId = assignment.id;
|
|
875
|
+
try {
|
|
876
|
+
// A running AgentRun is stronger execution evidence than a lagging
|
|
877
|
+
// Assignment projection. Legacy/file workers can leave that projection
|
|
878
|
+
// at created or accepted; walk the legal FSM edges before recording the
|
|
879
|
+
// terminal failure instead of declining forever on created -> failed or
|
|
880
|
+
// accepted -> failed. Each edge is durable, so a crash resumes from the
|
|
881
|
+
// last projection on the next lazy pass.
|
|
882
|
+
if (assignment.status === 'created' || assignment.status === 'retrying') {
|
|
883
|
+
transitionAssignment(assignmentId, 'offered', {
|
|
884
|
+
actor,
|
|
885
|
+
syncAgentRun: false,
|
|
886
|
+
status_reason: `turn ${reservation.turn_id} failure projection catch-up`,
|
|
887
|
+
}, cwd);
|
|
888
|
+
assignment = loadAssignment(assignmentId, cwd);
|
|
889
|
+
}
|
|
890
|
+
if (assignment?.status === 'accepted') {
|
|
891
|
+
transitionAssignment(assignmentId, 'started', {
|
|
892
|
+
actor,
|
|
893
|
+
syncAgentRun: false,
|
|
894
|
+
status_reason: `turn ${reservation.turn_id} failure projection catch-up`,
|
|
895
|
+
}, cwd);
|
|
896
|
+
assignment = loadAssignment(assignmentId, cwd);
|
|
897
|
+
}
|
|
898
|
+
transitionAssignment(assignmentId, 'failed', {
|
|
899
|
+
actor,
|
|
900
|
+
syncAgentRun: false,
|
|
901
|
+
status_reason: `turn ${reservation.turn_id} failed: ${transportReason}`,
|
|
902
|
+
}, cwd);
|
|
903
|
+
}
|
|
904
|
+
catch (err) {
|
|
905
|
+
return {
|
|
906
|
+
converged: false,
|
|
907
|
+
claim_released: false,
|
|
908
|
+
reason: `assignment failure projection failed: ${err instanceof Error ? err.message : String(err)} — claim retained, next pass retries`,
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
}
|
|
714
912
|
const released = releaseAudited(authoritativeClaimId, transportReason);
|
|
715
913
|
return {
|
|
716
914
|
converged: true,
|
|
@@ -6,7 +6,7 @@ import { LOOP_ARTIFACT_BODY_MAX_BYTES } from './types.js';
|
|
|
6
6
|
* would make complete_turn throw and crash reconcileTurn. Byte-aware, drops any
|
|
7
7
|
* partial trailing multibyte char.
|
|
8
8
|
*/
|
|
9
|
-
function
|
|
9
|
+
export function capLoopArtifactBody(s) {
|
|
10
10
|
if (Buffer.byteLength(s, 'utf8') <= LOOP_ARTIFACT_BODY_MAX_BYTES)
|
|
11
11
|
return s;
|
|
12
12
|
const marker = '…[truncated]';
|
|
@@ -35,7 +35,7 @@ export const reviewReducer = (input, attempt) => {
|
|
|
35
35
|
return { artifacts: [], slot_outcome: 'failed', failure_reason: 'review author_response produced no body' };
|
|
36
36
|
}
|
|
37
37
|
return {
|
|
38
|
-
artifacts: [{ phase, type: 'author_response', body:
|
|
38
|
+
artifacts: [{ phase, type: 'author_response', body: capLoopArtifactBody(response), produced_by: attempt.agent }],
|
|
39
39
|
slot_outcome: 'done',
|
|
40
40
|
};
|
|
41
41
|
}
|
|
@@ -46,7 +46,7 @@ export const reviewReducer = (input, attempt) => {
|
|
|
46
46
|
return { artifacts: [], slot_outcome: 'failed', failure_reason: 'review lane completed without a review_verdict — cannot converge the loop' };
|
|
47
47
|
}
|
|
48
48
|
const summary = (lane.review_summary ?? '').trim();
|
|
49
|
-
const body =
|
|
49
|
+
const body = capLoopArtifactBody(lane.review_verdict === 'approve'
|
|
50
50
|
? `accepted${summary ? `: ${summary}` : ''}`
|
|
51
51
|
: `changes-requested${summary ? `: ${summary}` : ''}`);
|
|
52
52
|
return {
|
|
@@ -96,7 +96,7 @@ export const ideationReducer = (input, attempt) => {
|
|
|
96
96
|
artifacts: [{
|
|
97
97
|
phase,
|
|
98
98
|
type: artifactType,
|
|
99
|
-
body:
|
|
99
|
+
body: capLoopArtifactBody(body),
|
|
100
100
|
produced_by: attempt.agent,
|
|
101
101
|
addresses_critique: uniqueAddresses,
|
|
102
102
|
implementation_verify: lane.implementation_verify,
|
|
@@ -104,7 +104,7 @@ export const ideationReducer = (input, attempt) => {
|
|
|
104
104
|
slot_outcome: 'done',
|
|
105
105
|
};
|
|
106
106
|
}
|
|
107
|
-
return { artifacts: [{ phase, type: artifactType, body:
|
|
107
|
+
return { artifacts: [{ phase, type: artifactType, body: capLoopArtifactBody(body), produced_by: attempt.agent }], slot_outcome: 'done' };
|
|
108
108
|
}
|
|
109
109
|
if (lane.artifact_type !== 'critique') {
|
|
110
110
|
return { artifacts: [], slot_outcome: 'failed', failure_reason: "ideation critique requires artifact_type 'critique'" };
|
|
@@ -114,7 +114,7 @@ export const ideationReducer = (input, attempt) => {
|
|
|
114
114
|
}
|
|
115
115
|
return {
|
|
116
116
|
artifacts: critiques.map((c) => ({
|
|
117
|
-
phase, type: 'critique', body:
|
|
117
|
+
phase, type: 'critique', body: capLoopArtifactBody(c.body), produced_by: attempt.agent,
|
|
118
118
|
...(c.addresses_critique ? { addresses_critique: c.addresses_critique } : {}),
|
|
119
119
|
})),
|
|
120
120
|
slot_outcome: 'done',
|
|
@@ -131,7 +131,7 @@ export const defaultReducer = (input, attempt) => {
|
|
|
131
131
|
return { artifacts: [], slot_outcome: 'failed', failure_reason: `lane status is ${lane.status}, not completed` };
|
|
132
132
|
}
|
|
133
133
|
return {
|
|
134
|
-
artifacts: [{ phase, type: 'lane_result', body:
|
|
134
|
+
artifacts: [{ phase, type: 'lane_result', body: capLoopArtifactBody(lane.summary), produced_by: attempt.agent }],
|
|
135
135
|
slot_outcome: 'done',
|
|
136
136
|
};
|
|
137
137
|
};
|
|
@@ -159,7 +159,7 @@ function typedPhaseReducer(kind, artifactByPhase) {
|
|
|
159
159
|
return { artifacts: [], slot_outcome: 'failed', failure_reason: `${kind} phase '${phase}' produced no artifact body` };
|
|
160
160
|
}
|
|
161
161
|
return {
|
|
162
|
-
artifacts: [{ phase, type: expectedType, body:
|
|
162
|
+
artifacts: [{ phase, type: expectedType, body: capLoopArtifactBody(body), produced_by: attempt.agent }],
|
|
163
163
|
slot_outcome: 'done',
|
|
164
164
|
};
|
|
165
165
|
};
|