brainclaw 1.17.0 → 1.19.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 +5 -5
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/commands/code-map.js +4 -1
- package/dist/commands/codev.js +61 -30
- package/dist/commands/doctor.js +14 -1
- package/dist/commands/harvest.js +223 -43
- package/dist/commands/inbox.js +10 -4
- package/dist/commands/install-hooks.js +184 -27
- package/dist/commands/loop.js +2 -2
- package/dist/commands/loops-handlers.js +82 -1
- package/dist/commands/mcp-catalog.js +12 -4
- package/dist/commands/mcp-read-handlers.js +90 -7
- package/dist/commands/mcp-schemas.generated.js +3 -0
- package/dist/commands/mcp-write-claims.js +57 -0
- package/dist/commands/mcp-write-coordination.js +216 -57
- package/dist/commands/mcp-write-entities.js +11 -0
- package/dist/commands/mcp.js +29 -2
- package/dist/commands/session-end.js +15 -0
- package/dist/commands/session-start.js +19 -0
- package/dist/core/agentrun-reconciler.js +171 -7
- package/dist/core/agentruns.js +6 -1
- package/dist/core/claim-conformity.js +193 -0
- package/dist/core/claim-scope.js +155 -0
- package/dist/core/claims.js +127 -2
- package/dist/core/code-map/aggregate.js +473 -0
- package/dist/core/code-map/backend.js +36 -10
- package/dist/core/code-map/freshness.js +36 -1
- package/dist/core/code-map/lang/c/imports.scm +12 -0
- package/dist/core/code-map/lang/c/index.js +150 -0
- package/dist/core/code-map/lang/c/tags.scm +68 -0
- package/dist/core/code-map/lang/cpp/imports.scm +14 -0
- package/dist/core/code-map/lang/cpp/index.js +149 -0
- package/dist/core/code-map/lang/cpp/tags.scm +87 -0
- package/dist/core/code-map/lang/csharp/imports.scm +20 -0
- package/dist/core/code-map/lang/csharp/index.js +224 -0
- package/dist/core/code-map/lang/csharp/tags.scm +63 -0
- package/dist/core/code-map/lang/go/imports.scm +13 -0
- package/dist/core/code-map/lang/go/index.js +139 -0
- package/dist/core/code-map/lang/go/tags.scm +36 -0
- package/dist/core/code-map/lang/providers.js +12 -1
- package/dist/core/code-map/lang/ruby/imports.scm +24 -0
- package/dist/core/code-map/lang/ruby/index.js +198 -0
- package/dist/core/code-map/lang/ruby/tags.scm +49 -0
- package/dist/core/code-map/lang/rust/imports.scm +44 -0
- package/dist/core/code-map/lang/rust/index.js +136 -0
- package/dist/core/code-map/lang/rust/tags.scm +47 -0
- package/dist/core/code-map/query.js +229 -80
- package/dist/core/code-map/types.js +18 -0
- package/dist/core/code-map/work-section.js +8 -7
- package/dist/core/codev-responses.js +16 -0
- package/dist/core/dispatcher.js +176 -22
- package/dist/core/execution-adapters.js +29 -3
- package/dist/core/facade-schema.js +32 -0
- package/dist/core/guidance-telemetry.js +197 -0
- package/dist/core/ideation-loop-close.js +152 -0
- package/dist/core/instruction-templates.js +11 -3
- package/dist/core/loops/artifact-resolver.js +197 -0
- package/dist/core/loops/attempt-reservation.js +576 -0
- package/dist/core/loops/commit-intent.js +494 -0
- package/dist/core/loops/facade-schema.js +48 -0
- package/dist/core/loops/impl-bind.js +144 -0
- package/dist/core/loops/index.js +1 -1
- package/dist/core/loops/iteration-engine.js +29 -0
- package/dist/core/loops/lock.js +14 -0
- package/dist/core/loops/project-resolution.js +157 -0
- package/dist/core/loops/reconcile-turn.js +369 -0
- package/dist/core/loops/result-reducers.js +88 -0
- package/dist/core/loops/store.js +46 -7
- package/dist/core/loops/types.js +139 -11
- package/dist/core/loops/verbs.js +49 -4
- package/dist/core/loops/verify-command.js +209 -0
- package/dist/core/messaging.js +58 -5
- package/dist/core/next-actions.js +157 -0
- package/dist/core/review-loop-close.js +27 -6
- package/dist/core/review-loop-turn-dispatch.js +290 -28
- package/dist/core/runtime-signals.js +68 -0
- package/dist/core/schema.js +64 -0
- package/dist/core/surface-freshness.js +150 -0
- package/dist/core/warnings.js +98 -0
- package/dist/core/worktree.js +24 -0
- package/dist/facts.js +9 -9
- package/dist/facts.json +8 -8
- package/dist/wasm/tree-sitter-c.wasm +0 -0
- package/dist/wasm/tree-sitter-c_sharp.wasm +0 -0
- package/dist/wasm/tree-sitter-cpp.wasm +0 -0
- package/dist/wasm/tree-sitter-go.wasm +0 -0
- package/dist/wasm/tree-sitter-ruby.wasm +0 -0
- package/dist/wasm/tree-sitter-rust.wasm +0 -0
- package/docs/cli.md +1 -1
- package/docs/code-map.md +22 -6
- package/docs/concepts/loop-engine.md +24 -0
- package/docs/concepts/observer-protocol.md +22 -0
- package/docs/concepts/plans-and-claims.md +57 -0
- package/docs/integrations/claude-code.md +53 -0
- package/docs/integrations/mcp.md +45 -0
- package/docs/mcp-schema-changelog.md +118 -2
- package/package.json +1 -1
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { getReservation, evidenceMatchesAttempt, currentNonce, deriveTurnId, launchGrant } from './attempt-reservation.js';
|
|
3
|
+
import { getLoop } from './store.js';
|
|
4
|
+
import { complete_turn, add_artifact, advance } from './verbs.js';
|
|
5
|
+
import { reducerForKind } from './result-reducers.js';
|
|
6
|
+
import { loadAgentRun, transitionAgentRun } from '../agentruns.js';
|
|
7
|
+
import { loadAssignment, transitionAssignment } from '../assignments.js';
|
|
8
|
+
import { loadClaim, releaseClaim } from '../claims.js';
|
|
9
|
+
import { createRuntimeEvent } from '../events.js';
|
|
10
|
+
import { readCompletionSignals } from '../runtime-signals.js';
|
|
11
|
+
import { buildFixCycleTask } from '../review-loop-close.js';
|
|
12
|
+
import { withLoopLock, LockTimeoutError, LockLostError } from './lock.js';
|
|
13
|
+
// The terminal loop statuses (LOOP_STATUSES = open|paused|completed|blocked|cancelled).
|
|
14
|
+
// 'blocked' is LOAD-BEARING (pln#630 PR3b): the iteration cap closes a fix cycle to
|
|
15
|
+
// `blocked`, and a blocked loop must be treated as terminal both by the idempotent
|
|
16
|
+
// early-return below and by the fix-cycle already-bumped branch. (The legacy 'closed'/
|
|
17
|
+
// 'abandoned' entries are not real loop statuses — kept as harmless historical aliases.)
|
|
18
|
+
const LOOP_TERMINAL = new Set(['closed', 'cancelled', 'completed', 'abandoned', 'blocked']);
|
|
19
|
+
/** Move a turn-owned run to `completed` via `running` if it never got there. Best-effort. */
|
|
20
|
+
function settleRunCompleted(runId, actor, cwd) {
|
|
21
|
+
try {
|
|
22
|
+
const run = loadAgentRun(runId, cwd);
|
|
23
|
+
if (!run || run.status === 'completed')
|
|
24
|
+
return;
|
|
25
|
+
// created/launching/waiting_input/blocked can't go straight to completed —
|
|
26
|
+
// route through running first (matrix allows created→running, running→completed).
|
|
27
|
+
if (run.status !== 'running') {
|
|
28
|
+
try {
|
|
29
|
+
transitionAgentRun(runId, 'running', { actor, status_reason: 'reconcileTurn: settling for completion' }, cwd);
|
|
30
|
+
}
|
|
31
|
+
catch { /* already past running, or terminal — fall through */ }
|
|
32
|
+
}
|
|
33
|
+
const after = loadAgentRun(runId, cwd);
|
|
34
|
+
if (after && after.status === 'running') {
|
|
35
|
+
transitionAgentRun(runId, 'completed', { actor, status_reason: 'reconcileTurn: turn-keyed completion accepted' }, cwd);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch { /* best-effort — loop convergence does not depend on run status */ }
|
|
39
|
+
}
|
|
40
|
+
/** Complete this turn's assignment (idempotent, best-effort). The re-dispatch of a fix
|
|
41
|
+
* cycle mints a FRESH assignment, so completing the old one is always correct. */
|
|
42
|
+
function settleAssignment(assignmentId, actor, cwd) {
|
|
43
|
+
try {
|
|
44
|
+
const asg = loadAssignment(assignmentId, cwd);
|
|
45
|
+
if (asg && asg.status !== 'completed' && asg.status !== 'cancelled') {
|
|
46
|
+
try {
|
|
47
|
+
transitionAssignment(assignmentId, 'completed', { actor }, cwd);
|
|
48
|
+
}
|
|
49
|
+
catch { /* transition may be illegal from current state — best-effort */ }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch { /* best-effort */ }
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Release the coordinator claim (idempotent, best-effort). pln#630 PR3b: DEFERRED until
|
|
56
|
+
* after the advance decision and skipped when the fix cycle retains the claim/worktree. The
|
|
57
|
+
* caller passes the AUTHORITATIVE claim the slot/assignment is bound to (dec#149 #3) — NOT
|
|
58
|
+
* reservation.claim_id, which is the dead first-reserver claim in the recovery-winner path.
|
|
59
|
+
*/
|
|
60
|
+
function releaseCoordinatorClaim(claimId, cwd) {
|
|
61
|
+
if (!claimId)
|
|
62
|
+
return;
|
|
63
|
+
try {
|
|
64
|
+
const claim = loadClaim(claimId, cwd);
|
|
65
|
+
if (claim && claim.status === 'active')
|
|
66
|
+
releaseClaim(claimId, cwd);
|
|
67
|
+
}
|
|
68
|
+
catch { /* best-effort */ }
|
|
69
|
+
}
|
|
70
|
+
export function reconcileTurn(input) {
|
|
71
|
+
const { turn_id, lane, cwd } = input;
|
|
72
|
+
const actor = input.actor ?? 'reconciler';
|
|
73
|
+
const reservation = getReservation(turn_id, cwd);
|
|
74
|
+
if (!reservation)
|
|
75
|
+
return { reconciled: false, reason: `unknown turn_id ${turn_id}` };
|
|
76
|
+
// ── Containment gate (§8 Q6): the reservation must belong to the store we are
|
|
77
|
+
// operating on. Before any mutation / on-behalf convergence, refuse to reconcile
|
|
78
|
+
// a reservation whose store_root resolves to a DIFFERENT project than `cwd` — a
|
|
79
|
+
// cross-project reconcile must never converge another store's loop/claim.
|
|
80
|
+
// Case-folded on win32 (review Finding 2) so a `C:\`-vs-`c:\` casing difference
|
|
81
|
+
// between the reserve-time cwd and the reconcile cwd never false-rejects on the
|
|
82
|
+
// Windows-primary target. ──
|
|
83
|
+
const operatingRoot = path.resolve(cwd ?? process.cwd());
|
|
84
|
+
const reservationRoot = path.resolve(reservation.store_root);
|
|
85
|
+
const sameStore = process.platform === 'win32'
|
|
86
|
+
? reservationRoot.toLowerCase() === operatingRoot.toLowerCase()
|
|
87
|
+
: reservationRoot === operatingRoot;
|
|
88
|
+
if (!sameStore) {
|
|
89
|
+
return { reconciled: false, reason: `containment: reservation store_root ${reservation.store_root} != operating store ${operatingRoot}` };
|
|
90
|
+
}
|
|
91
|
+
// ── §2 read-strict evidence gate: the LANE must be turn-keyed to THIS attempt's
|
|
92
|
+
// current launch generation. A stale/mismatched result never converges the loop. ──
|
|
93
|
+
if (!evidenceMatchesAttempt(reservation, { turn_id: lane.turn_id, run_id: lane.run_id, nonce: lane.nonce })) {
|
|
94
|
+
const nonce = currentNonce(reservation);
|
|
95
|
+
return {
|
|
96
|
+
reconciled: false,
|
|
97
|
+
reason: nonce === undefined
|
|
98
|
+
? 'no live launch generation (revoked/never-armed) — cannot accept evidence'
|
|
99
|
+
: `lane evidence (turn=${lane.turn_id} run=${lane.run_id} nonce=${lane.nonce}) does not match attempt ${turn_id}`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
// ── §13 R4 contradiction: a turn-keyed FAILED sentinel present alongside a
|
|
103
|
+
// completed result (the lane or a completed sentinel) → WITHHOLD convergence,
|
|
104
|
+
// journal a conflict (never silently accept). Compared against the LANE's
|
|
105
|
+
// completed status too (review Finding 5): a worker that wrote a completed
|
|
106
|
+
// LANE-RESULT then exited non-zero (turn-keyed failed sentinel) is a conflict,
|
|
107
|
+
// not a clean close. ──
|
|
108
|
+
try {
|
|
109
|
+
const bodies = readCompletionSignals(cwd ?? process.cwd(), reservation.child_ids.assignment_id);
|
|
110
|
+
const matchedCompleted = bodies.completed?.status === 'completed' && evidenceMatchesAttempt(reservation, bodies.completed);
|
|
111
|
+
const matchedFailed = bodies.failed?.status === 'failed' && evidenceMatchesAttempt(reservation, bodies.failed);
|
|
112
|
+
if (matchedFailed && (matchedCompleted || lane.status === 'completed')) {
|
|
113
|
+
try {
|
|
114
|
+
createRuntimeEvent({
|
|
115
|
+
agent: actor,
|
|
116
|
+
event_type: 'run_blocked',
|
|
117
|
+
text: `reconcileTurn: turn ${turn_id} has a completed(lane/sentinel)+failed(sentinel) contradiction — auto-stop WITHHELD (§13 R4), escalating to human`,
|
|
118
|
+
tags: ['loops', 'reconcile', 'conflict', 'turn-attempt'],
|
|
119
|
+
assignment_id: reservation.child_ids.assignment_id,
|
|
120
|
+
run_id: reservation.child_ids.run_id,
|
|
121
|
+
status_reason: 'turn_evidence_contradiction',
|
|
122
|
+
}, cwd);
|
|
123
|
+
}
|
|
124
|
+
catch { /* conflict-journal best-effort */ }
|
|
125
|
+
return { reconciled: false, conflict: true, reason: 'completed+failed contradiction — convergence withheld (§13 R4)' };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch { /* signal read best-effort — absence of sentinels is not a contradiction */ }
|
|
129
|
+
// pln#630 PR3b (adversarial review Finding 1) — the guard-read + advance + release compound
|
|
130
|
+
// MUST be atomic + serialized. Without a lock two concurrent reconciles of the same turn each
|
|
131
|
+
// pass the iteration-equality bump guard on a STALE snapshot and each advance from a fresh
|
|
132
|
+
// read → i→i+1 and i+1→i+2 → two turn_ids → the launch fence spawns BOTH rounds. Run the
|
|
133
|
+
// mutation under the loop lock (re-reading the loop INSIDE), mirroring the legacy closer's
|
|
134
|
+
// BLOCKING-3 fix. Lock contention → reconciled:false (a later trigger retries); a REAL error
|
|
135
|
+
// still propagates (Finding 6), never silently swallowed.
|
|
136
|
+
try {
|
|
137
|
+
return withLoopLock({
|
|
138
|
+
cwd,
|
|
139
|
+
intent: 'reconcile-turn',
|
|
140
|
+
agentId: actor,
|
|
141
|
+
scope: { kind: 'loop', loopId: reservation.loop_id },
|
|
142
|
+
work: () => convergeLockedTurn(reservation, input, actor, cwd),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
if (err instanceof LockTimeoutError || err instanceof LockLostError) {
|
|
147
|
+
return { reconciled: false, reason: `reconcile deferred (${err.name}); a later trigger retries` };
|
|
148
|
+
}
|
|
149
|
+
throw err;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The locked convergence body of reconcileTurn (pln#630 PR3b). Runs INSIDE withLoopLock so the
|
|
154
|
+
* iteration-equality bump guard and the advance observe ONE serialized snapshot — the loop is
|
|
155
|
+
* re-read here as the fresh in-lock read. Logic is otherwise identical to the pre-lock inline
|
|
156
|
+
* version (plus the terminal-early-return claim release, review Finding 2).
|
|
157
|
+
*/
|
|
158
|
+
function convergeLockedTurn(reservation, input, actor, cwd) {
|
|
159
|
+
const { turn_id, lane } = input;
|
|
160
|
+
const loop = getLoop(reservation.loop_id, cwd);
|
|
161
|
+
if (!loop)
|
|
162
|
+
return { reconciled: false, reason: `loop ${reservation.loop_id} not found` };
|
|
163
|
+
const slot = loop.slots.find((s) => s.slot_id === reservation.slot_id);
|
|
164
|
+
if (!slot)
|
|
165
|
+
return { reconciled: false, reason: `slot ${reservation.slot_id} not in loop ${reservation.loop_id}` };
|
|
166
|
+
// ── Superseded-turn guard (PR4 / review round-2 follow-up). A NEWER turn has
|
|
167
|
+
// taken over this slot when `slot.current_turn_id` is set and points elsewhere
|
|
168
|
+
// (each dispatch's turn() rebinds the slot pointer). A late/duplicate reconcile
|
|
169
|
+
// of the OLD turn must then NOT re-terminalize the slot or advance on a stale
|
|
170
|
+
// outcome — the slot has moved on. No-op. (When current_turn_id is unset — a
|
|
171
|
+
// direct reconcile with no preceding turn() — this does not fire, so the
|
|
172
|
+
// primitive stays testable in isolation.) This is safe against
|
|
173
|
+
// crossed_not_revocable: we never try to revoke the old crossed grant; we just
|
|
174
|
+
// decline to converge a superseded turn. ──
|
|
175
|
+
if (slot.current_turn_id !== undefined && slot.current_turn_id !== turn_id) {
|
|
176
|
+
return { reconciled: false, reason: `turn ${turn_id} superseded by current turn ${slot.current_turn_id} on slot ${slot.slot_id}` };
|
|
177
|
+
}
|
|
178
|
+
// A terminal loop already converged → idempotent no-op (any trigger may fire us). Still
|
|
179
|
+
// release the claim (review Finding 2): a cap-blocked loop that crashed AFTER the block
|
|
180
|
+
// transition but BEFORE its own deferred release would otherwise leak the retained claim
|
|
181
|
+
// until the staleness sweep — releaseCoordinatorClaim is idempotent (no-op if not active).
|
|
182
|
+
if (LOOP_TERMINAL.has(loop.status)) {
|
|
183
|
+
releaseCoordinatorClaim(loadAssignment(reservation.child_ids.assignment_id, cwd)?.claim_id ?? reservation.claim_id, cwd);
|
|
184
|
+
return { reconciled: true, reason: `loop already ${loop.status} (idempotent no-op)`, artifacts_added: 0, loop_status: loop.status };
|
|
185
|
+
}
|
|
186
|
+
// ── Idempotency (review Findings 1+2): a TERMINAL slot durably means this turn's
|
|
187
|
+
// verdict was already recorded — complete_turn sets the slot terminal ATOMICALLY
|
|
188
|
+
// with its artifact via the crash-atomic WAL, so a terminal slot is the
|
|
189
|
+
// recorded-marker (each new turn resets its slot to `assigned` first, so a
|
|
190
|
+
// terminal slot can only be THIS turn's own convergence). Re-running the reducer
|
|
191
|
+
// /complete_turn would double-record (Finding 2). So SKIP recording when
|
|
192
|
+
// terminal — but STILL fall through to advance below (Finding 1: a crash between
|
|
193
|
+
// complete_turn and advance must still close the loop on the next trigger). ──
|
|
194
|
+
const slotTerminal = slot.status === 'done' || slot.status === 'failed' || slot.status === 'cancelled';
|
|
195
|
+
let slot_outcome;
|
|
196
|
+
let artifacts_added = 0;
|
|
197
|
+
if (slotTerminal) {
|
|
198
|
+
slot_outcome = slot.status === 'done' ? 'done' : 'failed';
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
// ── §6 reducer: validated result → loop artifacts + slot outcome. ──
|
|
202
|
+
const reducerInput = { lane, phase: reservation.phase, critiques: input.critiques };
|
|
203
|
+
const reduced = reducerForKind(loop.kind)(reducerInput, reservation);
|
|
204
|
+
artifacts_added = reduced.artifacts.length;
|
|
205
|
+
slot_outcome = reduced.slot_outcome;
|
|
206
|
+
// Record artifacts + complete the turn (crash-atomic WAL via complete_turn).
|
|
207
|
+
// Guarded: a body-cap/schema error becomes a graceful reconciled:false rather
|
|
208
|
+
// than an unhandled throw out of reconcileTurn (review Finding 3 defense).
|
|
209
|
+
try {
|
|
210
|
+
const [primary, ...extras] = reduced.artifacts;
|
|
211
|
+
for (const a of extras) {
|
|
212
|
+
try {
|
|
213
|
+
add_artifact({ id: loop.id, actor, artifact: { phase: a.phase, type: a.type, body: a.body, produced_by: a.produced_by } }, cwd);
|
|
214
|
+
}
|
|
215
|
+
catch { /* an extra artifact failing must not abort convergence */ }
|
|
216
|
+
}
|
|
217
|
+
complete_turn({
|
|
218
|
+
id: loop.id,
|
|
219
|
+
slot_id: slot.slot_id,
|
|
220
|
+
actor,
|
|
221
|
+
outcome: reduced.slot_outcome,
|
|
222
|
+
failure_reason: reduced.failure_reason,
|
|
223
|
+
...(primary ? { artifact: { phase: primary.phase, type: primary.type, body: primary.body } } : {}),
|
|
224
|
+
}, cwd);
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
return { reconciled: false, reason: `complete_turn failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
228
|
+
}
|
|
229
|
+
// ── pln#521 P4 — observability: the turn's artifact was harvested + integrated
|
|
230
|
+
// into the loop. Best-effort (never aborts a successful convergence). ──
|
|
231
|
+
try {
|
|
232
|
+
createRuntimeEvent({
|
|
233
|
+
agent: actor,
|
|
234
|
+
event_type: 'loop_artifact_harvested',
|
|
235
|
+
text: `reconcileTurn: harvested turn ${turn_id} on slot ${slot.slot_id} → loop ${loop.id} (${slot_outcome}, ${artifacts_added} artifact(s), phase ${reservation.phase})`,
|
|
236
|
+
tags: ['loops', 'reconcile', 'harvest', 'turn-attempt'],
|
|
237
|
+
assignment_id: reservation.child_ids.assignment_id,
|
|
238
|
+
run_id: reservation.child_ids.run_id,
|
|
239
|
+
status_reason: `harvested_${slot_outcome}`,
|
|
240
|
+
}, cwd);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
/* observability best-effort — a telemetry failure must not undo the harvest */
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// ── Secondary convergence (best-effort + idempotent): run + assignment. The CLAIM
|
|
247
|
+
// release is DEFERRED to after the advance decision (pln#630 PR3b) so a fix-cycle round
|
|
248
|
+
// can RETAIN the claim/worktree. Run + assignment settle unconditionally on both paths
|
|
249
|
+
// (a crash that recorded the turn but not the settle still converges on replay; the
|
|
250
|
+
// fix-cycle re-dispatch mints a fresh run/assignment, so completing the old is correct). ──
|
|
251
|
+
settleRunCompleted(reservation.child_ids.run_id, actor, cwd);
|
|
252
|
+
settleAssignment(reservation.child_ids.assignment_id, actor, cwd);
|
|
253
|
+
// ── Advance / stop decision. On a `done` outcome we either drive a deterministic stop
|
|
254
|
+
// (reviewer_green / gate → close), continue a symmetric fix cycle (bump the round + retain
|
|
255
|
+
// the claim + emit next_turn), or leave the loop open (asymmetric / no successor). ──
|
|
256
|
+
let auto_closed = false;
|
|
257
|
+
let retainClaim = false;
|
|
258
|
+
let next_turn;
|
|
259
|
+
// Build the fix-cycle re-dispatch descriptor for a given round (shared by the bump arm and
|
|
260
|
+
// the strand self-heal below so the reviewer brief + slot binding never drift).
|
|
261
|
+
const mkNextTurn = (phase, iteration) => ({
|
|
262
|
+
slot_id: slot.slot_id,
|
|
263
|
+
role: slot.role ?? 'reviewer',
|
|
264
|
+
agent: slot.agent ?? '',
|
|
265
|
+
...(slot.agent_id ? { agent_id: slot.agent_id } : {}),
|
|
266
|
+
phase,
|
|
267
|
+
iteration,
|
|
268
|
+
task: buildFixCycleTask(lane.review_summary ?? lane.summary ?? '', iteration),
|
|
269
|
+
});
|
|
270
|
+
if (slot_outcome === 'done') {
|
|
271
|
+
// symmetricRC detection is INDEPENDENT of the bump guard below (safety race 2): a
|
|
272
|
+
// re-reconcile in the pre-redispatch window must NOT fall into the approve/asymmetric
|
|
273
|
+
// arm, which would advance the phase FORWARD (to author_response) and corrupt the cycle.
|
|
274
|
+
const symmetricRC = loop.kind === 'review' &&
|
|
275
|
+
lane.review_verdict === 'request_changes' &&
|
|
276
|
+
loop.protocol?.review_mode === 'symmetric';
|
|
277
|
+
if (symmetricRC) {
|
|
278
|
+
// EXACTLY-ONCE bump (the one non-negotiable safety guard): each bump changes
|
|
279
|
+
// deriveTurnId(loop, slot, iteration), so a DOUBLE bump would mint two turn_ids and
|
|
280
|
+
// the launch fence would spawn BOTH rounds. Bump only when this turn's round is still
|
|
281
|
+
// current; a re-reconcile after the bump takes the else-branch (no re-bump, no re-emit).
|
|
282
|
+
if (loop.iteration_count === reservation.iteration) {
|
|
283
|
+
// Legacy backward-bump (advance to the SAME phase → iteration_count += 1). The
|
|
284
|
+
// post-advance stop check closes to `blocked` when the max_iterations cap is hit.
|
|
285
|
+
const adv = advance({ id: loop.id, to_phase: loop.current_phase, actor }, cwd);
|
|
286
|
+
if (adv.auto_closed || LOOP_TERMINAL.has(adv.loop.status)) {
|
|
287
|
+
auto_closed = true; // iteration cap → blocked/terminal → release the claim below
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
retainClaim = true; // keep the coordinator claim + worktree for the re-dispatch
|
|
291
|
+
next_turn = mkNextTurn(adv.loop.current_phase, adv.loop.iteration_count);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
// Already bumped by a prior pass. Distinguish a benign re-reconcile (the next round
|
|
296
|
+
// WAS dispatched) from a genuine STRAND (pln#630 PR4, closes dec#149 #2/F3): the pass
|
|
297
|
+
// that bumped crashed BEFORE harvest re-dispatched, so the loop sits open with the
|
|
298
|
+
// claim retained and NO worker in flight. Detect it precisely — is there a reservation
|
|
299
|
+
// for the bumped round's deterministic turn_id? If NOT → strand → RE-EMIT next_turn to
|
|
300
|
+
// self-heal (the launch fence dedups, so a benign duplicate is DENIED, never a
|
|
301
|
+
// double-spawn) and journal a recovery event. If a reservation exists → the next round
|
|
302
|
+
// is already in flight → just retain, no re-emit (no churn on benign re-reconciles).
|
|
303
|
+
const cur = getLoop(loop.id, cwd);
|
|
304
|
+
if (cur && !LOOP_TERMINAL.has(cur.status)) {
|
|
305
|
+
retainClaim = true;
|
|
306
|
+
// The bumped round is LIVE only if its reservation exists AND its launch grant is
|
|
307
|
+
// armed (dispatch in flight, pre-spawn) or crossed (spawned). A REVOKED grant
|
|
308
|
+
// (reserved_never_launched — crash between arm and consume + the expiry sweep) or an
|
|
309
|
+
// absent reservation is a STRAND (dec#149 R1): re-emit to self-heal. The re-dispatch's
|
|
310
|
+
// prepare re-arms a revoked grant at a higher epoch, so this round can actually relaunch.
|
|
311
|
+
const bumpedTurnId = deriveTurnId(loop.id, slot.slot_id, cur.iteration_count);
|
|
312
|
+
const bumpedGrant = launchGrant(bumpedTurnId, cwd);
|
|
313
|
+
const bumpedLive = getReservation(bumpedTurnId, cwd) !== undefined &&
|
|
314
|
+
(bumpedGrant?.status === 'armed' || bumpedGrant?.status === 'crossed');
|
|
315
|
+
if (!bumpedLive) {
|
|
316
|
+
next_turn = mkNextTurn(cur.current_phase, cur.iteration_count);
|
|
317
|
+
try {
|
|
318
|
+
createRuntimeEvent({
|
|
319
|
+
agent: actor,
|
|
320
|
+
event_type: 'run_blocked',
|
|
321
|
+
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
|
+
tags: ['loops', 'reconcile', 'turn-owned', 'strand-recovery'],
|
|
323
|
+
assignment_id: reservation.child_ids.assignment_id,
|
|
324
|
+
run_id: reservation.child_ids.run_id,
|
|
325
|
+
status_reason: 'fix_cycle_strand_reemit',
|
|
326
|
+
}, cwd);
|
|
327
|
+
}
|
|
328
|
+
catch { /* observability best-effort */ }
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
auto_closed = true;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
// approve OR asymmetric request_changes — unchanged PR3a / legacy-asymmetric behavior.
|
|
338
|
+
// Benign "cannot advance/close now" → loop stays open awaiting the next turn; any
|
|
339
|
+
// OTHER throw is a REAL error and must propagate, never be silently swallowed (Finding 6).
|
|
340
|
+
try {
|
|
341
|
+
auto_closed = advance({ id: loop.id, actor }, cwd).auto_closed;
|
|
342
|
+
}
|
|
343
|
+
catch (err) {
|
|
344
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
345
|
+
if (!/phase_advance_blocked|already at last phase|no post-cycle successor/.test(msg))
|
|
346
|
+
throw err;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
// Release the coordinator claim now — UNLESS a fix-cycle round retained it. Target the
|
|
351
|
+
// authoritative claim the assignment is bound to, not reservation.claim_id (dec#149 #3).
|
|
352
|
+
if (!retainClaim) {
|
|
353
|
+
const authoritativeClaimId = loadAssignment(reservation.child_ids.assignment_id, cwd)?.claim_id ?? reservation.claim_id;
|
|
354
|
+
releaseCoordinatorClaim(authoritativeClaimId, cwd);
|
|
355
|
+
}
|
|
356
|
+
const loop_status = getLoop(loop.id, cwd)?.status ?? loop.status;
|
|
357
|
+
return {
|
|
358
|
+
reconciled: true,
|
|
359
|
+
reason: next_turn
|
|
360
|
+
? `turn ${turn_id} → request_changes round ${next_turn.iteration}: claim retained, next fix turn emitted for re-dispatch`
|
|
361
|
+
: slotTerminal ? `turn ${turn_id} already recorded; advance re-attempted (${slot_outcome})` : `turn ${turn_id} reconciled (${slot_outcome})`,
|
|
362
|
+
slot_outcome,
|
|
363
|
+
artifacts_added,
|
|
364
|
+
auto_closed,
|
|
365
|
+
loop_status,
|
|
366
|
+
...(next_turn ? { next_turn } : {}),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
//# sourceMappingURL=reconcile-turn.js.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { LOOP_ARTIFACT_BODY_MAX_BYTES } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Cap an artifact body to LOOP_ARTIFACT_BODY_MAX_BYTES (review Finding 3).
|
|
4
|
+
* `review_summary`/`summary` are worker-controlled and unbounded, but
|
|
5
|
+
* LoopArtifactSchema hard-rejects a body over the cap — an un-truncated body
|
|
6
|
+
* would make complete_turn throw and crash reconcileTurn. Byte-aware, drops any
|
|
7
|
+
* partial trailing multibyte char.
|
|
8
|
+
*/
|
|
9
|
+
function capBody(s) {
|
|
10
|
+
if (Buffer.byteLength(s, 'utf8') <= LOOP_ARTIFACT_BODY_MAX_BYTES)
|
|
11
|
+
return s;
|
|
12
|
+
const marker = '…[truncated]';
|
|
13
|
+
const room = LOOP_ARTIFACT_BODY_MAX_BYTES - Buffer.byteLength(marker, 'utf8');
|
|
14
|
+
return Buffer.from(s, 'utf8').subarray(0, room).toString('utf8').replace(/�+$/, '') + marker;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* review reducer (§6). `approve` → one `verdict` artifact whose body begins
|
|
18
|
+
* `accepted…` — EXACTLY what `isVerdictAccepted`/`reviewer_green` matches
|
|
19
|
+
* (verbs.ts) so the loop auto-closes; `request_changes` → a `changes-requested`
|
|
20
|
+
* verdict that does NOT fire reviewer_green (the symmetric fix cycle continues).
|
|
21
|
+
* A lane that did not complete, or completed with no verdict, fails the slot
|
|
22
|
+
* (no fake green).
|
|
23
|
+
*/
|
|
24
|
+
export const reviewReducer = (input, attempt) => {
|
|
25
|
+
const { lane, phase } = input;
|
|
26
|
+
if (lane.status !== 'completed') {
|
|
27
|
+
return { artifacts: [], slot_outcome: 'failed', failure_reason: `review lane status is ${lane.status}, not completed` };
|
|
28
|
+
}
|
|
29
|
+
if (!lane.review_verdict) {
|
|
30
|
+
return { artifacts: [], slot_outcome: 'failed', failure_reason: 'review lane completed without a review_verdict — cannot converge the loop' };
|
|
31
|
+
}
|
|
32
|
+
const summary = (lane.review_summary ?? '').trim();
|
|
33
|
+
const body = capBody(lane.review_verdict === 'approve'
|
|
34
|
+
? `accepted${summary ? `: ${summary}` : ''}`
|
|
35
|
+
: `changes-requested${summary ? `: ${summary}` : ''}`);
|
|
36
|
+
return {
|
|
37
|
+
artifacts: [{ phase, type: 'verdict', body, produced_by: attempt.agent }],
|
|
38
|
+
slot_outcome: 'done',
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* ideation reducer (§6). A `critique_batch` → N `critique` artifacts (so
|
|
43
|
+
* `min_artifacts_by_type` can open the next phase). A bare summary with no
|
|
44
|
+
* critique body → slot `failed`, gate stays shut (correct: no fake progress from
|
|
45
|
+
* a lane that produced no critiques).
|
|
46
|
+
*/
|
|
47
|
+
export const ideationReducer = (input, attempt) => {
|
|
48
|
+
const { lane, phase, critiques } = input;
|
|
49
|
+
if (lane.status !== 'completed') {
|
|
50
|
+
return { artifacts: [], slot_outcome: 'failed', failure_reason: `ideation lane status is ${lane.status}, not completed` };
|
|
51
|
+
}
|
|
52
|
+
if (!critiques || critiques.length === 0) {
|
|
53
|
+
return { artifacts: [], slot_outcome: 'failed', failure_reason: 'ideation lane produced no critiques (bare summary) — gate stays shut' };
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
artifacts: critiques.map((c) => ({
|
|
57
|
+
phase, type: 'critique', body: capBody(c.body), produced_by: attempt.agent,
|
|
58
|
+
...(c.addresses_critique ? { addresses_critique: c.addresses_critique } : {}),
|
|
59
|
+
})),
|
|
60
|
+
slot_outcome: 'done',
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Default reducer for loop kinds without a specialized one (implementation /
|
|
65
|
+
* research / debug / bootstrap): a completed lane → one generic `lane_result`
|
|
66
|
+
* artifact carrying the summary; a non-completed lane → slot failed. Keeps
|
|
67
|
+
* convergence sensible without fabricating structured artifacts a kind never
|
|
68
|
+
* declared.
|
|
69
|
+
*/
|
|
70
|
+
export const defaultReducer = (input, attempt) => {
|
|
71
|
+
const { lane, phase } = input;
|
|
72
|
+
if (lane.status !== 'completed') {
|
|
73
|
+
return { artifacts: [], slot_outcome: 'failed', failure_reason: `lane status is ${lane.status}, not completed` };
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
artifacts: [{ phase, type: 'lane_result', body: capBody(lane.summary), produced_by: attempt.agent }],
|
|
77
|
+
slot_outcome: 'done',
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
const RESULT_REDUCERS = {
|
|
81
|
+
review: reviewReducer,
|
|
82
|
+
ideation: ideationReducer,
|
|
83
|
+
};
|
|
84
|
+
/** The reducer for a loop kind — a specialized one when registered, else the default. */
|
|
85
|
+
export function reducerForKind(kind) {
|
|
86
|
+
return RESULT_REDUCERS[kind] ?? defaultReducer;
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=result-reducers.js.map
|
package/dist/core/loops/store.js
CHANGED
|
@@ -9,6 +9,7 @@ import { logCascadeReleaseResult, releaseClaimsCascade } from '../claims.js';
|
|
|
9
9
|
import { gcWorktreeIfHarvested } from '../worktree.js';
|
|
10
10
|
import { writeProjectMdSafe } from './hooks/bootstrap-write.js';
|
|
11
11
|
import { notifyOperatorOnInputRequested } from './hooks/notify-operator.js';
|
|
12
|
+
import { reconstructConsistentThread } from './commit-intent.js';
|
|
12
13
|
import { DEFAULT_PROTOCOLS, LoopArtifactSchema, LoopEventSchema, LoopThreadSchema, } from './types.js';
|
|
13
14
|
function loopsDir(cwd) {
|
|
14
15
|
return path.join(memoryDir(cwd ?? process.cwd()), 'loops');
|
|
@@ -86,7 +87,21 @@ export function appendEvent(loopId, event, cwd,
|
|
|
86
87
|
threadSnapshot) {
|
|
87
88
|
const parsed = LoopEventSchema.parse(event);
|
|
88
89
|
ensureLoopsDir(cwd);
|
|
89
|
-
|
|
90
|
+
// pln#630 PR1b — fsync the journal append so a materialized projection can
|
|
91
|
+
// never be durable ahead of the event that explains it (trp_8b17c2d0).
|
|
92
|
+
const fd = fs.openSync(eventsPath(loopId, cwd), 'a');
|
|
93
|
+
try {
|
|
94
|
+
// Loop the write to completion so a short/partial write never leaves a torn
|
|
95
|
+
// final record for the next append to fuse with (PR #102 review round 2).
|
|
96
|
+
const buf = Buffer.from(`${JSON.stringify(parsed)}\n`, 'utf8');
|
|
97
|
+
let off = 0;
|
|
98
|
+
while (off < buf.length)
|
|
99
|
+
off += fs.writeSync(fd, buf, off, buf.length - off);
|
|
100
|
+
fs.fsyncSync(fd);
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
fs.closeSync(fd);
|
|
104
|
+
}
|
|
90
105
|
// pln#513 step 4 — best-effort OS notification on input_requested events.
|
|
91
106
|
// Prefer the in-memory snapshot from the caller (carries the freshly-
|
|
92
107
|
// added operator_question). Fall back to a disk read so any direct
|
|
@@ -125,7 +140,12 @@ export function openLoop(input, cwd) {
|
|
|
125
140
|
// presets) wins over the kind/mode-derived default. When no override
|
|
126
141
|
// is supplied, fall back to the legacy resolveProtocol() path so
|
|
127
142
|
// existing callers (review, default ideation) are unaffected.
|
|
128
|
-
|
|
143
|
+
let protocol = input.protocol ?? resolveProtocol(input.kind, input.mode);
|
|
144
|
+
// pln#632 — merge an opener-provided verify command onto the resolved protocol (adds
|
|
145
|
+
// the engine-run command_green gate without discarding the kind-default iteration).
|
|
146
|
+
if (input.verify && !protocol?.verify) {
|
|
147
|
+
protocol = { ...(protocol ?? {}), verify: input.verify };
|
|
148
|
+
}
|
|
129
149
|
const thread = {
|
|
130
150
|
schema_version: 1,
|
|
131
151
|
id,
|
|
@@ -166,10 +186,14 @@ export function openLoop(input, cwd) {
|
|
|
166
186
|
}
|
|
167
187
|
export function getLoop(id, cwd) {
|
|
168
188
|
const filePath = threadPath(id, cwd);
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
189
|
+
const onDisk = fs.existsSync(filePath)
|
|
190
|
+
? LoopThreadSchema.parse(JSON.parse(fs.readFileSync(filePath, 'utf8')))
|
|
191
|
+
: undefined;
|
|
192
|
+
// pln#630 PR1b — if a durable-but-not-yet-applied commit intent is ahead of
|
|
193
|
+
// the on-disk thread, return the reconstructed consistent view. The mutation
|
|
194
|
+
// is durable in the intent; persistence catches up at the next lock-entry
|
|
195
|
+
// recovery (never a write on this read path — cf. trp_fdf3e590 / dec#137).
|
|
196
|
+
return reconstructConsistentThread(id, onDisk, cwd);
|
|
173
197
|
}
|
|
174
198
|
export function listLoops(filters = {}, cwd) {
|
|
175
199
|
const dir = threadsDir(cwd);
|
|
@@ -199,7 +223,22 @@ export function listLoopEvents(id, cwd) {
|
|
|
199
223
|
if (!fs.existsSync(filePath))
|
|
200
224
|
return [];
|
|
201
225
|
const lines = fs.readFileSync(filePath, 'utf8').split('\n').filter(Boolean);
|
|
202
|
-
|
|
226
|
+
const events = [];
|
|
227
|
+
for (let i = 0; i < lines.length; i++) {
|
|
228
|
+
try {
|
|
229
|
+
events.push(LoopEventSchema.parse(JSON.parse(lines[i])));
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
// pln#630 PR1b — a non-empty unparseable FINAL line is an uncommitted
|
|
233
|
+
// torn-append fragment (durably repaired at the next lock-entry recovery,
|
|
234
|
+
// commit-intent.ts). Tolerate it on read so seq allocation / reads never
|
|
235
|
+
// wedge on a torn tail. An EARLIER unparseable line is real corruption.
|
|
236
|
+
if (i === lines.length - 1)
|
|
237
|
+
break;
|
|
238
|
+
throw err;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return events;
|
|
203
242
|
}
|
|
204
243
|
/**
|
|
205
244
|
* pln#512 step 2 — sentinel thrown by closeLoop when the bootstrap close
|