brainclaw 1.20.1 → 1.20.3
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/commands/harvest.js +129 -13
- package/dist/core/agentrun-reconciler.js +67 -5
- package/dist/core/claims.js +93 -15
- package/dist/core/dispatch-status.js +33 -7
- package/dist/core/entity-operations.js +11 -1
- package/dist/core/hint-aging.js +26 -10
- package/dist/core/loops/reconcile-turn.js +136 -1
- package/dist/core/worktree.js +106 -0
- package/dist/facts.js +7 -7
- package/dist/facts.json +6 -6
- package/docs/mcp-schema-changelog.md +40 -0
- package/docs/playbooks/autonomy-regression-pack.md +91 -0
- package/docs/playbooks/store-snapshot.md +68 -0
- package/package.json +1 -1
|
Binary file
|
package/dist/commands/harvest.js
CHANGED
|
@@ -27,6 +27,7 @@ import { closeIdeationLoopFromLaneResult } from '../core/ideation-loop-close.js'
|
|
|
27
27
|
import { dispatchReviewLoopTurn, turnOwnedReviewEnabled } from '../core/review-loop-turn-dispatch.js';
|
|
28
28
|
import { reconcileTurn } from '../core/loops/reconcile-turn.js';
|
|
29
29
|
import { findReservationByAssignmentId } from '../core/loops/attempt-reservation.js';
|
|
30
|
+
import { getLoop } from '../core/loops/store.js';
|
|
30
31
|
import { readCompletionSignals } from '../core/runtime-signals.js';
|
|
31
32
|
import { reconcileClaimConformity } from '../core/claim-conformity.js';
|
|
32
33
|
import { toWarningDetail } from '../core/warnings.js';
|
|
@@ -70,8 +71,8 @@ function turnOwnedLaneEvidence(lane, cwd) {
|
|
|
70
71
|
return undefined; // reservation but NO turn-keyed evidence → legacy finalization
|
|
71
72
|
return { reservation, nonce };
|
|
72
73
|
}
|
|
73
|
-
function reconcileTurnOwnedReviewLane(lane, cwd) {
|
|
74
|
-
const ev = turnOwnedLaneEvidence(lane, cwd);
|
|
74
|
+
function reconcileTurnOwnedReviewLane(lane, cwd, evidence) {
|
|
75
|
+
const ev = evidence ?? turnOwnedLaneEvidence(lane, cwd);
|
|
75
76
|
if (!ev)
|
|
76
77
|
return undefined; // legacy lane OR no turn-keyed evidence — caller runs the legacy path
|
|
77
78
|
const { reservation, nonce } = ev;
|
|
@@ -99,6 +100,62 @@ function reconcileToReviewLoopResult(reservation, rr, lane) {
|
|
|
99
100
|
loop_status: rr.loop_status,
|
|
100
101
|
};
|
|
101
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* pln#644 — the warn-only branches must stay QUIET for a lane whose turn is no
|
|
105
|
+
* longer the live one: a prior round's LANE-RESULT re-scanned by `harvest --all`
|
|
106
|
+
* after the loop advanced (superseded) or closed (terminal) is a healthy flow,
|
|
107
|
+
* not a stall. Warning there would train operators to ignore the one warning
|
|
108
|
+
* that matters. Only an OPEN loop whose slot still points at THIS turn is
|
|
109
|
+
* actually awaiting convergence.
|
|
110
|
+
*/
|
|
111
|
+
function loopTurnAwaitsConvergence(reservation, cwd) {
|
|
112
|
+
const loop = getLoop(reservation.loop_id, cwd);
|
|
113
|
+
if (!loop)
|
|
114
|
+
return false;
|
|
115
|
+
// Only a live, advancing loop is awaiting convergence (PR #171 review P2-1).
|
|
116
|
+
// 'blocked' (iteration cap) is in reconcileTurn's LOOP_TERMINAL set, and a
|
|
117
|
+
// 'paused' loop refuses advancement until resumed — advising `--integrate`
|
|
118
|
+
// on either would be a false alarm. 'open' is the only non-terminal,
|
|
119
|
+
// advancing status (LoopStatus non-terminal = 'open' | 'paused').
|
|
120
|
+
if (loop.status !== 'open')
|
|
121
|
+
return false;
|
|
122
|
+
const slot = loop.slots.find((s) => s.slot_id === reservation.slot_id);
|
|
123
|
+
if (!slot)
|
|
124
|
+
return false;
|
|
125
|
+
// Mirror reconcileTurn's superseded guard exactly: an UNSET current_turn_id is
|
|
126
|
+
// "not superseded" (a dispatch whose turn() never rebound the slot pointer is
|
|
127
|
+
// still this turn's attempt), only a pointer to a DIFFERENT turn means the
|
|
128
|
+
// slot moved on and the stale lane deserves silence, not a warning.
|
|
129
|
+
return slot.current_turn_id === undefined || slot.current_turn_id === reservation.turn_id;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* pln#644 — the loud half of "converge or fail loudly". The message names the
|
|
133
|
+
* exact command that finalizes the turn (`harvest --integrate`) AND the manual
|
|
134
|
+
* loop-drive alternative, because the two lived stalls (2026-08-02/03) were
|
|
135
|
+
* both resolved manually once the coordinator finally noticed the open turn.
|
|
136
|
+
*/
|
|
137
|
+
function reviewTurnNotConvergedWarning(lane, reservation, why) {
|
|
138
|
+
return toWarningDetail({
|
|
139
|
+
code: 'review_turn_not_converged',
|
|
140
|
+
message: `Turn-owned review lane ${lane.assignment_id} harvested report-only: loop ${reservation.loop_id} ` +
|
|
141
|
+
`turn ${reservation.turn_id} is NOT converged — ${why}. ` +
|
|
142
|
+
`Run \`brainclaw harvest --integrate ${lane.assignment_id}\` to finalize it ` +
|
|
143
|
+
`(records the verdict; on request_changes it also drives the fix cycle), ` +
|
|
144
|
+
`or drive the loop manually (bclaw_loop add_artifact + complete_turn).`,
|
|
145
|
+
data: {
|
|
146
|
+
assignment_id: lane.assignment_id,
|
|
147
|
+
loop_id: reservation.loop_id,
|
|
148
|
+
turn_id: reservation.turn_id,
|
|
149
|
+
review_verdict: lane.review_verdict ?? null,
|
|
150
|
+
reason: why,
|
|
151
|
+
},
|
|
152
|
+
next_actions: [{
|
|
153
|
+
tool: 'bclaw_loop',
|
|
154
|
+
args: { intent: 'get', loop_id: reservation.loop_id },
|
|
155
|
+
when: 'inspect the open turn before converging it via --integrate or a manual complete_turn',
|
|
156
|
+
}],
|
|
157
|
+
});
|
|
158
|
+
}
|
|
102
159
|
/**
|
|
103
160
|
* Auto-detect all worktree directories under the brainclaw-managed base dir.
|
|
104
161
|
* Returns subdirectories that exist on disk (may or may not have an inbox).
|
|
@@ -358,20 +415,54 @@ export function harvestLaneResults(options = {}) {
|
|
|
358
415
|
// PR2: cycleOnRequestChanges=false — the report path only closes on approve;
|
|
359
416
|
// it must NOT advance a request_changes cycle it cannot follow through on
|
|
360
417
|
// (no re-dispatch, no claim retention). `harvest --integrate` owns the cycle.
|
|
418
|
+
// Hoisted for the catch below (PR #171 review P2-2): if turn-owned evidence was
|
|
419
|
+
// found before a throw, the swallowed failure must still surface as a warning.
|
|
420
|
+
let turnEvidenceForCatch;
|
|
361
421
|
try {
|
|
362
422
|
const laneAssignment = loadAssignment(lane.assignment_id, cwd);
|
|
363
423
|
if (laneAssignment) {
|
|
364
|
-
// pln#630 PR3a — a TURN-OWNED review lane
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
// (
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
|
|
372
|
-
|
|
424
|
+
// pln#644 (supersedes the pln#630 PR3a report-path deferral) — a TURN-OWNED review lane
|
|
425
|
+
// used to be skipped ENTIRELY here (finalization deferred to `--integrate`) with no
|
|
426
|
+
// signal at all: the operator ran `brainclaw harvest <asgn>`, read "1 harvested", and
|
|
427
|
+
// the loop turn silently stayed open. That stalled two live loops on 2026-08-02/03
|
|
428
|
+
// (lop_626271ee10ad09d8, lop_4d869568bd99ddc0), both converged by hand. The report
|
|
429
|
+
// path now converges what it safely CAN and says what it can't:
|
|
430
|
+
// - APPROVE → reconcileTurn right here — the same exactly-once idempotent finalizer
|
|
431
|
+
// `--integrate` uses (mirrors the pln#638 1c ideation closer firing on this path).
|
|
432
|
+
// Read-strict is NOT weakened: evidence still comes from the lane keys or the
|
|
433
|
+
// wrapper sentinel, and a mismatch still refuses (loudly, below).
|
|
434
|
+
// - REQUEST_CHANGES / no verdict / refused evidence → the loop is left alone (the
|
|
435
|
+
// report path still cannot follow through on a fix cycle: no re-dispatch, no
|
|
436
|
+
// commit-on-behalf — `--integrate` owns that) but a `review_turn_not_converged`
|
|
437
|
+
// WARNING now names the open turn and the recovery. Never a silent stall.
|
|
438
|
+
// Kill-switch (=0), a legacy lane (no reservation), OR a reservation WITHOUT evidence
|
|
439
|
+
// (review Finding 1: an inbox_only/non-ack-wrapped dispatch that never wrote a
|
|
440
|
+
// sentinel) → the lane takes the unchanged legacy close so it still converges.
|
|
441
|
+
// Ideation stays legacy (review-only).
|
|
442
|
+
const laneTurnEvidence = turnOwnedReviewEnabled() ? turnOwnedLaneEvidence(lane, cwd) : undefined;
|
|
443
|
+
turnEvidenceForCatch = laneTurnEvidence;
|
|
444
|
+
if (!laneTurnEvidence) {
|
|
373
445
|
closeReviewLoopFromLaneResult(laneAssignment, lane, agent, cwd, { cycleOnRequestChanges: false });
|
|
374
446
|
}
|
|
447
|
+
else if (lane.review_verdict === 'approve') {
|
|
448
|
+
const rr = reconcileTurnOwnedReviewLane(lane, cwd, laneTurnEvidence);
|
|
449
|
+
// Reason-based quietness (PR #171 review P2-1 refinement): a terminal loop
|
|
450
|
+
// returns reconciled:true (idempotent no-op) and a superseded turn is the one
|
|
451
|
+
// healthy decline (`harvest --all` over a prior round's lane) — everything
|
|
452
|
+
// else (refused evidence, deferred lock, loop/slot not found, contradiction,
|
|
453
|
+
// paused complete_turn refusal) is a live approve that did NOT land and must
|
|
454
|
+
// say so. No getLoop re-read here: the decline reason already discriminates,
|
|
455
|
+
// and the loop store itself may be the failing component.
|
|
456
|
+
if (rr && !rr.result.reconciled && !/superseded/.test(rr.result.reason ?? '')) {
|
|
457
|
+
result.warnings.push(reviewTurnNotConvergedWarning(lane, laneTurnEvidence.reservation, `reconcileTurn refused: ${rr.result.reason ?? 'no reason given'}`));
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
else if (loopTurnAwaitsConvergence(laneTurnEvidence.reservation, cwd)) {
|
|
461
|
+
const why = lane.review_verdict === 'request_changes'
|
|
462
|
+
? 'a request_changes fix cycle needs re-dispatch + commit-on-behalf, which the report path does not do'
|
|
463
|
+
: 'the lane carries no review_verdict, so nothing proves the reviewer reached a verdict';
|
|
464
|
+
result.warnings.push(reviewTurnNotConvergedWarning(lane, laneTurnEvidence.reservation, why));
|
|
465
|
+
}
|
|
375
466
|
// pln#521 P2-bis — the ideation analog: a critic lane records its critique +
|
|
376
467
|
// advances the ideation loop. Returns undefined for non-ideate scopes (no-op here).
|
|
377
468
|
ideationLoop = closeIdeationLoopFromLaneResult(laneAssignment, lane, agent, cwd);
|
|
@@ -393,7 +484,22 @@ export function harvestLaneResults(options = {}) {
|
|
|
393
484
|
}
|
|
394
485
|
}
|
|
395
486
|
}
|
|
396
|
-
catch {
|
|
487
|
+
catch (err) {
|
|
488
|
+
// Never block harvest on loop-close — but a swallowed failure must not be
|
|
489
|
+
// SILENT for a turn-owned lane (PR #171 review P2-2): before this warning,
|
|
490
|
+
// an unexpected reconcile/store error left the operator with "1 harvested,
|
|
491
|
+
// 0 error(s)" and an open loop turn — the exact stall pln#644 exists to
|
|
492
|
+
// kill. Deliberately no liveness/superseded re-check here: that would
|
|
493
|
+
// re-read the loop store, which may be the very component that just threw
|
|
494
|
+
// (getLoop propagates parse errors). A rare over-warning on a broken store
|
|
495
|
+
// beats a silent stall on a live turn.
|
|
496
|
+
if (turnEvidenceForCatch) {
|
|
497
|
+
try {
|
|
498
|
+
result.warnings.push(reviewTurnNotConvergedWarning(lane, turnEvidenceForCatch.reservation, `loop-close failed unexpectedly: ${err instanceof Error ? err.message : String(err)}`));
|
|
499
|
+
}
|
|
500
|
+
catch { /* truly never block harvest */ }
|
|
501
|
+
}
|
|
502
|
+
}
|
|
397
503
|
const marker = laneHarvestedMarkerPath(cwd, lane.assignment_id);
|
|
398
504
|
if (fs.existsSync(marker)) {
|
|
399
505
|
result.skipped.push(lane.assignment_id);
|
|
@@ -1043,6 +1149,10 @@ export async function runHarvestLane(assignmentId, options = {}) {
|
|
|
1043
1149
|
harvested: result.harvested,
|
|
1044
1150
|
skipped: result.skipped,
|
|
1045
1151
|
errors: result.errors,
|
|
1152
|
+
// pln#644 — warnings (review_turn_not_converged, claim conformity) were
|
|
1153
|
+
// collected but never emitted on ANY channel; the silent half of the
|
|
1154
|
+
// 2026-08-02/03 review-loop stalls.
|
|
1155
|
+
warnings: result.warnings,
|
|
1046
1156
|
}, null, 2));
|
|
1047
1157
|
return;
|
|
1048
1158
|
}
|
|
@@ -1074,6 +1184,12 @@ export async function runHarvestLane(assignmentId, options = {}) {
|
|
|
1074
1184
|
for (const err of result.errors) {
|
|
1075
1185
|
console.error(` ✗ ${err}`);
|
|
1076
1186
|
}
|
|
1077
|
-
|
|
1187
|
+
// pln#644 — warnings must reach the operator's eyes: a turn-owned review lane
|
|
1188
|
+
// whose loop turn did not converge used to vanish behind "N harvested".
|
|
1189
|
+
for (const w of result.warnings) {
|
|
1190
|
+
console.log(` ⚠ ${w.message}`);
|
|
1191
|
+
}
|
|
1192
|
+
const warnTag = result.warnings.length > 0 ? `, ${result.warnings.length} warning(s)` : '';
|
|
1193
|
+
console.log(`\n✔ Lane harvest complete${dryTag}: ${result.harvested.length} harvested, ${result.skipped.length} skipped, ${result.errors.length} error(s)${warnTag}.`);
|
|
1078
1194
|
}
|
|
1079
1195
|
//# sourceMappingURL=harvest.js.map
|
|
@@ -40,6 +40,7 @@ import { createRuntimeEvent } from './events.js';
|
|
|
40
40
|
import { nowISO } from './ids.js';
|
|
41
41
|
import { readHeartbeat, readLogTail, signalExists, latestActivityMs, readCompletionSignals } from './runtime-signals.js';
|
|
42
42
|
import { findReservationByRunId, evidenceMatchesAttempt, launchGrant, revokeLaunchGrant } from './loops/attempt-reservation.js';
|
|
43
|
+
import { reconcileFailedTurn } from './loops/reconcile-turn.js';
|
|
43
44
|
// ── Constants ──────────────────────────────────────────────────────────────
|
|
44
45
|
/**
|
|
45
46
|
* Minimum age before a run is eligible for reconciliation. Below this, the
|
|
@@ -250,12 +251,33 @@ function fsActiveWithin(evidence, windowMs) {
|
|
|
250
251
|
* accumulating for manual cleanup. Best-effort + idempotent: only an active claim
|
|
251
252
|
* is released, and any error is swallowed (GC must never break reconciliation).
|
|
252
253
|
* Inference only fires after the stale window with no life evidence, so this is
|
|
253
|
-
* conservative.
|
|
254
|
+
* conservative.
|
|
255
|
+
*
|
|
256
|
+
* pln#641 (dec#151 option b): a TURN-OWNED run's claim is business state owned
|
|
257
|
+
* by its LOOP, and the pln#638 6c effects boundary forbids a transport verdict
|
|
258
|
+
* from carrying business effects. So a run with a reservation routes through
|
|
259
|
+
* reconcileFailedTurn — the failure is recorded ON the loop (complete_turn
|
|
260
|
+
* outcome:'failed') and the release is that convergence's business decision,
|
|
261
|
+
* in the same lazy pass. When the loop path DECLINES (lock contention, loop
|
|
262
|
+
* missing, containment), the claim deliberately stays for the next read-path
|
|
263
|
+
* pass — never a forced fallback release, that would re-open the boundary hole.
|
|
264
|
+
* Non-turn-owned runs keep the trp#433 cascade unchanged.
|
|
254
265
|
*/
|
|
255
|
-
function cascadeReleaseOnFailure(run, actor, cwd, terminalStatus = 'failed') {
|
|
266
|
+
function cascadeReleaseOnFailure(run, actor, cwd, terminalStatus = 'failed', reason) {
|
|
256
267
|
if (!run.claim_id)
|
|
257
268
|
return;
|
|
258
269
|
try {
|
|
270
|
+
const reservation = findReservationByRunId(run.id, cwd);
|
|
271
|
+
if (reservation) {
|
|
272
|
+
reconcileFailedTurn({
|
|
273
|
+
reservation,
|
|
274
|
+
run,
|
|
275
|
+
reason: reason ?? `run reconciled to ${terminalStatus}`,
|
|
276
|
+
actor,
|
|
277
|
+
cwd,
|
|
278
|
+
});
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
259
281
|
const claim = loadClaim(run.claim_id, cwd);
|
|
260
282
|
if (claim && claim.status === 'active') {
|
|
261
283
|
releaseClaim(run.claim_id, cwd);
|
|
@@ -274,6 +296,46 @@ function cascadeReleaseOnFailure(run, actor, cwd, terminalStatus = 'failed') {
|
|
|
274
296
|
}
|
|
275
297
|
catch { /* best-effort — never let GC break reconciliation */ }
|
|
276
298
|
}
|
|
299
|
+
/**
|
|
300
|
+
* pln#641 review P1 — the stranded-failure retry the read path was missing.
|
|
301
|
+
*
|
|
302
|
+
* `cascadeReleaseOnFailure` fires exactly once, on the failed/cancelled
|
|
303
|
+
* TRANSITION. When the business convergence declines in that pass (loop-lock
|
|
304
|
+
* contention, transient loop read failure) or the process crashes between the
|
|
305
|
+
* loop's WAL record and the release, the run is already terminal — and every
|
|
306
|
+
* read path deliberately skips terminal runs, so the "next pass retries"
|
|
307
|
+
* promise was unreachable: the claim stranded until the 24h stale sweep,
|
|
308
|
+
* contrary to the same-lazy-pass/non-famine guarantee of dec#151.
|
|
309
|
+
*
|
|
310
|
+
* This is that retry, called from the read-path walk ON terminal runs
|
|
311
|
+
* (entity-operations.ts loadAgentRunsWithReconciliation). Cheap by
|
|
312
|
+
* construction: in-memory status/claim_id/recency guards first, ONE claim
|
|
313
|
+
* read only for RECENT failed/cancelled runs that still name a claim; the
|
|
314
|
+
* cascade re-checks everything and stays idempotent. The recency window keeps
|
|
315
|
+
* the walk from re-reading a claim file for every historical failure forever —
|
|
316
|
+
* anything older has long been the stale sweep's business anyway.
|
|
317
|
+
*/
|
|
318
|
+
export const STRANDED_RELEASE_RETRY_WINDOW_MS = 48 * 60 * 60_000;
|
|
319
|
+
export function reconcileStrandedFailureClaimAtRead(run, cwd, options = {}) {
|
|
320
|
+
if (run.status !== 'failed' && run.status !== 'cancelled')
|
|
321
|
+
return false;
|
|
322
|
+
if (!run.claim_id)
|
|
323
|
+
return false;
|
|
324
|
+
const now = options.nowMs ?? Date.now();
|
|
325
|
+
const anchor = Date.parse(run.completed_at ?? run.updated_at ?? run.created_at);
|
|
326
|
+
if (Number.isFinite(anchor) && now - anchor > STRANDED_RELEASE_RETRY_WINDOW_MS)
|
|
327
|
+
return false;
|
|
328
|
+
try {
|
|
329
|
+
const claim = loadClaim(run.claim_id, cwd);
|
|
330
|
+
if (!claim || claim.status !== 'active')
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
cascadeReleaseOnFailure(run, options.actor ?? 'reconciler', cwd, run.status, run.status_reason ?? `stranded ${run.status} run — read-path retry`);
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
277
339
|
function anyCompletionEvidence(evidence) {
|
|
278
340
|
// pln#630 PR2b-c (§13 R3): a turn-owned run is completed ONLY on turn-keyed
|
|
279
341
|
// evidence — never a bare presence sentinel or an assignment-keyed proxy
|
|
@@ -462,7 +524,7 @@ export function reconcileAgentRun(runId, cwd, options = {}) {
|
|
|
462
524
|
const failHere = (reason) => {
|
|
463
525
|
try {
|
|
464
526
|
transitionAgentRun(runId, 'failed', { actor, status_reason: reason }, cwd);
|
|
465
|
-
cascadeReleaseOnFailure(run, actor, cwd);
|
|
527
|
+
cascadeReleaseOnFailure(run, actor, cwd, 'failed', reason);
|
|
466
528
|
return { run_id: runId, action: 'inferred_failed', reason, evidence, previous_status, current_status: 'failed' };
|
|
467
529
|
}
|
|
468
530
|
catch (err) {
|
|
@@ -579,7 +641,7 @@ function reconcileTurnOwnedPreRunLease(run, reservation, evidence, cwd, options)
|
|
|
579
641
|
}
|
|
580
642
|
try {
|
|
581
643
|
transitionAgentRun(run.id, targetStatus, { actor, status_reason: reason }, cwd);
|
|
582
|
-
cascadeReleaseOnFailure(run, actor, cwd, targetStatus);
|
|
644
|
+
cascadeReleaseOnFailure(run, actor, cwd, targetStatus, reason);
|
|
583
645
|
return { run_id: run.id, action, reason, evidence, previous_status, current_status: targetStatus };
|
|
584
646
|
}
|
|
585
647
|
catch (err) {
|
|
@@ -638,7 +700,7 @@ export function reconcileDeadPidRunningAgentRunAtRead(runId, cwd, options = {})
|
|
|
638
700
|
const failRun = (reason) => {
|
|
639
701
|
try {
|
|
640
702
|
transitionAgentRun(run.id, 'failed', { actor, status_reason: reason }, cwd);
|
|
641
|
-
cascadeReleaseOnFailure(run, actor, cwd);
|
|
703
|
+
cascadeReleaseOnFailure(run, actor, cwd, 'failed', reason);
|
|
642
704
|
return { run_id: run.id, action: 'inferred_failed', reason, evidence, previous_status: run.status, current_status: 'failed' };
|
|
643
705
|
}
|
|
644
706
|
catch (err) {
|
package/dist/core/claims.js
CHANGED
|
@@ -275,6 +275,38 @@ export function releaseClaim(id, cwd, auth) {
|
|
|
275
275
|
}
|
|
276
276
|
return released;
|
|
277
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* Release a claim ONLY if it is still `active`, reporting whether THIS call
|
|
280
|
+
* performed the active→released transition.
|
|
281
|
+
*
|
|
282
|
+
* pln#641 review round-2 P1: `releaseClaim` sets `released` unconditionally —
|
|
283
|
+
* an already-released claim is silently re-released — so any caller that
|
|
284
|
+
* check-then-releases to decide whether to AUDIT the transition lies under a
|
|
285
|
+
* concurrent external release (the check passes, the write "succeeds", the
|
|
286
|
+
* caller emits an event for a transition it never performed). Here the status
|
|
287
|
+
* check runs INSIDE the same store mutation that writes the release, so the
|
|
288
|
+
* answer is exact: `released: true` means this call, and no one else, moved
|
|
289
|
+
* the claim out of `active`.
|
|
290
|
+
*/
|
|
291
|
+
export function releaseClaimIfActive(id, cwd, auth) {
|
|
292
|
+
let overrideUsed = false;
|
|
293
|
+
let transitioned = false;
|
|
294
|
+
const result = mutate({ cwd }, () => {
|
|
295
|
+
const claim = loadClaim(id, cwd);
|
|
296
|
+
if (!claim || claim.status !== 'active')
|
|
297
|
+
return claim;
|
|
298
|
+
overrideUsed = assertReleaseOwnership(claim, auth).overrideUsed;
|
|
299
|
+
claim.status = 'released';
|
|
300
|
+
claim.released_at = nowISO();
|
|
301
|
+
saveClaimUnlocked(claim, cwd);
|
|
302
|
+
transitioned = true;
|
|
303
|
+
return claim;
|
|
304
|
+
});
|
|
305
|
+
if (transitioned && overrideUsed && auth && result) {
|
|
306
|
+
auditReleaseOverride(result, auth, cwd);
|
|
307
|
+
}
|
|
308
|
+
return { released: transitioned, claim: result };
|
|
309
|
+
}
|
|
278
310
|
/**
|
|
279
311
|
* Mark an active claim as `stale` — a distinct terminal state from `released`
|
|
280
312
|
* used when a claim is being torn down because its owner is gone (session
|
|
@@ -844,6 +876,10 @@ export function releaseStaleClaimsFromOtherAgents(currentAgent, cwd, currentSess
|
|
|
844
876
|
export function createCoordinatorClaim(options) {
|
|
845
877
|
// Scope lock is GLOBAL: any active claim on the same scope blocks, regardless of agent.
|
|
846
878
|
const existingScopeClaim = listClaims(options.cwd).find((claim) => claim.status === 'active' && claim.scope === options.scope);
|
|
879
|
+
// Set when the reuse candidate turns out to be RELEASED under the store lock
|
|
880
|
+
// (review PR#167 P1): the scope is genuinely free, so control falls through
|
|
881
|
+
// to the fresh-claim path instead of handing the dispatcher a dead claim.
|
|
882
|
+
let reuseCandidateGone = false;
|
|
847
883
|
if (existingScopeClaim) {
|
|
848
884
|
if (existingScopeClaim.agent === options.agent) {
|
|
849
885
|
// Same agent already has this scope — reuse the claim (backward compat,
|
|
@@ -853,33 +889,75 @@ export function createCoordinatorClaim(options) {
|
|
|
853
889
|
// worktree — the same silent false-negative the guard exists to prevent
|
|
854
890
|
// (pln#520 Tier 2 / codex r2).
|
|
855
891
|
let reuseWarning;
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
892
|
+
let reusedWorktreePath = existingScopeClaim.worktree_path;
|
|
893
|
+
if (reusedWorktreePath) {
|
|
894
|
+
if (options.worktreeBaseRef) {
|
|
895
|
+
const reset = resetWorktreeToRef(reusedWorktreePath, options.worktreeBaseRef);
|
|
859
896
|
if (!reset.ok) {
|
|
860
897
|
reuseWarning = `Reused claim ${existingScopeClaim.id} pinned to ref "${options.worktreeBaseRef}": ${reset.stderr.trim()}`;
|
|
861
898
|
}
|
|
862
899
|
}
|
|
863
|
-
|
|
864
|
-
|
|
900
|
+
}
|
|
901
|
+
else {
|
|
902
|
+
// trp_72b4e9b3(2) — a claim persisted after a failed worktree creation
|
|
903
|
+
// (e.g. the path collision this trap documents) used to WEDGE the scope:
|
|
904
|
+
// every later dispatch reused this worktree-less claim and the spawn
|
|
905
|
+
// refused ("Reused claim has no worktree to pin"). Heal it here:
|
|
906
|
+
// provision the worktree the claim should have had (createWorktree now
|
|
907
|
+
// ADOPTS an existing same-branch worktree, so the original collision
|
|
908
|
+
// cause resolves too) and patch the claim. On failure the old warning
|
|
909
|
+
// stands — visible, and the next call retries.
|
|
910
|
+
try {
|
|
911
|
+
reusedWorktreePath = createWorktree(options.cwd, `feat/${sanitizeBranchComponent(options.scope)}`, {
|
|
912
|
+
sessionId: options.sessionId,
|
|
913
|
+
agent: options.agent,
|
|
914
|
+
baseRef: options.worktreeBaseRef,
|
|
915
|
+
resetExistingBranch: options.resetExistingWorktreeBranch || Boolean(options.worktreeBaseRef),
|
|
916
|
+
});
|
|
917
|
+
const healedPath = reusedWorktreePath;
|
|
918
|
+
// Review PR#167 P1 — the patch REVALIDATES under the store lock.
|
|
919
|
+
// Provisioning takes seconds; if a release wins that window, the
|
|
920
|
+
// reused claim id must NEVER reach the dispatcher (it would spawn an
|
|
921
|
+
// assignment bound to a RELEASED claim). 'gone' falls through to the
|
|
922
|
+
// fresh-claim path below — the scope is genuinely free now, and the
|
|
923
|
+
// just-provisioned worktree is re-found by createWorktree's adoption.
|
|
924
|
+
const healed = mutate({ cwd: options.cwd }, () => {
|
|
925
|
+
const fresh = loadClaim(existingScopeClaim.id, options.cwd);
|
|
926
|
+
if (!fresh || fresh.status !== 'active')
|
|
927
|
+
return 'gone';
|
|
928
|
+
if (!fresh.worktree_path) {
|
|
929
|
+
fresh.worktree_path = healedPath;
|
|
930
|
+
saveClaimUnlocked(fresh, options.cwd);
|
|
931
|
+
}
|
|
932
|
+
return fresh.worktree_path === healedPath ? 'patched' : 'already';
|
|
933
|
+
});
|
|
934
|
+
if (healed === 'gone')
|
|
935
|
+
reuseCandidateGone = true;
|
|
936
|
+
}
|
|
937
|
+
catch (err) {
|
|
938
|
+
reuseWarning = `Reused claim ${existingScopeClaim.id} has no worktree, and provisioning one failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
865
939
|
}
|
|
866
940
|
}
|
|
941
|
+
if (!reuseCandidateGone) {
|
|
942
|
+
return {
|
|
943
|
+
claimId: existingScopeClaim.id,
|
|
944
|
+
worktreePath: reusedWorktreePath,
|
|
945
|
+
worktreeWarning: reuseWarning,
|
|
946
|
+
reusedExisting: true,
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
else {
|
|
951
|
+
// DIFFERENT agent has an active claim on this scope — scope is locked.
|
|
952
|
+
// Return the existing claim info + a conflict flag so the dispatcher can skip.
|
|
867
953
|
return {
|
|
868
954
|
claimId: existingScopeClaim.id,
|
|
869
955
|
worktreePath: existingScopeClaim.worktree_path,
|
|
870
|
-
worktreeWarning: reuseWarning,
|
|
871
956
|
reusedExisting: true,
|
|
957
|
+
scopeConflict: true,
|
|
958
|
+
conflictAgent: existingScopeClaim.agent,
|
|
872
959
|
};
|
|
873
960
|
}
|
|
874
|
-
// DIFFERENT agent has an active claim on this scope — scope is locked.
|
|
875
|
-
// Return the existing claim info + a conflict flag so the dispatcher can skip.
|
|
876
|
-
return {
|
|
877
|
-
claimId: existingScopeClaim.id,
|
|
878
|
-
worktreePath: existingScopeClaim.worktree_path,
|
|
879
|
-
reusedExisting: true,
|
|
880
|
-
scopeConflict: true,
|
|
881
|
-
conflictAgent: existingScopeClaim.agent,
|
|
882
|
-
};
|
|
883
961
|
}
|
|
884
962
|
const claimId = generateClaimId();
|
|
885
963
|
// Resolved OUTSIDE the lock below, for the same reason acquireClaimScope does
|
|
@@ -324,7 +324,27 @@ function computeDiagnosis(assignment, agentRun, runtime, options) {
|
|
|
324
324
|
// status is running / launching / waiting_input / blocked → check liveness
|
|
325
325
|
const lastEventMs = new Date(agentRun.last_event_at ?? agentRun.started_at ?? agentRun.created_at).getTime();
|
|
326
326
|
const stallAge = options.nowMs - lastEventMs;
|
|
327
|
+
// pln#527 — a stale last_event_at is NOT "stalled" when the filesystem is still
|
|
328
|
+
// active (logs streaming / worktree files edited). Workers emit no heartbeat
|
|
329
|
+
// during a long single operation (codex→stderr, claude -p buffering stdout),
|
|
330
|
+
// so fs activity is the truer liveness signal and vetoes the false-stalled.
|
|
331
|
+
const fsAge = runtime.last_fs_activity_ms;
|
|
332
|
+
const fsActive = fsAge !== undefined && fsAge < options.stallMs;
|
|
327
333
|
if (runtime.pid_alive === false) {
|
|
334
|
+
// pln#621 pack (review of PR #170) — the fs-activity veto applies HERE too,
|
|
335
|
+
// not only to the stalled branch below. On an ack-wrapped spawn the tracked
|
|
336
|
+
// pid is the WRAPPER, dead by design while the worker keeps writing — the
|
|
337
|
+
// exact pln#520 shape (6 workers "dead", committing minutes later). This
|
|
338
|
+
// branch used to skip the veto and advise "cancel + reroute" on a worker
|
|
339
|
+
// that was demonstrably mid-write: a destructive recommendation on
|
|
340
|
+
// ambiguous evidence, which is the one thing the regression pack forbids.
|
|
341
|
+
if (fsActive) {
|
|
342
|
+
return {
|
|
343
|
+
health: 'healthy',
|
|
344
|
+
summary: `pid ${runtime.pid} is dead but the filesystem is ACTIVE (${Math.round((fsAge ?? 0) / 1000)}s ago) — on an ack-wrapped spawn the tracked pid is the wrapper, which exits by design while the worker keeps writing (pln#520)`,
|
|
345
|
+
recommended_next_action: 'No destructive action — the worker is writing. Re-check until a terminal signal appears (LANE-RESULT, completion sentinel, or a commit on the lane branch).',
|
|
346
|
+
};
|
|
347
|
+
}
|
|
328
348
|
// pln#527 (#5) — surface a TARGETED diagnosis when the captured stderr matches
|
|
329
349
|
// a known fatal boot signature (codex model/service_tier mismatch, API 400)
|
|
330
350
|
// instead of a generic "silent_death".
|
|
@@ -338,12 +358,6 @@ function computeDiagnosis(assignment, agentRun, runtime, options) {
|
|
|
338
358
|
?? 'Read .stderr.log for the exit reason; then trigger reconciliation by calling bclaw_find(entity="agent_run") again, or cancel + reroute.',
|
|
339
359
|
};
|
|
340
360
|
}
|
|
341
|
-
// pln#527 — a stale last_event_at is NOT "stalled" when the filesystem is still
|
|
342
|
-
// active (logs streaming / worktree files edited). Workers emit no heartbeat
|
|
343
|
-
// during a long single operation (codex→stderr, claude -p buffering stdout),
|
|
344
|
-
// so fs activity is the truer liveness signal and vetoes the false-stalled.
|
|
345
|
-
const fsAge = runtime.last_fs_activity_ms;
|
|
346
|
-
const fsActive = fsAge !== undefined && fsAge < options.stallMs;
|
|
347
361
|
if (runtime.pid_alive === true && stallAge > options.stallMs && fsActive) {
|
|
348
362
|
return {
|
|
349
363
|
health: 'healthy',
|
|
@@ -428,11 +442,22 @@ export function getDispatchStatus(options) {
|
|
|
428
442
|
}
|
|
429
443
|
// pln#532 — the #1 verdict signal: a LANE-RESULT.json at the worktree root means
|
|
430
444
|
// the worker FINISHED (even if it couldn't self-update the run). Read + validate it.
|
|
445
|
+
// trp_e824d2af — validate OWNERSHIP too: assignment_id is required on the schema,
|
|
446
|
+
// and a reused worktree keeps the prior turn's file at the root. Unmatched, that
|
|
447
|
+
// file declared a freshly-spawned round 2 terminal with round 1's verdict (observed
|
|
448
|
+
// live 2026-08-02, lop_626271ee10ad09d8). A mismatch is surfaced as stale, never
|
|
449
|
+
// as this dispatch's result.
|
|
431
450
|
let laneResult;
|
|
451
|
+
let laneResultStale;
|
|
432
452
|
if (worktreeForFs) {
|
|
433
453
|
try {
|
|
434
454
|
const parsed = LaneResultSchema.parse(JSON.parse(fs.readFileSync(path.join(worktreeForFs, 'LANE-RESULT.json'), 'utf-8')));
|
|
435
|
-
|
|
455
|
+
if (parsed.assignment_id === assignmentId) {
|
|
456
|
+
laneResult = { status: parsed.status, summary: parsed.summary };
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
laneResultStale = { assignment_id: parsed.assignment_id, status: parsed.status, summary: parsed.summary };
|
|
460
|
+
}
|
|
436
461
|
}
|
|
437
462
|
catch { /* no / invalid LANE-RESULT.json */ }
|
|
438
463
|
}
|
|
@@ -451,6 +476,7 @@ export function getDispatchStatus(options) {
|
|
|
451
476
|
},
|
|
452
477
|
last_fs_activity_ms: lastFsActivityMs,
|
|
453
478
|
lane_result: laneResult,
|
|
479
|
+
...(laneResultStale ? { lane_result_stale: laneResultStale } : {}),
|
|
454
480
|
commits_ahead: evidence?.commitsAhead,
|
|
455
481
|
commits_ahead_raw: evidence?.commitsAheadRaw,
|
|
456
482
|
commits_ahead_base: evidence?.baseRef,
|
|
@@ -26,7 +26,7 @@ import { loadAllSessions } from './identity.js';
|
|
|
26
26
|
import { loadInstructions } from './instructions.js';
|
|
27
27
|
import { deleteAssignment, listAssignments, loadAssignment, saveAssignment, transitionAssignment } from './assignments.js';
|
|
28
28
|
import { listAgentRuns } from './agentruns.js';
|
|
29
|
-
import { reconcileAgentRun, reconcileDeadPidRunningAgentRunAtRead, TERMINAL_STATUSES } from './agentrun-reconciler.js';
|
|
29
|
+
import { reconcileAgentRun, reconcileDeadPidRunningAgentRunAtRead, reconcileStrandedFailureClaimAtRead, TERMINAL_STATUSES } from './agentrun-reconciler.js';
|
|
30
30
|
import { isObserverMode } from './observer-mode.js';
|
|
31
31
|
import { deleteRuntimeNote, listRuntimeNotes, saveRuntimeNote, } from './runtime.js';
|
|
32
32
|
import { createSequence, deleteSequence, listSequences, updateSequence, } from './sequence.js';
|
|
@@ -244,7 +244,17 @@ function loadAgentRunsWithReconciliation(cwd) {
|
|
|
244
244
|
reconcileAgentRun(run.id, cwd);
|
|
245
245
|
}
|
|
246
246
|
catch { /* best-effort: never block reads on reconciliation errors */ }
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
// pln#641 review P1 — the failure cascade fires only on the terminal
|
|
250
|
+
// TRANSITION, and this walk used to skip terminal runs entirely, so a
|
|
251
|
+
// declined/crashed business release stranded its claim until the 24h
|
|
252
|
+
// sweep. Recent failed/cancelled runs still holding an active claim
|
|
253
|
+
// retry the (idempotent) convergence here.
|
|
254
|
+
try {
|
|
255
|
+
reconcileStrandedFailureClaimAtRead(run, cwd);
|
|
247
256
|
}
|
|
257
|
+
catch { /* best-effort: never block reads on reconciliation errors */ }
|
|
248
258
|
}
|
|
249
259
|
// Re-list to capture any transitions made above.
|
|
250
260
|
return listAgentRuns(cwd);
|
package/dist/core/hint-aging.js
CHANGED
|
@@ -8,10 +8,12 @@
|
|
|
8
8
|
* exactly the reflex we cannot afford.
|
|
9
9
|
*
|
|
10
10
|
* Contract: an entry is served in detail `k` times, then folded into a single
|
|
11
|
-
* aggregate line that carries the
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* aggregate line that carries the folded IDS themselves (trp_336e8054: it used
|
|
12
|
+
* to point at `bclaw_find(status:'stale')`, a filter that returns nothing
|
|
13
|
+
* because staleness is computed, not stored) so the agent still has one clear,
|
|
14
|
+
* WORKING pointer. The counter is a small JSON file, safe to lose — hitting it
|
|
15
|
+
* twice per stale item is worse than losing it once, and a missing file just
|
|
16
|
+
* resets counts to zero.
|
|
15
17
|
*
|
|
16
18
|
* @module
|
|
17
19
|
*/
|
|
@@ -63,8 +65,9 @@ function bump(counter, nowIso) {
|
|
|
63
65
|
}
|
|
64
66
|
/**
|
|
65
67
|
* Fold stale warnings served ≥ k times into a single aggregate line. The
|
|
66
|
-
* aggregate carries
|
|
67
|
-
* next-action
|
|
68
|
+
* aggregate carries the folded item IDS (grouped by entity) so the agent has
|
|
69
|
+
* an exact, executable next-action — never a filter the engine cannot resolve
|
|
70
|
+
* (trp_336e8054).
|
|
68
71
|
*
|
|
69
72
|
* Idempotence: calling with recordServe=false is pure — the returned split is
|
|
70
73
|
* derived from the current registry alone. With recordServe=true the counter
|
|
@@ -103,11 +106,24 @@ export function ageStaleWarnings(warnings, cwd, options = {}) {
|
|
|
103
106
|
}
|
|
104
107
|
let aggregate;
|
|
105
108
|
if (folded.length > 0) {
|
|
109
|
+
// trp_336e8054 — the aggregate used to recommend `bclaw_find(status:'stale')`,
|
|
110
|
+
// a filter that returns NOTHING: staleness is COMPUTED at session-start, never
|
|
111
|
+
// stored as a status, so the operator could not retrieve the very items the
|
|
112
|
+
// line announced. The folded items are in hand right here — carry their ids,
|
|
113
|
+
// grouped by entity, so the recovery (`bclaw_get` each id) works verbatim.
|
|
106
114
|
const byEntity = new Map();
|
|
107
|
-
for (const w of folded)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
115
|
+
for (const w of folded) {
|
|
116
|
+
const ids = byEntity.get(w.entity) ?? [];
|
|
117
|
+
ids.push(w.id);
|
|
118
|
+
byEntity.set(w.entity, ids);
|
|
119
|
+
}
|
|
120
|
+
const ID_CAP = 8;
|
|
121
|
+
const parts = [...byEntity.entries()].map(([entity, ids]) => {
|
|
122
|
+
const shown = ids.slice(0, ID_CAP);
|
|
123
|
+
const overflow = ids.length - shown.length;
|
|
124
|
+
return `${ids.length} ${entity}${ids.length === 1 ? '' : 's'}: ${shown.join(', ')}${overflow > 0 ? ` +${overflow} more` : ''}`;
|
|
125
|
+
});
|
|
126
|
+
aggregate = `${folded.length} stale item${folded.length === 1 ? '' : 's'} you've already been offered (${parts.join('; ')}) — bclaw_get each id to review, or bclaw_transition to retire.`;
|
|
111
127
|
}
|
|
112
128
|
return { warnings: detail, aggregate, served_ids, folded_ids };
|
|
113
129
|
}
|
|
@@ -5,7 +5,7 @@ import { complete_turn, add_artifact, advance } from './verbs.js';
|
|
|
5
5
|
import { reducerForKind } from './result-reducers.js';
|
|
6
6
|
import { loadAgentRun, transitionAgentRun } from '../agentruns.js';
|
|
7
7
|
import { loadAssignment, transitionAssignment } from '../assignments.js';
|
|
8
|
-
import { loadClaim, releaseClaim } from '../claims.js';
|
|
8
|
+
import { loadClaim, releaseClaim, releaseClaimIfActive } from '../claims.js';
|
|
9
9
|
import { createRuntimeEvent } from '../events.js';
|
|
10
10
|
import { readCompletionSignals } from '../runtime-signals.js';
|
|
11
11
|
import { buildFixCycleTask } from '../review-loop-close.js';
|
|
@@ -366,4 +366,139 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
|
|
|
366
366
|
...(next_turn ? { next_turn } : {}),
|
|
367
367
|
};
|
|
368
368
|
}
|
|
369
|
+
/**
|
|
370
|
+
* Converge a TURN-OWNED lane whose worker died at the TRANSPORT level (no lane
|
|
371
|
+
* result will ever arrive). dec#151, operator-decided option (b): the lane's
|
|
372
|
+
* claim is business state owned by its loop, so its release must be a business
|
|
373
|
+
* decision recorded ON the loop — through the same convergence family harvest
|
|
374
|
+
* uses (reconcileTurn) — never a side-effect of a transport verdict. Before
|
|
375
|
+
* this, the trp#433 GC cascade released the claim straight from the transport
|
|
376
|
+
* reconciler, which is exactly what the pln#638 6c effects boundary forbids.
|
|
377
|
+
*
|
|
378
|
+
* What "business decision" means concretely here: complete_turn(outcome:
|
|
379
|
+
* 'failed') writes the failure into the loop journal FIRST (crash-atomic WAL),
|
|
380
|
+
* and only then is the claim released — so a released claim always has a loop
|
|
381
|
+
* record explaining WHY, and a crash between the two converges on the next
|
|
382
|
+
* read-path pass (release is idempotent). Retry lanes are not starved: the
|
|
383
|
+
* release happens in the same lazy pass that inferred the failure, just via
|
|
384
|
+
* the loop's machinery instead of around it.
|
|
385
|
+
*
|
|
386
|
+
* Declines (converged:false — the claim STAYS, a later read-path pass retries):
|
|
387
|
+
* - containment mismatch (never converge another store's loop/claim);
|
|
388
|
+
* - lock contention (LockTimeout/LockLost);
|
|
389
|
+
* - loop missing (a claim should not be stripped on evidence we cannot read).
|
|
390
|
+
* SUPERSEDED is converged:true WITHOUT release: a newer turn owns the slot, and
|
|
391
|
+
* claim REUSE across rounds is real (trp_e824d2af) — releasing the old round's
|
|
392
|
+
* claim would strip the live one.
|
|
393
|
+
*/
|
|
394
|
+
export function reconcileFailedTurn(input) {
|
|
395
|
+
const { reservation, run, cwd } = input;
|
|
396
|
+
const actor = input.actor ?? 'reconciler';
|
|
397
|
+
// Containment gate — same rule as reconcileTurn (§8 Q6), same win32 case-fold.
|
|
398
|
+
const operatingRoot = path.resolve(cwd ?? process.cwd());
|
|
399
|
+
const reservationRoot = path.resolve(reservation.store_root);
|
|
400
|
+
const sameStore = process.platform === 'win32'
|
|
401
|
+
? reservationRoot.toLowerCase() === operatingRoot.toLowerCase()
|
|
402
|
+
: reservationRoot === operatingRoot;
|
|
403
|
+
if (!sameStore) {
|
|
404
|
+
return { converged: false, claim_released: false, reason: `containment: reservation store_root ${reservation.store_root} != operating store ${operatingRoot}` };
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
return withLoopLock({
|
|
408
|
+
cwd,
|
|
409
|
+
intent: 'reconcile-failed-turn',
|
|
410
|
+
agentId: actor,
|
|
411
|
+
scope: { kind: 'loop', loopId: reservation.loop_id },
|
|
412
|
+
work: () => convergeFailedLockedTurn(reservation, run, input.reason, actor, cwd),
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
catch (err) {
|
|
416
|
+
if (err instanceof LockTimeoutError || err instanceof LockLostError) {
|
|
417
|
+
return { converged: false, claim_released: false, reason: `deferred (${err.name}); the next read-path pass retries` };
|
|
418
|
+
}
|
|
419
|
+
throw err;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
function convergeFailedLockedTurn(reservation, run, transportReason, actor, cwd) {
|
|
423
|
+
// Audit-exact release (review PR#166 round-2 P1): the event is emitted only
|
|
424
|
+
// when THIS call performed the active→released transition. The check lives
|
|
425
|
+
// INSIDE the claim store's own mutation (releaseClaimIfActive), so an
|
|
426
|
+
// external bclaw_release_claim landing concurrently can no longer slip
|
|
427
|
+
// between a caller-side check and the write and produce a phantom
|
|
428
|
+
// business-release event.
|
|
429
|
+
const releaseAudited = (claimId, why) => {
|
|
430
|
+
if (!claimId)
|
|
431
|
+
return false;
|
|
432
|
+
try {
|
|
433
|
+
if (!releaseClaimIfActive(claimId, cwd).released)
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
createRuntimeEvent({
|
|
441
|
+
agent: actor,
|
|
442
|
+
event_type: 'run_failed',
|
|
443
|
+
text: `Released claim ${claimId} as the BUSINESS convergence of failed turn ${reservation.turn_id}: ${why}`,
|
|
444
|
+
tags: ['loops', 'reconcile', 'claim-release', 'effects-boundary'],
|
|
445
|
+
assignment_id: reservation.child_ids.assignment_id,
|
|
446
|
+
run_id: run.id,
|
|
447
|
+
claim_id: claimId,
|
|
448
|
+
status_reason: 'turn_failure_business_release',
|
|
449
|
+
}, cwd);
|
|
450
|
+
}
|
|
451
|
+
catch { /* observability best-effort — never undo the release */ }
|
|
452
|
+
return true;
|
|
453
|
+
};
|
|
454
|
+
const loop = getLoop(reservation.loop_id, cwd);
|
|
455
|
+
if (!loop) {
|
|
456
|
+
// No loop to record on — do NOT strip the claim on evidence we cannot read.
|
|
457
|
+
// A genuinely deleted loop leaves the claim to the staleness sweep.
|
|
458
|
+
return { converged: false, claim_released: false, reason: `loop ${reservation.loop_id} not found — claim retained for the staleness sweep` };
|
|
459
|
+
}
|
|
460
|
+
const authoritativeClaimId = loadAssignment(reservation.child_ids.assignment_id, cwd)?.claim_id ?? reservation.claim_id;
|
|
461
|
+
// Terminal loop: the business story is already over — releasing is pure
|
|
462
|
+
// idempotent cleanup, mirroring reconcileTurn's terminal early-return.
|
|
463
|
+
if (LOOP_TERMINAL.has(loop.status)) {
|
|
464
|
+
const released = releaseAudited(authoritativeClaimId, `loop already ${loop.status}`);
|
|
465
|
+
return { converged: true, claim_released: released, reason: `loop already ${loop.status} — release-only cleanup` };
|
|
466
|
+
}
|
|
467
|
+
const slot = loop.slots.find((s) => s.slot_id === reservation.slot_id);
|
|
468
|
+
if (!slot) {
|
|
469
|
+
return { converged: false, claim_released: false, reason: `slot ${reservation.slot_id} not in loop ${reservation.loop_id} — claim retained` };
|
|
470
|
+
}
|
|
471
|
+
// Superseded: a NEWER turn owns this slot. Claim reuse across rounds is real
|
|
472
|
+
// (trp_e824d2af — round 2 rode round 1's claim), so releasing here would strip
|
|
473
|
+
// the LIVE attempt. Nothing to do for the dead round; the live one converges it.
|
|
474
|
+
if (slot.current_turn_id !== undefined && slot.current_turn_id !== reservation.turn_id) {
|
|
475
|
+
return { converged: true, claim_released: false, reason: `turn ${reservation.turn_id} superseded by ${slot.current_turn_id} — claim belongs to the live turn` };
|
|
476
|
+
}
|
|
477
|
+
// Record the business failure on the loop FIRST (crash-atomic WAL), then
|
|
478
|
+
// release. A slot already terminal means a prior pass recorded it — skip the
|
|
479
|
+
// double-record, still release (idempotent).
|
|
480
|
+
const slotTerminal = slot.status === 'done' || slot.status === 'failed' || slot.status === 'cancelled';
|
|
481
|
+
if (!slotTerminal) {
|
|
482
|
+
try {
|
|
483
|
+
complete_turn({
|
|
484
|
+
id: loop.id,
|
|
485
|
+
slot_id: slot.slot_id,
|
|
486
|
+
actor,
|
|
487
|
+
outcome: 'failed',
|
|
488
|
+
failure_reason: transportReason,
|
|
489
|
+
}, cwd);
|
|
490
|
+
}
|
|
491
|
+
catch (err) {
|
|
492
|
+
return { converged: false, claim_released: false, reason: `complete_turn failed: ${err instanceof Error ? err.message : String(err)} — claim retained, next pass retries` };
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
const released = releaseAudited(authoritativeClaimId, transportReason);
|
|
496
|
+
return {
|
|
497
|
+
converged: true,
|
|
498
|
+
claim_released: released,
|
|
499
|
+
reason: slotTerminal
|
|
500
|
+
? `turn ${reservation.turn_id} failure already recorded — release re-attempted`
|
|
501
|
+
: `turn ${reservation.turn_id} failure recorded on loop ${loop.id}; claim ${released ? 'released' : 'not active'}`,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
369
504
|
//# sourceMappingURL=reconcile-turn.js.map
|
package/dist/core/worktree.js
CHANGED
|
@@ -515,6 +515,24 @@ export function resetWorktreeToRef(worktreePath, ref) {
|
|
|
515
515
|
if (!res.ok) {
|
|
516
516
|
return { ok: false, stderr: res.stderr };
|
|
517
517
|
}
|
|
518
|
+
// trp_e824d2af — LANE-RESULT.json is a TERMINAL SIGNAL, and `reset --hard`
|
|
519
|
+
// does not touch untracked files: left at the root, the PRIOR turn's result
|
|
520
|
+
// reads as the NEXT turn's completion (dispatch_status declared a
|
|
521
|
+
// freshly-spawned round 2 "worker reported done" with round 1's verdict,
|
|
522
|
+
// observed live 2026-08-02). Archive it into the worktree's .brainclaw/
|
|
523
|
+
// sidecar — preserved for forensics, out of the signal path, and already
|
|
524
|
+
// excluded from the residue check below. Best-effort: an archive failure
|
|
525
|
+
// falls through to the residue check, which then names the file loudly
|
|
526
|
+
// instead of passing it silently.
|
|
527
|
+
try {
|
|
528
|
+
const laneResultPath = path.join(worktreePath, 'LANE-RESULT.json');
|
|
529
|
+
if (fs.existsSync(laneResultPath)) {
|
|
530
|
+
const archiveDir = path.join(worktreePath, '.brainclaw');
|
|
531
|
+
fs.mkdirSync(archiveDir, { recursive: true });
|
|
532
|
+
fs.renameSync(laneResultPath, path.join(archiveDir, `LANE-RESULT.prev-${fs.statSync(laneResultPath).mtimeMs.toFixed(0)}.json`));
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
catch { /* best-effort — the residue check below surfaces what remains */ }
|
|
518
536
|
// `reset --hard` realigns HEAD + tracked files, but leaves UNTRACKED residue
|
|
519
537
|
// from a prior use of the worktree — files the worker could still compile or
|
|
520
538
|
// test against even though they don't exist at the pinned ref (codex r3).
|
|
@@ -555,6 +573,32 @@ export function resetWorktreeToRef(worktreePath, ref) {
|
|
|
555
573
|
}
|
|
556
574
|
return { ok: true, stderr: '' };
|
|
557
575
|
}
|
|
576
|
+
/**
|
|
577
|
+
* trp_72b4e9b3 — on worktree ADOPTION, re-stamp the sidecar's base anchor.
|
|
578
|
+
*
|
|
579
|
+
* The sidecar's `base_ref_sha` (trp#926) is the anchor `commits_ahead` counts
|
|
580
|
+
* from. An adopted worktree keeps its ROUND-1 creation stamp, so round 2's git
|
|
581
|
+
* evidence counted commits since the previous round's base — observed live as
|
|
582
|
+
* a nonsensical `commits_ahead: 2` on a freshly re-pointed worktree. Best-effort:
|
|
583
|
+
* a missing/unreadable sidecar (hand-made worktree) is left untouched.
|
|
584
|
+
*/
|
|
585
|
+
function refreshSidecarBaseSha(worktreePath, mainWorktreePath, baseRef) {
|
|
586
|
+
const sidecarPath = path.join(worktreePath, '.brainclaw-worktree.json');
|
|
587
|
+
try {
|
|
588
|
+
if (!fs.existsSync(sidecarPath))
|
|
589
|
+
return;
|
|
590
|
+
const meta = JSON.parse(fs.readFileSync(sidecarPath, 'utf-8'));
|
|
591
|
+
const rev = runGit(['rev-parse', baseRef], mainWorktreePath);
|
|
592
|
+
meta.base_ref = baseRef;
|
|
593
|
+
if (rev.ok)
|
|
594
|
+
meta.base_ref_sha = rev.stdout.trim();
|
|
595
|
+
else
|
|
596
|
+
delete meta.base_ref_sha;
|
|
597
|
+
meta.adopted_at = new Date().toISOString();
|
|
598
|
+
fs.writeFileSync(sidecarPath, JSON.stringify(meta, null, 2));
|
|
599
|
+
}
|
|
600
|
+
catch { /* best-effort — stale anchor is observability-only */ }
|
|
601
|
+
}
|
|
558
602
|
/**
|
|
559
603
|
* Detects whether multiple distinct brainclaw sessions are using the same
|
|
560
604
|
* physical worktree directory (shared-checkout risk).
|
|
@@ -765,6 +809,68 @@ export function createWorktree(mainWorktreePath, branchName, options = {}) {
|
|
|
765
809
|
}
|
|
766
810
|
const targetPath = resolveWorktreePath(mainWorktreePath, branchName);
|
|
767
811
|
if (fs.existsSync(targetPath)) {
|
|
812
|
+
// trp_72b4e9b3 — the path derives from the branch name, and loop-scoped
|
|
813
|
+
// dispatches derive the SAME branch every round (feat/review-loop-<lop>).
|
|
814
|
+
// A fresh claim on round 2 therefore collided here and the whole spawn
|
|
815
|
+
// failed (spawn_no_worktree) — worse, the claim persisted without a
|
|
816
|
+
// worktree and wedged every later dispatch on the scope. When the existing
|
|
817
|
+
// path is a REGISTERED worktree of this repo checked out on EXACTLY the
|
|
818
|
+
// requested branch, ADOPT it: honor the reused-branch contract first
|
|
819
|
+
// (can_2e282880 — never destroy unharvested commits unless the caller
|
|
820
|
+
// explicitly pinned a reset), then re-point it to the base. Anything else
|
|
821
|
+
// at the path (foreign dir, other branch) still refuses loudly.
|
|
822
|
+
const attachedPath = findWorktreePathForBranch(listWorktrees(mainWorktreePath), branchName);
|
|
823
|
+
const sameTarget = attachedPath !== undefined && (process.platform === 'win32'
|
|
824
|
+
? path.resolve(attachedPath).toLowerCase() === path.resolve(targetPath).toLowerCase()
|
|
825
|
+
: path.resolve(attachedPath) === path.resolve(targetPath));
|
|
826
|
+
if (sameTarget) {
|
|
827
|
+
// Resolve the base to a SHA in the MAIN repo first: resetWorktreeToRef
|
|
828
|
+
// runs inside the worktree, where a symbolic ref like "HEAD" resolves to
|
|
829
|
+
// the WORKTREE's own tip — a silent no-op reset onto the stale round.
|
|
830
|
+
const requestedBase = options.baseRef ?? 'HEAD';
|
|
831
|
+
const baseRev = runGit(['rev-parse', requestedBase], mainWorktreePath);
|
|
832
|
+
if (!baseRev.ok) {
|
|
833
|
+
throw new Error(`Cannot adopt worktree ${targetPath}: base ref ${requestedBase} does not resolve: ${baseRev.stderr.trim()}`);
|
|
834
|
+
}
|
|
835
|
+
const adoptBase = baseRev.stdout.trim();
|
|
836
|
+
// Review PR#167 P1 — TRACKED dirt is unharvested work, unconditionally.
|
|
837
|
+
// A sandboxed codex CANNOT commit (.git read-only): its entire review
|
|
838
|
+
// output lives as staged/unstaged edits the coordinator harvests via
|
|
839
|
+
// `git diff HEAD`. The commit guard below never sees those, and the
|
|
840
|
+
// hard reset would destroy them silently — even under an explicit
|
|
841
|
+
// reset pin, because the pin means "start from this base", never
|
|
842
|
+
// "discard a worker's unharvested output". Untracked files are fine:
|
|
843
|
+
// the reset leaves them and resetWorktreeToRef's residue check governs.
|
|
844
|
+
const dirt = runGit(['status', '--porcelain=v1', '-z', '--untracked-files=no'], targetPath);
|
|
845
|
+
if (!dirt.ok) {
|
|
846
|
+
throw new Error(`Cannot adopt worktree ${targetPath}: dirty-state check failed: ${dirt.stderr.trim()}`);
|
|
847
|
+
}
|
|
848
|
+
if (dirt.stdout.length > 0) {
|
|
849
|
+
const files = dirt.stdout.split('\0').filter(Boolean).map((e) => e.slice(3)).slice(0, 5).join(', ');
|
|
850
|
+
throw new Error(`Refusing to adopt worktree ${targetPath}: it has uncommitted TRACKED changes (${files}) — a sandboxed worker's ` +
|
|
851
|
+
`unharvested output. Harvest the diff first (git diff HEAD in that worktree) or remove the worktree.`);
|
|
852
|
+
}
|
|
853
|
+
if (!options.resetExistingBranch) {
|
|
854
|
+
const ahead = runGit(['rev-list', '--count', `${adoptBase}..${branchName}`], mainWorktreePath);
|
|
855
|
+
const aheadCount = ahead.ok ? parseInt(ahead.stdout.trim(), 10) : NaN;
|
|
856
|
+
if (!Number.isFinite(aheadCount)) {
|
|
857
|
+
throw new Error(`Cannot assess divergence of existing worktree branch ${branchName} vs ${adoptBase}: ${ahead.stderr.trim()}`);
|
|
858
|
+
}
|
|
859
|
+
if (aheadCount > 0) {
|
|
860
|
+
const commits = runGit(['log', '--oneline', '-n', '5', `${adoptBase}..${branchName}`], mainWorktreePath);
|
|
861
|
+
throw new Error(`Refusing to adopt worktree ${targetPath}: branch ${branchName} has ${aheadCount} commit(s) not on ${adoptBase} (unharvested work). ` +
|
|
862
|
+
`Harvest/merge or remove the worktree first. Divergent commits:\n${commits.stdout.trim()}`);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
const reset = resetWorktreeToRef(targetPath, adoptBase);
|
|
866
|
+
if (!reset.ok) {
|
|
867
|
+
throw new Error(`Worktree path already exists and could not be adopted (reset to ${adoptBase} failed: ${reset.stderr.trim()}). ` +
|
|
868
|
+
`Remove it first with 'brainclaw worktree remove'.`);
|
|
869
|
+
}
|
|
870
|
+
logger.warn(`[worktree] adopted existing worktree ${targetPath} (branch ${branchName}) and re-pointed it to ${adoptBase}`);
|
|
871
|
+
refreshSidecarBaseSha(targetPath, mainWorktreePath, adoptBase);
|
|
872
|
+
return targetPath;
|
|
873
|
+
}
|
|
768
874
|
throw new Error(`Worktree path already exists: ${targetPath}. Remove it first with 'brainclaw worktree remove'.`);
|
|
769
875
|
}
|
|
770
876
|
// Ensure parent directory exists
|
package/dist/facts.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Generated by scripts/emit-site-facts.mjs at build time. Do not edit manually.
|
|
2
|
-
// Source: brainclaw v1.20.
|
|
2
|
+
// Source: brainclaw v1.20.3 on 2026-08-03T12:57:46.375Z
|
|
3
3
|
export const FACTS = {
|
|
4
|
-
"version": "1.20.
|
|
5
|
-
"generated_at": "2026-08-
|
|
4
|
+
"version": "1.20.3",
|
|
5
|
+
"generated_at": "2026-08-03T12:57:46.375Z",
|
|
6
6
|
"tools": {
|
|
7
7
|
"count": 67,
|
|
8
8
|
"published_count": 65,
|
|
@@ -474,7 +474,7 @@ export const FACTS = {
|
|
|
474
474
|
},
|
|
475
475
|
"bench": {
|
|
476
476
|
"schema": "brainclaw.bench.v1",
|
|
477
|
-
"generated_at": "2026-08-
|
|
477
|
+
"generated_at": "2026-08-03T12:57:44.250Z",
|
|
478
478
|
"node_version": "v24.18.0",
|
|
479
479
|
"platform": "linux-x64",
|
|
480
480
|
"repeats": 3,
|
|
@@ -483,7 +483,7 @@ export const FACTS = {
|
|
|
483
483
|
"name": "cold_onboard",
|
|
484
484
|
"volume": "empty",
|
|
485
485
|
"description": "fresh machine → init → first useful context. Baseline for time-to-first-value.",
|
|
486
|
-
"duration_ms_median":
|
|
486
|
+
"duration_ms_median": 79,
|
|
487
487
|
"payload_chars_median": 1640,
|
|
488
488
|
"payload_tokens_est_median": 410
|
|
489
489
|
},
|
|
@@ -491,7 +491,7 @@ export const FACTS = {
|
|
|
491
491
|
"name": "warm_work",
|
|
492
492
|
"volume": "medium",
|
|
493
493
|
"description": "bclaw_work consult over a real-shaped store (~200 plans / 500 handoffs / 450 claims).",
|
|
494
|
-
"duration_ms_median":
|
|
494
|
+
"duration_ms_median": 129,
|
|
495
495
|
"payload_chars_median": 2626,
|
|
496
496
|
"payload_tokens_est_median": 657
|
|
497
497
|
},
|
|
@@ -499,7 +499,7 @@ export const FACTS = {
|
|
|
499
499
|
"name": "first_edit",
|
|
500
500
|
"volume": "medium",
|
|
501
501
|
"description": "code_find + code_brief on the fresh-agent path (missing index, first touch).",
|
|
502
|
-
"duration_ms_median":
|
|
502
|
+
"duration_ms_median": 13,
|
|
503
503
|
"payload_chars_median": 499,
|
|
504
504
|
"payload_tokens_est_median": 125
|
|
505
505
|
}
|
package/dist/facts.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.20.
|
|
3
|
-
"generated_at": "2026-08-
|
|
2
|
+
"version": "1.20.3",
|
|
3
|
+
"generated_at": "2026-08-03T12:57:46.375Z",
|
|
4
4
|
"tools": {
|
|
5
5
|
"count": 67,
|
|
6
6
|
"published_count": 65,
|
|
@@ -472,7 +472,7 @@
|
|
|
472
472
|
},
|
|
473
473
|
"bench": {
|
|
474
474
|
"schema": "brainclaw.bench.v1",
|
|
475
|
-
"generated_at": "2026-08-
|
|
475
|
+
"generated_at": "2026-08-03T12:57:44.250Z",
|
|
476
476
|
"node_version": "v24.18.0",
|
|
477
477
|
"platform": "linux-x64",
|
|
478
478
|
"repeats": 3,
|
|
@@ -481,7 +481,7 @@
|
|
|
481
481
|
"name": "cold_onboard",
|
|
482
482
|
"volume": "empty",
|
|
483
483
|
"description": "fresh machine → init → first useful context. Baseline for time-to-first-value.",
|
|
484
|
-
"duration_ms_median":
|
|
484
|
+
"duration_ms_median": 79,
|
|
485
485
|
"payload_chars_median": 1640,
|
|
486
486
|
"payload_tokens_est_median": 410
|
|
487
487
|
},
|
|
@@ -489,7 +489,7 @@
|
|
|
489
489
|
"name": "warm_work",
|
|
490
490
|
"volume": "medium",
|
|
491
491
|
"description": "bclaw_work consult over a real-shaped store (~200 plans / 500 handoffs / 450 claims).",
|
|
492
|
-
"duration_ms_median":
|
|
492
|
+
"duration_ms_median": 129,
|
|
493
493
|
"payload_chars_median": 2626,
|
|
494
494
|
"payload_tokens_est_median": 657
|
|
495
495
|
},
|
|
@@ -497,7 +497,7 @@
|
|
|
497
497
|
"name": "first_edit",
|
|
498
498
|
"volume": "medium",
|
|
499
499
|
"description": "code_find + code_brief on the fresh-agent path (missing index, first touch).",
|
|
500
|
-
"duration_ms_median":
|
|
500
|
+
"duration_ms_median": 13,
|
|
501
501
|
"payload_chars_median": 499,
|
|
502
502
|
"payload_tokens_est_median": 125
|
|
503
503
|
}
|
|
@@ -8,6 +8,46 @@ guarantees this changelog follows.
|
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
11
|
+
## [1.20.3] — 2026-08-03
|
|
12
|
+
|
|
13
|
+
**Changed — `bclaw_dispatch_status` diagnosis values under the fs-activity veto (#170)**
|
|
14
|
+
- A dead tracked pid combined with FRESH filesystem activity (log/worktree
|
|
15
|
+
mtime within the activity window) now yields `diagnosis.health: "healthy"`
|
|
16
|
+
with a "worker is writing" summary — previously this combination could
|
|
17
|
+
reach the `silent_death` branch and recommend "cancel + reroute" against a
|
|
18
|
+
live worker. Value change only on `diagnosis.health` /
|
|
19
|
+
`diagnosis.recommended_next_action` for that evidence combination; no field
|
|
20
|
+
added/removed, no inputSchema change. Consumers gating on
|
|
21
|
+
`health === "silent_death"` see strictly FEWER (more accurate) firings.
|
|
22
|
+
- No MCP contract change from pln#644 (#171): the `review_turn_not_converged`
|
|
23
|
+
warning lives on the CLI harvest surface (`brainclaw harvest` text/`--json`
|
|
24
|
+
output), not on an MCP tool response.
|
|
25
|
+
|
|
26
|
+
## [1.20.2] — 2026-08-03
|
|
27
|
+
|
|
28
|
+
**Added — `lane_result_stale` on the dispatch_status runtime snapshot (#167)**
|
|
29
|
+
- Additive sibling of `runtime.lane_result`: a LANE-RESULT.json found at the
|
|
30
|
+
worktree root that belongs to a DIFFERENT assignment (a prior turn in a
|
|
31
|
+
reused worktree) now lands here — `{ assignment_id, status, summary }` —
|
|
32
|
+
and is NEVER treated as this dispatch's terminal signal. `lane_result`
|
|
33
|
+
itself is unchanged in shape; it is simply no longer populated by a foreign
|
|
34
|
+
file (that population was the trp_e824d2af defect, not a contract).
|
|
35
|
+
|
|
36
|
+
**Added — new audited runtime-event reason (#166)**
|
|
37
|
+
- `status_reason: "turn_failure_business_release"` on `run_failed` events:
|
|
38
|
+
the business release of a turn-owned lane's claim after a transport
|
|
39
|
+
failure, emitted by the loop convergence (replaces the GC cascade's
|
|
40
|
+
`gc_cascade_release_on_failure` for turn-owned lanes only; non-turn-owned
|
|
41
|
+
runs keep emitting the GC reason).
|
|
42
|
+
|
|
43
|
+
**Changed — stale-warnings aggregate text now carries ids (#167)**
|
|
44
|
+
- The `stale_warnings_aggregate` string embeds the folded item ids grouped by
|
|
45
|
+
entity (cap 8 per entity + overflow note) instead of recommending
|
|
46
|
+
`bclaw_find(status:'stale')`, which the engine cannot resolve. Prose-only;
|
|
47
|
+
no structured field changed.
|
|
48
|
+
- Read contract only across all three: no tool added/removed/renamed, no
|
|
49
|
+
inputSchema change, no surface-fingerprint movement.
|
|
50
|
+
|
|
11
51
|
## [1.20.1] — 2026-08-02
|
|
12
52
|
|
|
13
53
|
**Changed — `generated_surfaces_stale` recovery data is now true, and structured (#163)**
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Autonomy-safety regression pack (pln#621)
|
|
2
|
+
|
|
3
|
+
Every scenario below is a REAL incident from the dogfood store (trap ids), mapped
|
|
4
|
+
to the non-destruction invariant it taught us and to the test that now pins it.
|
|
5
|
+
The acceptance bar: **zero false destructive verdicts on this corpus** — a
|
|
6
|
+
coordination engine may be wrong about liveness, but it must never destroy work
|
|
7
|
+
(kill a working agent, reset an unharvested diff, strand or steal a claim,
|
|
8
|
+
double-apply a verdict) on ambiguous evidence.
|
|
9
|
+
|
|
10
|
+
Classification (step 3 of the plan): ✅ pinned = trap resolved with a
|
|
11
|
+
counterfactual/regression test · 📋 mitigated = documented operator rule, not
|
|
12
|
+
engine-enforceable · 🔴 red = open defect → the only candidates for new
|
|
13
|
+
implementation work.
|
|
14
|
+
|
|
15
|
+
Baseline corpus: snapshot `2026-08-03T09-38-35-129Z`
|
|
16
|
+
(hash `399599d1…`, see docs/playbooks/store-snapshot.md). Shape fixtures for
|
|
17
|
+
synthetic scenario stores: `tests/fixtures/store-corpus/`.
|
|
18
|
+
|
|
19
|
+
## Kill / reroute — liveness verdicts must never destroy work
|
|
20
|
+
|
|
21
|
+
| Incident | Invariant | Status |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| pln#520 — 6 workers killed on a dead WRAPPER pid, they committed 4-7 min later | Commits ahead + clean tree ⇒ verdict "harvest it", never kill/reroute, even with a dead pid | ✅ `dispatch-status.test.ts` "never kill-and-reroute" |
|
|
24
|
+
| pln#520 variant — dead pid but the worker is WRITING (logs/worktree mtime fresh) | Fresh fs activity ⇒ the recommendation never contains ANY destructive instruction (kill/cancel/reroute) and the verdict is never silent_death | ✅ `dispatch-status.test.ts` "never says kill" — building this pin found a REAL gap: the silent_death branch skipped the fs-activity veto and advised "cancel + reroute" on a writing worker; fixed with the pin (review of PR #170) |
|
|
25
|
+
| pln#527 — stale heartbeat during a long single operation | Stale heartbeat + fresh fs activity ⇒ "working, not stalled" (no fail inference) | ✅ `agentrun-reconciler.test.ts` heartbeat/fs-veto cases |
|
|
26
|
+
| trp#292 — spawn deaths mis-diagnosed; stderr never read | Known fatal boot signatures in stderr yield a TARGETED diagnosis instead of a generic silent_death | ✅ `fs-activity-liveness.test.ts` recognizeStderrSignature cases (narrower than "every failure verdict carries the tail" — the reconciler's logTailSuffix covers the fail-inference side) |
|
|
27
|
+
| Operator rule — never blanket-kill agents by process name (IDE runs them too) | Kill only pids from `agent_run.pid` cross-checked with `launched_at` | 📋 mitigated (operator rule; engine cannot see foreign processes) |
|
|
28
|
+
|
|
29
|
+
## Harvest — verdicts and results are exactly-once and owned
|
|
30
|
+
|
|
31
|
+
| Incident | Invariant | Status |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| trp_e824d2af — round 1's LANE-RESULT read as round 2's terminal signal | A lane result is terminal ONLY for the assignment named in its own `assignment_id`; foreign ⇒ `lane_result_stale` | ✅ `dispatch-status-lane-result.test.ts` |
|
|
34
|
+
| Same trap, write side — stale terminal file survives worktree reuse | `resetWorktreeToRef` archives the prior LANE-RESULT out of the signal path | ✅ `worktree.test.ts` re-dispatch hygiene |
|
|
35
|
+
| Double harvest of one lane | Re-ingesting the same LANE-RESULT is an idempotent no-op (marker) | ✅ `lane-result-harvest.test.ts` "skips on re-run" |
|
|
36
|
+
| Double integration of a turn-owned approve lane | Exactly-once finalization — no duplicate verdict, loop stays terminal | ✅ `loops-pr3a-harvest-reconcile.test.ts` T7 |
|
|
37
|
+
| pln#638 1c — CLI-harvested ideation lane never converged its loop | The ideation closer fires on the CLI harvest path too | ✅ `lane-harvest-cli-convergence.test.ts` |
|
|
38
|
+
| 2026-08-02/03 ×2 — CLI-harvested REVIEW lane (file protocol, no turn keys) left its loop turn open; coordinator converged manually both times | A review lane harvested by assignment_id converges its turn on the report path (approve → reconcileTurn) or warns loudly naming the recovery (`review_turn_not_converged`), never a silent stall | ✅ `loops-pr3a-harvest-reconcile.test.ts` pln#644 suite (counterfactual: 8 tests red pre-fix) |
|
|
39
|
+
|
|
40
|
+
## Worktrees — the workspace carries unharvested work
|
|
41
|
+
|
|
42
|
+
| Incident | Invariant | Status |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| trp_72b4e9b3 — round-2 path collision wedged the scope | Same-branch registered worktree is ADOPTED, not refused | ✅ `worktree.test.ts` adoption |
|
|
45
|
+
| PR#167 review P1 — adoption would hard-reset a sandboxed worker's uncommitted output | ANY tracked dirt refuses adoption, unconditionally (a reset pin is not a discard order) | ✅ `worktree.test.ts` tracked-dirt refusal |
|
|
46
|
+
| can_2e282880 — branch reuse ran a worker on an April base / would destroy commits | Unharvested commits refuse silent reuse/adoption | ✅ `worktree.test.ts` unharvested-commits guards |
|
|
47
|
+
| trp (2026-08-01) — `git worktree remove` follows Windows junctions, wiped main-repo node_modules | brainclaw's own removal detaches junctions first; raw git remove remains dangerous | ✅ engine path (`detachWorktreeJunctions`) · 📋 the raw-git variant stays an operator rule |
|
|
48
|
+
| trp#950 — two >48-char scopes collapsed to one branch slug | Distinct scopes yield distinct valid slugs | ✅ `worktree.test.ts` slug suite |
|
|
49
|
+
| trp#926 — a squash-merged lane read as "worker delivered" again (`commits_ahead` counted the ancestry, not the patches) | Squash-merged work counts 0 via patch-id refinement — no false delivered verdict, no false GC | ✅ `worktree-squash-aware.test.ts` (row added on review of PR #170 — the audit found the incident missing from the catalog) |
|
|
50
|
+
| Duplicate spawn — a respawned assignment ran beside its "dead" predecessor in one worktree | Two live workers must never share a worktree unknowingly | 📋 mitigated (heartbeat-file coordination; pln#630 launch fence covers the turn-owned path: reservation + grant make a duplicate launch DENIED) — engine-wide guard tracked in pln#644 classification notes |
|
|
51
|
+
|
|
52
|
+
## Claims / cascades — advisory locks that cannot lie or leak
|
|
53
|
+
|
|
54
|
+
| Incident | Invariant | Status |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| trp#433 — dead runs left active claims accumulating | Failed runs release their claim (GC cascade, non-turn-owned) | ✅ `agentrun-reconciler.test.ts` trp#433 cases |
|
|
57
|
+
| pln#638 6c — transport completion released claims / triggered reviews | Transport completion never RELEASES the claim (`inferred_completed` with the claim still active) | ✅ `agentrun-reconciler.test.ts` 6c pin — narrower than the full boundary: the no-review-trigger half was audited true in the 6c pass but has no dedicated pin (honest gap, not a defect) |
|
|
58
|
+
| dec#151 — turn-owned failure released via transport GC | Turn-owned release is a LOOP business decision (recorded on the loop first, audited) | ✅ `agentrun-reconciler.test.ts` pln#641 cases |
|
|
59
|
+
| PR#166 review P1 — the promised lazy retry was unreachable (read paths skip terminal runs) | A stranded claim converges from a plain `bclaw_find(agent_run)` read | ✅ `agentrun-reconciler.test.ts` P1 cases incl. `listEntities` surface test |
|
|
60
|
+
| PR#166 review P1 round 2 — the release audit could lie under a concurrent release | `releaseClaimIfActive`: check+transition atomic; the event fires only for the call that transitioned | ✅ `loops-reconcile-turn.test.ts` atomic-contract cases |
|
|
61
|
+
| trp_72b4e9b3(2) — a worktree-less claim wedged all dispatch on its scope | Reuse heals the claim (provisions + patches under the store lock) | ✅ `worktree.test.ts` heal test — the concurrently-released-claim window is guarded by locked revalidation ('gone' → fresh-claim path, codex-verified control flow, PR #167) but is not injection-testable; the heal pin covers the wedge only |
|
|
62
|
+
| trp#928 — any caller could release any claim | Ownership-checked release; coordinator override is explicit + audited | ✅ claim auth suites |
|
|
63
|
+
| Claim reuse across review rounds (trp_e824d2af context) | A SUPERSEDED turn's convergence never releases the live turn's reused claim | ✅ `loops-reconcile-turn.test.ts` superseded case |
|
|
64
|
+
|
|
65
|
+
## Loop closure — verdicts recorded once, loops never wedged
|
|
66
|
+
|
|
67
|
+
| Incident | Invariant | Status |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| pln#630 — double reconcile spawned both rounds | Exactly-once iteration bump under the loop lock | ✅ `loops-reconcile-turn.test.ts` findings 1/2 |
|
|
70
|
+
| §13 R4 — completed+failed contradiction auto-accepted | Contradiction WITHHOLDS convergence, journals a conflict | ✅ `loops-reconcile-turn.test.ts` R4 cases |
|
|
71
|
+
| pln#639 — empty artifacts satisfied phase gates | Gates reject empty artifact bodies | ✅ gate suites |
|
|
72
|
+
| Stale/mismatched lane evidence converging the wrong attempt | Read-strict turn keys (turn_id+run_id+nonce) or no convergence | ✅ `loops-reconcile-turn.test.ts` read-strict case |
|
|
73
|
+
|
|
74
|
+
## Red defects opened by this classification
|
|
75
|
+
|
|
76
|
+
- **pln#644 — review-loop CLI-harvest turn convergence** *(RESOLVED)*: a review
|
|
77
|
+
lane delivered via the file protocol without turn keys was harvested into the
|
|
78
|
+
assignment but its loop turn stayed open silently; the coordinator converged
|
|
79
|
+
by hand twice on 2026-08-02/03 (loops `lop_626271ee10ad09d8`,
|
|
80
|
+
`lop_4d869568bd99ddc0`). Fixed at the CLI harvest site: the report path now
|
|
81
|
+
finalizes an APPROVE turn-owned lane via the same exactly-once
|
|
82
|
+
`reconcileTurn` that `--integrate` uses (evidence sourced from the lane keys
|
|
83
|
+
or the wrapper sentinel — read-strict untouched), and every other
|
|
84
|
+
non-converged case (request_changes, missing verdict, refused evidence)
|
|
85
|
+
emits a `review_turn_not_converged` warning naming the open turn and the
|
|
86
|
+
recovery, surfaced on both the CLI text and `--json` outputs. Pinned in
|
|
87
|
+
`loops-pr3a-harvest-reconcile.test.ts` (pln#644 suite, counterfactual-red).
|
|
88
|
+
|
|
89
|
+
Everything else in the corpus is pinned or explicitly an operator rule. New
|
|
90
|
+
incidents: add the trap, add the row, add the counterfactual test — in that
|
|
91
|
+
order.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Store snapshot — the snapshot-before-curation rule (pln#619)
|
|
2
|
+
|
|
3
|
+
The loaded dogfood store IS a corpus: months of real claims, loops, assignments,
|
|
4
|
+
journals and debris that no synthetic fixture reproduces. Every curation
|
|
5
|
+
(candidate triage, GC, migration, mass-ack) destroys part of it. The rule:
|
|
6
|
+
|
|
7
|
+
> **Take a snapshot before ANY curation of the store.** Cleanup must be
|
|
8
|
+
> reversible and traceable; the regression pack (pln#621) needs a stable
|
|
9
|
+
> baseline to reproduce metrics against.
|
|
10
|
+
|
|
11
|
+
## Tool
|
|
12
|
+
|
|
13
|
+
`scripts/store-snapshot.mjs` — zero dependencies, four commands:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
node scripts/store-snapshot.mjs create [--store <dir>] [--out <dir>] [--brainclaw-version <v>]
|
|
17
|
+
node scripts/store-snapshot.mjs verify --snapshot <dir>
|
|
18
|
+
node scripts/store-snapshot.mjs restore --snapshot <dir> --to <empty-dir>
|
|
19
|
+
node scripts/store-snapshot.mjs fixtures [--store <dir>] --out <dir>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
- **create** copies the store into `<out>/store/`, writes `manifest.json`
|
|
23
|
+
(schema version, timestamp, source path + git HEAD, per-directory totals,
|
|
24
|
+
per-entity counts, one deterministic `corpus_hash` over path+size+sha256 of
|
|
25
|
+
every file), then marks the snapshot files read-only. Default destination is
|
|
26
|
+
`~/.brainclaw/snapshots/<project>/<stamp>/` — deliberately OUTSIDE any git
|
|
27
|
+
repo: the store carries private coordination content and must never transit
|
|
28
|
+
through a public remote. The hash is computed on the COPY, so a live store
|
|
29
|
+
mutating mid-copy still yields an internally consistent manifest (for a
|
|
30
|
+
perfectly quiescent capture, snapshot while the MCP is idle).
|
|
31
|
+
- **verify** recomputes totals, entity counts and the corpus hash and compares
|
|
32
|
+
them to the manifest — immutability is checked, never assumed. Exit 1 on any
|
|
33
|
+
divergence.
|
|
34
|
+
- **restore** copies into an EMPTY target only (never in place), clears the
|
|
35
|
+
read-only bits, then verifies the restored tree against the manifest — the
|
|
36
|
+
pln#619 acceptance criterion ("a restored store reproduces the metrics")
|
|
37
|
+
executed on every restore.
|
|
38
|
+
- **fixtures** exports SHAPE summaries per entity collection (field names,
|
|
39
|
+
value types, presence ratios) and observed values for an ALLOWLIST of
|
|
40
|
+
enum-like field names only (status, type, severity, …). Free text and
|
|
41
|
+
identity values are never exported, whatever their length — the rule is
|
|
42
|
+
allowlist-only because two heuristics failed before it: a length gate leaked
|
|
43
|
+
the hostname and OS username, and its identity-field patch still leaked
|
|
44
|
+
short free text (adversarial review of PR #169 caught it in the shipped
|
|
45
|
+
fixtures). Committed under `tests/fixtures/store-corpus/` for the
|
|
46
|
+
regression pack.
|
|
47
|
+
|
|
48
|
+
The corpus hash serializes records NUL-delimited: a POSIX filename may legally
|
|
49
|
+
contain a newline, so newline-delimited records would let two distinct trees
|
|
50
|
+
share a preimage (same review, finding 2).
|
|
51
|
+
|
|
52
|
+
## Baseline of record
|
|
53
|
+
|
|
54
|
+
The pre-curation baseline of 2026-08-03 (brainclaw 1.20.2, store at ~1.9 GB /
|
|
55
|
+
12 121 files, 644 claims / 296 assignments / 243 loops):
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
~/.brainclaw/snapshots/shared_agent_memory_mvp/2026-08-03T09-53-14-067Z
|
|
59
|
+
corpus_hash 2732797349b1d61c38ba838f8d03ccba6fe901c755788ba212f0375db479094c
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
(The candidate-triage curation of 2026-08-03 was performed against the earlier
|
|
63
|
+
capture of the same morning; this retake follows the hash-encoding fix and
|
|
64
|
+
supersedes it.)
|
|
65
|
+
|
|
66
|
+
Restore-verified on capture day (hash + entity counts reproduced in an
|
|
67
|
+
isolated target). Any later "did the curation lose something?" question is
|
|
68
|
+
answered against this manifest.
|