brainclaw 1.12.0 → 1.14.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 +40 -0
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +11 -3
- package/dist/commands/claim-resource.js +1 -0
- package/dist/commands/doctor.js +98 -0
- package/dist/commands/estimation-report.js +1 -1
- package/dist/commands/harvest.js +38 -24
- package/dist/commands/mcp.js +328 -46
- package/dist/commands/release-claim.js +21 -1
- package/dist/commands/session-start.js +16 -1
- package/dist/core/agent-capability.js +15 -4
- package/dist/core/agent-registry.js +7 -1
- package/dist/core/assignment-sweeper.js +92 -11
- 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/gc-semantic.js +79 -0
- package/dist/core/hint-aging.js +188 -0
- package/dist/core/hygiene-policy.js +77 -0
- package/dist/core/loops/store.js +33 -0
- package/dist/core/reputation.js +18 -0
- package/dist/core/schema.js +29 -0
- package/dist/core/worktree.js +198 -22
- package/dist/facts.js +36 -3
- package/dist/facts.json +35 -2
- package/docs/concepts/dispatch-supervisor.md +393 -0
- package/docs/mcp-schema-changelog.md +7 -2
- package/package.json +6 -4
|
@@ -16,7 +16,9 @@ import { releaseStaleClaimsFromOtherAgents } from '../core/claims.js';
|
|
|
16
16
|
import { SessionSnapshotSchema } from '../core/schema.js';
|
|
17
17
|
import { auditLocalAgentWorkspaceFiles } from '../core/agent-files.js';
|
|
18
18
|
import { buildAgentInventory, loadAgentInventory, saveAgentInventory, diffInventory } from '../core/agent-inventory.js';
|
|
19
|
-
import { checkMemoryPressure, enforceRuntimeNoteRetention } from '../core/gc-semantic.js';
|
|
19
|
+
import { checkMemoryPressure, enforceRuntimeNoteRetention, parkClosedAutoHandoffs } from '../core/gc-semantic.js';
|
|
20
|
+
import { sweepAssignments } from '../core/assignment-sweeper.js';
|
|
21
|
+
import { loadHygienePolicy } from '../core/hygiene-policy.js';
|
|
20
22
|
import { maybeCreateCheckpoint } from '../core/events/checkpoint.js';
|
|
21
23
|
import { pullSignalsFromLinkedProjects, markSignalProcessed } from '../core/federation-transport.js';
|
|
22
24
|
import { pullSignalsFromCloud, isCloudSyncEnabled } from '../core/federation-cloud.js';
|
|
@@ -207,6 +209,19 @@ export async function startSession(options = {}) {
|
|
|
207
209
|
enforceRuntimeNoteRetention({ cwd: options.cwd });
|
|
208
210
|
}
|
|
209
211
|
catch { /* non-fatal — retention sweep must never block session start */ }
|
|
212
|
+
// pln#602 — coordination hygiene pass. Converge orphan offered/accepted
|
|
213
|
+
// assignments (workers that died without a self-report — fable-audit-2026-07
|
|
214
|
+
// witnesses) and park closed auto-generated handoffs so bclaw_work stops
|
|
215
|
+
// serving debris. Runs at session-start ONLY (not on the hot read path);
|
|
216
|
+
// opt-out via config.hygiene.disabled honoured through the policy load.
|
|
217
|
+
try {
|
|
218
|
+
const policy = loadHygienePolicy(options.cwd);
|
|
219
|
+
if (!policy.disabled) {
|
|
220
|
+
sweepAssignments(options.cwd, { actor: 'session-start', policy });
|
|
221
|
+
parkClosedAutoHandoffs(options.cwd ?? process.cwd(), Math.floor(policy.handoff_closed_ttl_ms / (24 * 60 * 60 * 1000)));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
catch { /* non-fatal — hygiene sweep must never block session start */ }
|
|
210
225
|
// pln#566 Inc0 — keep a recent journal-derived checkpoint available off the
|
|
211
226
|
// hot path so the (capability-gated, OFF by default) checkpointRead read
|
|
212
227
|
// path has something to serve once enabled. Gated by a growth threshold so
|
|
@@ -175,6 +175,11 @@ const PROFILES = {
|
|
|
175
175
|
// Aligning with the regular spawn template (workspace-write) is the
|
|
176
176
|
// accepted pattern per agent_spawn_inventory memory.
|
|
177
177
|
invoke_review_template: 'codex exec -c approval_policy="never" --sandbox workspace-write "{prompt}"',
|
|
178
|
+
// pln#606: `codex exec -m <MODEL>` / `--model` (verified empirically on
|
|
179
|
+
// codex 0.130). We use the long form `--model` for symmetry with the
|
|
180
|
+
// other agent profiles and readability.
|
|
181
|
+
model_flag: '--model',
|
|
182
|
+
model_flag_insert_index: 2,
|
|
178
183
|
},
|
|
179
184
|
antigravity: {
|
|
180
185
|
name: 'antigravity', category: 'code-agent', workflowModel: 'interactive',
|
|
@@ -204,6 +209,10 @@ const PROFILES = {
|
|
|
204
209
|
invoke_template: 'copilot -p "{prompt}" --allow-all --no-ask-user',
|
|
205
210
|
invoke_binary: 'copilot',
|
|
206
211
|
invoke_review_template: 'copilot -p "{prompt}" --allow-all --no-ask-user',
|
|
212
|
+
// pln#606: `copilot --model <model>` (verified on Copilot CLI 1.0.35+).
|
|
213
|
+
// 'auto' lets Copilot pick automatically; concrete ids come from the
|
|
214
|
+
// entitled catalog fetched by the CLI at startup.
|
|
215
|
+
model_flag: '--model',
|
|
207
216
|
},
|
|
208
217
|
kilocode: {
|
|
209
218
|
name: 'kilocode', category: 'code-agent', workflowModel: 'interactive',
|
|
@@ -564,11 +573,13 @@ export function buildInvokeCommand(name, prompt, options = {}) {
|
|
|
564
573
|
const rawTokens = parseTemplateString(templateStr);
|
|
565
574
|
if (rawTokens.length === 0)
|
|
566
575
|
return undefined;
|
|
567
|
-
// pln#520 step 3: inject the resolved model
|
|
568
|
-
// choice is decoupled from agent identity. Only when the
|
|
569
|
-
// `model_flag` and the template doesn't already pin a model
|
|
576
|
+
// pln#520 step 3: inject the resolved model at the profile's model argument
|
|
577
|
+
// position so model choice is decoupled from agent identity. Only when the
|
|
578
|
+
// profile declares a `model_flag` and the template doesn't already pin a model
|
|
579
|
+
// (don't double it).
|
|
570
580
|
if (options.model && profile.model_flag && !rawTokens.includes(profile.model_flag)) {
|
|
571
|
-
|
|
581
|
+
const insertIndex = Math.min(Math.max(profile.model_flag_insert_index ?? 1, 1), rawTokens.length);
|
|
582
|
+
rawTokens.splice(insertIndex, 0, profile.model_flag, options.model);
|
|
572
583
|
}
|
|
573
584
|
const executable = rawTokens[0];
|
|
574
585
|
const interpolatedTokens = rawTokens.slice(1).map((tok) => tok === '{prompt}' ? embeddedPrompt : tok);
|
|
@@ -150,7 +150,13 @@ function buildIdentityKey(agentId, env = process.env, forceRegenerate = false) {
|
|
|
150
150
|
let publicKeyPem;
|
|
151
151
|
if (!forceRegenerate && fs.existsSync(filepath)) {
|
|
152
152
|
const privateKey = crypto.createPrivateKey(fs.readFileSync(filepath, 'utf-8'));
|
|
153
|
-
|
|
153
|
+
// @types/node 26 dropped the KeyObject overload from createPublicKey's signature
|
|
154
|
+
// (regression — Node accepts a private KeyObject to derive its public key, as documented).
|
|
155
|
+
// Cast to a parameter type the .d.ts still accepts; runtime behaviour is unchanged.
|
|
156
|
+
publicKeyPem = crypto
|
|
157
|
+
.createPublicKey(privateKey)
|
|
158
|
+
.export({ type: 'spki', format: 'pem' })
|
|
159
|
+
.toString();
|
|
154
160
|
}
|
|
155
161
|
else {
|
|
156
162
|
const generated = crypto.generateKeyPairSync('ed25519');
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { spawnSync } from 'node:child_process';
|
|
17
17
|
import { listAssignments, transitionAssignment } from './assignments.js';
|
|
18
18
|
import { signalExists, readHeartbeat, latestActivityMs } from './runtime-signals.js';
|
|
19
|
+
import { DEFAULT_HYGIENE_POLICY } from './hygiene-policy.js';
|
|
19
20
|
function lastCommitAgeMs(worktreePath, nowMs) {
|
|
20
21
|
if (!worktreePath)
|
|
21
22
|
return undefined;
|
|
@@ -104,28 +105,60 @@ function collectImplicitEvidence(assignment, cwd, nowMs, sinceMs, freshTtlMs) {
|
|
|
104
105
|
* @param options.actor - Actor name for audit trail (default: 'sweeper')
|
|
105
106
|
*/
|
|
106
107
|
export function sweepAssignments(cwd, options) {
|
|
108
|
+
return sweepAssignmentsFromList(listAssignments(cwd), cwd, options);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Read-path variant: sweep only the assignments the caller ALREADY loaded
|
|
112
|
+
* (typically open_work.active_assignments). No `listAssignments` call, so no
|
|
113
|
+
* additional store scan on the hot bclaw_work path (pln#602 perf guardrail
|
|
114
|
+
* per the pln#578 read-path optimisation). Use `sweepAssignmentsFromList`
|
|
115
|
+
* with a bounded slice when a full pass would violate the budget.
|
|
116
|
+
*
|
|
117
|
+
* @param assignments - pre-loaded assignments to consider (only non-terminal ones matter)
|
|
118
|
+
* @param cwd - project root
|
|
119
|
+
* @param options.nowMs - Override current time for testing
|
|
120
|
+
* @param options.actor - Actor for the audit trail (default: 'sweeper-readpath')
|
|
121
|
+
* @param options.policy - Family-level TTL/policy overrides
|
|
122
|
+
*/
|
|
123
|
+
export function sweepAssignmentsFromList(assignments, cwd, options) {
|
|
124
|
+
const policy = options?.policy;
|
|
125
|
+
if (policy?.disabled) {
|
|
126
|
+
return { timed_out: [], expired: [], implicitly_advanced: [] };
|
|
127
|
+
}
|
|
107
128
|
const now = options?.nowMs ?? Date.now();
|
|
108
129
|
const actor = options?.actor ?? 'sweeper';
|
|
109
130
|
const result = { timed_out: [], expired: [], implicitly_advanced: [] };
|
|
110
|
-
const
|
|
111
|
-
|
|
131
|
+
for (const assignment of assignments) {
|
|
132
|
+
// pln#602 / Codex PR#48 finding 2: when a hygiene `policy` is supplied
|
|
133
|
+
// (session-start full sweep, bclaw_work read-path), the age comparison,
|
|
134
|
+
// the implicit-evidence freshness window, AND the status_reason MUST use
|
|
135
|
+
// the family TTLs (offered 3d / accepted 1d / started 1d by default), NOT
|
|
136
|
+
// the assignment's embedded heartbeat_ttl_ms/acceptance_ttl_ms (~30/15min).
|
|
137
|
+
// Otherwise a 20-min offered assignment that `doctor --hygiene` does not
|
|
138
|
+
// list as a candidate could still be expired here — the exact incoherence
|
|
139
|
+
// Codex flagged. Without a policy (the dispatcher convergence sweep,
|
|
140
|
+
// dispatcher.ts), fall back to the embedded TTLs so short-window dispatch
|
|
141
|
+
// convergence is unchanged.
|
|
142
|
+
const startedTtl = policy?.assignment_started_ttl_ms ?? assignment.heartbeat_ttl_ms;
|
|
143
|
+
const acceptedTtl = policy?.assignment_accepted_ttl_ms ?? assignment.acceptance_ttl_ms;
|
|
144
|
+
const offeredTtl = policy?.assignment_offered_ttl_ms ?? assignment.acceptance_ttl_ms;
|
|
112
145
|
// Check started assignments for heartbeat timeout
|
|
113
146
|
if (assignment.status === 'started') {
|
|
114
147
|
const lastBeat = assignment.last_heartbeat_at ?? assignment.started_at;
|
|
115
148
|
if (!lastBeat)
|
|
116
149
|
continue;
|
|
117
150
|
const ageMs = now - new Date(lastBeat).getTime();
|
|
118
|
-
if (ageMs >
|
|
151
|
+
if (ageMs > startedTtl) {
|
|
119
152
|
// can_948acfd6: a worker without MCP cannot bump last_heartbeat_at —
|
|
120
153
|
// its file evidence is the heartbeat. Fresh file activity vetoes the
|
|
121
154
|
// administrative timeout.
|
|
122
155
|
const sinceMs = new Date(assignment.started_at ?? assignment.created_at).getTime();
|
|
123
|
-
const evidence = collectImplicitEvidence(assignment, cwd, now, sinceMs,
|
|
156
|
+
const evidence = collectImplicitEvidence(assignment, cwd, now, sinceMs, startedTtl);
|
|
124
157
|
if (evidence.fresh)
|
|
125
158
|
continue;
|
|
126
159
|
try {
|
|
127
160
|
transitionAssignment(assignment.id, 'timed_out', {
|
|
128
|
-
status_reason: `No heartbeat for ${Math.round(ageMs / 60_000)} minutes (TTL: ${Math.round(
|
|
161
|
+
status_reason: `No heartbeat for ${Math.round(ageMs / 60_000)} minutes (TTL: ${Math.round(startedTtl / 60_000)}min); implicit evidence: ${evidence.description}`,
|
|
129
162
|
actor,
|
|
130
163
|
}, cwd);
|
|
131
164
|
result.timed_out.push({ assignment_id: assignment.id, agent: assignment.agent, age_ms: ageMs });
|
|
@@ -139,10 +172,12 @@ export function sweepAssignments(cwd, options) {
|
|
|
139
172
|
if (!acceptedAt)
|
|
140
173
|
continue;
|
|
141
174
|
const ageMs = now - new Date(acceptedAt).getTime();
|
|
142
|
-
// Use
|
|
143
|
-
|
|
175
|
+
// Use the accepted-family TTL for accepted→timed_out (agent should start
|
|
176
|
+
// soon after accepting; family default 1d, or embedded acceptance_ttl_ms
|
|
177
|
+
// for the policy-less convergence sweep).
|
|
178
|
+
if (ageMs > acceptedTtl) {
|
|
144
179
|
const sinceMs = new Date(acceptedAt).getTime();
|
|
145
|
-
const evidence = collectImplicitEvidence(assignment, cwd, now, sinceMs,
|
|
180
|
+
const evidence = collectImplicitEvidence(assignment, cwd, now, sinceMs, acceptedTtl);
|
|
146
181
|
if (evidence.fresh) {
|
|
147
182
|
// Working without MCP — record the implicit start so the FSM matches reality.
|
|
148
183
|
try {
|
|
@@ -171,13 +206,13 @@ export function sweepAssignments(cwd, options) {
|
|
|
171
206
|
if (!offeredAt)
|
|
172
207
|
continue;
|
|
173
208
|
const ageMs = now - new Date(offeredAt).getTime();
|
|
174
|
-
if (ageMs >
|
|
209
|
+
if (ageMs > offeredTtl) {
|
|
175
210
|
// can_948acfd6: ANY worker evidence (ack sentinel touched pre-exec,
|
|
176
211
|
// heartbeat written, files edited, commit landed) is an implicit
|
|
177
212
|
// acceptance — the worker just couldn't say so via MCP. Expiring it
|
|
178
213
|
// is the false-administrative-death observed three times in sprint 1.
|
|
179
214
|
const sinceMs = new Date(offeredAt).getTime();
|
|
180
|
-
const evidence = collectImplicitEvidence(assignment, cwd, now, sinceMs,
|
|
215
|
+
const evidence = collectImplicitEvidence(assignment, cwd, now, sinceMs, offeredTtl);
|
|
181
216
|
if (evidence.any) {
|
|
182
217
|
try {
|
|
183
218
|
transitionAssignment(assignment.id, 'accepted', {
|
|
@@ -191,7 +226,7 @@ export function sweepAssignments(cwd, options) {
|
|
|
191
226
|
}
|
|
192
227
|
try {
|
|
193
228
|
transitionAssignment(assignment.id, 'expired', {
|
|
194
|
-
status_reason: `Not accepted within ${Math.round(ageMs / 60_000)} minutes (TTL: ${Math.round(
|
|
229
|
+
status_reason: `Not accepted within ${Math.round(ageMs / 60_000)} minutes (TTL: ${Math.round(offeredTtl / 60_000)}min); no implicit evidence`,
|
|
195
230
|
actor,
|
|
196
231
|
}, cwd);
|
|
197
232
|
result.expired.push({ assignment_id: assignment.id, agent: assignment.agent, age_ms: ageMs });
|
|
@@ -202,4 +237,50 @@ export function sweepAssignments(cwd, options) {
|
|
|
202
237
|
}
|
|
203
238
|
return result;
|
|
204
239
|
}
|
|
240
|
+
/**
|
|
241
|
+
* Pure candidate selection for the bclaw_work read-path sweep (Codex PR#48
|
|
242
|
+
* finding 3, pln#578 guardrail). Given ONLY the in-memory projections that
|
|
243
|
+
* buildContext already surfaced, return the ids worth a full loadAssignment:
|
|
244
|
+
* - status must be sweepable (offered/accepted/started) — created/terminal
|
|
245
|
+
* rows can never transition and are dropped BEFORE any file read, so a
|
|
246
|
+
* healthy store full of `created` assignments costs zero extra I/O;
|
|
247
|
+
* - among those, only rows whose surfaced heartbeat is older than the
|
|
248
|
+
* smallest family TTL (or that carry no heartbeat) are suspicious;
|
|
249
|
+
* - capped at read_path_sweep_budget.
|
|
250
|
+
* Extracted so the hot-path zero-read guarantee is unit-testable without the
|
|
251
|
+
* MCP handler.
|
|
252
|
+
*/
|
|
253
|
+
export function selectReadPathSweepCandidates(projections, policy, nowMs) {
|
|
254
|
+
if (policy.disabled)
|
|
255
|
+
return [];
|
|
256
|
+
const minTtl = Math.min(policy.assignment_offered_ttl_ms, policy.assignment_accepted_ttl_ms, policy.assignment_started_ttl_ms);
|
|
257
|
+
return projections
|
|
258
|
+
.filter((a) => {
|
|
259
|
+
if (a.status !== 'offered' && a.status !== 'accepted' && a.status !== 'started')
|
|
260
|
+
return false;
|
|
261
|
+
const beat = a.last_heartbeat_at;
|
|
262
|
+
if (!beat)
|
|
263
|
+
return true;
|
|
264
|
+
return nowMs - new Date(beat).getTime() > minTtl;
|
|
265
|
+
})
|
|
266
|
+
.slice(0, policy.read_path_sweep_budget)
|
|
267
|
+
.map((a) => a.id);
|
|
268
|
+
}
|
|
269
|
+
export function sweepAssignmentsAtReadPath(assignments, cwd, options) {
|
|
270
|
+
const policy = options?.policy ?? DEFAULT_HYGIENE_POLICY;
|
|
271
|
+
if (policy.disabled) {
|
|
272
|
+
return { timed_out: [], expired: [], implicitly_advanced: [] };
|
|
273
|
+
}
|
|
274
|
+
const budget = policy.read_path_sweep_budget;
|
|
275
|
+
// Prefer offered/accepted (the empirical debris class); the sweep is a no-op
|
|
276
|
+
// for terminal statuses so filtering is a perf hygiene, not correctness.
|
|
277
|
+
const eligible = assignments
|
|
278
|
+
.filter((a) => a.status === 'offered' || a.status === 'accepted' || a.status === 'started')
|
|
279
|
+
.slice(0, budget);
|
|
280
|
+
return sweepAssignmentsFromList(eligible, cwd, {
|
|
281
|
+
...options,
|
|
282
|
+
actor: options?.actor ?? 'sweeper-readpath',
|
|
283
|
+
policy,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
205
286
|
//# sourceMappingURL=assignment-sweeper.js.map
|
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 });
|