brainclaw 1.12.0 → 1.13.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 +32 -0
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +9 -2
- package/dist/commands/claim-resource.js +1 -0
- package/dist/commands/estimation-report.js +1 -1
- package/dist/commands/harvest.js +30 -22
- package/dist/commands/mcp.js +279 -44
- package/dist/commands/release-claim.js +21 -1
- package/dist/core/agent-capability.js +15 -4
- package/dist/core/agent-registry.js +7 -1
- package/dist/core/claims.js +160 -1
- package/dist/core/context.js +11 -4
- package/dist/core/dispatch-status.js +113 -7
- package/dist/core/entity-operations.js +51 -3
- package/dist/core/loops/store.js +33 -0
- package/dist/core/reputation.js +18 -0
- package/dist/core/schema.js +7 -0
- package/dist/core/worktree.js +146 -22
- package/dist/facts.js +36 -3
- package/dist/facts.json +35 -2
- package/docs/mcp-schema-changelog.md +7 -2
- package/package.json +6 -4
package/dist/core/claims.js
CHANGED
|
@@ -153,9 +153,15 @@ function assertReleaseOwnership(claim, auth) {
|
|
|
153
153
|
return { overrideUsed: false };
|
|
154
154
|
if (auth.override)
|
|
155
155
|
return { overrideUsed: true };
|
|
156
|
+
// pln#607 rule + trp#928 — the error must be executable as-is: the caller
|
|
157
|
+
// should be able to copy the coordinator_override:true param straight from the
|
|
158
|
+
// message into their next bclaw_release_claim call. "Coordinator-level callers
|
|
159
|
+
// may release with override" was diagnostically useless before — no param name,
|
|
160
|
+
// no path forward. Ghost claim clm_ed9b8386 stayed active for weeks because
|
|
161
|
+
// this error was raised, swallowed by a best-effort catch, and never surfaced.
|
|
156
162
|
throw new Error(`claim '${claim.id}' is held by '${claim.agent}'${claim.session_id ? ` (session ${claim.session_id})` : ''}; `
|
|
157
163
|
+ `caller '${auth.agent ?? auth.agent_id ?? auth.session_id ?? 'unknown'}' does not own it. `
|
|
158
|
-
+
|
|
164
|
+
+ `Retry with coordinator_override:true (requires trusted+ trust level; the release is audited).`);
|
|
159
165
|
}
|
|
160
166
|
function auditReleaseOverride(claim, auth, cwd) {
|
|
161
167
|
appendAuditEntry({
|
|
@@ -184,6 +190,43 @@ export function releaseClaim(id, cwd, auth) {
|
|
|
184
190
|
}
|
|
185
191
|
return released;
|
|
186
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* Mark an active claim as `stale` — a distinct terminal state from `released`
|
|
195
|
+
* used when a claim is being torn down because its owner is gone (session
|
|
196
|
+
* expired, worker died, coordinator abandoned the lane). Same ownership rules
|
|
197
|
+
* as releaseClaim (trusted+ coordinators may override with audit).
|
|
198
|
+
*
|
|
199
|
+
* trp#928 — the `active → stale` transition documented on the entity registry
|
|
200
|
+
* had no imperative path before; callers had to fall back to mass sweeps
|
|
201
|
+
* (`expireStaleActiveClaims`) or write status directly. Now bclaw_transition
|
|
202
|
+
* (entity=claim, to='stale') reaches this function via entity-operations.
|
|
203
|
+
*/
|
|
204
|
+
export function markClaimStale(id, cwd, auth) {
|
|
205
|
+
let overrideUsed = false;
|
|
206
|
+
const staled = mutate({ cwd }, () => {
|
|
207
|
+
const claim = loadClaim(id, cwd);
|
|
208
|
+
overrideUsed = assertReleaseOwnership(claim, auth).overrideUsed;
|
|
209
|
+
claim.status = 'stale';
|
|
210
|
+
claim.released_at = nowISO();
|
|
211
|
+
saveClaimUnlocked(claim, cwd);
|
|
212
|
+
return claim;
|
|
213
|
+
});
|
|
214
|
+
appendAuditEntry({
|
|
215
|
+
actor: staled.agent,
|
|
216
|
+
actor_id: staled.agent_id,
|
|
217
|
+
action: 'release_claim',
|
|
218
|
+
item_id: staled.id,
|
|
219
|
+
item_type: 'claim',
|
|
220
|
+
scope: staled.scope,
|
|
221
|
+
session_id: staled.session_id,
|
|
222
|
+
host_id: staled.host_id,
|
|
223
|
+
after: { status: 'stale' },
|
|
224
|
+
}, cwd);
|
|
225
|
+
if (overrideUsed && auth) {
|
|
226
|
+
auditReleaseOverride(staled, auth, cwd);
|
|
227
|
+
}
|
|
228
|
+
return staled;
|
|
229
|
+
}
|
|
187
230
|
/**
|
|
188
231
|
* Release a claim and optionally cascade the status to its linked plan.
|
|
189
232
|
*
|
|
@@ -295,6 +338,122 @@ export function isClaimExpired(claim) {
|
|
|
295
338
|
return false;
|
|
296
339
|
return new Date(claim.expires_at) < new Date();
|
|
297
340
|
}
|
|
341
|
+
/**
|
|
342
|
+
* Release every ACTIVE claim linked to a given target (plan / assignment / loop
|
|
343
|
+
* slot claim). trp#928 — the cascade must LOG per-claim (released or
|
|
344
|
+
* skipped+reason) so a silent ownership failure is observable at the harvest /
|
|
345
|
+
* loop-close boundary. Ownership follows the same ReleaseClaimAuth contract as
|
|
346
|
+
* releaseClaim: a system caller (auth undefined) bypasses the check; a caller
|
|
347
|
+
* with auth honors ownership + coordinator_override.
|
|
348
|
+
*/
|
|
349
|
+
export function releaseClaimsCascade(claimIds, options = {}) {
|
|
350
|
+
const entries = [];
|
|
351
|
+
// Deduplicate — callers may pass the same claim id via both an assignment and
|
|
352
|
+
// a slot; a duplicate would double-audit.
|
|
353
|
+
const seen = new Set();
|
|
354
|
+
for (const id of claimIds) {
|
|
355
|
+
if (!id || seen.has(id))
|
|
356
|
+
continue;
|
|
357
|
+
seen.add(id);
|
|
358
|
+
let claim;
|
|
359
|
+
try {
|
|
360
|
+
claim = loadClaim(id, options.cwd);
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
entries.push({ claim_id: id, released: false, reason: 'not_found' });
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
if (claim.status !== 'active') {
|
|
367
|
+
entries.push({ claim_id: id, released: false, reason: 'already_terminal' });
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
const rel = releaseClaimWithCascade(id, {
|
|
372
|
+
planStatus: options.planStatus,
|
|
373
|
+
cwd: options.cwd,
|
|
374
|
+
auth: options.auth,
|
|
375
|
+
});
|
|
376
|
+
const overrideUsed = options.auth?.override === true
|
|
377
|
+
&& !ownerMatches(claim, options.auth);
|
|
378
|
+
entries.push({
|
|
379
|
+
claim_id: id,
|
|
380
|
+
released: rel.claim.status === 'released',
|
|
381
|
+
reason: rel.claim.status === 'released' ? 'released' : 'error',
|
|
382
|
+
...(overrideUsed ? { override_used: true } : {}),
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
catch (err) {
|
|
386
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
387
|
+
// The specific ownership-check error thrown by assertReleaseOwnership
|
|
388
|
+
// gets its own reason bucket so a caller can surface an executable hint
|
|
389
|
+
// (retry with coordinator_override:true) instead of a generic error.
|
|
390
|
+
const reason = /coordinator_override/i.test(message) ? 'ownership_denied' : 'error';
|
|
391
|
+
entries.push({ claim_id: id, released: false, reason, error: message });
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
const released_count = entries.filter((e) => e.released).length;
|
|
395
|
+
const error_count = entries.filter((e) => e.reason === 'error' || e.reason === 'ownership_denied').length;
|
|
396
|
+
return {
|
|
397
|
+
entries,
|
|
398
|
+
released_count,
|
|
399
|
+
skipped_count: entries.length - released_count - error_count,
|
|
400
|
+
error_count,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Extract of assertReleaseOwnership's owner check without the throw. Used by
|
|
405
|
+
* releaseClaimsCascade to know whether a successful release used the override
|
|
406
|
+
* path (so it can be reported in the per-claim log).
|
|
407
|
+
*/
|
|
408
|
+
function ownerMatches(claim, auth) {
|
|
409
|
+
return ((auth.session_id !== undefined && claim.session_id !== undefined && auth.session_id === claim.session_id)
|
|
410
|
+
|| (auth.agent_id !== undefined && claim.agent_id !== undefined && auth.agent_id === claim.agent_id)
|
|
411
|
+
|| (auth.agent !== undefined && claim.agent === auth.agent));
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Find every active claim linked to a plan (via plan_id). Used by
|
|
415
|
+
* bclaw_transition(entity='plan', to='done') to implement the
|
|
416
|
+
* `release_linked_claims_if_last` cascade tag advertised on the entity
|
|
417
|
+
* registry (before trp#928 the tag was documentation only; the imperative
|
|
418
|
+
* cascade never ran).
|
|
419
|
+
*/
|
|
420
|
+
export function findActiveClaimsForPlan(planId, cwd) {
|
|
421
|
+
return listClaims(cwd).filter((c) => c.plan_id === planId && c.status === 'active');
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Emit a runtime event summarising a cascade release outcome, one entry per
|
|
425
|
+
* claim in the metadata. trp#928 — every cascade caller (plan-done,
|
|
426
|
+
* loop close, assignment→completed, harvest --integrate) MUST log per-claim
|
|
427
|
+
* status so silent failures are observable via bclaw_find(entity='agent_run')
|
|
428
|
+
* / bclaw_find(entity='claim'). Best-effort: never breaks the parent flow.
|
|
429
|
+
*/
|
|
430
|
+
export function logCascadeReleaseResult(input) {
|
|
431
|
+
const { released_count, skipped_count, error_count, entries } = input.cascade;
|
|
432
|
+
if (entries.length === 0)
|
|
433
|
+
return;
|
|
434
|
+
const text = `cascade[${input.trigger}]: released=${released_count} skipped=${skipped_count} errors=${error_count}`
|
|
435
|
+
+ ` — ${entries.map((e) => `${e.claim_id}:${e.reason}`).join(', ')}`;
|
|
436
|
+
try {
|
|
437
|
+
createRuntimeEvent({
|
|
438
|
+
agent: input.actor,
|
|
439
|
+
event_type: 'assignment_progress',
|
|
440
|
+
text,
|
|
441
|
+
tags: ['cascade', 'claim-release', input.trigger, ...(error_count > 0 ? ['ownership-issue'] : [])],
|
|
442
|
+
plan_id: input.plan_id,
|
|
443
|
+
assignment_id: input.assignment_id,
|
|
444
|
+
claim_id: input.claim_id,
|
|
445
|
+
metadata: {
|
|
446
|
+
trigger: input.trigger,
|
|
447
|
+
released_count,
|
|
448
|
+
skipped_count,
|
|
449
|
+
error_count,
|
|
450
|
+
entries,
|
|
451
|
+
...(input.loop_id ? { loop_id: input.loop_id } : {}),
|
|
452
|
+
},
|
|
453
|
+
}, input.cwd);
|
|
454
|
+
}
|
|
455
|
+
catch { /* best-effort logging — never break the parent flow */ }
|
|
456
|
+
}
|
|
298
457
|
/** Mark active claims past their expires_at as released. Returns count of expired claims. */
|
|
299
458
|
export function expireStaleActiveClaims(cwd) {
|
|
300
459
|
return mutate({ cwd }, () => {
|
package/dist/core/context.js
CHANGED
|
@@ -243,8 +243,12 @@ export function buildContext(options = {}) {
|
|
|
243
243
|
},
|
|
244
244
|
});
|
|
245
245
|
}
|
|
246
|
+
// pln#578 — single pending-candidates read, reused by the includePending
|
|
247
|
+
// items, scoped activity, and staleness passes below (same idiom as the
|
|
248
|
+
// pln#564 runtime-notes reuse: one scan, three consumers).
|
|
249
|
+
const pendingCandidates = listCandidates('pending', contextCwd);
|
|
246
250
|
if (options.includePending) {
|
|
247
|
-
for (const p of
|
|
251
|
+
for (const p of pendingCandidates) {
|
|
248
252
|
const meta = [`${p.type}`, `stars:${p.star_count ?? 0}`, `uses:${p.usage_count ?? 0}`];
|
|
249
253
|
if (p.author_id)
|
|
250
254
|
meta.push(`author_id:${p.author_id}`);
|
|
@@ -438,7 +442,7 @@ export function buildContext(options = {}) {
|
|
|
438
442
|
project,
|
|
439
443
|
state,
|
|
440
444
|
runtimeNotes,
|
|
441
|
-
pendingCandidates
|
|
445
|
+
pendingCandidates,
|
|
442
446
|
});
|
|
443
447
|
// Density reflects what the store HAS, not what the char budget keeps:
|
|
444
448
|
// classify pre-budget so a tight budget_tokens on a rich store never
|
|
@@ -585,7 +589,7 @@ export function buildContext(options = {}) {
|
|
|
585
589
|
// flows through the same surface.
|
|
586
590
|
let staleWarnings;
|
|
587
591
|
try {
|
|
588
|
-
const pendingCandidatesForStaleness =
|
|
592
|
+
const pendingCandidatesForStaleness = pendingCandidates;
|
|
589
593
|
// pln#564 step A — reuse the runtime notes already loaded above (line ~316)
|
|
590
594
|
// instead of a second unfiltered full scan of the runtime-note tree. On a
|
|
591
595
|
// store with thousands of notes that 2nd scan dominated buildContext cost
|
|
@@ -669,7 +673,10 @@ export function buildContext(options = {}) {
|
|
|
669
673
|
: undefined,
|
|
670
674
|
estimation_calibration: (() => {
|
|
671
675
|
try {
|
|
672
|
-
|
|
676
|
+
// pln#578 — reuse the state loaded at the top of buildContext; the
|
|
677
|
+
// report only reads plan_items and a fresh loadState here was one of
|
|
678
|
+
// the four full-store passes per context build.
|
|
679
|
+
const report = buildEstimationReport({ agent, cwd: contextCwd, state });
|
|
673
680
|
return report.summary.with_both >= 3 ? report.summary.calibration_hint : undefined;
|
|
674
681
|
}
|
|
675
682
|
catch {
|
|
@@ -34,24 +34,128 @@ const DEFAULT_TAIL = 20;
|
|
|
34
34
|
const DEFAULT_STALL_MS = 5 * 60_000;
|
|
35
35
|
const DEFAULT_BASE_REF = 'master';
|
|
36
36
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
37
|
+
* trp#926 — read the worktree's recorded creation ref (SHA) from its brainclaw
|
|
38
|
+
* sidecar so gitEvidence can measure "commits the worker added" against the
|
|
39
|
+
* anchor the worktree was BORN at, not the caller's moving default (which is
|
|
40
|
+
* usually `master`). Comparing to master after master advanced was the
|
|
41
|
+
* observed false-positive on rtn_c5542b05: lane HEAD's commits still appeared
|
|
42
|
+
* "ahead of master" long after they were squash-merged, so dispatch_status
|
|
43
|
+
* reported "worker delivered" for a fully integrated lane.
|
|
44
|
+
*
|
|
45
|
+
* Returns the SHA when the sidecar records `base_ref_sha`. A legacy sidecar
|
|
46
|
+
* without that field means "unknown": falling back to the caller's moving
|
|
47
|
+
* `master` would recreate the false `worker delivered` signal this fixes.
|
|
48
|
+
*/
|
|
49
|
+
function readWorktreeBaseRef(worktreePath) {
|
|
50
|
+
const sidecar = path.join(worktreePath, '.brainclaw-worktree.json');
|
|
51
|
+
try {
|
|
52
|
+
const meta = JSON.parse(fs.readFileSync(sidecar, 'utf-8'));
|
|
53
|
+
if (typeof meta.base_ref_sha === 'string' && meta.base_ref_sha.length > 0) {
|
|
54
|
+
return { ref: meta.base_ref_sha, legacySidecar: false };
|
|
55
|
+
}
|
|
56
|
+
return { legacySidecar: true };
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return { legacySidecar: fs.existsSync(sidecar) };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function aggregateChangesMatchIntegrationBase(worktreePath, creationBase, integrationBase) {
|
|
63
|
+
try {
|
|
64
|
+
const changed = execFileSync('git', ['-C', worktreePath, 'diff', '--name-only', '-z', creationBase, 'HEAD'], {
|
|
65
|
+
encoding: 'utf-8', timeout: 15000,
|
|
66
|
+
});
|
|
67
|
+
const paths = changed.split('\0').filter(Boolean);
|
|
68
|
+
if (paths.length === 0)
|
|
69
|
+
return true;
|
|
70
|
+
for (let i = 0; i < paths.length; i += 100) {
|
|
71
|
+
const chunk = paths.slice(i, i + 100);
|
|
72
|
+
execFileSync('git', ['-C', worktreePath, 'diff', '--quiet', 'HEAD', integrationBase, '--', ...chunk], {
|
|
73
|
+
encoding: 'utf-8', timeout: 15000,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* pln#554 + trp#926 — worktree git evidence, the signal that beats process /
|
|
84
|
+
* administrative status: a worker that committed everything to its lane branch
|
|
85
|
+
* has DELIVERED, whatever its pid/heartbeat/assignment.status say. Shared by
|
|
86
|
+
* dispatch-status and `brainclaw dispatch watch`. Returns undefined when there
|
|
87
|
+
* is no worktree or git could not be queried (never conclude "no commits" from
|
|
88
|
+
* a failed read).
|
|
89
|
+
*
|
|
90
|
+
* The comparison anchor is:
|
|
91
|
+
* 1. the worktree sidecar's recorded creation SHA (`base_ref_sha`) — the
|
|
92
|
+
* truthful anchor a worker was born at;
|
|
93
|
+
* 2. otherwise `commits_ahead_base` (caller-supplied, default `master`) ONLY
|
|
94
|
+
* when there is no brainclaw sidecar at all (plain/non-brainclaw git
|
|
95
|
+
* evidence callers).
|
|
96
|
+
* A legacy sidecar without `base_ref_sha` returns undefined. That is deliberate:
|
|
97
|
+
* unknown is safer than silently comparing to a moving `master`.
|
|
98
|
+
* Anchoring on the creation ref is what avoids the "worker delivered"
|
|
99
|
+
* false-positive after a squash-merge advanced master.
|
|
100
|
+
*
|
|
101
|
+
* Additionally, `commitsAhead` is refined via `git cherry <base> HEAD`
|
|
102
|
+
* (patch-id): a commit whose patch is already on `base` is treated as
|
|
103
|
+
* integrated even if its SHA is not an ancestor of `base` (squash-merge case).
|
|
104
|
+
* `commitsAheadRaw` preserves the historical ancestry-only count for callers /
|
|
105
|
+
* telemetry that need it.
|
|
42
106
|
*/
|
|
43
107
|
export function gitEvidence(worktreePath, baseRef) {
|
|
44
108
|
if (!worktreePath)
|
|
45
109
|
return undefined;
|
|
110
|
+
// Two DIFFERENT anchors:
|
|
111
|
+
// - creationBase: sidecar `base_ref_sha`, else caller `baseRef` for
|
|
112
|
+
// non-brainclaw paths only. A legacy sidecar without the SHA is unknown.
|
|
113
|
+
// stable anchor for "how much did the worker add?" (raw ahead count).
|
|
114
|
+
// - integrationBase: caller `baseRef` (default `master`) — the moving
|
|
115
|
+
// integration target the patch-id refinement compares against ("still
|
|
116
|
+
// un-integrated?"). Using the creation SHA here would falsely count a
|
|
117
|
+
// squash-merged commit as un-integrated (its patch is on master, but
|
|
118
|
+
// master isn't the creation ref).
|
|
119
|
+
const recordedBase = readWorktreeBaseRef(worktreePath);
|
|
120
|
+
if (recordedBase.legacySidecar && !recordedBase.ref) {
|
|
121
|
+
logger.debug('dispatch status: git evidence unavailable: legacy worktree sidecar lacks base_ref_sha');
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
const creationBase = recordedBase.ref ?? baseRef;
|
|
125
|
+
const integrationBase = baseRef;
|
|
46
126
|
try {
|
|
47
|
-
const
|
|
127
|
+
const aheadRaw = execFileSync('git', ['-C', worktreePath, 'rev-list', '--count', `${creationBase}..HEAD`], {
|
|
48
128
|
encoding: 'utf-8', timeout: 15000,
|
|
49
129
|
}).trim();
|
|
50
130
|
const status = execFileSync('git', ['-C', worktreePath, 'status', '--short'], {
|
|
51
131
|
encoding: 'utf-8', timeout: 15000,
|
|
52
132
|
});
|
|
53
133
|
const dirty = status.split('\n').filter((l) => l.trim() && !l.startsWith('??')).length;
|
|
54
|
-
|
|
134
|
+
const rawCount = Number.parseInt(aheadRaw, 10) || 0;
|
|
135
|
+
// Patch-id refinement against the INTEGRATION base: `git cherry` marks
|
|
136
|
+
// each commit in `base..HEAD` as `-` (patch on base — squash-merged /
|
|
137
|
+
// cherry-picked) or `+` (still un-integrated). Refined count = `+` lines.
|
|
138
|
+
// Best-effort — a failed cherry falls back to the raw ancestry count.
|
|
139
|
+
let refined = rawCount;
|
|
140
|
+
if (rawCount > 0) {
|
|
141
|
+
try {
|
|
142
|
+
const cherry = execFileSync('git', ['-C', worktreePath, 'cherry', integrationBase, 'HEAD'], {
|
|
143
|
+
encoding: 'utf-8', timeout: 15000,
|
|
144
|
+
});
|
|
145
|
+
const plusLines = cherry.split(/\r?\n/).filter((l) => l.startsWith('+ '));
|
|
146
|
+
refined = plusLines.length;
|
|
147
|
+
if (refined > 0 && aggregateChangesMatchIntegrationBase(worktreePath, creationBase, integrationBase)) {
|
|
148
|
+
refined = 0;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch { /* keep raw count */ }
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
commitsAhead: refined,
|
|
155
|
+
commitsAheadRaw: rawCount,
|
|
156
|
+
dirtyTracked: dirty,
|
|
157
|
+
baseRef: creationBase,
|
|
158
|
+
};
|
|
55
159
|
}
|
|
56
160
|
catch (err) {
|
|
57
161
|
logger.debug('dispatch status: git evidence unavailable:', err);
|
|
@@ -348,6 +452,8 @@ export function getDispatchStatus(options) {
|
|
|
348
452
|
last_fs_activity_ms: lastFsActivityMs,
|
|
349
453
|
lane_result: laneResult,
|
|
350
454
|
commits_ahead: evidence?.commitsAhead,
|
|
455
|
+
commits_ahead_raw: evidence?.commitsAheadRaw,
|
|
456
|
+
commits_ahead_base: evidence?.baseRef,
|
|
351
457
|
dirty_tracked: evidence?.dirtyTracked,
|
|
352
458
|
};
|
|
353
459
|
let diagnosis = computeDiagnosis(assignment, agentRun, runtime, { stallMs, nowMs });
|
|
@@ -15,7 +15,7 @@ import path from 'node:path';
|
|
|
15
15
|
import { loadState, mutateState } from './state.js';
|
|
16
16
|
import { archiveCandidate, listCandidates, loadCandidate, saveCandidate, } from './candidates.js';
|
|
17
17
|
import { addCrossProjectLink, removeCrossProjectLink, resolveCrossProjectLinks, } from './cross-project.js';
|
|
18
|
-
import { listClaims, loadClaim, saveClaim } from './claims.js';
|
|
18
|
+
import { findActiveClaimsForPlan, listClaims, loadClaim, logCascadeReleaseResult, markClaimStale, releaseClaimsCascade, releaseClaimWithCascade, saveClaim, } from './claims.js';
|
|
19
19
|
import { listActionRequired } from './actions.js';
|
|
20
20
|
import { deleteAssignment, listAssignments, loadAssignment, saveAssignment, transitionAssignment } from './assignments.js';
|
|
21
21
|
import { listAgentRuns } from './agentruns.js';
|
|
@@ -599,8 +599,7 @@ export function removeEntity(name, id, cwd, purge = false) {
|
|
|
599
599
|
throw new EntityOperationUnsupportedError(name, 'remove');
|
|
600
600
|
}
|
|
601
601
|
}
|
|
602
|
-
|
|
603
|
-
export function transitionEntity(name, id, to, cwd, _reason) {
|
|
602
|
+
export function transitionEntity(name, id, to, cwd, _reason, auth) {
|
|
604
603
|
const spec = ENTITY_REGISTRY[name];
|
|
605
604
|
if (!spec.statusField) {
|
|
606
605
|
throw new Error(`${name} has no lifecycle (statusField is undefined)`);
|
|
@@ -619,6 +618,32 @@ export function transitionEntity(name, id, to, cwd, _reason) {
|
|
|
619
618
|
switch (name) {
|
|
620
619
|
case 'plan': {
|
|
621
620
|
updatePlan({ id, status: to }, cwd);
|
|
621
|
+
// trp#928 — implement the `release_linked_claims_if_last` cascade tag.
|
|
622
|
+
// Before this landing the tag was advertised by the entity registry but
|
|
623
|
+
// the imperative cascade never ran, so a plan closed while its worker
|
|
624
|
+
// claims stayed active (ghost claims). The cascade now:
|
|
625
|
+
// - runs only on plan → done (the tag's actual trigger)
|
|
626
|
+
// - releases each active claim linked via plan_id
|
|
627
|
+
// - LOGS per claim (released / skipped+reason / error) via the runtime
|
|
628
|
+
// event journal so `bclaw_find(entity=agent_run)` and dashboards can
|
|
629
|
+
// observe silent ownership failures instead of guessing at them.
|
|
630
|
+
// Ownership check: this path runs from bclaw_transition, so it inherits
|
|
631
|
+
// the caller's TransitionAuth (populated for entity='claim' but not for
|
|
632
|
+
// entity='plan'). auth undefined = system convergence (bypass ownership),
|
|
633
|
+
// matching the historical implicit contract for plan cascades.
|
|
634
|
+
if (to === 'done') {
|
|
635
|
+
const linked = findActiveClaimsForPlan(id, cwd);
|
|
636
|
+
if (linked.length > 0) {
|
|
637
|
+
const cascade = releaseClaimsCascade(linked.map((c) => c.id), { cwd });
|
|
638
|
+
logCascadeReleaseResult({
|
|
639
|
+
actor: 'system',
|
|
640
|
+
trigger: 'plan_done',
|
|
641
|
+
plan_id: id,
|
|
642
|
+
cascade,
|
|
643
|
+
cwd,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
}
|
|
622
647
|
return { entity: name, id, from, to, side_effects: sideEffects };
|
|
623
648
|
}
|
|
624
649
|
case 'decision':
|
|
@@ -656,6 +681,29 @@ export function transitionEntity(name, id, to, cwd, _reason) {
|
|
|
656
681
|
updateSequence({ id, status: to }, cwd);
|
|
657
682
|
return { entity: name, id, from, to, side_effects: sideEffects };
|
|
658
683
|
}
|
|
684
|
+
case 'claim': {
|
|
685
|
+
// trp#928 — the entity registry advertised `active → released|stale` but
|
|
686
|
+
// transitionEntity never routed for entity=claim. The isValidTransition
|
|
687
|
+
// check above passed for anyone calling `bclaw_transition(entity='claim',
|
|
688
|
+
// to='released')`, but the transition then fell through to the
|
|
689
|
+
// EntityOperationUnsupportedError default. Now: released hits the same
|
|
690
|
+
// cascade path bclaw_release_claim uses (audit + plan-done cascade); stale
|
|
691
|
+
// uses markClaimStale (audit + `stale` terminal status). Reuses ReleaseClaimAuth
|
|
692
|
+
// so a trusted+ coordinator can release across ownership with override.
|
|
693
|
+
const releaseAuth = auth
|
|
694
|
+
? { agent: auth.agent, agent_id: auth.agent_id, session_id: auth.session_id, override: auth.override }
|
|
695
|
+
: undefined;
|
|
696
|
+
if (to === 'released') {
|
|
697
|
+
releaseClaimWithCascade(id, { planStatus: _reason === 'done' ? 'done' : undefined, cwd, auth: releaseAuth });
|
|
698
|
+
return { entity: name, id, from, to, side_effects: sideEffects };
|
|
699
|
+
}
|
|
700
|
+
if (to === 'stale') {
|
|
701
|
+
markClaimStale(id, cwd, releaseAuth);
|
|
702
|
+
return { entity: name, id, from, to, side_effects: sideEffects };
|
|
703
|
+
}
|
|
704
|
+
// isValidTransition already excluded every other target — belt-and-braces:
|
|
705
|
+
throw new InvalidTransitionError(name, from, to);
|
|
706
|
+
}
|
|
659
707
|
default:
|
|
660
708
|
throw new EntityOperationUnsupportedError(name, 'transition', `Lifecycle transitions for ${name} not yet wired.`);
|
|
661
709
|
}
|
package/dist/core/loops/store.js
CHANGED
|
@@ -5,6 +5,7 @@ import { memoryDir, writeFileAtomic } from '../io.js';
|
|
|
5
5
|
import { nowISO } from '../ids.js';
|
|
6
6
|
import { logger } from '../logger.js';
|
|
7
7
|
import { convergeAssignmentToTerminal, loadAssignment } from '../assignments.js';
|
|
8
|
+
import { logCascadeReleaseResult, releaseClaimsCascade } from '../claims.js';
|
|
8
9
|
import { gcWorktreeIfHarvested } from '../worktree.js';
|
|
9
10
|
import { writeProjectMdSafe } from './hooks/bootstrap-write.js';
|
|
10
11
|
import { notifyOperatorOnInputRequested } from './hooks/notify-operator.js';
|
|
@@ -435,6 +436,38 @@ export function closeLoop(input, cwd) {
|
|
|
435
436
|
}
|
|
436
437
|
catch { /* never block loop close on assignment convergence */ }
|
|
437
438
|
}
|
|
439
|
+
// trp#928 — cascade-release claims linked to slots + slot-linked assignments.
|
|
440
|
+
// Before this landing, loop close converged the assignment lifecycle but left
|
|
441
|
+
// reviewer claims active indefinitely (dogfooding 2026-07: 23 ghost claims,
|
|
442
|
+
// most from closed review loops). System-actor release: no auth → the
|
|
443
|
+
// ownership check is skipped, matching convergeAssignmentToTerminal's contract
|
|
444
|
+
// above (loop close is a system action, not a user-driven release).
|
|
445
|
+
const claimIdsFromSlots = [];
|
|
446
|
+
for (const slot of next.slots) {
|
|
447
|
+
if (slot.claim_id)
|
|
448
|
+
claimIdsFromSlots.push(slot.claim_id);
|
|
449
|
+
if (slot.assignment_id) {
|
|
450
|
+
try {
|
|
451
|
+
const assignment = loadAssignment(slot.assignment_id, cwd);
|
|
452
|
+
if (assignment?.claim_id)
|
|
453
|
+
claimIdsFromSlots.push(assignment.claim_id);
|
|
454
|
+
}
|
|
455
|
+
catch { /* assignment gone — skip */ }
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (claimIdsFromSlots.length > 0) {
|
|
459
|
+
try {
|
|
460
|
+
const cascade = releaseClaimsCascade(claimIdsFromSlots, { cwd });
|
|
461
|
+
logCascadeReleaseResult({
|
|
462
|
+
actor: input.actor,
|
|
463
|
+
trigger: 'loop_close',
|
|
464
|
+
loop_id: input.id,
|
|
465
|
+
cascade,
|
|
466
|
+
cwd,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
catch { /* never block loop close on cascade release */ }
|
|
470
|
+
}
|
|
438
471
|
// pln#594: GC the dispatched sub-agent worktrees now the loop is done, so
|
|
439
472
|
// review/dispatch worktrees stop accumulating under ~/.brainclaw/worktrees/.
|
|
440
473
|
// Only on a COMPLETED close — cancelled/blocked keep their worktree (+ run
|
package/dist/core/reputation.js
CHANGED
|
@@ -281,6 +281,24 @@ export function buildReputationSnapshot(cwd) {
|
|
|
281
281
|
resume_weight: 0.35,
|
|
282
282
|
mcp_exposure: false,
|
|
283
283
|
};
|
|
284
|
+
// pln#578 — disabled reputation (the default) must not pay for the sweep.
|
|
285
|
+
// Every consumer already treats a disabled snapshot as empty: agents is []
|
|
286
|
+
// (line below gates on enabled), so ranking bonuses are 0 and the resume
|
|
287
|
+
// summary is undefined. Yet the full signal sweep (pending + archived
|
|
288
|
+
// candidates, all runtime notes, all claims, a complete loadState) was still
|
|
289
|
+
// running — two of the four full-store read passes a single buildContext
|
|
290
|
+
// performed on a large store. Exit before any store read when disabled.
|
|
291
|
+
if (!reputationConfig.enabled) {
|
|
292
|
+
return {
|
|
293
|
+
enabled: false,
|
|
294
|
+
visibility: reputationConfig.visibility,
|
|
295
|
+
window_days: reputationConfig.decay_days,
|
|
296
|
+
generated_at: nowISO(),
|
|
297
|
+
project_id: config.project_id,
|
|
298
|
+
current_agent_id: resolveCurrentAgentIdentity(cwd)?.agent_id,
|
|
299
|
+
agents: [],
|
|
300
|
+
};
|
|
301
|
+
}
|
|
284
302
|
const registered = listAgentIdentities(cwd);
|
|
285
303
|
const currentAgent = resolveCurrentAgentIdentity(cwd);
|
|
286
304
|
const resolvers = buildIdentityResolvers(registered);
|
package/dist/core/schema.js
CHANGED
|
@@ -1122,6 +1122,13 @@ export const CurrentSessionStateSchema = z.object({
|
|
|
1122
1122
|
branch: z.string().optional(),
|
|
1123
1123
|
/** Isolation mode: shared-checkout (default) or dedicated-worktree. */
|
|
1124
1124
|
isolation_mode: IsolationModeSchema.optional(),
|
|
1125
|
+
/**
|
|
1126
|
+
* True when the session was materialized by an auto-repair path (e.g. a
|
|
1127
|
+
* canonical write arriving without a prior session). Tag lets aggressive
|
|
1128
|
+
* harvesting distinguish worker-authored sessions from operator sessions
|
|
1129
|
+
* (pln#602 lesson on the pln#578 887-file blowup).
|
|
1130
|
+
*/
|
|
1131
|
+
auto_created: z.boolean().optional(),
|
|
1125
1132
|
});
|
|
1126
1133
|
export const MemorySeedKindSchema = z.enum([
|
|
1127
1134
|
'command',
|