brainclaw 1.13.0 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +2 -1
- package/dist/commands/doctor.js +98 -0
- package/dist/commands/harvest.js +8 -2
- package/dist/commands/mcp.js +49 -2
- package/dist/commands/session-start.js +16 -1
- package/dist/core/assignment-sweeper.js +92 -11
- 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/schema.js +22 -0
- package/dist/core/worktree.js +52 -0
- package/dist/facts.js +6 -6
- package/dist/facts.json +5 -5
- package/docs/concepts/dispatch-supervisor.md +393 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -431,6 +431,14 @@ npm run test:coverage # with coverage report
|
|
|
431
431
|
|
|
432
432
|
For older releases (v0.x and the early v1.0 launch series), `git log` on `master` is the source of truth — every release commit follows the `chore(release): bump version to <semver>` convention, and the matching feature/fix commits reference their plan id (e.g. `feat(mcp): self-heal ... (pln#478)`).
|
|
433
433
|
|
|
434
|
+
### v1.14.0
|
|
435
|
+
|
|
436
|
+
Coordination hygiene, a dispatch-supervisor spec, and a monorepo worktree fix — from continued cross-machine dogfooding, each Codex-reviewed before merge:
|
|
437
|
+
|
|
438
|
+
- **Coordination hygiene v1** (pln#602) — family-level TTL sweep (park-don't-delete, `config.hygiene`-overridable), lazy at the `bclaw_work` read path (zero extra reads on a healthy store) + full at session-start, K-times aging of stale warnings/hints into one actionable aggregate, and `brainclaw doctor --hygiene`.
|
|
439
|
+
- **Worktree creation for in-tree projects** (pln#614) — `bclaw_coordinate(assign|review)` on a project dir that isn't the git root (app inside a monorepo) now resolves the real toplevel (`resolveGitToplevel` with a parent-walk past an invalid nested `.git`), so it spawns instead of failing with "not a git repository". Standalone projects unchanged; validated E2E on the reporting monorepo.
|
|
440
|
+
- **Dispatch-supervisor round-3 spec** (pln#545, docs) — `docs/concepts/dispatch-supervisor.md`: honest worker-liveness attribution (Node supervisor + `run_id`-keyed sentinels), A0→B hard dependency, Windows Job Object FAIL-CLOSED, complete behavior matrix. Implementation lands later as A0-first increments.
|
|
441
|
+
|
|
434
442
|
### v1.13.0
|
|
435
443
|
|
|
436
444
|
Operator-maturity batch from two days of heavy multi-agent dogfooding — dispatch/worktree lifecycle, claim parity, write-path auto-repair, model routing, benchmark gate, 2× faster context reads:
|
|
Binary file
|
package/dist/cli.js
CHANGED
|
@@ -782,12 +782,13 @@ program
|
|
|
782
782
|
.option('--verify-journal', 'Phase-2 cutover gate (pln#565): rebuild state from the event journal and diff vs live projections; exits non-zero on any drift')
|
|
783
783
|
.option('--spawn-check', 'Real spawn round-trip per installed agent before dispatch (pln#520 step 2): validates delivery + handshake on this host, exits non-zero on any installed-agent failure')
|
|
784
784
|
.option('--spawn-check-timeout <ms>', 'Per-agent timeout for --spawn-check (default 15000)', parseInt)
|
|
785
|
+
.option('--hygiene', 'Coordination-hygiene snapshot (pln#602): counts per family, park candidates, serve-count aging stats. Read-only.')
|
|
785
786
|
.action(async (options) => {
|
|
786
787
|
if (options.spawnCheck) {
|
|
787
788
|
await runDoctorSpawnCheck({ cwd: options.cwd, json: options.json, timeoutMs: options.spawnCheckTimeout });
|
|
788
789
|
return;
|
|
789
790
|
}
|
|
790
|
-
runDoctor({ ...options, afterMigration: options.afterMigration, dispatch: options.dispatch, verifyJournal: options.verifyJournal });
|
|
791
|
+
runDoctor({ ...options, afterMigration: options.afterMigration, dispatch: options.dispatch, verifyJournal: options.verifyJournal, hygiene: options.hygiene });
|
|
791
792
|
});
|
|
792
793
|
// --- repair (Phase 4 Sprint 2 Lane C / pln#397) ---
|
|
793
794
|
program
|
package/dist/commands/doctor.js
CHANGED
|
@@ -4,6 +4,10 @@ import os from 'node:os';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import * as childProcess from 'node:child_process';
|
|
6
6
|
import { reconcileAllOpenRuns } from '../core/agentrun-reconciler.js';
|
|
7
|
+
import { loadHygienePolicy } from '../core/hygiene-policy.js';
|
|
8
|
+
import { computeServeStats, loadServeRegistry } from '../core/hint-aging.js';
|
|
9
|
+
import { listAssignments } from '../core/assignments.js';
|
|
10
|
+
import { parkClosedAutoHandoffs } from '../core/gc-semantic.js';
|
|
7
11
|
import { runSpawnCheck, renderSpawnCheckReport } from '../core/spawn-check.js';
|
|
8
12
|
import { loadAgentRun } from '../core/agentruns.js';
|
|
9
13
|
import { listAgentIdentities, listDebrisAgentIdentities, resolveCurrentAgentIdentity } from '../core/agent-registry.js';
|
|
@@ -638,7 +642,101 @@ function runJournalVerification(options) {
|
|
|
638
642
|
if (drift.length > 0)
|
|
639
643
|
process.exit(1);
|
|
640
644
|
}
|
|
645
|
+
function medianAgeDays(items, nowMs) {
|
|
646
|
+
if (items.length === 0)
|
|
647
|
+
return 0;
|
|
648
|
+
const ages = items
|
|
649
|
+
.map((a) => Math.floor((nowMs - new Date(a.created_at).getTime()) / 86_400_000))
|
|
650
|
+
.sort((a, b) => a - b);
|
|
651
|
+
const mid = Math.floor(ages.length / 2);
|
|
652
|
+
return ages.length % 2 === 1 ? ages[mid] : (ages[mid - 1] + ages[mid]) / 2;
|
|
653
|
+
}
|
|
654
|
+
export function runHygieneReport(options = {}) {
|
|
655
|
+
const cwd = options.cwd;
|
|
656
|
+
const policy = loadHygienePolicy(cwd);
|
|
657
|
+
const nowMs = Date.now();
|
|
658
|
+
const allAssignments = listAssignments(cwd);
|
|
659
|
+
const open = allAssignments.filter((a) => a.status === 'offered' || a.status === 'accepted' || a.status === 'started');
|
|
660
|
+
const offered = open.filter((a) => a.status === 'offered');
|
|
661
|
+
const accepted = open.filter((a) => a.status === 'accepted');
|
|
662
|
+
const started = open.filter((a) => a.status === 'started');
|
|
663
|
+
const heartbeatAgeMs = (a) => {
|
|
664
|
+
const anchor = a.last_heartbeat_at ?? a.offered_at ?? a.created_at;
|
|
665
|
+
return nowMs - new Date(anchor).getTime();
|
|
666
|
+
};
|
|
667
|
+
const offered_park_candidates = offered.filter((a) => heartbeatAgeMs(a) > policy.assignment_offered_ttl_ms).length;
|
|
668
|
+
const accepted_park_candidates = accepted.filter((a) => heartbeatAgeMs(a) > policy.assignment_accepted_ttl_ms).length;
|
|
669
|
+
const handoffParkDry = parkClosedAutoHandoffs(cwd ?? process.cwd(), Math.floor(policy.handoff_closed_ttl_ms / 86_400_000), true);
|
|
670
|
+
const registry = loadServeRegistry(cwd);
|
|
671
|
+
const staleStats = computeServeStats(registry.warnings, policy.stale_warning_serve_k);
|
|
672
|
+
const hintsStats = computeServeStats(registry.hints, policy.workflow_hint_serve_k);
|
|
673
|
+
return {
|
|
674
|
+
generated_at: new Date(nowMs).toISOString(),
|
|
675
|
+
disabled: policy.disabled,
|
|
676
|
+
policy,
|
|
677
|
+
families: {
|
|
678
|
+
assignments: {
|
|
679
|
+
total_open: open.length,
|
|
680
|
+
offered: offered.length,
|
|
681
|
+
accepted: accepted.length,
|
|
682
|
+
started: started.length,
|
|
683
|
+
offered_park_candidates,
|
|
684
|
+
accepted_park_candidates,
|
|
685
|
+
median_open_age_days: medianAgeDays(open, nowMs),
|
|
686
|
+
},
|
|
687
|
+
handoffs: {
|
|
688
|
+
closed_park_candidates: handoffParkDry.candidates,
|
|
689
|
+
},
|
|
690
|
+
stale_warnings: {
|
|
691
|
+
total_tracked: staleStats.total,
|
|
692
|
+
over_threshold: staleStats.over_threshold,
|
|
693
|
+
median_count: staleStats.median_count,
|
|
694
|
+
oldest_first_at: staleStats.oldest_first_at,
|
|
695
|
+
},
|
|
696
|
+
workflow_hints: {
|
|
697
|
+
total_tracked: hintsStats.total,
|
|
698
|
+
over_threshold: hintsStats.over_threshold,
|
|
699
|
+
median_count: hintsStats.median_count,
|
|
700
|
+
oldest_first_at: hintsStats.oldest_first_at,
|
|
701
|
+
},
|
|
702
|
+
},
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
function renderHygieneReport(report) {
|
|
706
|
+
const lines = [];
|
|
707
|
+
lines.push(`Coordination hygiene — snapshot ${report.generated_at}`);
|
|
708
|
+
if (report.disabled) {
|
|
709
|
+
lines.push(' ✗ policy.disabled=true — hygiene sweep + aging are opted out via config.hygiene.disabled.');
|
|
710
|
+
}
|
|
711
|
+
lines.push('');
|
|
712
|
+
const a = report.families.assignments;
|
|
713
|
+
lines.push(`Assignments (open ${a.total_open}: offered ${a.offered} / accepted ${a.accepted} / started ${a.started}, median age ${a.median_open_age_days}d)`);
|
|
714
|
+
lines.push(` Park candidates: offered=${a.offered_park_candidates}, accepted=${a.accepted_park_candidates} — next sweep at session-start or bclaw_work will converge.`);
|
|
715
|
+
const h = report.families.handoffs;
|
|
716
|
+
lines.push(`Handoffs closed park candidates: ${h.closed_park_candidates} (auto-generated, older than ${Math.floor(report.policy.handoff_closed_ttl_ms / 86_400_000)}d)`);
|
|
717
|
+
const sw = report.families.stale_warnings;
|
|
718
|
+
lines.push(`Stale warnings tracked: ${sw.total_tracked} (over serve-K=${report.policy.stale_warning_serve_k}: ${sw.over_threshold}, median count ${sw.median_count}${sw.oldest_first_at ? `, oldest first-served ${sw.oldest_first_at.slice(0, 10)}` : ''})`);
|
|
719
|
+
const wh = report.families.workflow_hints;
|
|
720
|
+
lines.push(`Workflow hints tracked: ${wh.total_tracked} (over serve-K=${report.policy.workflow_hint_serve_k}: ${wh.over_threshold}, median count ${wh.median_count}${wh.oldest_first_at ? `, oldest first-served ${wh.oldest_first_at.slice(0, 10)}` : ''})`);
|
|
721
|
+
lines.push('');
|
|
722
|
+
lines.push('Read-only: no state was mutated. Session-start and bclaw_work drive the actual sweep/park; the counters age at bclaw_work read paths.');
|
|
723
|
+
return lines.join('\n');
|
|
724
|
+
}
|
|
641
725
|
export function runDoctor(options = {}) {
|
|
726
|
+
if (options.hygiene) {
|
|
727
|
+
if (!memoryExists(options.cwd)) {
|
|
728
|
+
console.error('Error: .brainclaw/ not found. Run `brainclaw init` first.');
|
|
729
|
+
process.exit(1);
|
|
730
|
+
}
|
|
731
|
+
const report = runHygieneReport(options);
|
|
732
|
+
if (options.json) {
|
|
733
|
+
console.log(JSON.stringify(report, null, 2));
|
|
734
|
+
}
|
|
735
|
+
else {
|
|
736
|
+
console.log(renderHygieneReport(report));
|
|
737
|
+
}
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
642
740
|
if (options.verifyJournal) {
|
|
643
741
|
runJournalVerification(options);
|
|
644
742
|
return;
|
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';
|
|
@@ -5095,6 +5099,45 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
5095
5099
|
}
|
|
5096
5100
|
}
|
|
5097
5101
|
}
|
|
5102
|
+
let stalePostAging;
|
|
5103
|
+
let staleAggregate;
|
|
5104
|
+
let hintsPostAging;
|
|
5105
|
+
let hintsAggregate;
|
|
5106
|
+
if (contextResult) {
|
|
5107
|
+
try {
|
|
5108
|
+
const policy = loadHygienePolicy(targetCwd);
|
|
5109
|
+
if (!policy.disabled) {
|
|
5110
|
+
// Filter candidates from the projection (no extra reads) — only
|
|
5111
|
+
// assignments whose surfaced last_heartbeat_at is old enough to
|
|
5112
|
+
// possibly cross a family TTL. Zero read overhead when the open
|
|
5113
|
+
// work is fresh (the common case).
|
|
5114
|
+
const openAssignments = contextResult.open_work?.active_assignments ?? [];
|
|
5115
|
+
// Codex PR#48 finding 3 (pln#578 guardrail): select candidate ids
|
|
5116
|
+
// from the already-surfaced projection — created/terminal rows are
|
|
5117
|
+
// dropped BEFORE any full loadAssignment, so a healthy store costs
|
|
5118
|
+
// zero extra file reads. Selection logic is unit-tested in
|
|
5119
|
+
// selectReadPathSweepCandidates.
|
|
5120
|
+
const candidateIds = selectReadPathSweepCandidates(openAssignments, policy, Date.now());
|
|
5121
|
+
if (candidateIds.length > 0) {
|
|
5122
|
+
const full = candidateIds
|
|
5123
|
+
.map((id) => loadAssignment(id, targetCwd))
|
|
5124
|
+
.filter((a) => a !== undefined);
|
|
5125
|
+
sweepAssignmentsAtReadPath(full, targetCwd, {
|
|
5126
|
+
actor: 'bclaw_work-readpath',
|
|
5127
|
+
policy,
|
|
5128
|
+
});
|
|
5129
|
+
}
|
|
5130
|
+
const registry = loadServeRegistry(targetCwd);
|
|
5131
|
+
const aged = ageStaleWarnings(contextResult.stale_warnings ?? [], targetCwd, { policy, registry });
|
|
5132
|
+
stalePostAging = aged.warnings;
|
|
5133
|
+
staleAggregate = aged.aggregate;
|
|
5134
|
+
const agedHints = ageWorkflowHints(contextResult.workflow_hints ?? [], targetCwd, { policy, registry });
|
|
5135
|
+
hintsPostAging = agedHints.hints;
|
|
5136
|
+
hintsAggregate = agedHints.aggregate;
|
|
5137
|
+
}
|
|
5138
|
+
}
|
|
5139
|
+
catch { /* non-fatal — hygiene must never break bclaw_work */ }
|
|
5140
|
+
}
|
|
5098
5141
|
// Build the full context result, then compact it if requested.
|
|
5099
5142
|
// Compact mode (default) strips the heavy ContextResult down to a
|
|
5100
5143
|
// minimal summary that fits within MCP token limits (~25k chars).
|
|
@@ -5110,7 +5153,8 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
5110
5153
|
status: item.extra ?? 'unknown',
|
|
5111
5154
|
plan_id: item.plan_id,
|
|
5112
5155
|
}));
|
|
5113
|
-
const
|
|
5156
|
+
const stalePool = stalePostAging ?? contextResult.stale_warnings ?? [];
|
|
5157
|
+
const staleTop3 = stalePool.slice(0, 3).map((w) => ({
|
|
5114
5158
|
id: w.id,
|
|
5115
5159
|
entity: w.entity,
|
|
5116
5160
|
text: w.text.slice(0, 80),
|
|
@@ -5134,6 +5178,7 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
5134
5178
|
: {}),
|
|
5135
5179
|
}
|
|
5136
5180
|
: undefined;
|
|
5181
|
+
const hintsPool = hintsPostAging ?? contextResult.workflow_hints ?? [];
|
|
5137
5182
|
resultPayload = {
|
|
5138
5183
|
context_schema: contextResult.context_schema,
|
|
5139
5184
|
profile: contextResult.profile,
|
|
@@ -5142,7 +5187,9 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
5142
5187
|
context_diff: trimmedDiff ?? null,
|
|
5143
5188
|
plan_summary: planItems,
|
|
5144
5189
|
stale_warnings: staleTop3,
|
|
5145
|
-
|
|
5190
|
+
...(staleAggregate ? { stale_warnings_aggregate: staleAggregate } : {}),
|
|
5191
|
+
workflow_hints: hintsPool.slice(0, 3),
|
|
5192
|
+
...(hintsAggregate ? { workflow_hints_aggregate: hintsAggregate } : {}),
|
|
5146
5193
|
claim_conflicts: contextResult.claim_conflicts ?? [],
|
|
5147
5194
|
open_work: contextResult.open_work ?? null,
|
|
5148
5195
|
_compact: true,
|
|
@@ -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
|
|
@@ -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/gc-semantic.js
CHANGED
|
@@ -543,6 +543,85 @@ export function enforceRuntimeNoteRetention(options = {}) {
|
|
|
543
543
|
result.backup_path = backupPath;
|
|
544
544
|
return result;
|
|
545
545
|
}
|
|
546
|
+
/**
|
|
547
|
+
* pln#602 — park closed auto-generated handoffs older than the cutoff.
|
|
548
|
+
*
|
|
549
|
+
* The stale-warning surface counts every open handoff older than 14d as
|
|
550
|
+
* something the agent should act on. Once a handoff is `closed` (accepted +
|
|
551
|
+
* fulfilled OR explicitly retired), it stops being a workflow signal and
|
|
552
|
+
* starts being noise: the fable-audit trace repeatedly rendered fully-served
|
|
553
|
+
* handoffs from months prior. Park them next to the released-claims archive
|
|
554
|
+
* (same JSONL + backup pattern) so `bclaw_find` still surfaces them on demand
|
|
555
|
+
* but the compact bclaw_work context can move on.
|
|
556
|
+
*
|
|
557
|
+
* Auto-generated detection matches the same "Session sess_ … auto-generated
|
|
558
|
+
* handoff" prefix that `dedupAutoHandoffs` uses — human-authored handoffs
|
|
559
|
+
* stay put regardless of age (they may carry decisions the agent needs).
|
|
560
|
+
*/
|
|
561
|
+
export function parkClosedAutoHandoffs(cwd, minAgeDays = DEFAULT_MIN_AGE_DAYS, dryRun = false) {
|
|
562
|
+
const cutoff = new Date(Date.now() - minAgeDays * 24 * 60 * 60 * 1000).toISOString();
|
|
563
|
+
const handoffsDir = path.join(cwd, '.brainclaw', 'coordination', 'handoffs');
|
|
564
|
+
if (!fs.existsSync(handoffsDir))
|
|
565
|
+
return { candidates: 0, parked: 0 };
|
|
566
|
+
const eligible = [];
|
|
567
|
+
const files = fs.readdirSync(handoffsDir).filter((f) => f.endsWith('.json'));
|
|
568
|
+
for (const file of files) {
|
|
569
|
+
const filePath = path.join(handoffsDir, file);
|
|
570
|
+
try {
|
|
571
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
572
|
+
const parsed = JSON.parse(content);
|
|
573
|
+
const status = typeof parsed.status === 'string' ? parsed.status : '';
|
|
574
|
+
if (status !== 'closed')
|
|
575
|
+
continue;
|
|
576
|
+
const text = typeof parsed.text === 'string' ? parsed.text : '';
|
|
577
|
+
const isAutoGenerated = text.startsWith('Session sess_') && text.includes('auto-generated handoff');
|
|
578
|
+
if (!isAutoGenerated)
|
|
579
|
+
continue;
|
|
580
|
+
const updatedAt = typeof parsed.updated_at === 'string' ? parsed.updated_at
|
|
581
|
+
: typeof parsed.created_at === 'string' ? parsed.created_at : '';
|
|
582
|
+
if (!updatedAt || updatedAt > cutoff)
|
|
583
|
+
continue;
|
|
584
|
+
eligible.push({ filePath, content });
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
// Skip unparseable — a separate check (loadDirectoryItems) will surface it.
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
if (dryRun || eligible.length === 0) {
|
|
591
|
+
return { candidates: eligible.length, parked: 0 };
|
|
592
|
+
}
|
|
593
|
+
const archivePath = path.join(handoffsDir, 'compacted.jsonl');
|
|
594
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
595
|
+
const backupPath = path.join(cwd, '.brainclaw', 'gc-backups', `compact-handoffs-closed-${timestamp}.jsonl`);
|
|
596
|
+
fs.mkdirSync(path.dirname(backupPath), { recursive: true });
|
|
597
|
+
let parked = 0;
|
|
598
|
+
for (const { filePath, content } of eligible) {
|
|
599
|
+
// Codex PR#48 finding 4: order the steps so a partial failure can never
|
|
600
|
+
// leave the source on disk AND its record already in the compaction log
|
|
601
|
+
// (which produced a duplicate compacted record on the next pass). The safe
|
|
602
|
+
// order is backup → unlink → archive:
|
|
603
|
+
// 1. backup first — park-don't-delete safety net is written before any
|
|
604
|
+
// removal, so the raw handoff is always recoverable.
|
|
605
|
+
// 2. unlink next — if this throws, we do NOT archive, so no compacted
|
|
606
|
+
// record exists for a source that is still present → no duplicate.
|
|
607
|
+
// 3. archive last — if this throws after a successful unlink, the source
|
|
608
|
+
// is gone (in the backup) and simply absent from compacted.jsonl; the
|
|
609
|
+
// next pass cannot re-see it, so still no duplicate.
|
|
610
|
+
try {
|
|
611
|
+
const parsed = JSON.parse(content);
|
|
612
|
+
parsed._compacted_at = new Date().toISOString();
|
|
613
|
+
parsed._compaction_type = 'closed-auto-handoff';
|
|
614
|
+
fs.appendFileSync(backupPath, content.trim() + '\n', 'utf-8');
|
|
615
|
+
fs.unlinkSync(filePath);
|
|
616
|
+
fs.appendFileSync(archivePath, JSON.stringify(parsed) + '\n', 'utf-8');
|
|
617
|
+
parked += 1;
|
|
618
|
+
}
|
|
619
|
+
catch (err) {
|
|
620
|
+
logger.debug('parkClosedAutoHandoffs: failed to park', err);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return { candidates: eligible.length, parked, backup_path: backupPath };
|
|
624
|
+
}
|
|
546
625
|
/**
|
|
547
626
|
* Deduplicate auto-generated session-end handoffs. These carry the same
|
|
548
627
|
* commits list when several sessions close on the same project state, so the
|