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.
- package/README.md +6 -252
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +184 -15
- package/dist/commands/doctor.js +98 -0
- package/dist/commands/harvest.js +8 -2
- package/dist/commands/mcp.js +67 -6
- package/dist/commands/session-start.js +16 -1
- package/dist/core/agent-registry.js +51 -3
- package/dist/core/assignment-sweeper.js +92 -11
- package/dist/core/claims.js +18 -0
- package/dist/core/facade-schema.js +12 -0
- package/dist/core/federation-cloud.js +142 -11
- package/dist/core/federation-outbox.js +292 -0
- package/dist/core/federation-signing.js +115 -0
- package/dist/core/gc-semantic.js +79 -0
- package/dist/core/hint-aging.js +188 -0
- package/dist/core/hygiene-policy.js +77 -0
- package/dist/core/io.js +6 -0
- package/dist/core/schema.js +33 -0
- package/dist/core/worktree.js +77 -4
- package/dist/facts.js +6 -6
- package/dist/facts.json +5 -5
- package/docs/concepts/dispatch-supervisor.md +393 -0
- package/docs/mcp-schema-changelog.md +18 -2
- package/package.json +1 -1
package/dist/commands/harvest.js
CHANGED
|
@@ -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
|
-
|
|
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 })
|
package/dist/commands/mcp.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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:
|
|
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
|
|
111
|
-
|
|
131
|
+
for (const assignment of assignments) {
|
|
132
|
+
// pln#602 / Codex PR#48 finding 2: when a hygiene `policy` is supplied
|
|
133
|
+
// (session-start full sweep, bclaw_work read-path), the age comparison,
|
|
134
|
+
// the implicit-evidence freshness window, AND the status_reason MUST use
|
|
135
|
+
// the family TTLs (offered 3d / accepted 1d / started 1d by default), NOT
|
|
136
|
+
// the assignment's embedded heartbeat_ttl_ms/acceptance_ttl_ms (~30/15min).
|
|
137
|
+
// Otherwise a 20-min offered assignment that `doctor --hygiene` does not
|
|
138
|
+
// list as a candidate could still be expired here — the exact incoherence
|
|
139
|
+
// Codex flagged. Without a policy (the dispatcher convergence sweep,
|
|
140
|
+
// dispatcher.ts), fall back to the embedded TTLs so short-window dispatch
|
|
141
|
+
// convergence is unchanged.
|
|
142
|
+
const startedTtl = policy?.assignment_started_ttl_ms ?? assignment.heartbeat_ttl_ms;
|
|
143
|
+
const acceptedTtl = policy?.assignment_accepted_ttl_ms ?? assignment.acceptance_ttl_ms;
|
|
144
|
+
const offeredTtl = policy?.assignment_offered_ttl_ms ?? assignment.acceptance_ttl_ms;
|
|
112
145
|
// Check started assignments for heartbeat timeout
|
|
113
146
|
if (assignment.status === 'started') {
|
|
114
147
|
const lastBeat = assignment.last_heartbeat_at ?? assignment.started_at;
|
|
115
148
|
if (!lastBeat)
|
|
116
149
|
continue;
|
|
117
150
|
const ageMs = now - new Date(lastBeat).getTime();
|
|
118
|
-
if (ageMs >
|
|
151
|
+
if (ageMs > startedTtl) {
|
|
119
152
|
// can_948acfd6: a worker without MCP cannot bump last_heartbeat_at —
|
|
120
153
|
// its file evidence is the heartbeat. Fresh file activity vetoes the
|
|
121
154
|
// administrative timeout.
|
|
122
155
|
const sinceMs = new Date(assignment.started_at ?? assignment.created_at).getTime();
|
|
123
|
-
const evidence = collectImplicitEvidence(assignment, cwd, now, sinceMs,
|
|
156
|
+
const evidence = collectImplicitEvidence(assignment, cwd, now, sinceMs, startedTtl);
|
|
124
157
|
if (evidence.fresh)
|
|
125
158
|
continue;
|
|
126
159
|
try {
|
|
127
160
|
transitionAssignment(assignment.id, 'timed_out', {
|
|
128
|
-
status_reason: `No heartbeat for ${Math.round(ageMs / 60_000)} minutes (TTL: ${Math.round(
|
|
161
|
+
status_reason: `No heartbeat for ${Math.round(ageMs / 60_000)} minutes (TTL: ${Math.round(startedTtl / 60_000)}min); implicit evidence: ${evidence.description}`,
|
|
129
162
|
actor,
|
|
130
163
|
}, cwd);
|
|
131
164
|
result.timed_out.push({ assignment_id: assignment.id, agent: assignment.agent, age_ms: ageMs });
|
|
@@ -139,10 +172,12 @@ export function sweepAssignments(cwd, options) {
|
|
|
139
172
|
if (!acceptedAt)
|
|
140
173
|
continue;
|
|
141
174
|
const ageMs = now - new Date(acceptedAt).getTime();
|
|
142
|
-
// Use
|
|
143
|
-
|
|
175
|
+
// Use the accepted-family TTL for accepted→timed_out (agent should start
|
|
176
|
+
// soon after accepting; family default 1d, or embedded acceptance_ttl_ms
|
|
177
|
+
// for the policy-less convergence sweep).
|
|
178
|
+
if (ageMs > acceptedTtl) {
|
|
144
179
|
const sinceMs = new Date(acceptedAt).getTime();
|
|
145
|
-
const evidence = collectImplicitEvidence(assignment, cwd, now, sinceMs,
|
|
180
|
+
const evidence = collectImplicitEvidence(assignment, cwd, now, sinceMs, acceptedTtl);
|
|
146
181
|
if (evidence.fresh) {
|
|
147
182
|
// Working without MCP — record the implicit start so the FSM matches reality.
|
|
148
183
|
try {
|
|
@@ -171,13 +206,13 @@ export function sweepAssignments(cwd, options) {
|
|
|
171
206
|
if (!offeredAt)
|
|
172
207
|
continue;
|
|
173
208
|
const ageMs = now - new Date(offeredAt).getTime();
|
|
174
|
-
if (ageMs >
|
|
209
|
+
if (ageMs > offeredTtl) {
|
|
175
210
|
// can_948acfd6: ANY worker evidence (ack sentinel touched pre-exec,
|
|
176
211
|
// heartbeat written, files edited, commit landed) is an implicit
|
|
177
212
|
// acceptance — the worker just couldn't say so via MCP. Expiring it
|
|
178
213
|
// is the false-administrative-death observed three times in sprint 1.
|
|
179
214
|
const sinceMs = new Date(offeredAt).getTime();
|
|
180
|
-
const evidence = collectImplicitEvidence(assignment, cwd, now, sinceMs,
|
|
215
|
+
const evidence = collectImplicitEvidence(assignment, cwd, now, sinceMs, offeredTtl);
|
|
181
216
|
if (evidence.any) {
|
|
182
217
|
try {
|
|
183
218
|
transitionAssignment(assignment.id, 'accepted', {
|
|
@@ -191,7 +226,7 @@ export function sweepAssignments(cwd, options) {
|
|
|
191
226
|
}
|
|
192
227
|
try {
|
|
193
228
|
transitionAssignment(assignment.id, 'expired', {
|
|
194
|
-
status_reason: `Not accepted within ${Math.round(ageMs / 60_000)} minutes (TTL: ${Math.round(
|
|
229
|
+
status_reason: `Not accepted within ${Math.round(ageMs / 60_000)} minutes (TTL: ${Math.round(offeredTtl / 60_000)}min); no implicit evidence`,
|
|
195
230
|
actor,
|
|
196
231
|
}, cwd);
|
|
197
232
|
result.expired.push({ assignment_id: assignment.id, agent: assignment.agent, age_ms: ageMs });
|
|
@@ -202,4 +237,50 @@ export function sweepAssignments(cwd, options) {
|
|
|
202
237
|
}
|
|
203
238
|
return result;
|
|
204
239
|
}
|
|
240
|
+
/**
|
|
241
|
+
* Pure candidate selection for the bclaw_work read-path sweep (Codex PR#48
|
|
242
|
+
* finding 3, pln#578 guardrail). Given ONLY the in-memory projections that
|
|
243
|
+
* buildContext already surfaced, return the ids worth a full loadAssignment:
|
|
244
|
+
* - status must be sweepable (offered/accepted/started) — created/terminal
|
|
245
|
+
* rows can never transition and are dropped BEFORE any file read, so a
|
|
246
|
+
* healthy store full of `created` assignments costs zero extra I/O;
|
|
247
|
+
* - among those, only rows whose surfaced heartbeat is older than the
|
|
248
|
+
* smallest family TTL (or that carry no heartbeat) are suspicious;
|
|
249
|
+
* - capped at read_path_sweep_budget.
|
|
250
|
+
* Extracted so the hot-path zero-read guarantee is unit-testable without the
|
|
251
|
+
* MCP handler.
|
|
252
|
+
*/
|
|
253
|
+
export function selectReadPathSweepCandidates(projections, policy, nowMs) {
|
|
254
|
+
if (policy.disabled)
|
|
255
|
+
return [];
|
|
256
|
+
const minTtl = Math.min(policy.assignment_offered_ttl_ms, policy.assignment_accepted_ttl_ms, policy.assignment_started_ttl_ms);
|
|
257
|
+
return projections
|
|
258
|
+
.filter((a) => {
|
|
259
|
+
if (a.status !== 'offered' && a.status !== 'accepted' && a.status !== 'started')
|
|
260
|
+
return false;
|
|
261
|
+
const beat = a.last_heartbeat_at;
|
|
262
|
+
if (!beat)
|
|
263
|
+
return true;
|
|
264
|
+
return nowMs - new Date(beat).getTime() > minTtl;
|
|
265
|
+
})
|
|
266
|
+
.slice(0, policy.read_path_sweep_budget)
|
|
267
|
+
.map((a) => a.id);
|
|
268
|
+
}
|
|
269
|
+
export function sweepAssignmentsAtReadPath(assignments, cwd, options) {
|
|
270
|
+
const policy = options?.policy ?? DEFAULT_HYGIENE_POLICY;
|
|
271
|
+
if (policy.disabled) {
|
|
272
|
+
return { timed_out: [], expired: [], implicitly_advanced: [] };
|
|
273
|
+
}
|
|
274
|
+
const budget = policy.read_path_sweep_budget;
|
|
275
|
+
// Prefer offered/accepted (the empirical debris class); the sweep is a no-op
|
|
276
|
+
// for terminal statuses so filtering is a perf hygiene, not correctness.
|
|
277
|
+
const eligible = assignments
|
|
278
|
+
.filter((a) => a.status === 'offered' || a.status === 'accepted' || a.status === 'started')
|
|
279
|
+
.slice(0, budget);
|
|
280
|
+
return sweepAssignmentsFromList(eligible, cwd, {
|
|
281
|
+
...options,
|
|
282
|
+
actor: options?.actor ?? 'sweeper-readpath',
|
|
283
|
+
policy,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
205
286
|
//# sourceMappingURL=assignment-sweeper.js.map
|
package/dist/core/claims.js
CHANGED
|
@@ -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(),
|