brainclaw 1.13.0 → 1.15.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.
@@ -21,13 +21,19 @@ import { memoryExists } from '../core/io.js';
21
21
  import { loadAssignment, transitionAssignment } from '../core/assignments.js';
22
22
  import { loadClaim, releaseClaimsCascade, logCascadeReleaseResult } from '../core/claims.js';
23
23
  import { getCapabilityProfile, dispatchCanCommit } from '../core/agent-capability.js';
24
- import { commitWorktreeOnBehalf, worktreesBaseDir } from '../core/worktree.js';
24
+ import { commitWorktreeOnBehalf, worktreesBaseDir, resolveGitToplevel } from '../core/worktree.js';
25
25
  /**
26
26
  * Auto-detect all worktree directories under the brainclaw-managed base dir.
27
27
  * Returns subdirectories that exist on disk (may or may not have an inbox).
28
28
  */
29
29
  function autoDetectWorktreePaths(cwd) {
30
- const base = worktreesBaseDir(cwd);
30
+ // Codex review of PR #49 (MED): createWorktree now writes in-tree worktrees
31
+ // under the git-TOPLEVEL hash (pln#614), so the scan base must resolve the
32
+ // toplevel too — otherwise `harvest --all` / candidates from an in-tree
33
+ // project subdir scan the stale subdir hash and miss every lane result. Only
34
+ // the scan base is toplevel-resolved; .brainclaw store reads/writes elsewhere
35
+ // keep the original project cwd.
36
+ const base = worktreesBaseDir(resolveGitToplevel(cwd));
31
37
  if (!fs.existsSync(base))
32
38
  return [];
33
39
  return fs.readdirSync(base, { withFileTypes: true })
@@ -9,6 +9,10 @@ import { generatedSchemas } from './mcp-schemas.generated.js';
9
9
  import { getTriggeredItems, renderTriggeredItems } from '../core/lifecycle.js';
10
10
  import { resolveCrossProjectLinks, resolveCrossProjectWritableTarget, resolveProjectCwd, writeCrossProjectSignal } from '../core/cross-project.js';
11
11
  import { buildContext, renderContextMarkdown, renderContextPromptTemplate, renderContextBriefing } from '../core/context.js';
12
+ import { ageStaleWarnings, ageWorkflowHints, loadServeRegistry } from '../core/hint-aging.js';
13
+ import { loadHygienePolicy } from '../core/hygiene-policy.js';
14
+ import { sweepAssignmentsAtReadPath, selectReadPathSweepCandidates } from '../core/assignment-sweeper.js';
15
+ import { loadAssignment } from '../core/assignments.js';
12
16
  import { buildCoordinationSnapshot } from '../core/coordination.js';
13
17
  import { checkBrainclawInstallableUpdate, getInstalledBrainclawVersion, readDiskBrainclawVersion, renderBrainclawInstallableUpdateNotice } from '../core/brainclaw-version.js';
14
18
  import { loadConfig } from '../core/config.js';
@@ -23,7 +27,7 @@ import { ENTITY_REGISTRY } from '../core/entity-registry.js';
23
27
  import { generateClaimId, listClaims, loadClaim, saveClaim, createCoordinatorClaim, adoptClaimSession, attachAssignmentMessageToClaim, linkClaimToAssignment, releaseClaimWithCascade } from '../core/claims.js';
24
28
  import { createSequence, updateSequence, deleteSequence } from '../core/sequence.js';
25
29
  import { assertCrossProjectBoundary, checkPolicy } from '../core/policy.js';
26
- import { createWorktree as coreCreateWorktree } from '../core/worktree.js';
30
+ import { createWorktree as coreCreateWorktree, sanitizeBranchComponent } from '../core/worktree.js';
27
31
  import { createRuntimeNote } from './runtime-note.js';
28
32
  import { createCandidateFromInput } from './reflect.js';
29
33
  import { acceptCandidate } from './accept.js';
@@ -52,7 +56,7 @@ import { deleteMemoryItem, updateMemoryItem } from '../core/operations/memory-mu
52
56
  import { assessMemoryPressure, buildCompactionTemplate, applyCompaction } from '../core/gc-semantic.js';
53
57
  import { WorkRequestSchema, CoordinateRequestSchema } from '../core/facade-schema.js';
54
58
  import { codeMapWorkSection, codeMapRefreshNextActions } from '../core/code-map/work-section.js';
55
- import { getSpawnableAgents, getCapabilityProfile, buildInvokeCommand, validateAgentForDispatch } from '../core/agent-capability.js';
59
+ import { getSpawnableAgents, getCapabilityProfile, buildInvokeCommand, validateAgentForDispatch, resolveModel } from '../core/agent-capability.js';
56
60
  import { attemptExecution } from '../core/execution.js';
57
61
  import { createAgentRun, transitionAgentRun } from '../core/agentruns.js';
58
62
  import { sweepDeadPidRunningAgentRunsAtRead } from '../core/agentrun-reconciler.js';
@@ -516,6 +520,7 @@ const MCP_WRITE_TOOLS = [
516
520
  agents: { type: 'array', items: { type: 'string' }, description: 'Only dispatch to these agents. Default: all available.' },
517
521
  lanes: { type: 'array', items: { type: 'string' }, description: 'Only dispatch items in these lanes. Also used by intent=analysis.' },
518
522
  maxAssignments: { type: 'number', description: 'Max assignments to make (default: all ready). intent=execute only.' },
523
+ model: { type: 'string', description: 'Model to run on spawned workers, decoupled from agent identity (e.g. "sonnet", "gpt-5-codex"). Injected as `<model_flag> <model>` for agents that declare one (claude-code/codex/copilot); no-op for template-pinned identities. Mirrors the CLI `brainclaw dispatch run --model`. intent=execute only.' },
519
524
  dryRun: { type: 'boolean', description: 'Preview without sending. Accepted by all intents.' },
520
525
  autoExecute: { type: 'boolean', description: 'Attempt to spawn agents after delivery (default: true). intent=execute only.' },
521
526
  // intent=review args (forwarded to bclaw_dispatch_review)
@@ -1047,6 +1052,7 @@ const MCP_WRITE_TOOLS = [
1047
1052
  project: { type: 'string', description: 'Optional (pln#359 phase 1b): name of a linked project to dispatch into. When set, claim/assignment/message all land in the target project — the target agent picks the brief up async via its own bclaw_work. Auto-spawn is disabled in cross-project mode. Accepts cross_project_links and workspace store-chain children (see `brainclaw link list`).' },
1048
1053
  allow_dirty: { type: 'boolean', description: 'Override the scope-aware dirty-working-tree guard (trp#371 Tier 2). The guard runs only for worktree-spawning intents (assign/review/reroute) and blocks only when uncommitted files overlap — or cannot be proven disjoint from — the dispatch scope (the worker spawns from HEAD and will not see them). `.brainclaw/` and `.git/` are always excluded. Set true to proceed anyway (the block is downgraded to a warning that lists the overlapping files). Boolean; the string "true"/"false" are also coerced.' },
1049
1054
  ref: { type: 'string', description: 'Optional git ref (commit/branch/tag) for assign/review/reroute: the dispatched worker builds its worktree from this ref instead of HEAD. When set, uncommitted working-tree changes are intentionally out of scope and the dirty guard allows the dispatch. Ignored by consult/ideate/summarize (no worktree).' },
1055
+ model: { type: 'string', description: 'Model to run on the spawned worker, decoupled from agent identity (e.g. "sonnet", "gpt-5-codex", "gpt-5.4"). Injected as `<model_flag> <model>` into the invoke command for agents that declare one (claude-code/codex/github-copilot); no-op for template-pinned pseudo-identities (e.g. claude-sonnet) or agents without a model_flag. Highest-priority link in the model resolution chain (override > lane > identity > default). Applies to intents that spawn a worker (assign/consult/review/reroute/ideate); ignored by summarize.' },
1050
1056
  },
1051
1057
  required: ['intent', 'task'],
1052
1058
  },
@@ -3538,7 +3544,9 @@ async function _executeMcpToolCallInner(payload) {
3538
3544
  // worktree:false) for an advisory-only lock with no worktree.
3539
3545
  const advisoryClaim = args.advisory === true || args.worktree === false;
3540
3546
  if (!advisoryClaim) {
3541
- const branchSlug = claimScope.replace(/[^a-zA-Z0-9._-]/g, '-').replace(/-+/g, '-').slice(0, 48);
3547
+ // Shared slug logic (trp#950): collision-resistant when the scope
3548
+ // exceeds the branch-component cap, and identical to createCoordinatorClaim.
3549
+ const branchSlug = sanitizeBranchComponent(claimScope);
3542
3550
  const worktreeBranch = args.worktreeBranch?.trim() || `feat/${branchSlug}`;
3543
3551
  try {
3544
3552
  worktreePath = coreCreateWorktree(claimCwd, worktreeBranch, {
@@ -3604,7 +3612,7 @@ async function _executeMcpToolCallInner(payload) {
3604
3612
  const { execSync } = await import('node:child_process');
3605
3613
  const branch = execSync('git branch --show-current', { cwd: claimCwd, encoding: 'utf-8' }).trim();
3606
3614
  if (branch === 'master' || branch === 'main') {
3607
- const branchSlug = claimScope.replace(/[^a-zA-Z0-9._-]/g, '-').replace(/-+/g, '-').slice(0, 48);
3615
+ const branchSlug = sanitizeBranchComponent(claimScope);
3608
3616
  branchWarn = `\n⚠️ You are on ${branch}. Create a feature branch before editing: git checkout -b feat/${branchSlug}`;
3609
3617
  }
3610
3618
  }
@@ -4044,6 +4052,7 @@ async function _executeMcpToolCallInner(payload) {
4044
4052
  dispatcherAgentId: resolved.identity.agent_id,
4045
4053
  sessionId: connectionSessionId,
4046
4054
  autoExecute: args.autoExecute,
4055
+ model: args.model,
4047
4056
  }, cwd);
4048
4057
  if (!result) {
4049
4058
  return { response: createToolErrorResponse('operation_error', 'No active sequence found. Create a sequence first.') };
@@ -5095,6 +5104,45 @@ async function _executeMcpToolCallInner(payload) {
5095
5104
  }
5096
5105
  }
5097
5106
  }
5107
+ let stalePostAging;
5108
+ let staleAggregate;
5109
+ let hintsPostAging;
5110
+ let hintsAggregate;
5111
+ if (contextResult) {
5112
+ try {
5113
+ const policy = loadHygienePolicy(targetCwd);
5114
+ if (!policy.disabled) {
5115
+ // Filter candidates from the projection (no extra reads) — only
5116
+ // assignments whose surfaced last_heartbeat_at is old enough to
5117
+ // possibly cross a family TTL. Zero read overhead when the open
5118
+ // work is fresh (the common case).
5119
+ const openAssignments = contextResult.open_work?.active_assignments ?? [];
5120
+ // Codex PR#48 finding 3 (pln#578 guardrail): select candidate ids
5121
+ // from the already-surfaced projection — created/terminal rows are
5122
+ // dropped BEFORE any full loadAssignment, so a healthy store costs
5123
+ // zero extra file reads. Selection logic is unit-tested in
5124
+ // selectReadPathSweepCandidates.
5125
+ const candidateIds = selectReadPathSweepCandidates(openAssignments, policy, Date.now());
5126
+ if (candidateIds.length > 0) {
5127
+ const full = candidateIds
5128
+ .map((id) => loadAssignment(id, targetCwd))
5129
+ .filter((a) => a !== undefined);
5130
+ sweepAssignmentsAtReadPath(full, targetCwd, {
5131
+ actor: 'bclaw_work-readpath',
5132
+ policy,
5133
+ });
5134
+ }
5135
+ const registry = loadServeRegistry(targetCwd);
5136
+ const aged = ageStaleWarnings(contextResult.stale_warnings ?? [], targetCwd, { policy, registry });
5137
+ stalePostAging = aged.warnings;
5138
+ staleAggregate = aged.aggregate;
5139
+ const agedHints = ageWorkflowHints(contextResult.workflow_hints ?? [], targetCwd, { policy, registry });
5140
+ hintsPostAging = agedHints.hints;
5141
+ hintsAggregate = agedHints.aggregate;
5142
+ }
5143
+ }
5144
+ catch { /* non-fatal — hygiene must never break bclaw_work */ }
5145
+ }
5098
5146
  // Build the full context result, then compact it if requested.
5099
5147
  // Compact mode (default) strips the heavy ContextResult down to a
5100
5148
  // minimal summary that fits within MCP token limits (~25k chars).
@@ -5110,7 +5158,8 @@ async function _executeMcpToolCallInner(payload) {
5110
5158
  status: item.extra ?? 'unknown',
5111
5159
  plan_id: item.plan_id,
5112
5160
  }));
5113
- const staleTop3 = (contextResult.stale_warnings ?? []).slice(0, 3).map((w) => ({
5161
+ const stalePool = stalePostAging ?? contextResult.stale_warnings ?? [];
5162
+ const staleTop3 = stalePool.slice(0, 3).map((w) => ({
5114
5163
  id: w.id,
5115
5164
  entity: w.entity,
5116
5165
  text: w.text.slice(0, 80),
@@ -5134,6 +5183,7 @@ async function _executeMcpToolCallInner(payload) {
5134
5183
  : {}),
5135
5184
  }
5136
5185
  : undefined;
5186
+ const hintsPool = hintsPostAging ?? contextResult.workflow_hints ?? [];
5137
5187
  resultPayload = {
5138
5188
  context_schema: contextResult.context_schema,
5139
5189
  profile: contextResult.profile,
@@ -5142,7 +5192,9 @@ async function _executeMcpToolCallInner(payload) {
5142
5192
  context_diff: trimmedDiff ?? null,
5143
5193
  plan_summary: planItems,
5144
5194
  stale_warnings: staleTop3,
5145
- workflow_hints: (contextResult.workflow_hints ?? []).slice(0, 3),
5195
+ ...(staleAggregate ? { stale_warnings_aggregate: staleAggregate } : {}),
5196
+ workflow_hints: hintsPool.slice(0, 3),
5197
+ ...(hintsAggregate ? { workflow_hints_aggregate: hintsAggregate } : {}),
5146
5198
  claim_conflicts: contextResult.claim_conflicts ?? [],
5147
5199
  open_work: contextResult.open_work ?? null,
5148
5200
  _compact: true,
@@ -5580,6 +5632,15 @@ async function _executeMcpToolCallInner(payload) {
5580
5632
  side_effects.push({ action: 'create', entity: 'message', id: msgResult.id });
5581
5633
  const invoke = buildInvokeCommand(input.agent, input.text, {
5582
5634
  mode: input.commandMode ?? 'worker',
5635
+ // pln#520/#606 — decouple model from agent identity. req.model is the
5636
+ // override link; when unset, resolveModel intentionally falls back to
5637
+ // the profile's default_model (the documented last link in the chain),
5638
+ // mirroring the dispatcher's resolveModel usage (dispatcher.ts) so
5639
+ // coordinate and dispatch spawn with the same model. No profile ships
5640
+ // a default_model today, so omitting model stays a no-op in practice
5641
+ // (gpt-5.6-luna review). Flows to both the manual commandHint and the
5642
+ // auto-spawn path (runCoordinateExecution reuses this invoke).
5643
+ model: resolveModel(input.agent, { override: req.model }),
5583
5644
  });
5584
5645
  // Build env prefix for claim routing — centralised in
5585
5646
  // execution-profile.ts:buildClaimEnvPrefix as of pln#496 step
@@ -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
@@ -140,8 +140,17 @@ function ensureParentDir(filepath) {
140
140
  fs.mkdirSync(dir, { recursive: true });
141
141
  }
142
142
  }
143
- function fingerprintPublicKey(publicKey) {
144
- return crypto.createHash('sha256').update(publicKey).digest('hex');
143
+ /**
144
+ * Canonical Ed25519 public-key fingerprint (pln#101): sha256 over the PEM with
145
+ * carriage returns stripped and surrounding whitespace trimmed, so a trailing
146
+ * newline or CRLF (e.g. introduced by copy-paste through the cloud UI) does NOT
147
+ * change the fingerprint. The cloud computes the same canonical value — see
148
+ * brainclaw-cloud/src/handlers/agents.ts fingerprintPem — so a local↔remote
149
+ * match is a reliable proof of the same key regardless of PEM formatting.
150
+ */
151
+ export function fingerprintPublicKeyPem(publicKeyPem) {
152
+ const canonical = publicKeyPem.replace(/\r/g, '').trim();
153
+ return crypto.createHash('sha256').update(canonical).digest('hex');
145
154
  }
146
155
  function buildIdentityKey(agentId, env = process.env, forceRegenerate = false) {
147
156
  migrateLegacyAgentKey(agentId, env);
@@ -168,10 +177,49 @@ function buildIdentityKey(agentId, env = process.env, forceRegenerate = false) {
168
177
  return {
169
178
  algorithm: 'ed25519',
170
179
  public_key: publicKeyPem,
171
- fingerprint: fingerprintPublicKey(publicKeyPem),
180
+ fingerprint: fingerprintPublicKeyPem(publicKeyPem),
172
181
  created_at: createdAt,
173
182
  };
174
183
  }
184
+ /**
185
+ * Load an agent's Ed25519 signing material for cloud request signing (pln#100).
186
+ *
187
+ * Reads the private key from the neutral key store (~/.brainclaw/keys/),
188
+ * migrating from the legacy CODEX_HOME location if needed, and derives the SPKI
189
+ * public-key PEM plus its fingerprint — the same sha256(pem) the cloud stores as
190
+ * agents.key_fingerprint, so a local↔remote fingerprint match is a byte-for-byte
191
+ * proof of the same key. Returns undefined when no key has been generated yet:
192
+ * signing NEVER silently mints a key (the public key must be registered with the
193
+ * cloud first). Use registerAgentIdentity({ generateFingerprint: true }) to mint one.
194
+ */
195
+ export function loadAgentSigningKey(agentId, env = process.env) {
196
+ migrateLegacyAgentKey(agentId, env);
197
+ const filepath = agentKeyPath(agentId);
198
+ if (!fs.existsSync(filepath))
199
+ return undefined;
200
+ const privateKeyPem = fs.readFileSync(filepath, 'utf-8');
201
+ const privateKey = crypto.createPrivateKey(privateKeyPem);
202
+ // @types/node 26 dropped the KeyObject overload from createPublicKey's signature
203
+ // (see buildIdentityKey) — cast to a parameter type the .d.ts still accepts.
204
+ const publicKeyPem = crypto
205
+ .createPublicKey(privateKey)
206
+ .export({ type: 'spki', format: 'pem' })
207
+ .toString();
208
+ return { privateKeyPem, publicKeyPem, fingerprint: fingerprintPublicKeyPem(publicKeyPem) };
209
+ }
210
+ /**
211
+ * Ensure an agent has an Ed25519 signing key, WITHOUT rotating an existing one
212
+ * (pln#101). Generates the keypair on first call, returns the existing key on
213
+ * subsequent calls — so it is safe to run after the public key has been
214
+ * approved in the cloud (rotating would break the fingerprint match). Returns
215
+ * the SPKI public-key PEM + its sha256(pem) fingerprint.
216
+ */
217
+ export function ensureAgentSigningKey(agentId, env = process.env) {
218
+ const key = buildIdentityKey(agentId, env, false);
219
+ if (!key)
220
+ throw new Error(`Failed to derive signing key for agent ${agentId}`);
221
+ return { publicKeyPem: key.public_key, fingerprint: key.fingerprint };
222
+ }
175
223
  function withIdentityKey(agent, env = process.env, forceRegenerate = false) {
176
224
  return {
177
225
  ...agent,
@@ -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 all = listAssignments(cwd);
111
- for (const assignment of all) {
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 > assignment.heartbeat_ttl_ms) {
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, assignment.heartbeat_ttl_ms);
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(assignment.heartbeat_ttl_ms / 60_000)}min); implicit evidence: ${evidence.description}`,
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 acceptance_ttl for accepted→timed_out (same window: agent should start quickly after accepting)
143
- if (ageMs > assignment.acceptance_ttl_ms) {
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, assignment.acceptance_ttl_ms);
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 > assignment.acceptance_ttl_ms) {
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, assignment.acceptance_ttl_ms);
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(assignment.acceptance_ttl_ms / 60_000)}min); no implicit evidence`,
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
@@ -14,6 +14,7 @@ import { loadSessionById } from './identity.js';
14
14
  import { loadState, persistState } from './state.js';
15
15
  import { createRuntimeEvent } from './events.js';
16
16
  import { emitRegistryPostImage, registryFaultPoint } from './events/registry-post-image.js';
17
+ import { maybeEnqueueClaimTransition, isFederationEnqueueActive } from './federation-outbox.js';
17
18
  /** Parse duration string like '4h', '30m' to ms. */
18
19
  function parseTtl(value) {
19
20
  const match = /^(\d+)([mhd])$/i.exec(value.trim());
@@ -69,9 +70,26 @@ function saveClaimUnlocked(claim, cwd, options) {
69
70
  // pln#568 (I2): journal the post-image BEFORE the projection write, so a
70
71
  // crash can only leave the journal ahead of the projection, never behind.
71
72
  const created = !store.exists(parsed.id);
73
+ // Federation (pln#101): capture the PREVIOUS status BEFORE the write so we can
74
+ // diff it after (create or active↔terminal transition ⇒ enqueue for cloud
75
+ // sync). Only pay the prev-load when federation is actually active; this whole
76
+ // block runs under the store mutation mutex, which serializes rev reservation.
77
+ const fedActive = isFederationEnqueueActive(cwd, options?.federation?.suppressEnqueue);
78
+ let fedPrevStatus;
79
+ if (fedActive) {
80
+ try {
81
+ fedPrevStatus = loadClaimFromAnyDir(parsed.id, cwd).status;
82
+ }
83
+ catch {
84
+ fedPrevStatus = undefined;
85
+ }
86
+ }
72
87
  emitRegistryPostImage('claim', parsed, { created, agent: parsed.agent, agent_id: parsed.agent_id, session_id: parsed.session_id, cwd });
73
88
  registryFaultPoint('after_registry_journal');
74
89
  store.save(parsed);
90
+ if (fedActive) {
91
+ maybeEnqueueClaimTransition(parsed, fedPrevStatus, fedPrevStatus === undefined, cwd, options?.federation?.suppressEnqueue);
92
+ }
75
93
  const writeDir = claimsDir(cwd, 'write');
76
94
  for (const dirPath of claimDirs(cwd)) {
77
95
  if (dirPath === writeDir)
@@ -109,6 +109,18 @@ export const CoordinateRequestSchema = z.object({
109
109
  * rejected as `preset_kind_mismatch`.
110
110
  */
111
111
  preset: z.string().min(1).optional(),
112
+ /**
113
+ * pln#520 step 3 / pln#606 — model to run on the spawned worker, decoupled
114
+ * from agent identity. Passed through to `resolveModel({ override })` and
115
+ * injected as `<model_flag> <model>` into the invoke command for agents that
116
+ * declare a `model_flag` (e.g. `claude-code --model sonnet`, `codex exec
117
+ * --model …`, `copilot --model …`). No-op for agents whose template already
118
+ * pins a model (e.g. the `claude-sonnet` pseudo-identity) or that declare no
119
+ * `model_flag`. Highest-priority link in the model resolution chain. Ignored
120
+ * by intents that don't spawn a worker (summarize). Mirrors the CLI
121
+ * `brainclaw dispatch run --model <name>` flag for CLI/MCP parity.
122
+ */
123
+ model: z.string().min(1).optional(),
112
124
  });
113
125
  export const FacadeArtifactSchema = z.object({
114
126
  type: z.string(),