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.
@@ -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
- return { age_ms, has_post_start_commit, claim_released, assignment_completed, process_alive };
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.has_post_start_commit
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
- // Failure inference: stale + dead process + no evidence.
284
- if (evidence.age_ms >= stale && evidence.process_alive === false) {
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
- actor,
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
- // Stale + provably dead + still no evidence -> genuine silent failure. This
389
- // MUST converge HERE: the canonical read path (entity-operations.ts) and the
390
- // MCP pre-read sweep route `running` runs through this function, never
391
- // through reconcileAgentRun, so deferring would leave a crashed run `running`
392
- // forever (trp#292). The 30-min stale window — vs the immediate cancel before
393
- // pln#520 — gives a worker behind an untrusted pid ample time to leave
394
- // evidence first. Reported as `failed` (it died), not `cancelled`.
395
- const stale = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
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
- try {
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 { getCapabilityProfile } from './agent-capability.js';
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 profile = getCapabilityProfile(identity.agent_name);
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: maxTasks,
189
- slots_remaining: maxTasks, // will be reduced when claims are counted
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 profile = getCapabilityProfile(claim.agent);
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: maxTasks, slots_remaining: maxTasks,
199
+ max_tasks: limit, slots_remaining: limit,
202
200
  };
203
201
  existing.claim_count++;
204
- existing.slots_remaining = Math.max(0, existing.max_tasks - existing.claim_count);
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;
@@ -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
- const profile = getCapabilityProfile(agent);
167
- const max_tasks = profile?.max_concurrent_tasks ?? 1;
168
- return { agent, active_claims, max_tasks, slots_remaining: Math.max(0, max_tasks - active_claims) };
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: those with remaining capacity (slots_remaining > 0)
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 graduated by utilization (claims / max_concurrent_tasks)
425
- // Include in-cycle assignments so load-balance works within a single dispatch call
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 maxTasks = profile?.max_concurrent_tasks ?? 1;
428
- const utilization = Math.min(1.0, agentClaims / maxTasks);
429
- const availability = 1.0 - (utilization * 0.5); // range [0.5, 1.0]
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: check claims (existing + this cycle) against max_concurrent_tasks.
507
- // This is the authoritative capacity check covers both options.agents and analysis.available_agents paths.
508
- const existingClaims = allActiveClaims.filter(c => c.agent === candidate.agent).length;
509
- const inCycleCount = cycleAssignments.get(candidate.agent) ?? 0;
510
- const maxTasks = getCapabilityProfile(candidate.agent)?.max_concurrent_tasks ?? 1;
511
- if (existingClaims + inCycleCount >= maxTasks) {
512
- result.warnings.push(`${candidate.agent}: at capacity (${existingClaims + inCycleCount}/${maxTasks} claims)`);
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 dryExisting = allActiveClaims.filter(c => c.agent === targetAgent).length;
575
- const dryCycle = cycleAssignments.get(targetAgent) ?? 0;
576
- const dryMax = getCapabilityProfile(targetAgent)?.max_concurrent_tasks ?? 1;
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 (existing claims + this cycle's assignments)
711
- const existingClaims = allActiveClaims.filter(c => c.agent === targetAgent).length;
712
- const cycleCount = cycleAssignments.get(targetAgent) ?? 0;
713
- const maxTasks = getCapabilityProfile(targetAgent)?.max_concurrent_tasks ?? 1;
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