brainclaw 1.7.0 → 1.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -5
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +12 -2
- package/dist/commands/dispatch.js +2 -0
- package/dist/commands/doctor.js +17 -0
- package/dist/commands/mcp.js +31 -7
- package/dist/commands/switch.js +40 -8
- package/dist/core/agent-capability.js +67 -0
- package/dist/core/agentrun-reconciler.js +126 -52
- package/dist/core/coordination.js +10 -9
- package/dist/core/dispatcher.js +99 -29
- package/dist/core/entity-operations.js +54 -1
- package/dist/core/execution-adapters.js +32 -51
- package/dist/core/execution.js +14 -8
- package/dist/core/identity.js +40 -13
- package/dist/core/instruction-templates.js +4 -3
- package/dist/core/runtime-signals.js +102 -0
- package/dist/core/spawn-check.js +125 -0
- package/dist/facts.js +3 -3
- package/dist/facts.json +2 -2
- package/docs/cli.md +8 -4
- package/docs/integrations/mcp.md +48 -15
- package/docs/mcp-schema-changelog.md +16 -5
- package/docs/playbooks/team/index.md +7 -5
- package/package.json +1 -1
|
@@ -38,6 +38,7 @@ import { loadClaim } from './claims.js';
|
|
|
38
38
|
import { loadAssignment } from './assignments.js';
|
|
39
39
|
import { createRuntimeEvent } from './events.js';
|
|
40
40
|
import { nowISO } from './ids.js';
|
|
41
|
+
import { readHeartbeat, readLogTail, signalExists } from './runtime-signals.js';
|
|
41
42
|
// ── Constants ──────────────────────────────────────────────────────────────
|
|
42
43
|
/**
|
|
43
44
|
* Minimum age before a run is eligible for reconciliation. Below this, the
|
|
@@ -52,6 +53,11 @@ export const DEFAULT_HEALTH_CHECK_GRACE_MS = 60_000;
|
|
|
52
53
|
export const DEFAULT_STALE_AFTER_MS = 30 * 60_000;
|
|
53
54
|
export const DEFAULT_DEAD_PID_READ_SWEEP_AGE_MS = 5 * 60_000;
|
|
54
55
|
export const DEFAULT_DEAD_PID_READ_SWEEP_LIMIT = 50;
|
|
56
|
+
/**
|
|
57
|
+
* pln#520 step 1 — a heartbeat older than this (with no completion signal) means
|
|
58
|
+
* the worker reached its loop then went silent: `stalled`. Default 10 min.
|
|
59
|
+
*/
|
|
60
|
+
export const DEFAULT_HEARTBEAT_STALE_MS = 10 * 60_000;
|
|
55
61
|
const TERMINAL_STATUSES = new Set([
|
|
56
62
|
'completed', 'failed', 'cancelled', 'timed_out', 'interrupted',
|
|
57
63
|
]);
|
|
@@ -152,15 +158,51 @@ export function collectEvidence(run, cwd, options) {
|
|
|
152
158
|
}
|
|
153
159
|
catch { /* defensive */ }
|
|
154
160
|
const process_alive = isProcessAlive(run.pid);
|
|
155
|
-
|
|
161
|
+
// pln#520 step 1 — sentinel evidence. Signals live under the project
|
|
162
|
+
// coordination dir (the dispatcher's ackRoot), which is `cwd` for the
|
|
163
|
+
// reconciler. Keyed by assignment_id.
|
|
164
|
+
const signalRoot = cwd ?? process.cwd();
|
|
165
|
+
let completed_signal = false;
|
|
166
|
+
let failed_signal = false;
|
|
167
|
+
let heartbeat_exists = false;
|
|
168
|
+
let heartbeat_age_ms;
|
|
169
|
+
try {
|
|
170
|
+
completed_signal = signalExists(signalRoot, run.assignment_id, 'completed');
|
|
171
|
+
failed_signal = signalExists(signalRoot, run.assignment_id, 'failed');
|
|
172
|
+
const hb = readHeartbeat(signalRoot, run.assignment_id);
|
|
173
|
+
heartbeat_exists = hb.exists;
|
|
174
|
+
if (hb.exists && hb.mtimeMs !== undefined)
|
|
175
|
+
heartbeat_age_ms = now - hb.mtimeMs;
|
|
176
|
+
}
|
|
177
|
+
catch { /* defensive */ }
|
|
178
|
+
return {
|
|
179
|
+
age_ms, has_post_start_commit, claim_released, assignment_completed, process_alive,
|
|
180
|
+
completed_signal, failed_signal, heartbeat_exists, heartbeat_age_ms,
|
|
181
|
+
};
|
|
156
182
|
}
|
|
157
183
|
function anyCompletionEvidence(evidence) {
|
|
158
|
-
return evidence.
|
|
184
|
+
return evidence.completed_signal
|
|
185
|
+
|| evidence.has_post_start_commit
|
|
159
186
|
|| evidence.claim_released
|
|
160
187
|
|| evidence.assignment_completed;
|
|
161
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* pln#520 step 1 — a short tail of the captured stderr (or stdout) for
|
|
191
|
+
* failed_silent / stalled diagnostics, so the verdict carries the worker's
|
|
192
|
+
* last words instead of just a status code.
|
|
193
|
+
*/
|
|
194
|
+
function logTailSuffix(run, cwd) {
|
|
195
|
+
const root = cwd ?? process.cwd();
|
|
196
|
+
const tail = (readLogTail(root, run.assignment_id, 'stderr', 500).trim()
|
|
197
|
+
|| readLogTail(root, run.assignment_id, 'stdout', 500).trim());
|
|
198
|
+
if (!tail)
|
|
199
|
+
return '';
|
|
200
|
+
return ` | log tail: ${tail.replace(/\s+/g, ' ').slice(0, 300)}`;
|
|
201
|
+
}
|
|
162
202
|
function describeEvidence(evidence) {
|
|
163
203
|
const reasons = [];
|
|
204
|
+
if (evidence.completed_signal)
|
|
205
|
+
reasons.push('wrapper wrote completed sentinel');
|
|
164
206
|
if (evidence.has_post_start_commit)
|
|
165
207
|
reasons.push('post-start commit on worktree branch');
|
|
166
208
|
if (evidence.claim_released)
|
|
@@ -231,6 +273,7 @@ export function reconcileAgentRun(runId, cwd, options = {}) {
|
|
|
231
273
|
const evidence = {
|
|
232
274
|
age_ms: 0, has_post_start_commit: false, claim_released: false,
|
|
233
275
|
assignment_completed: false, process_alive: undefined,
|
|
276
|
+
completed_signal: false, failed_signal: false, heartbeat_exists: false,
|
|
234
277
|
};
|
|
235
278
|
return {
|
|
236
279
|
run_id: runId, action: 'no_op', reason: 'run not found', evidence,
|
|
@@ -280,18 +323,12 @@ export function reconcileAgentRun(runId, cwd, options = {}) {
|
|
|
280
323
|
};
|
|
281
324
|
}
|
|
282
325
|
}
|
|
283
|
-
//
|
|
284
|
-
|
|
326
|
+
// pln#520 step 1 — sentinel-based failure (fast + trustworthy, pid-independent).
|
|
327
|
+
const heartbeatStale = options.heartbeatStaleMs ?? DEFAULT_HEARTBEAT_STALE_MS;
|
|
328
|
+
const failHere = (reason) => {
|
|
285
329
|
try {
|
|
286
|
-
transitionAgentRun(runId, 'failed', {
|
|
287
|
-
|
|
288
|
-
status_reason: 'silent_termination_no_evidence',
|
|
289
|
-
}, cwd);
|
|
290
|
-
return {
|
|
291
|
-
run_id: runId, action: 'inferred_failed',
|
|
292
|
-
reason: 'silent_termination_no_evidence',
|
|
293
|
-
evidence, previous_status, current_status: 'failed',
|
|
294
|
-
};
|
|
330
|
+
transitionAgentRun(runId, 'failed', { actor, status_reason: reason }, cwd);
|
|
331
|
+
return { run_id: runId, action: 'inferred_failed', reason, evidence, previous_status, current_status: 'failed' };
|
|
295
332
|
}
|
|
296
333
|
catch (err) {
|
|
297
334
|
return {
|
|
@@ -300,6 +337,26 @@ export function reconcileAgentRun(runId, cwd, options = {}) {
|
|
|
300
337
|
evidence, previous_status, current_status: run.status,
|
|
301
338
|
};
|
|
302
339
|
}
|
|
340
|
+
};
|
|
341
|
+
// `failed` sentinel — the wrapper saw a non-zero agent exit.
|
|
342
|
+
if (evidence.failed_signal) {
|
|
343
|
+
return failHere(`failed_silent: wrapper reported non-zero exit${logTailSuffix(run, cwd)}`);
|
|
344
|
+
}
|
|
345
|
+
// Heartbeat present but stale → reached the loop then went silent.
|
|
346
|
+
if (evidence.heartbeat_exists && evidence.heartbeat_age_ms !== undefined && evidence.heartbeat_age_ms >= heartbeatStale) {
|
|
347
|
+
return failHere(`stalled: heartbeat last seen ${Math.round(evidence.heartbeat_age_ms / 1000)}s ago${logTailSuffix(run, cwd)}`);
|
|
348
|
+
}
|
|
349
|
+
// Fresh heartbeat → alive; trust it over the untrustworthy wrapper pid.
|
|
350
|
+
if (evidence.heartbeat_exists) {
|
|
351
|
+
return {
|
|
352
|
+
run_id: runId, action: 'no_op',
|
|
353
|
+
reason: `heartbeat fresh (${Math.round((evidence.heartbeat_age_ms ?? 0) / 1000)}s) — worker alive, pid untrusted`,
|
|
354
|
+
evidence, previous_status, current_status: run.status,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
// Failure inference: stale + dead process + no evidence.
|
|
358
|
+
if (evidence.age_ms >= stale && evidence.process_alive === false) {
|
|
359
|
+
return failHere('silent_termination_no_evidence');
|
|
303
360
|
}
|
|
304
361
|
// Health-check window: past grace, not yet stale, no evidence either way.
|
|
305
362
|
// Emit a non-mutating event so callers see the uncertainty without
|
|
@@ -339,6 +396,7 @@ export function reconcileDeadPidRunningAgentRunAtRead(runId, cwd, options = {})
|
|
|
339
396
|
const evidence = {
|
|
340
397
|
age_ms: 0, has_post_start_commit: false, claim_released: false,
|
|
341
398
|
assignment_completed: false, process_alive: undefined,
|
|
399
|
+
completed_signal: false, failed_signal: false, heartbeat_exists: false,
|
|
342
400
|
};
|
|
343
401
|
return {
|
|
344
402
|
run_id: runId, action: 'no_op', reason: 'run not found', evidence,
|
|
@@ -352,19 +410,25 @@ export function reconcileDeadPidRunningAgentRunAtRead(runId, cwd, options = {})
|
|
|
352
410
|
evidence, previous_status: run.status, current_status: run.status,
|
|
353
411
|
};
|
|
354
412
|
}
|
|
355
|
-
if (evidence.process_alive !== false) {
|
|
356
|
-
return {
|
|
357
|
-
run_id: run.id, action: 'no_op',
|
|
358
|
-
reason: evidence.process_alive === true ? 'process alive' : 'pid liveness unknown',
|
|
359
|
-
evidence, previous_status: run.status, current_status: run.status,
|
|
360
|
-
};
|
|
361
|
-
}
|
|
362
|
-
// pid reads dead — but the tracked pid is NOT trustworthy (see doc above),
|
|
363
|
-
// so a bare dead pid NEVER cancels. Evidence of real work wins; otherwise
|
|
364
|
-
// surface the uncertainty non-destructively and leave the run `running` for
|
|
365
|
-
// reconcileAgentRun's stale-threshold path to fail it only after a fair,
|
|
366
|
-
// evidence-based delay.
|
|
367
413
|
const actor = options.actor ?? 'reconciler';
|
|
414
|
+
const stale = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
415
|
+
const heartbeatStale = options.heartbeatStaleMs ?? DEFAULT_HEARTBEAT_STALE_MS;
|
|
416
|
+
const failRun = (reason) => {
|
|
417
|
+
try {
|
|
418
|
+
transitionAgentRun(run.id, 'failed', { actor, status_reason: reason }, cwd);
|
|
419
|
+
return { run_id: run.id, action: 'inferred_failed', reason, evidence, previous_status: run.status, current_status: 'failed' };
|
|
420
|
+
}
|
|
421
|
+
catch (err) {
|
|
422
|
+
return {
|
|
423
|
+
run_id: run.id, action: 'no_op',
|
|
424
|
+
reason: `failure transition rejected: ${err instanceof Error ? err.message : String(err)}`,
|
|
425
|
+
evidence, previous_status: run.status, current_status: run.status,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
// ── pln#520 step 1: SENTINELS are authoritative, independent of the
|
|
430
|
+
// untrustworthy wrapper pid. Check them first. ──────────────────────────
|
|
431
|
+
// 1. Completion evidence (mechanical `completed` sentinel or work evidence).
|
|
368
432
|
if (anyCompletionEvidence(evidence)) {
|
|
369
433
|
try {
|
|
370
434
|
transitionAgentRun(run.id, 'completed', {
|
|
@@ -385,33 +449,43 @@ export function reconcileDeadPidRunningAgentRunAtRead(runId, cwd, options = {})
|
|
|
385
449
|
};
|
|
386
450
|
}
|
|
387
451
|
}
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
//
|
|
395
|
-
|
|
452
|
+
// 2. `failed` sentinel — the wrapper saw a non-zero agent exit. This is the
|
|
453
|
+
// FAST, TRUSTWORTHY failed_silent detector (vs the pid heuristic that caused
|
|
454
|
+
// can_f792cacd false negatives). Carries the captured log tail.
|
|
455
|
+
if (evidence.failed_signal) {
|
|
456
|
+
return failRun(`failed_silent: wrapper reported non-zero exit${logTailSuffix(run, cwd)}`);
|
|
457
|
+
}
|
|
458
|
+
// 3. Heartbeat present but STALE → the worker reached its loop then went
|
|
459
|
+
// silent (e.g. hung). pid-independent: a hung worker keeps the wrapper alive.
|
|
460
|
+
if (evidence.heartbeat_exists && evidence.heartbeat_age_ms !== undefined && evidence.heartbeat_age_ms >= heartbeatStale) {
|
|
461
|
+
return failRun(`stalled: heartbeat last seen ${Math.round(evidence.heartbeat_age_ms / 1000)}s ago${logTailSuffix(run, cwd)}`);
|
|
462
|
+
}
|
|
463
|
+
// 4. Fresh heartbeat → the worker is alive and working; trust it OVER the
|
|
464
|
+
// (untrustworthy) wrapper pid. This is the can_f792cacd fix: never fail a
|
|
465
|
+
// live, heartbeating worker just because its wrapper pid reads dead.
|
|
466
|
+
if (evidence.heartbeat_exists) {
|
|
467
|
+
return {
|
|
468
|
+
run_id: run.id, action: 'no_op',
|
|
469
|
+
reason: `heartbeat fresh (${Math.round((evidence.heartbeat_age_ms ?? 0) / 1000)}s) — worker alive, pid untrusted`,
|
|
470
|
+
evidence, previous_status: run.status, current_status: run.status,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
// ── No sentinel, no heartbeat: fall back to the pid-conservative path. The
|
|
474
|
+
// wrapper writes completed/failed on any normal exit, so reaching here means
|
|
475
|
+
// the worker has not exited and never heartbeat. Do NOT fast-fail on a dead
|
|
476
|
+
// pid (it's the wrapper's, not the worker's). ──────────────────────────────
|
|
477
|
+
if (evidence.process_alive !== false) {
|
|
478
|
+
return {
|
|
479
|
+
run_id: run.id, action: 'no_op',
|
|
480
|
+
reason: evidence.process_alive === true ? 'process alive' : 'pid liveness unknown',
|
|
481
|
+
evidence, previous_status: run.status, current_status: run.status,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
// pid dead + no sentinel + no heartbeat: only converge after the long stale
|
|
485
|
+
// window (trp#292 — must converge HERE since the read path never routes
|
|
486
|
+
// through reconcileAgentRun), giving an untrusted-pid worker ample time.
|
|
396
487
|
if (evidence.age_ms >= stale) {
|
|
397
|
-
|
|
398
|
-
transitionAgentRun(run.id, 'failed', {
|
|
399
|
-
actor,
|
|
400
|
-
status_reason: 'silent_termination_no_evidence',
|
|
401
|
-
}, cwd);
|
|
402
|
-
return {
|
|
403
|
-
run_id: run.id, action: 'inferred_failed',
|
|
404
|
-
reason: 'silent_termination_no_evidence',
|
|
405
|
-
evidence, previous_status: run.status, current_status: 'failed',
|
|
406
|
-
};
|
|
407
|
-
}
|
|
408
|
-
catch (err) {
|
|
409
|
-
return {
|
|
410
|
-
run_id: run.id, action: 'no_op',
|
|
411
|
-
reason: `failure transition rejected: ${err instanceof Error ? err.message : String(err)}`,
|
|
412
|
-
evidence, previous_status: run.status, current_status: run.status,
|
|
413
|
-
};
|
|
414
|
-
}
|
|
488
|
+
return failRun('silent_termination_no_evidence');
|
|
415
489
|
}
|
|
416
490
|
emitUnverifiedEvent(run, evidence, actor, cwd);
|
|
417
491
|
return {
|
|
@@ -457,7 +531,7 @@ export function reconcileAllOpenRuns(cwd, filter = {}, options = {}) {
|
|
|
457
531
|
catch {
|
|
458
532
|
results.push({
|
|
459
533
|
run_id: run.id, action: 'no_op', reason: 'reconcile threw — skipped',
|
|
460
|
-
evidence: { age_ms: 0, has_post_start_commit: false, claim_released: false, assignment_completed: false, process_alive: undefined },
|
|
534
|
+
evidence: { age_ms: 0, has_post_start_commit: false, claim_released: false, assignment_completed: false, process_alive: undefined, completed_signal: false, failed_signal: false, heartbeat_exists: false },
|
|
461
535
|
previous_status: run.status, current_status: run.status,
|
|
462
536
|
});
|
|
463
537
|
}
|
|
@@ -11,7 +11,7 @@ import { inferProjectFromTarget, loadInstructions, resolveInstructions } from '.
|
|
|
11
11
|
import { buildReputationSummary, findAgentReputationSummary } from './reputation.js';
|
|
12
12
|
import { listRuntimeNotes } from './runtime.js';
|
|
13
13
|
import { loadState, persistState } from './state.js';
|
|
14
|
-
import {
|
|
14
|
+
import { resolveConcurrencyLimit, serializeConcurrencyLimit } from './agent-capability.js';
|
|
15
15
|
import { loadAllSessions } from './identity.js';
|
|
16
16
|
import { countActionable } from './messaging.js';
|
|
17
17
|
import { listCandidates } from './candidates.js';
|
|
@@ -176,8 +176,7 @@ function buildOtherAgentsSummary(claims, notes, currentAgent, cwd) {
|
|
|
176
176
|
for (const identity of listAgentIdentities(cwd)) {
|
|
177
177
|
if (identity.agent_name === currentAgent)
|
|
178
178
|
continue;
|
|
179
|
-
const
|
|
180
|
-
const maxTasks = profile?.max_concurrent_tasks ?? 1;
|
|
179
|
+
const limit = serializeConcurrencyLimit(resolveConcurrencyLimit(identity.agent_name));
|
|
181
180
|
agentMap.set(identity.agent_name, {
|
|
182
181
|
name: identity.agent_name,
|
|
183
182
|
trust_level: identity.trust_level ?? 'contributor',
|
|
@@ -185,23 +184,25 @@ function buildOtherAgentsSummary(claims, notes, currentAgent, cwd) {
|
|
|
185
184
|
scopes: [],
|
|
186
185
|
has_open_session: false,
|
|
187
186
|
instance_count: sessionCounts.get(identity.agent_name) ?? 0,
|
|
188
|
-
max_tasks:
|
|
189
|
-
slots_remaining:
|
|
187
|
+
max_tasks: limit,
|
|
188
|
+
slots_remaining: limit, // will be reduced when claims are counted (null stays unlimited)
|
|
190
189
|
});
|
|
191
190
|
}
|
|
192
191
|
// Enrich with active claims
|
|
193
192
|
for (const claim of claims) {
|
|
194
193
|
if (claim.agent === currentAgent)
|
|
195
194
|
continue;
|
|
196
|
-
const
|
|
197
|
-
const maxTasks = profile?.max_concurrent_tasks ?? 1;
|
|
195
|
+
const limit = serializeConcurrencyLimit(resolveConcurrencyLimit(claim.agent));
|
|
198
196
|
const existing = agentMap.get(claim.agent) ?? {
|
|
199
197
|
name: claim.agent, trust_level: 'contributor', claim_count: 0, scopes: [],
|
|
200
198
|
has_open_session: false, instance_count: sessionCounts.get(claim.agent) ?? 0,
|
|
201
|
-
max_tasks:
|
|
199
|
+
max_tasks: limit, slots_remaining: limit,
|
|
202
200
|
};
|
|
203
201
|
existing.claim_count++;
|
|
204
|
-
|
|
202
|
+
// null max_tasks = unlimited → slots stay unlimited.
|
|
203
|
+
existing.slots_remaining = existing.max_tasks === null
|
|
204
|
+
? null
|
|
205
|
+
: Math.max(0, existing.max_tasks - existing.claim_count);
|
|
205
206
|
existing.scopes.push(claim.scope);
|
|
206
207
|
if (!existing.last_active || claim.created_at > existing.last_active) {
|
|
207
208
|
existing.last_active = claim.created_at;
|
package/dist/core/dispatcher.js
CHANGED
|
@@ -43,7 +43,8 @@ import { memoryDir } from './io.js';
|
|
|
43
43
|
import { loadVersionedJsonFile } from './migration.js';
|
|
44
44
|
import fs from 'node:fs';
|
|
45
45
|
import path from 'node:path';
|
|
46
|
-
import { buildInvokeCommand, resolveBriefMode, getCapabilityProfile } from './agent-capability.js';
|
|
46
|
+
import { buildInvokeCommand, resolveBriefMode, getCapabilityProfile, resolveConcurrencyLimit, resolveResourceKey, resolveModel, serializeConcurrencyLimit } from './agent-capability.js';
|
|
47
|
+
import { getRuntimeSignalPath } from './runtime-signals.js';
|
|
47
48
|
import { attemptExecution } from './execution.js';
|
|
48
49
|
import { createAssignment, transitionAssignment, generateAssignmentId, patchAssignmentMessageId } from './assignments.js';
|
|
49
50
|
import { createAgentRun, transitionAgentRun } from './agentruns.js';
|
|
@@ -163,13 +164,20 @@ export function analyzeSequence(cwd) {
|
|
|
163
164
|
.map(a => a.agent_name);
|
|
164
165
|
const agent_capacity = allAgentNames.map(agent => {
|
|
165
166
|
const active_claims = agentClaimCounts.get(agent) ?? 0;
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
167
|
+
// pln#520 step 3: limit is resolved (default unlimited for parallelizable
|
|
168
|
+
// CLI agents), not the per-name structural constant.
|
|
169
|
+
const limit = resolveConcurrencyLimit(agent);
|
|
170
|
+
const slots = Number.isFinite(limit) ? Math.max(0, limit - active_claims) : Infinity;
|
|
171
|
+
return {
|
|
172
|
+
agent,
|
|
173
|
+
active_claims,
|
|
174
|
+
max_tasks: serializeConcurrencyLimit(limit),
|
|
175
|
+
slots_remaining: serializeConcurrencyLimit(slots),
|
|
176
|
+
};
|
|
169
177
|
});
|
|
170
|
-
// Available agents:
|
|
178
|
+
// Available agents: unlimited (null) or with remaining capacity (> 0).
|
|
171
179
|
const available_agents = agent_capacity
|
|
172
|
-
.filter(a => a.slots_remaining > 0)
|
|
180
|
+
.filter(a => a.slots_remaining === null || a.slots_remaining > 0)
|
|
173
181
|
.map(a => a.agent);
|
|
174
182
|
return { sequence, ready, active, blocked, done, available_agents, agent_capacity };
|
|
175
183
|
}
|
|
@@ -188,6 +196,37 @@ export function analyzeSequence(cwd) {
|
|
|
188
196
|
* Protocol section IS useful to them — `resolveBriefMode` was updated to
|
|
189
197
|
* return 'full' for that combination.
|
|
190
198
|
*/
|
|
199
|
+
/**
|
|
200
|
+
* pln#520 step 5 — the liveness section of a generated brief. An imperative
|
|
201
|
+
* "do this first" instruction telling the worker to write its `work_loop_reached`
|
|
202
|
+
* heartbeat to an ABSOLUTE, writable signals path BEFORE any other action, then
|
|
203
|
+
* refresh it periodically. Zero-MCP (a plain shell redirect) so even sandboxed
|
|
204
|
+
* agents without the brainclaw MCP can comply. Completion is recorded
|
|
205
|
+
* mechanically by the spawn wrapper (step 4), so the agent only owns the
|
|
206
|
+
* heartbeat. This is the worker-side half of the liveness contract whose
|
|
207
|
+
* engine-side floor is the wrapper + reconciler (steps 4 + 1).
|
|
208
|
+
*/
|
|
209
|
+
export function buildLivenessSection(cwd, assignmentId) {
|
|
210
|
+
const hbPath = getRuntimeSignalPath(cwd, assignmentId, 'heartbeat');
|
|
211
|
+
const isWin = process.platform === 'win32';
|
|
212
|
+
const writeCmd = isWin
|
|
213
|
+
? `echo work_loop_reached ${assignmentId} > "${hbPath}"`
|
|
214
|
+
: `printf 'work_loop_reached ${assignmentId} %s' "$(date +%s)" > "${hbPath}"`;
|
|
215
|
+
return [
|
|
216
|
+
'## Liveness — DO THIS FIRST (step 0)',
|
|
217
|
+
'Before ANY other action, prove you reached your work loop by writing a heartbeat,',
|
|
218
|
+
'then refresh it every few minutes while you work. brainclaw uses this to tell',
|
|
219
|
+
'"alive and working" from "spawned but dead" — a missing/stale heartbeat marks the',
|
|
220
|
+
'run stalled. Completion is recorded automatically by the spawn wrapper; you do NOT',
|
|
221
|
+
'need to write a completed/failed signal.',
|
|
222
|
+
'',
|
|
223
|
+
'```sh',
|
|
224
|
+
writeCmd,
|
|
225
|
+
'```',
|
|
226
|
+
`Heartbeat file (absolute, writable): ${hbPath}`,
|
|
227
|
+
'',
|
|
228
|
+
].join('\n');
|
|
229
|
+
}
|
|
191
230
|
export function buildProtocolSection(options) {
|
|
192
231
|
const parts = [];
|
|
193
232
|
parts.push('## Protocol');
|
|
@@ -305,6 +344,12 @@ export function generateBrief(plan, item, cwd, briefMode, options) {
|
|
|
305
344
|
if (plan.estimated_effort)
|
|
306
345
|
parts.push(`Estimated effort: ${plan.estimated_effort} minutes`);
|
|
307
346
|
parts.push('');
|
|
347
|
+
// pln#520 step 5 — liveness heartbeat instruction, first actionable block so
|
|
348
|
+
// the worker writes work_loop_reached before anything else. Only when an
|
|
349
|
+
// assignment id is known (the heartbeat is keyed by it).
|
|
350
|
+
if (options?.assignmentId) {
|
|
351
|
+
parts.push(buildLivenessSection(cwd, options.assignmentId));
|
|
352
|
+
}
|
|
308
353
|
// Steps if any
|
|
309
354
|
if (plan.steps?.length) {
|
|
310
355
|
parts.push('## Steps');
|
|
@@ -421,14 +466,18 @@ export function scoreAgents(agentPool, plan, activeClaims, cycleAssignments) {
|
|
|
421
466
|
const canExecute = profile?.role_capabilities.includes('execute') ?? false;
|
|
422
467
|
const canSpawn = profile?.runtime.canBeSpawnedCli ?? false;
|
|
423
468
|
const capability = canExecute ? (canSpawn ? 1.0 : 0.5) : 0.1;
|
|
424
|
-
// Factor 3: Availability
|
|
425
|
-
//
|
|
469
|
+
// Factor 3 & 4: Availability + load balance.
|
|
470
|
+
// pln#520 step 3: these are based on the agent's RAW load (active claims +
|
|
471
|
+
// in-cycle assignments), decoupled from any concurrency cap. Dividing by the
|
|
472
|
+
// cap (as before) made every agent look identically idle once concurrency
|
|
473
|
+
// went unlimited, collapsing load-balancing — work piled onto the single
|
|
474
|
+
// top-scored agent. A cap-independent load fraction keeps spreading work to
|
|
475
|
+
// the least-busy agent whether or not a cap is set. The hard cap is enforced
|
|
476
|
+
// separately by the capacity guard in the dispatch loop.
|
|
426
477
|
const agentClaims = (claimCounts.get(agent) ?? 0) + (cycleAssignments?.get(agent) ?? 0);
|
|
427
|
-
const
|
|
428
|
-
const
|
|
429
|
-
const
|
|
430
|
-
// Factor 4: Load balance — normalized by agent's capacity, not raw claim count
|
|
431
|
-
const load_balance = 1.0 - utilization;
|
|
478
|
+
const loadFraction = agentClaims / (agentClaims + 1); // 0 when idle, →1 as load grows
|
|
479
|
+
const availability = 1.0 - loadFraction * 0.5; // range (0.5, 1.0]
|
|
480
|
+
const load_balance = 1.0 - loadFraction; // range (0, 1]
|
|
432
481
|
const score = preference * W_PREFERENCE +
|
|
433
482
|
capability * W_CAPABILITY +
|
|
434
483
|
availability * W_AVAILABILITY +
|
|
@@ -438,6 +487,20 @@ export function scoreAgents(agentPool, plan, activeClaims, cycleAssignments) {
|
|
|
438
487
|
}
|
|
439
488
|
// Re-export checkActiveInstance for consumers who import from dispatcher
|
|
440
489
|
export { checkActiveInstance } from './execution.js';
|
|
490
|
+
/**
|
|
491
|
+
* pln#520 step 3 — sum in-cycle assignments across every agent identity that
|
|
492
|
+
* shares the same host-binary resource (e.g. claude-code + claude-sonnet both
|
|
493
|
+
* map to `claude`). Pairs with `resolveResourceKey` so a concurrency cap pools
|
|
494
|
+
* by binary, not by agent name.
|
|
495
|
+
*/
|
|
496
|
+
function countCycleByResource(cycleAssignments, resourceKey) {
|
|
497
|
+
let total = 0;
|
|
498
|
+
for (const [agent, count] of cycleAssignments) {
|
|
499
|
+
if (resolveResourceKey(agent) === resourceKey)
|
|
500
|
+
total += count;
|
|
501
|
+
}
|
|
502
|
+
return total;
|
|
503
|
+
}
|
|
441
504
|
export function selectWorktreeBaseForReadyLane(item, analysis) {
|
|
442
505
|
const hardAfter = item.hard_after ?? [];
|
|
443
506
|
if (hardAfter.length === 0)
|
|
@@ -503,13 +566,17 @@ export async function dispatch(options, cwd) {
|
|
|
503
566
|
continue; // truly active — skip
|
|
504
567
|
// Claim released but message not archived: stale assignment, allow re-dispatch
|
|
505
568
|
}
|
|
506
|
-
// Claim-based capacity guard
|
|
507
|
-
//
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
569
|
+
// Claim-based capacity guard (pln#520 step 3): count usage per host-binary
|
|
570
|
+
// resource (claude-code + claude-sonnet share `claude`), compare against the
|
|
571
|
+
// resolved limit (default unlimited — no arbitrary per-identity throttle).
|
|
572
|
+
// This is the authoritative capacity check — covers both options.agents and
|
|
573
|
+
// analysis.available_agents paths.
|
|
574
|
+
const resourceKey = resolveResourceKey(candidate.agent);
|
|
575
|
+
const existingClaims = allActiveClaims.filter(c => resolveResourceKey(c.agent) === resourceKey).length;
|
|
576
|
+
const inCycleCount = countCycleByResource(cycleAssignments, resourceKey);
|
|
577
|
+
const limit = resolveConcurrencyLimit(candidate.agent, { override: options.maxConcurrency });
|
|
578
|
+
if (existingClaims + inCycleCount >= limit) {
|
|
579
|
+
result.warnings.push(`${candidate.agent}: at capacity (${existingClaims + inCycleCount}/${limit} ${resourceKey} slots)`);
|
|
513
580
|
continue; // try next agent
|
|
514
581
|
}
|
|
515
582
|
targetAgent = candidate.agent;
|
|
@@ -561,7 +628,7 @@ export async function dispatch(options, cwd) {
|
|
|
561
628
|
if (options.dryRun) {
|
|
562
629
|
const briefMode = resolveBriefMode(targetAgent);
|
|
563
630
|
const brief = generateBrief(readyItem.plan, readyItem.item, cwd, briefMode, { claimId, worktreePath });
|
|
564
|
-
const invokeCmd = buildInvokeCommand(targetAgent, brief);
|
|
631
|
+
const invokeCmd = buildInvokeCommand(targetAgent, brief, { model: resolveModel(targetAgent, { override: options.model }) });
|
|
565
632
|
if (invokeCmd) {
|
|
566
633
|
const cmdPrefix = buildEnvPrefix(claimId);
|
|
567
634
|
result.commands.push({ agent: targetAgent, lane: readyItem.lane, command: `${cmdPrefix}${invokeCmd.bashCommand}`, shell: process.platform === 'win32' ? 'cmd' : (invokeCmd.shell ? 'bash' : 'sh') });
|
|
@@ -571,9 +638,10 @@ export async function dispatch(options, cwd) {
|
|
|
571
638
|
result.messages_sent.push(deliveryEntry);
|
|
572
639
|
assigned++;
|
|
573
640
|
cycleAssignments.set(targetAgent, (cycleAssignments.get(targetAgent) ?? 0) + 1);
|
|
574
|
-
const
|
|
575
|
-
const
|
|
576
|
-
const
|
|
641
|
+
const dryResourceKey = resolveResourceKey(targetAgent);
|
|
642
|
+
const dryExisting = allActiveClaims.filter(c => resolveResourceKey(c.agent) === dryResourceKey).length;
|
|
643
|
+
const dryCycle = countCycleByResource(cycleAssignments, dryResourceKey);
|
|
644
|
+
const dryMax = resolveConcurrencyLimit(targetAgent, { override: options.maxConcurrency });
|
|
577
645
|
if (dryExisting + dryCycle >= dryMax) {
|
|
578
646
|
const idx = agentPool.indexOf(targetAgent);
|
|
579
647
|
if (idx >= 0)
|
|
@@ -616,7 +684,7 @@ export async function dispatch(options, cwd) {
|
|
|
616
684
|
agent: targetAgent,
|
|
617
685
|
});
|
|
618
686
|
// Step 3: Build invoke command
|
|
619
|
-
const invokeCmd = buildInvokeCommand(targetAgent, brief);
|
|
687
|
+
const invokeCmd = buildInvokeCommand(targetAgent, brief, { model: resolveModel(targetAgent, { override: options.model }) });
|
|
620
688
|
if (invokeCmd) {
|
|
621
689
|
const cmdPrefix = buildEnvPrefix(claimId);
|
|
622
690
|
result.commands.push({
|
|
@@ -707,10 +775,12 @@ export async function dispatch(options, cwd) {
|
|
|
707
775
|
assigned++;
|
|
708
776
|
// Track assignments this cycle for multi-slot capacity
|
|
709
777
|
cycleAssignments.set(targetAgent, (cycleAssignments.get(targetAgent) ?? 0) + 1);
|
|
710
|
-
// Remove agent from pool only when at capacity
|
|
711
|
-
|
|
712
|
-
const
|
|
713
|
-
const
|
|
778
|
+
// Remove agent from pool only when at capacity, counted per host-binary
|
|
779
|
+
// resource against the resolved limit (pln#520 step 3).
|
|
780
|
+
const liveResourceKey = resolveResourceKey(targetAgent);
|
|
781
|
+
const existingClaims = allActiveClaims.filter(c => resolveResourceKey(c.agent) === liveResourceKey).length;
|
|
782
|
+
const cycleCount = countCycleByResource(cycleAssignments, liveResourceKey);
|
|
783
|
+
const maxTasks = resolveConcurrencyLimit(targetAgent, { override: options.maxConcurrency });
|
|
714
784
|
if (existingClaims + cycleCount >= maxTasks) {
|
|
715
785
|
const idx = agentPool.indexOf(targetAgent);
|
|
716
786
|
if (idx >= 0)
|
|
@@ -21,12 +21,13 @@ import { deleteAssignment, listAssignments, loadAssignment, saveAssignment, tran
|
|
|
21
21
|
import { listAgentRuns } from './agentruns.js';
|
|
22
22
|
import { reconcileAgentRun, reconcileDeadPidRunningAgentRunAtRead, TERMINAL_STATUSES } from './agentrun-reconciler.js';
|
|
23
23
|
import { deleteRuntimeNote, listRuntimeNotes, saveRuntimeNote, } from './runtime.js';
|
|
24
|
+
import { createSequence, deleteSequence, listSequences, updateSequence, } from './sequence.js';
|
|
24
25
|
import { createConstraint, createDecision, createTrap, } from './operations/memory-write.js';
|
|
25
26
|
import { deleteMemoryItem, findMemoryItemInChain, updateMemoryItem, } from './operations/memory-mutation.js';
|
|
26
27
|
import { createPlan, deletePlan, updatePlan, } from './operations/plan.js';
|
|
27
28
|
import { ENTITY_REGISTRY, isValidTransition, } from './entity-registry.js';
|
|
28
29
|
import { generateId } from './ids.js';
|
|
29
|
-
import { CandidateTypeSchema, ConstraintCategorySchema, DecisionOutcomeSchema, MemoryVisibilitySchema, PlanTypeEnumSchema, PrioritySchema, RuntimeNoteTypeSchema, SeveritySchema, } from './schema.js';
|
|
30
|
+
import { CandidateTypeSchema, ConstraintCategorySchema, DecisionOutcomeSchema, MemoryVisibilitySchema, PlanTypeEnumSchema, PrioritySchema, RuntimeNoteTypeSchema, SequenceStatusSchema, SeveritySchema, } from './schema.js';
|
|
30
31
|
/**
|
|
31
32
|
* Default provenance stamp applied on create when the caller does not
|
|
32
33
|
* supply one. `user` kind with whatever author is in the payload; the
|
|
@@ -134,6 +135,7 @@ function loadAll(name, cwd) {
|
|
|
134
135
|
case 'handoff': return loadState(cwd).open_handoffs;
|
|
135
136
|
case 'candidate': return listCandidates(undefined, cwd);
|
|
136
137
|
case 'runtime_note': return listRuntimeNotes(undefined, cwd);
|
|
138
|
+
case 'sequence': return listSequences(cwd);
|
|
137
139
|
case 'claim': return listClaims(cwd);
|
|
138
140
|
case 'action': return listActionRequired(cwd);
|
|
139
141
|
case 'assignment': return listAssignments(cwd);
|
|
@@ -310,6 +312,19 @@ export function createEntity(name, data, cwd) {
|
|
|
310
312
|
saveCandidate(candidate, cwd);
|
|
311
313
|
return { entity: name, id };
|
|
312
314
|
}
|
|
315
|
+
case 'sequence': {
|
|
316
|
+
const res = createSequence({
|
|
317
|
+
name: requireString(data, 'name'),
|
|
318
|
+
description: data.description,
|
|
319
|
+
status: requireEnum(data, 'status', SequenceStatusSchema.options, { optional: true }),
|
|
320
|
+
items: optionalSequenceItems(data),
|
|
321
|
+
owner: data.owner,
|
|
322
|
+
author: requireString(data, 'author'),
|
|
323
|
+
authorId: data.agent_id,
|
|
324
|
+
tags: data.tags,
|
|
325
|
+
}, cwd);
|
|
326
|
+
return { entity: name, id: res.id, short_label: res.shortLabel };
|
|
327
|
+
}
|
|
313
328
|
case 'cross_project_link': {
|
|
314
329
|
const link = addCrossProjectLink({
|
|
315
330
|
path: requireString(data, 'path'),
|
|
@@ -398,6 +413,20 @@ export function updateEntity(name, id, patch, cwd) {
|
|
|
398
413
|
saveCandidate(patched, cwd);
|
|
399
414
|
return { entity: name, id };
|
|
400
415
|
}
|
|
416
|
+
case 'sequence': {
|
|
417
|
+
// `status` is intentionally NOT in sequence.updatable — lifecycle moves
|
|
418
|
+
// go through bclaw_transition. The invalidFields guard above already
|
|
419
|
+
// rejects it, so only name/description/tags/items/owner reach here.
|
|
420
|
+
const result = updateSequence({
|
|
421
|
+
id,
|
|
422
|
+
name: patch.name,
|
|
423
|
+
description: patch.description,
|
|
424
|
+
items: optionalSequenceItems(patch),
|
|
425
|
+
owner: patch.owner,
|
|
426
|
+
tags: patch.tags,
|
|
427
|
+
}, cwd);
|
|
428
|
+
return { entity: name, id: result.id };
|
|
429
|
+
}
|
|
401
430
|
case 'cross_project_link': {
|
|
402
431
|
// In-place patch: find by id (= name/path), remove, re-add with merged
|
|
403
432
|
// fields. Same path semantics as resolveCrossProjectTarget so callers can
|
|
@@ -450,6 +479,16 @@ export function removeEntity(name, id, cwd, purge = false) {
|
|
|
450
479
|
archiveCandidate(candidate, 'rejected', cwd);
|
|
451
480
|
return { entity: name, id, archived: true, purged: false };
|
|
452
481
|
}
|
|
482
|
+
case 'sequence': {
|
|
483
|
+
// purge → hard-delete the file; default → soft-archive (status='archived',
|
|
484
|
+
// the sequence terminal state) so the lane history stays auditable.
|
|
485
|
+
if (purge) {
|
|
486
|
+
const deleted = deleteSequence(id, cwd);
|
|
487
|
+
return { entity: name, id: deleted.id, archived: false, purged: true };
|
|
488
|
+
}
|
|
489
|
+
const archived = updateSequence({ id, status: 'archived' }, cwd);
|
|
490
|
+
return { entity: name, id: archived.id, archived: true, purged: false };
|
|
491
|
+
}
|
|
453
492
|
case 'cross_project_link': {
|
|
454
493
|
const removed = removeCrossProjectLink(id, cwd);
|
|
455
494
|
return { entity: name, id: removed.name ?? removed.path, archived: false, purged: true };
|
|
@@ -530,6 +569,12 @@ export function transitionEntity(name, id, to, cwd, _reason) {
|
|
|
530
569
|
}, cwd);
|
|
531
570
|
return { entity: name, id, from, to, side_effects: sideEffects };
|
|
532
571
|
}
|
|
572
|
+
case 'sequence': {
|
|
573
|
+
// isValidTransition above already enforced the registry matrix
|
|
574
|
+
// (draft→active|archived, active→archived); updateSequence persists it.
|
|
575
|
+
updateSequence({ id, status: to }, cwd);
|
|
576
|
+
return { entity: name, id, from, to, side_effects: sideEffects };
|
|
577
|
+
}
|
|
533
578
|
default:
|
|
534
579
|
throw new EntityOperationUnsupportedError(name, 'transition', `Lifecycle transitions for ${name} not yet wired.`);
|
|
535
580
|
}
|
|
@@ -559,6 +604,14 @@ function requireString(data, field) {
|
|
|
559
604
|
}
|
|
560
605
|
return value;
|
|
561
606
|
}
|
|
607
|
+
function optionalSequenceItems(data) {
|
|
608
|
+
if (!('items' in data) || data.items === undefined || data.items === null)
|
|
609
|
+
return undefined;
|
|
610
|
+
if (!Array.isArray(data.items)) {
|
|
611
|
+
throw new Error(`Invalid value for 'items': expected an array of sequence item objects`);
|
|
612
|
+
}
|
|
613
|
+
return data.items;
|
|
614
|
+
}
|
|
562
615
|
/**
|
|
563
616
|
* Validates that data[field] is one of `validValues`, throwing a clear
|
|
564
617
|
* error message when the value is invalid. Fixes the silent-data-loss bug
|