brainclaw 1.7.3 → 1.7.4
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 +16 -0
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/commands/mcp.js +22 -9
- package/dist/core/agent-capability.js +28 -0
- package/dist/core/agentrun-reconciler.js +72 -6
- package/dist/core/dispatch-status.js +67 -4
- package/dist/core/dispatcher.js +41 -3
- package/dist/core/entity-operations.js +36 -0
- package/dist/core/entity-registry.js +1 -1
- package/dist/core/runtime-signals.js +72 -0
- package/dist/core/worktree.js +81 -0
- package/dist/facts.js +3 -3
- package/dist/facts.json +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -345,6 +345,22 @@ npm run test:coverage # with coverage report
|
|
|
345
345
|
|
|
346
346
|
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)`).
|
|
347
347
|
|
|
348
|
+
### v1.7.4
|
|
349
|
+
|
|
350
|
+
- **Dispatch observability + worker DX hardening** (from a real cross-project
|
|
351
|
+
field session) — `bclaw_dispatch_status` and the reconciler now derive liveness
|
|
352
|
+
from filesystem activity (log + worktree mtime), so a worker actively editing
|
|
353
|
+
files is no longer falsely flagged `stalled`, and known codex boot-failure
|
|
354
|
+
stderr signatures get a targeted diagnosis; briefs are transport-aware (a
|
|
355
|
+
sandboxed agent without MCP/commit gets the file protocol, not instructions it
|
|
356
|
+
can't follow), backed by a derived capability matrix
|
|
357
|
+
(`dispatchHasMcp`/`dispatchCanCommit`); `bclaw_claim` gains an advisory
|
|
358
|
+
(no-worktree) mode; `bclaw_find` payloads are size-bounded with pagination
|
|
359
|
+
metadata; an opt-in per-worktree `tsc --noEmit` pre-commit gate; gated ready
|
|
360
|
+
lanes carry a code-propagation advisory; the reconciler auto-releases the claim
|
|
361
|
+
of a run it infers failed; and `plan.related_paths` is now updatable.
|
|
362
|
+
(pln#479, pln#491, pln#527, pln#528, pln#529, trp#291, trp#431, trp#433, trp#434)
|
|
363
|
+
|
|
348
364
|
### v1.7.3
|
|
349
365
|
|
|
350
366
|
- **Multi-agent dispatch hardening for JS/TS monorepos** — dispatched worktrees
|
|
Binary file
|
package/dist/commands/mcp.js
CHANGED
|
@@ -16,7 +16,7 @@ import { collectLoadValidationWarnings, findLoadValidationWarning, loadState, pe
|
|
|
16
16
|
import { generateIdWithLabel } from '../core/ids.js';
|
|
17
17
|
import { memoryExists } from '../core/io.js';
|
|
18
18
|
import { generateCandidateIdWithLabel, loadCandidate, saveCandidate } from '../core/candidates.js';
|
|
19
|
-
import { createEntity, getEntity, listEntities, removeEntity, transitionEntity, updateEntity, } from '../core/entity-operations.js';
|
|
19
|
+
import { createEntity, getEntity, listEntities, boundListResult, removeEntity, transitionEntity, updateEntity, } from '../core/entity-operations.js';
|
|
20
20
|
import { ENTITY_REGISTRY } from '../core/entity-registry.js';
|
|
21
21
|
import { generateClaimId, listClaims, loadClaim, saveClaim, createCoordinatorClaim, adoptClaimSession, attachAssignmentMessageToClaim, linkClaimToAssignment, releaseClaimWithCascade } from '../core/claims.js';
|
|
22
22
|
import { createSequence, updateSequence, deleteSequence } from '../core/sequence.js';
|
|
@@ -575,7 +575,7 @@ const MCP_WRITE_TOOLS = [
|
|
|
575
575
|
},
|
|
576
576
|
{
|
|
577
577
|
name: 'bclaw_claim',
|
|
578
|
-
description: 'Claim a work scope (advisory lock).
|
|
578
|
+
description: 'Claim a work scope (advisory lock). By default creates an isolated git worktree for the claim (multi-agent safety). Pass advisory:true (or worktree:false) for an advisory-only lock with NO worktree — use this when the work already lives uncommitted in the main tree and a fresh worktree would be counterproductive (trp#431). Requires contributor trust level or above.',
|
|
579
579
|
annotations: { tier: 'standard', category: 'coordination', headlessApproval: 'auto' },
|
|
580
580
|
inputSchema: {
|
|
581
581
|
type: 'object',
|
|
@@ -588,6 +588,8 @@ const MCP_WRITE_TOOLS = [
|
|
|
588
588
|
project: { type: 'string', description: 'Project name or path. Use this when working on a project different from the MCP server workspace (e.g. CLI agents in a different directory).' },
|
|
589
589
|
store: { type: 'string', description: 'Target store level: local (default), repo, workspace.' },
|
|
590
590
|
worktreeBranch: { type: 'string', description: 'Branch name for the worktree. Defaults to feat/<scope-slug>.' },
|
|
591
|
+
worktree: { type: 'boolean', description: 'Whether to create an isolated git worktree (default true). Pass false for an advisory-only lock with no worktree (trp#431) — for in-place work in the main tree.' },
|
|
592
|
+
advisory: { type: 'boolean', description: 'Alias for worktree:false — advisory-only lock with no worktree (trp#431).' },
|
|
591
593
|
handoffMode: { type: 'string', enum: ['self-commit', 'integrator'], description: 'Handoff mode: "self-commit" (worker commits+merges) or "integrator" (another agent reviews+merges). Default: self-commit.' },
|
|
592
594
|
},
|
|
593
595
|
required: ['scope', 'description'],
|
|
@@ -1033,7 +1035,7 @@ const MCP_WRITE_TOOLS = [
|
|
|
1033
1035
|
},
|
|
1034
1036
|
{
|
|
1035
1037
|
name: 'bclaw_assignment_update',
|
|
1036
|
-
description: 'Report assignment lifecycle status. Part of the Agent SDK runtime protocol. Workers call this to report: accepted (acknowledging receipt), started (work begun), progress (heartbeat), completed (done with artifacts), failed (error), or blocked (external blocker). The assignment_id is provided in the dispatch brief.',
|
|
1038
|
+
description: 'Report assignment lifecycle status. Part of the Agent SDK runtime protocol. Workers call this to report: accepted (acknowledging receipt), started (work begun), progress (heartbeat), completed (done with artifacts), failed (error), or blocked (external blocker). The assignment_id is provided in the dispatch brief. OWNERSHIP (trp#291): only the agent the assignment is OWNED BY (the dispatched worker) may update it — a different agent (e.g. the coordinator) gets `Agent <x> cannot update assignment owned by <y>`. If you are the coordinator and need to converge a worker run, do NOT call this; verify via bclaw_dispatch_status instead (the reconciler infers completion from sentinels/commits).',
|
|
1037
1039
|
annotations: { tier: 'standard', category: 'coordination', headlessApproval: 'auto' },
|
|
1038
1040
|
inputSchema: {
|
|
1039
1041
|
type: 'object',
|
|
@@ -1111,7 +1113,7 @@ const MCP_WRITE_TOOLS = [
|
|
|
1111
1113
|
// Promoted to `standard` tier at the v1.0 cut.
|
|
1112
1114
|
{
|
|
1113
1115
|
name: 'bclaw_find',
|
|
1114
|
-
description: 'Canonical list query over a brainclaw entity. Default read filter excludes records with provenance.kind="legacy" and auto_reflect records below 0.6 confidence — override via filter.includeLegacy / filter.minAutoReflectConfidence. Tag filters accept `tag: string` for one tag or `tags: string[]` for any-match. For entity="agent_run", filters also accept assignment_id, claim_id, and message_id. Pass `project` to query a linked project instead of the current one.',
|
|
1116
|
+
description: 'Canonical list query over a brainclaw entity. Default read filter excludes records with provenance.kind="legacy" and auto_reflect records below 0.6 confidence — override via filter.includeLegacy / filter.minAutoReflectConfidence. Tag filters accept `tag: string` for one tag or `tags: string[]` for any-match. For entity="agent_run", filters also accept assignment_id, claim_id, and message_id. Pass `project` to query a linked project instead of the current one. PAGINATION & SIZE (pln#491): returns at most filter.limit items (default 50), and the page is additionally shrunk if it would exceed the MCP size budget. The response carries `total` (full match count), `returned`, and — when more remain — `has_more: true`, `next_offset`, and a `hint`; pass `filter.offset=<next_offset>` (or a narrower filter) to page rather than expecting everything at once. ORDERING: results follow on-disk/load order, NOT recency — do not assume the first item is the newest (trp#291); filter explicitly (e.g. status, plan_id) to target what you need.',
|
|
1115
1117
|
annotations: { tier: 'standard', category: 'memory', headlessApproval: 'auto' },
|
|
1116
1118
|
inputSchema: {
|
|
1117
1119
|
type: 'object',
|
|
@@ -2906,9 +2908,13 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
2906
2908
|
const claimId = generateClaimId();
|
|
2907
2909
|
let worktreePath;
|
|
2908
2910
|
let worktreeWarn = '';
|
|
2909
|
-
//
|
|
2910
|
-
//
|
|
2911
|
-
|
|
2911
|
+
// trp#431: advisory mode skips worktree creation. Default is to create an
|
|
2912
|
+
// isolated worktree (multi-agent safety), but when the work already lives
|
|
2913
|
+
// (uncommitted) in the main tree a fresh worktree is counterproductive and
|
|
2914
|
+
// the agent ends up skipping the claim. Pass advisory:true (or
|
|
2915
|
+
// worktree:false) for an advisory-only lock with no worktree.
|
|
2916
|
+
const advisoryClaim = args.advisory === true || args.worktree === false;
|
|
2917
|
+
if (!advisoryClaim) {
|
|
2912
2918
|
const branchSlug = claimScope.replace(/[^a-zA-Z0-9._-]/g, '-').replace(/-+/g, '-').slice(0, 48);
|
|
2913
2919
|
const worktreeBranch = args.worktreeBranch?.trim() || `feat/${branchSlug}`;
|
|
2914
2920
|
try {
|
|
@@ -5788,6 +5794,12 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
5788
5794
|
};
|
|
5789
5795
|
}
|
|
5790
5796
|
const result = listEntities(entity, targetCwd, filter);
|
|
5797
|
+
// pln#491 — bound the payload (count is already capped by applyPaging;
|
|
5798
|
+
// this caps SIZE) so a verbose result set never overflows the MCP token
|
|
5799
|
+
// cap and silently pushes the agent to the CLI (trp#449). Advertises
|
|
5800
|
+
// has_more / next_offset / hint for explicit pagination.
|
|
5801
|
+
const offset = Math.max(0, Number(filter.offset) || 0);
|
|
5802
|
+
const bounded = boundListResult(result, offset);
|
|
5791
5803
|
const warnings = collectLoadValidationWarnings(entity, targetCwd);
|
|
5792
5804
|
// structuredContent is the canonical MCP return channel that clients
|
|
5793
5805
|
// (VS Code extension, Codex, etc.) read for machine-parseable data.
|
|
@@ -5795,10 +5807,11 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
5795
5807
|
// response body, which got dropped by the MCP protocol wrapper so
|
|
5796
5808
|
// `result.items` arrived as undefined on the client — the root cause
|
|
5797
5809
|
// of the VS Code Backlog section rendering empty.
|
|
5810
|
+
const moreNote = bounded.has_more ? ` (returned ${bounded.returned}; ${result.total - bounded.returned} more — offset ${bounded.next_offset})` : '';
|
|
5798
5811
|
return {
|
|
5799
5812
|
response: toolResponse({
|
|
5800
|
-
content: [{ type: 'text', text: `✔ ${result.total} ${entity} item(s)` }],
|
|
5801
|
-
structuredContent: { ...
|
|
5813
|
+
content: [{ type: 'text', text: `✔ ${result.total} ${entity} item(s)${moreNote}` }],
|
|
5814
|
+
structuredContent: { ...bounded, warnings },
|
|
5802
5815
|
}),
|
|
5803
5816
|
};
|
|
5804
5817
|
}
|
|
@@ -664,6 +664,34 @@ export function resolveBriefMode(agentName) {
|
|
|
664
664
|
return 'compact';
|
|
665
665
|
return 'full';
|
|
666
666
|
}
|
|
667
|
+
// ── Dispatch-time capability matrix (pln#528) ──────────────────────────────
|
|
668
|
+
/**
|
|
669
|
+
* pln#528 — capability matrix DERIVED from the spawn template, so it stays in
|
|
670
|
+
* sync with how each agent is actually invoked (no per-profile duplication).
|
|
671
|
+
*
|
|
672
|
+
* The motivating reality (debrief LeaseUp): codex is spawned with
|
|
673
|
+
* `--sandbox workspace-write`, which (a) does NOT wire the brainclaw MCP server
|
|
674
|
+
* and (b) puts `.git` outside the sandbox root — so a sandboxed worker can
|
|
675
|
+
* neither call `bclaw_*` nor `git commit`, regardless of the profile's nominal
|
|
676
|
+
* `runtime.mcp_direct` flag. These helpers expose that so the brief / handoff /
|
|
677
|
+
* harvest logic can adapt to the transport instead of issuing instructions the
|
|
678
|
+
* worker cannot follow.
|
|
679
|
+
*/
|
|
680
|
+
export function isSandboxedSpawn(profile) {
|
|
681
|
+
return /--sandbox\b/.test(profile.invoke_template ?? '');
|
|
682
|
+
}
|
|
683
|
+
/** Whether the agent, AS SPAWNED by the dispatcher, can reach brainclaw MCP. */
|
|
684
|
+
export function dispatchHasMcp(profile) {
|
|
685
|
+
return profile.runtime.mcp_direct && !isSandboxedSpawn(profile);
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Whether the spawned worker can `git commit`. A sandbox whose root excludes
|
|
689
|
+
* `.git` cannot — the coordinator must integrate the worker's output instead of
|
|
690
|
+
* relying on a self-commit handoff.
|
|
691
|
+
*/
|
|
692
|
+
export function dispatchCanCommit(profile) {
|
|
693
|
+
return !isSandboxedSpawn(profile);
|
|
694
|
+
}
|
|
667
695
|
// ── getDefaultInvokeTemplate ───────────────────────────────────────────────
|
|
668
696
|
/**
|
|
669
697
|
* Get the default invoke template for an agent.
|
|
@@ -34,11 +34,11 @@
|
|
|
34
34
|
*/
|
|
35
35
|
import { spawnSync } from 'node:child_process';
|
|
36
36
|
import { loadAgentRun, transitionAgentRun, listAgentRuns } from './agentruns.js';
|
|
37
|
-
import { loadClaim } from './claims.js';
|
|
37
|
+
import { loadClaim, releaseClaim } from './claims.js';
|
|
38
38
|
import { loadAssignment } from './assignments.js';
|
|
39
39
|
import { createRuntimeEvent } from './events.js';
|
|
40
40
|
import { nowISO } from './ids.js';
|
|
41
|
-
import { readHeartbeat, readLogTail, signalExists } from './runtime-signals.js';
|
|
41
|
+
import { readHeartbeat, readLogTail, signalExists, latestActivityMs } from './runtime-signals.js';
|
|
42
42
|
// ── Constants ──────────────────────────────────────────────────────────────
|
|
43
43
|
/**
|
|
44
44
|
* Minimum age before a run is eligible for reconciliation. Below this, the
|
|
@@ -175,11 +175,59 @@ export function collectEvidence(run, cwd, options) {
|
|
|
175
175
|
heartbeat_age_ms = now - hb.mtimeMs;
|
|
176
176
|
}
|
|
177
177
|
catch { /* defensive */ }
|
|
178
|
+
// pln#527 — filesystem-activity liveness (logs + worktree). Independent of the
|
|
179
|
+
// heartbeat: a worker can be actively editing files / streaming to stderr while
|
|
180
|
+
// its heartbeat is frozen (written once at step 0).
|
|
181
|
+
let fs_activity_age_ms;
|
|
182
|
+
try {
|
|
183
|
+
const lastFs = latestActivityMs(signalRoot, run.assignment_id, run.worktree_path);
|
|
184
|
+
if (lastFs !== undefined)
|
|
185
|
+
fs_activity_age_ms = now - lastFs;
|
|
186
|
+
}
|
|
187
|
+
catch { /* defensive */ }
|
|
178
188
|
return {
|
|
179
189
|
age_ms, has_post_start_commit, claim_released, assignment_completed, process_alive,
|
|
180
|
-
completed_signal, failed_signal, heartbeat_exists, heartbeat_age_ms,
|
|
190
|
+
completed_signal, failed_signal, heartbeat_exists, heartbeat_age_ms, fs_activity_age_ms,
|
|
181
191
|
};
|
|
182
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* pln#527 — true when the run shows filesystem activity within `windowMs`
|
|
195
|
+
* (logs growing / worktree files touched). Used to VETO a `stalled` verdict: a
|
|
196
|
+
* stale heartbeat with fresh fs activity means "working", not "hung".
|
|
197
|
+
*/
|
|
198
|
+
function fsActiveWithin(evidence, windowMs) {
|
|
199
|
+
return evidence.fs_activity_age_ms !== undefined && evidence.fs_activity_age_ms < windowMs;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* trp#433 — when a run is reconciled to `failed` (silent_death / stalled), release
|
|
203
|
+
* its linked claim so dead runs stop leaving active claims (and their worktrees)
|
|
204
|
+
* accumulating for manual cleanup. Best-effort + idempotent: only an active claim
|
|
205
|
+
* is released, and any error is swallowed (GC must never break reconciliation).
|
|
206
|
+
* Inference only fires after the stale window with no life evidence, so this is
|
|
207
|
+
* conservative. (Loop auto-close on failure is a follow-up.)
|
|
208
|
+
*/
|
|
209
|
+
function cascadeReleaseOnFailure(run, actor, cwd) {
|
|
210
|
+
if (!run.claim_id)
|
|
211
|
+
return;
|
|
212
|
+
try {
|
|
213
|
+
const claim = loadClaim(run.claim_id, cwd);
|
|
214
|
+
if (claim && claim.status === 'active') {
|
|
215
|
+
releaseClaim(run.claim_id, cwd);
|
|
216
|
+
createRuntimeEvent({
|
|
217
|
+
agent: actor,
|
|
218
|
+
session_id: run.session_id,
|
|
219
|
+
event_type: 'run_failed',
|
|
220
|
+
text: `Auto-released claim ${run.claim_id} after run ${run.id} was reconciled to failed (trp#433 GC cascade)`,
|
|
221
|
+
tags: ['reconciler', 'gc', 'claim-release'],
|
|
222
|
+
assignment_id: run.assignment_id,
|
|
223
|
+
run_id: run.id,
|
|
224
|
+
claim_id: run.claim_id,
|
|
225
|
+
status_reason: 'gc_cascade_release_on_failure',
|
|
226
|
+
}, cwd);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
catch { /* best-effort — never let GC break reconciliation */ }
|
|
230
|
+
}
|
|
183
231
|
function anyCompletionEvidence(evidence) {
|
|
184
232
|
return evidence.completed_signal
|
|
185
233
|
|| evidence.has_post_start_commit
|
|
@@ -328,6 +376,7 @@ export function reconcileAgentRun(runId, cwd, options = {}) {
|
|
|
328
376
|
const failHere = (reason) => {
|
|
329
377
|
try {
|
|
330
378
|
transitionAgentRun(runId, 'failed', { actor, status_reason: reason }, cwd);
|
|
379
|
+
cascadeReleaseOnFailure(run, actor, cwd);
|
|
331
380
|
return { run_id: runId, action: 'inferred_failed', reason, evidence, previous_status, current_status: 'failed' };
|
|
332
381
|
}
|
|
333
382
|
catch (err) {
|
|
@@ -342,9 +391,18 @@ export function reconcileAgentRun(runId, cwd, options = {}) {
|
|
|
342
391
|
if (evidence.failed_signal) {
|
|
343
392
|
return failHere(`failed_silent: wrapper reported non-zero exit${logTailSuffix(run, cwd)}`);
|
|
344
393
|
}
|
|
345
|
-
// Heartbeat present but stale → reached the loop then went silent
|
|
394
|
+
// Heartbeat present but stale → reached the loop then went silent — UNLESS the
|
|
395
|
+
// filesystem shows recent activity (pln#527): a frozen heartbeat with fresh
|
|
396
|
+
// log/worktree writes means the worker is mid-operation, not hung.
|
|
346
397
|
if (evidence.heartbeat_exists && evidence.heartbeat_age_ms !== undefined && evidence.heartbeat_age_ms >= heartbeatStale) {
|
|
347
|
-
|
|
398
|
+
if (fsActiveWithin(evidence, heartbeatStale)) {
|
|
399
|
+
return {
|
|
400
|
+
run_id: runId, action: 'no_op',
|
|
401
|
+
reason: `heartbeat stale (${Math.round(evidence.heartbeat_age_ms / 1000)}s) but fs active ${Math.round((evidence.fs_activity_age_ms ?? 0) / 1000)}s ago — working, not stalled`,
|
|
402
|
+
evidence, previous_status, current_status: run.status,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
return failHere(`stalled: heartbeat last seen ${Math.round(evidence.heartbeat_age_ms / 1000)}s ago, no fs activity${logTailSuffix(run, cwd)}`);
|
|
348
406
|
}
|
|
349
407
|
// Fresh heartbeat → alive; trust it over the untrustworthy wrapper pid.
|
|
350
408
|
if (evidence.heartbeat_exists) {
|
|
@@ -416,6 +474,7 @@ export function reconcileDeadPidRunningAgentRunAtRead(runId, cwd, options = {})
|
|
|
416
474
|
const failRun = (reason) => {
|
|
417
475
|
try {
|
|
418
476
|
transitionAgentRun(run.id, 'failed', { actor, status_reason: reason }, cwd);
|
|
477
|
+
cascadeReleaseOnFailure(run, actor, cwd);
|
|
419
478
|
return { run_id: run.id, action: 'inferred_failed', reason, evidence, previous_status: run.status, current_status: 'failed' };
|
|
420
479
|
}
|
|
421
480
|
catch (err) {
|
|
@@ -458,7 +517,14 @@ export function reconcileDeadPidRunningAgentRunAtRead(runId, cwd, options = {})
|
|
|
458
517
|
// 3. Heartbeat present but STALE → the worker reached its loop then went
|
|
459
518
|
// silent (e.g. hung). pid-independent: a hung worker keeps the wrapper alive.
|
|
460
519
|
if (evidence.heartbeat_exists && evidence.heartbeat_age_ms !== undefined && evidence.heartbeat_age_ms >= heartbeatStale) {
|
|
461
|
-
|
|
520
|
+
if (fsActiveWithin(evidence, heartbeatStale)) {
|
|
521
|
+
return {
|
|
522
|
+
run_id: run.id, action: 'no_op',
|
|
523
|
+
reason: `heartbeat stale (${Math.round(evidence.heartbeat_age_ms / 1000)}s) but fs active ${Math.round((evidence.fs_activity_age_ms ?? 0) / 1000)}s ago — working, not stalled`,
|
|
524
|
+
evidence, previous_status: run.status, current_status: run.status,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
return failRun(`stalled: heartbeat last seen ${Math.round(evidence.heartbeat_age_ms / 1000)}s ago, no fs activity${logTailSuffix(run, cwd)}`);
|
|
462
528
|
}
|
|
463
529
|
// 4. Fresh heartbeat → the worker is alive and working; trust it OVER the
|
|
464
530
|
// (untrustworthy) wrapper pid. This is the can_f792cacd fix: never fail a
|
|
@@ -25,6 +25,7 @@ import { loadAgentRun, listAgentRuns } from './agentruns.js';
|
|
|
25
25
|
import { loadClaim } from './claims.js';
|
|
26
26
|
import { getLoop, listLoops } from './loops/store.js';
|
|
27
27
|
import { isProcessAlive } from './agentrun-reconciler.js';
|
|
28
|
+
import { latestActivityMs } from './runtime-signals.js';
|
|
28
29
|
const DEFAULT_TAIL = 20;
|
|
29
30
|
const DEFAULT_STALL_MS = 5 * 60_000;
|
|
30
31
|
// ── Internal helpers ──────────────────────────────────────────────────────
|
|
@@ -96,6 +97,37 @@ function resolveTarget(targetId, cwd) {
|
|
|
96
97
|
const TERMINAL_RUN_STATUSES = new Set([
|
|
97
98
|
'completed', 'failed', 'cancelled', 'timed_out', 'interrupted',
|
|
98
99
|
]);
|
|
100
|
+
/**
|
|
101
|
+
* pln#527 (#5) — recognize known fatal boot signatures in a worker's stderr tail
|
|
102
|
+
* so dispatch_status returns a targeted diagnosis + remediation instead of a
|
|
103
|
+
* generic silent_death. These are agent/CLI/config faults (NOT brainclaw bugs)
|
|
104
|
+
* that a coordinator can fix and re-dispatch. Patterns sourced from field traps
|
|
105
|
+
* (trp#292 codex service_tier / model mismatch).
|
|
106
|
+
*/
|
|
107
|
+
export function recognizeStderrSignature(tail) {
|
|
108
|
+
if (!tail || tail.length === 0)
|
|
109
|
+
return undefined;
|
|
110
|
+
const text = tail.join('\n');
|
|
111
|
+
if (/service_tier/i.test(text) && /flex|unsupported/i.test(text)) {
|
|
112
|
+
return {
|
|
113
|
+
summary: 'codex rejected an unsupported `service_tier` (e.g. flex) — a config/model mismatch at boot, not a brainclaw fault',
|
|
114
|
+
recommended_next_action: 'Fix ~/.codex/config.toml `service_tier` (remove it or set a supported value) or upgrade codex, then re-dispatch. See trap trp#292.',
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (/unknown variant/i.test(text)) {
|
|
118
|
+
return {
|
|
119
|
+
summary: 'codex CLI rejected an unknown config variant — the installed codex does not support a value in ~/.codex/config.toml (e.g. model/approval)',
|
|
120
|
+
recommended_next_action: 'Reconcile ~/.codex/config.toml with the installed codex (`codex --version`) or upgrade codex, then re-dispatch.',
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
if (/\b400\b/.test(text) && /(unsupported|requires a newer|model)/i.test(text)) {
|
|
124
|
+
return {
|
|
125
|
+
summary: 'the model API returned 400 (unsupported model / needs a newer CLI) — the worker died at boot, before doing work',
|
|
126
|
+
recommended_next_action: 'Check the configured model vs the installed CLI version; upgrade the agent CLI or pick a supported model, then re-dispatch.',
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
99
131
|
function computeDiagnosis(assignment, agentRun, runtime, options) {
|
|
100
132
|
if (!assignment && !agentRun) {
|
|
101
133
|
return {
|
|
@@ -127,17 +159,37 @@ function computeDiagnosis(assignment, agentRun, runtime, options) {
|
|
|
127
159
|
const lastEventMs = new Date(agentRun.last_event_at ?? agentRun.started_at ?? agentRun.created_at).getTime();
|
|
128
160
|
const stallAge = options.nowMs - lastEventMs;
|
|
129
161
|
if (runtime.pid_alive === false) {
|
|
162
|
+
// pln#527 (#5) — surface a TARGETED diagnosis when the captured stderr matches
|
|
163
|
+
// a known fatal boot signature (codex model/service_tier mismatch, API 400)
|
|
164
|
+
// instead of a generic "silent_death".
|
|
165
|
+
const sig = recognizeStderrSignature(runtime.log_files.stderr?.tail);
|
|
130
166
|
return {
|
|
131
167
|
health: 'silent_death',
|
|
132
|
-
summary:
|
|
133
|
-
|
|
168
|
+
summary: sig
|
|
169
|
+
? `agent_run.status="${agentRun.status}", pid ${runtime.pid} dead — ${sig.summary}`
|
|
170
|
+
: `agent_run.status="${agentRun.status}" but pid ${runtime.pid} is dead — worker exited without self-reporting; lazy reconciler will mark it failed after the stale window (default 30min)`,
|
|
171
|
+
recommended_next_action: sig?.recommended_next_action
|
|
172
|
+
?? 'Read .stderr.log for the exit reason; then trigger reconciliation by calling bclaw_find(entity="agent_run") again, or cancel + reroute.',
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
// pln#527 — a stale last_event_at is NOT "stalled" when the filesystem is still
|
|
176
|
+
// active (logs streaming / worktree files edited). Workers emit no heartbeat
|
|
177
|
+
// during a long single operation (codex→stderr, claude -p buffering stdout),
|
|
178
|
+
// so fs activity is the truer liveness signal and vetoes the false-stalled.
|
|
179
|
+
const fsAge = runtime.last_fs_activity_ms;
|
|
180
|
+
const fsActive = fsAge !== undefined && fsAge < options.stallMs;
|
|
181
|
+
if (runtime.pid_alive === true && stallAge > options.stallMs && fsActive) {
|
|
182
|
+
return {
|
|
183
|
+
health: 'healthy',
|
|
184
|
+
summary: `agent_run alive (pid=${runtime.pid}); last_event_at stale (${Math.round(stallAge / 1000)}s) but filesystem active ${Math.round((fsAge ?? 0) / 1000)}s ago — working through a long op without a heartbeat`,
|
|
185
|
+
recommended_next_action: 'No action — the worker is actively writing to logs/worktree. Re-check periodically until terminal.',
|
|
134
186
|
};
|
|
135
187
|
}
|
|
136
188
|
if (runtime.pid_alive === true && stallAge > options.stallMs) {
|
|
137
189
|
return {
|
|
138
190
|
health: 'stalled',
|
|
139
|
-
summary: `agent_run alive (pid=${runtime.pid}) but no activity for ${Math.round(stallAge / 1000)}s; last_event_at=${agentRun.last_event_at ?? '(never)'}`,
|
|
140
|
-
recommended_next_action: '
|
|
191
|
+
summary: `agent_run alive (pid=${runtime.pid}) but no activity for ${Math.round(stallAge / 1000)}s AND no filesystem writes${fsAge !== undefined ? ` (last fs ${Math.round(fsAge / 1000)}s ago)` : ' (no logs/worktree mtime)'}; last_event_at=${agentRun.last_event_at ?? '(never)'}`,
|
|
192
|
+
recommended_next_action: 'Worker appears genuinely hung (no log/file writes). Tail stderr to confirm, then kill the pid and reroute.',
|
|
141
193
|
};
|
|
142
194
|
}
|
|
143
195
|
if (runtime.pid_alive === true) {
|
|
@@ -186,6 +238,16 @@ export function getDispatchStatus(options) {
|
|
|
186
238
|
const ackPath = assignmentId ? path.join(runtimeRoot, 'ack', `${assignmentId}.ack`) : undefined;
|
|
187
239
|
const stdoutPath = assignmentId ? path.join(runtimeRoot, 'log', `${assignmentId}.stdout.log`) : undefined;
|
|
188
240
|
const stderrPath = assignmentId ? path.join(runtimeRoot, 'log', `${assignmentId}.stderr.log`) : undefined;
|
|
241
|
+
// pln#527 — filesystem-activity age: max mtime across the captured logs + the
|
|
242
|
+
// run's worktree files (skipping junctions). The truer liveness signal when
|
|
243
|
+
// the heartbeat / last_event_at is stale during a long single operation.
|
|
244
|
+
const worktreeForFs = agentRun?.worktree_path ?? claim?.worktree_path;
|
|
245
|
+
let lastFsActivityMs;
|
|
246
|
+
if (assignmentId) {
|
|
247
|
+
const lastFs = latestActivityMs(projectRoot, assignmentId, worktreeForFs);
|
|
248
|
+
if (lastFs !== undefined)
|
|
249
|
+
lastFsActivityMs = nowMs - lastFs;
|
|
250
|
+
}
|
|
189
251
|
const runtime = {
|
|
190
252
|
pid: agentRun?.pid,
|
|
191
253
|
pid_alive: isProcessAlive(agentRun?.pid),
|
|
@@ -197,6 +259,7 @@ export function getDispatchStatus(options) {
|
|
|
197
259
|
stdout: stdoutPath ? readLogTail(stdoutPath, tailLines) : undefined,
|
|
198
260
|
stderr: stderrPath ? readLogTail(stderrPath, tailLines) : undefined,
|
|
199
261
|
},
|
|
262
|
+
last_fs_activity_ms: lastFsActivityMs,
|
|
200
263
|
};
|
|
201
264
|
const diagnosis = computeDiagnosis(assignment, agentRun, runtime, { stallMs, nowMs });
|
|
202
265
|
return {
|
package/dist/core/dispatcher.js
CHANGED
|
@@ -43,7 +43,7 @@ import { memoryDir } from './io.js';
|
|
|
43
43
|
import { loadVersionedJsonFile } from './migration.js';
|
|
44
44
|
import fs from 'node:fs';
|
|
45
45
|
import path from 'node:path';
|
|
46
|
-
import { buildInvokeCommand, resolveBriefMode, getCapabilityProfile, resolveConcurrencyLimit, resolveResourceKey, resolveModel, serializeConcurrencyLimit } from './agent-capability.js';
|
|
46
|
+
import { buildInvokeCommand, resolveBriefMode, getCapabilityProfile, dispatchHasMcp, resolveConcurrencyLimit, resolveResourceKey, resolveModel, serializeConcurrencyLimit } from './agent-capability.js';
|
|
47
47
|
import { getRuntimeSignalPath } from './runtime-signals.js';
|
|
48
48
|
import { attemptExecution } from './execution.js';
|
|
49
49
|
import { createAssignment, transitionAssignment, generateAssignmentId, patchAssignmentMessageId } from './assignments.js';
|
|
@@ -156,6 +156,11 @@ export function analyzeSequence(cwd) {
|
|
|
156
156
|
plan,
|
|
157
157
|
lane: item.lane,
|
|
158
158
|
reason: `All hard dependencies met${softNote}`,
|
|
159
|
+
// pln#529 — readiness ≠ code-availability for gated lanes.
|
|
160
|
+
...(item.hard_after.length > 0 ? {
|
|
161
|
+
code_propagation_note: `Unblocked by hard_after [${item.hard_after.join(', ')}]. Ensure that work is committed AND on the dispatch base (HEAD), ` +
|
|
162
|
+
`or dispatch this lane with ref=<predecessor branch> — otherwise the worker spawns from HEAD without it.`,
|
|
163
|
+
} : {}),
|
|
159
164
|
});
|
|
160
165
|
}
|
|
161
166
|
// Build capacity summary per agent (multi-instance aware)
|
|
@@ -261,6 +266,11 @@ export function buildProtocolSection(options) {
|
|
|
261
266
|
parts.push(`${options.worktreePath ? '7' : '6'}. Release the claim: bclaw_release_claim(${claimRef}, planStatus: "done") — required for hard_after gating to unblock downstream tasks`);
|
|
262
267
|
parts.push(`${options.worktreePath ? '8' : '7'}. If blocked: bclaw_assignment_update(status: "blocked", blocker: "...")`);
|
|
263
268
|
parts.push(`${options.worktreePath ? '9' : '8'}. If failed: bclaw_assignment_update(status: "failed", error_message: "...")`);
|
|
269
|
+
// pln#479: compile-check contract for code workers — a per-worktree
|
|
270
|
+
// pre-commit gate may HARD-block a commit that fails tsc (opt-in).
|
|
271
|
+
if (options.worktreePath) {
|
|
272
|
+
parts.push('**Compile check**: before every commit, `tsc --noEmit` (or the project build) must pass — a per-worktree pre-commit gate may enforce this and reject the commit otherwise. Do not bypass with --no-verify unless you intend to hand off a known-broken state.');
|
|
273
|
+
}
|
|
264
274
|
// pln#526: standard fallback channel — works even when MCP is unreachable
|
|
265
275
|
// (sandboxed agents). The coordinator ingests it with `brainclaw harvest`.
|
|
266
276
|
parts.push(`Final fallback (if bclaw_assignment_update / MCP is unavailable, e.g. a sandboxed agent): write LANE-RESULT.json at the worktree root — {"assignment_id":"${options.assignmentId}","status":"completed|blocked|failed","summary":"<what you did>","files_changed":["..."],"artifacts":["..."]}. The coordinator harvests it via \`brainclaw harvest ${options.assignmentId}\`.`);
|
|
@@ -416,6 +426,25 @@ export function generateBrief(plan, item, cwd, briefMode, options) {
|
|
|
416
426
|
if (mode === 'full') {
|
|
417
427
|
parts.push(buildProtocolSection(options));
|
|
418
428
|
}
|
|
429
|
+
// pln#528 — transport-aware addendum (debrief LeaseUp P1#2). When the agent is
|
|
430
|
+
// spawned sandboxed (no MCP + no git commit — e.g. codex --sandbox
|
|
431
|
+
// workspace-write), the MCP lifecycle lines in the Protocol section do NOT
|
|
432
|
+
// apply. Say so explicitly and make the FILE protocol authoritative, so the
|
|
433
|
+
// worker never receives instructions it cannot follow nor has to guess the
|
|
434
|
+
// fallback. (Note: resolveBriefMode still returns 'full' for codex per pln#496
|
|
435
|
+
// so the reconciler-independent path is preserved; this addendum disambiguates
|
|
436
|
+
// the transport rather than stripping the section — the full compact reversal
|
|
437
|
+
// is a separate human-owned call on the May-vs-June MCP-availability conflict.)
|
|
438
|
+
const briefProfile = options?.agent ? getCapabilityProfile(options.agent) : undefined;
|
|
439
|
+
if (briefProfile && !dispatchHasMcp(briefProfile)) {
|
|
440
|
+
parts.push('## ⚠ Transport: sandboxed run (no MCP, no commit)');
|
|
441
|
+
parts.push('Your runtime is sandboxed — the brainclaw MCP server is NOT reachable and `git commit` is unavailable (.git is outside the sandbox root). Any `bclaw_*` MCP instruction above does NOT apply to you. Report your outcome via the FILE protocol only — it is authoritative for this run:');
|
|
442
|
+
const asgn = options?.assignmentId ?? '<assignment_id>';
|
|
443
|
+
parts.push(`- When done, write LANE-RESULT.json at the worktree root: {"assignment_id":"${asgn}","status":"completed|blocked|failed","summary":"<what you did>","files_changed":["..."]}.`);
|
|
444
|
+
parts.push('- Capture decisions/traps as candidate JSON under .brainclaw/coordination/inbox/ (the coordinator harvests them).');
|
|
445
|
+
parts.push('- Do NOT call bclaw_* tools — they are unavailable here. The coordinator harvests your result and integrates/commits it.');
|
|
446
|
+
parts.push('');
|
|
447
|
+
}
|
|
419
448
|
// Codex-specific constraints: focus and speed guidance for sandboxed runs.
|
|
420
449
|
// Gated on agent identity (not brief mode) so future non-codex compact consumers
|
|
421
450
|
// don't inherit sandbox-specific wording. (Codex review cnd#561)
|
|
@@ -423,7 +452,6 @@ export function generateBrief(plan, item, cwd, briefMode, options) {
|
|
|
423
452
|
parts.push('## Constraints');
|
|
424
453
|
parts.push('- Focus on specified files only — do not explore the broader codebase');
|
|
425
454
|
parts.push('- Produce output quickly; if blocked, capture as trap candidate and move on');
|
|
426
|
-
parts.push('- Sandbox blocks MCP writes: use filesystem writes for candidates, coordinator harvests');
|
|
427
455
|
parts.push('');
|
|
428
456
|
}
|
|
429
457
|
return parts.join('\n');
|
|
@@ -447,12 +475,22 @@ export function generateDispatchBrief(options) {
|
|
|
447
475
|
assignmentId: options.assignmentId,
|
|
448
476
|
}));
|
|
449
477
|
}
|
|
478
|
+
// pln#528 — transport-aware addendum for sandboxed agents (see generateBrief).
|
|
479
|
+
const taskBriefProfile = options.agent ? getCapabilityProfile(options.agent) : undefined;
|
|
480
|
+
if (taskBriefProfile && !dispatchHasMcp(taskBriefProfile)) {
|
|
481
|
+
parts.push('## ⚠ Transport: sandboxed run (no MCP, no commit)');
|
|
482
|
+
parts.push('Your runtime is sandboxed — the brainclaw MCP server is NOT reachable and `git commit` is unavailable (.git is outside the sandbox root). Any `bclaw_*` MCP instruction above does NOT apply to you. Report your outcome via the FILE protocol only — it is authoritative for this run:');
|
|
483
|
+
const asgn = options.assignmentId ?? '<assignment_id>';
|
|
484
|
+
parts.push(`- When done, write LANE-RESULT.json at the worktree root: {"assignment_id":"${asgn}","status":"completed|blocked|failed","summary":"<what you did>","files_changed":["..."]}.`);
|
|
485
|
+
parts.push('- Capture decisions/traps as candidate JSON under .brainclaw/coordination/inbox/ (the coordinator harvests them).');
|
|
486
|
+
parts.push('- Do NOT call bclaw_* tools — they are unavailable here. The coordinator harvests your result and integrates/commits it.');
|
|
487
|
+
parts.push('');
|
|
488
|
+
}
|
|
450
489
|
// Codex-specific constraints: focus and speed guidance for sandboxed runs
|
|
451
490
|
if (options.agent === 'codex') {
|
|
452
491
|
parts.push('## Constraints');
|
|
453
492
|
parts.push('- Focus on specified files only — do not explore the broader codebase');
|
|
454
493
|
parts.push('- Produce output quickly; if blocked, capture as trap candidate and move on');
|
|
455
|
-
parts.push('- Sandbox blocks MCP writes: use filesystem writes for candidates, coordinator harvests');
|
|
456
494
|
parts.push('');
|
|
457
495
|
}
|
|
458
496
|
return parts.join('\n');
|
|
@@ -126,6 +126,42 @@ export function listEntities(name, cwd, filter = {}) {
|
|
|
126
126
|
const paged = applyPaging(filtered, filter);
|
|
127
127
|
return { entity: name, total: filtered.length, items: paged };
|
|
128
128
|
}
|
|
129
|
+
/** Default serialized-items budget (chars) — keeps a bclaw_find payload well under the ~25k-token MCP cap (trp#449). */
|
|
130
|
+
export const DEFAULT_FIND_CHAR_BUDGET = 40000;
|
|
131
|
+
/**
|
|
132
|
+
* pln#491 — bound a list payload so a verbose result set never overflows the MCP
|
|
133
|
+
* token cap (which makes agents silently fall back to the CLI, trp#449).
|
|
134
|
+
* `listEntities` already caps COUNT (default 50 via applyPaging); this additionally
|
|
135
|
+
* caps SIZE: if the serialized items exceed `charBudget`, the page is shrunk until
|
|
136
|
+
* it fits (always keeping at least one item). Either way the result advertises
|
|
137
|
+
* has_more / next_offset / a hint so the caller paginates explicitly instead of
|
|
138
|
+
* guessing or falling back to the terminal.
|
|
139
|
+
*/
|
|
140
|
+
export function boundListResult(result, offset, charBudget = DEFAULT_FIND_CHAR_BUDGET) {
|
|
141
|
+
let items = result.items;
|
|
142
|
+
let omittedForSize = 0;
|
|
143
|
+
while (items.length > 1 && JSON.stringify(items).length > charBudget) {
|
|
144
|
+
const drop = Math.max(1, Math.ceil(items.length * 0.25));
|
|
145
|
+
items = items.slice(0, items.length - drop);
|
|
146
|
+
omittedForSize = result.items.length - items.length;
|
|
147
|
+
}
|
|
148
|
+
const returned = items.length;
|
|
149
|
+
const hasMore = offset + returned < result.total;
|
|
150
|
+
const bounded = {
|
|
151
|
+
...result,
|
|
152
|
+
items,
|
|
153
|
+
returned,
|
|
154
|
+
has_more: hasMore,
|
|
155
|
+
...(omittedForSize > 0 ? { omitted_for_size: omittedForSize } : {}),
|
|
156
|
+
};
|
|
157
|
+
if (hasMore) {
|
|
158
|
+
bounded.next_offset = offset + returned;
|
|
159
|
+
bounded.hint = omittedForSize > 0
|
|
160
|
+
? `Payload size-bounded: returned ${returned} of ${result.total} ${result.entity} item(s). Fetch more with filter.offset=${bounded.next_offset}, or narrow the filter (status/tag/author).`
|
|
161
|
+
: `Returned ${returned} of ${result.total} ${result.entity} item(s). Page with filter.offset=${bounded.next_offset}, or narrow the filter.`;
|
|
162
|
+
}
|
|
163
|
+
return bounded;
|
|
164
|
+
}
|
|
129
165
|
function loadAll(name, cwd) {
|
|
130
166
|
switch (name) {
|
|
131
167
|
case 'plan': return loadState(cwd).plan_items;
|
|
@@ -23,7 +23,7 @@ const plan = {
|
|
|
23
23
|
name: 'plan',
|
|
24
24
|
shortLabelPrefix: 'pln',
|
|
25
25
|
schema: PlanItemSchema,
|
|
26
|
-
updatable: ['text', 'priority', 'tags', 'assignee', 'estimated_effort', 'actual_effort', 'depends_on'],
|
|
26
|
+
updatable: ['text', 'priority', 'tags', 'assignee', 'estimated_effort', 'actual_effort', 'depends_on', 'related_paths'],
|
|
27
27
|
statusField: 'status',
|
|
28
28
|
transitions: {
|
|
29
29
|
todo: ['in_progress', 'blocked', 'done', 'dropped'],
|
|
@@ -99,4 +99,76 @@ export function readLogTail(root, assignmentId, stream, maxBytes = 2000) {
|
|
|
99
99
|
return '';
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* pln#527 — directories never worth walking for filesystem-activity (junction
|
|
104
|
+
* targets / VCS / coordination store). Skipping them keeps the worktree mtime
|
|
105
|
+
* scan cheap AND avoids following node_modules/dist junctions into the main repo.
|
|
106
|
+
*/
|
|
107
|
+
const FS_ACTIVITY_SKIP_DIRS = new Set(['.git', '.brainclaw', 'node_modules', 'dist', '.venv', 'venv', 'vendor']);
|
|
108
|
+
/**
|
|
109
|
+
* pln#527 — most-recent file mtime (ms) under a worktree, via a bounded walk that
|
|
110
|
+
* NEVER follows symlinks/junctions (lstat) and skips dependency/VCS dirs. This is
|
|
111
|
+
* the liveness signal for workers that edit files but emit no heartbeat/stdout
|
|
112
|
+
* (e.g. `claude -p` buffers stdout; a long single edit pass refreshes no
|
|
113
|
+
* sentinel). Returns undefined when the path is absent/unreadable.
|
|
114
|
+
*/
|
|
115
|
+
export function latestWorktreeFileMtimeMs(worktreePath, maxDepth = 4) {
|
|
116
|
+
let latest;
|
|
117
|
+
const walk = (dir, depth) => {
|
|
118
|
+
if (depth > maxDepth)
|
|
119
|
+
return;
|
|
120
|
+
let entries;
|
|
121
|
+
try {
|
|
122
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
if (entry.isSymbolicLink())
|
|
129
|
+
continue; // never follow junctions (node_modules/dist)
|
|
130
|
+
const full = path.join(dir, entry.name);
|
|
131
|
+
if (entry.isDirectory()) {
|
|
132
|
+
if (FS_ACTIVITY_SKIP_DIRS.has(entry.name))
|
|
133
|
+
continue;
|
|
134
|
+
walk(full, depth + 1);
|
|
135
|
+
}
|
|
136
|
+
else if (entry.isFile()) {
|
|
137
|
+
try {
|
|
138
|
+
const m = fs.statSync(full).mtimeMs;
|
|
139
|
+
if (latest === undefined || m > latest)
|
|
140
|
+
latest = m;
|
|
141
|
+
}
|
|
142
|
+
catch { /* ignore */ }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
walk(worktreePath, 0);
|
|
147
|
+
return latest;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* pln#527 — the most recent filesystem activity (ms since epoch) attributable to
|
|
151
|
+
* a dispatched run: the max mtime across its captured stdout/stderr logs AND any
|
|
152
|
+
* file in its worktree. Lets the reconciler / dispatch_status distinguish
|
|
153
|
+
* "no heartbeat BUT fs active" (working — e.g. codex streaming to stderr, or
|
|
154
|
+
* claude -p editing files) from "no heartbeat AND fs inert" (genuinely stalled),
|
|
155
|
+
* fixing the false-`stalled` verdict (debrief LeaseUp P1#1). Returns undefined
|
|
156
|
+
* when nothing is observable.
|
|
157
|
+
*/
|
|
158
|
+
export function latestActivityMs(root, assignmentId, worktreePath) {
|
|
159
|
+
let latest;
|
|
160
|
+
const bump = (ms) => {
|
|
161
|
+
if (ms !== undefined && (latest === undefined || ms > latest))
|
|
162
|
+
latest = ms;
|
|
163
|
+
};
|
|
164
|
+
for (const stream of ['stdout', 'stderr']) {
|
|
165
|
+
try {
|
|
166
|
+
bump(fs.statSync(getRuntimeLogPath(root, assignmentId, stream)).mtimeMs);
|
|
167
|
+
}
|
|
168
|
+
catch { /* no log */ }
|
|
169
|
+
}
|
|
170
|
+
if (worktreePath)
|
|
171
|
+
bump(latestWorktreeFileMtimeMs(worktreePath));
|
|
172
|
+
return latest;
|
|
173
|
+
}
|
|
102
174
|
//# sourceMappingURL=runtime-signals.js.map
|
package/dist/core/worktree.js
CHANGED
|
@@ -370,6 +370,14 @@ export function createWorktree(mainWorktreePath, branchName, options = {}) {
|
|
|
370
370
|
// Symlinking .brainclaw/ causes hooks and session_start to trigger on the
|
|
371
371
|
// shared store, creating session conflicts and potentially blocking agents
|
|
372
372
|
// (especially Claude CLI which auto-detects .brainclaw/ presence).
|
|
373
|
+
// pln#479: opt-in per-worktree typecheck gate. Off by default — on large
|
|
374
|
+
// monorepos `tsc` is slow and a per-commit gate would be punishing — enable
|
|
375
|
+
// with BRAINCLAW_WORKTREE_TYPECHECK_GATE=1. Isolated to this worktree, so the
|
|
376
|
+
// main repo's commits are never affected.
|
|
377
|
+
let typecheckGate;
|
|
378
|
+
if (process.env.BRAINCLAW_WORKTREE_TYPECHECK_GATE === '1') {
|
|
379
|
+
typecheckGate = installWorktreeTypecheckGate(mainWorktreePath, targetPath);
|
|
380
|
+
}
|
|
373
381
|
const mainGitignorePath = path.join(mainWorktreePath, '.gitignore');
|
|
374
382
|
const targetGitignorePath = path.join(targetPath, '.gitignore');
|
|
375
383
|
if (fs.existsSync(mainGitignorePath)) {
|
|
@@ -389,10 +397,83 @@ export function createWorktree(mainWorktreePath, branchName, options = {}) {
|
|
|
389
397
|
// that could not be created) so the worker / supervisor can see why a build
|
|
390
398
|
// might fail, instead of an invisible degradation.
|
|
391
399
|
...(symlinkWarnings.length > 0 ? { symlink_warnings: symlinkWarnings } : {}),
|
|
400
|
+
// pln#479: record whether the per-worktree typecheck gate is active.
|
|
401
|
+
...(typecheckGate?.installed ? { typecheck_gate: true } : {}),
|
|
392
402
|
};
|
|
393
403
|
fs.writeFileSync(path.join(targetPath, '.brainclaw-worktree.json'), JSON.stringify(meta, null, 2));
|
|
394
404
|
return targetPath;
|
|
395
405
|
}
|
|
406
|
+
/** Directory (relative to a worktree root) holding the per-worktree git hooks. */
|
|
407
|
+
export const WORKTREE_HOOKS_DIRNAME = '.brainclaw-hooks';
|
|
408
|
+
/**
|
|
409
|
+
* The pre-commit gate body (pln#479). Runs via `node -e` — same SIGPIPE-avoiding
|
|
410
|
+
* pattern as install-hooks.ts. Git runs hooks with cwd = worktree root, so the
|
|
411
|
+
* relative paths resolve there. `node` and the tsc entry point are invoked with
|
|
412
|
+
* forward-slash relative paths to stay cross-platform (no quoting/backslash
|
|
413
|
+
* pitfalls). If typescript is absent the gate degrades to a warning rather than
|
|
414
|
+
* blocking — a tooling gap must not trap a worker.
|
|
415
|
+
*/
|
|
416
|
+
export function buildTypecheckPreCommitScript() {
|
|
417
|
+
return `#!/bin/sh
|
|
418
|
+
# brainclaw worktree typecheck gate (pln#479) — do not edit manually.
|
|
419
|
+
# Blocks the commit when 'tsc --noEmit' fails. Bypass: git commit --no-verify.
|
|
420
|
+
exec node -e "
|
|
421
|
+
const fs = require('fs');
|
|
422
|
+
const { execSync } = require('child_process');
|
|
423
|
+
if (!fs.existsSync('tsconfig.json')) process.exit(0);
|
|
424
|
+
if (!fs.existsSync('node_modules/typescript/bin/tsc')) {
|
|
425
|
+
process.stderr.write('\\\\n[brainclaw] typecheck gate: typescript not found in worktree node_modules — skipping (commit allowed).\\\\n');
|
|
426
|
+
process.exit(0);
|
|
427
|
+
}
|
|
428
|
+
try {
|
|
429
|
+
execSync('node node_modules/typescript/bin/tsc --noEmit', { stdio: 'inherit' });
|
|
430
|
+
} catch (e) {
|
|
431
|
+
process.stderr.write('\\\\n[brainclaw] commit blocked: tsc --noEmit reported type errors (above). Fix them, or bypass with: git commit --no-verify\\\\n\\\\n');
|
|
432
|
+
process.exit(1);
|
|
433
|
+
}
|
|
434
|
+
" 2>&1 || exit $?
|
|
435
|
+
`;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* pln#479 — install an ISOLATED pre-commit gate in a dispatched worktree that
|
|
439
|
+
* blocks a commit when `tsc --noEmit` fails, so a worker cannot land code that
|
|
440
|
+
* breaks the type-check (observed: workers committing strict-mode-broken TS that
|
|
441
|
+
* only blew up at merge/build time, pln#466).
|
|
442
|
+
*
|
|
443
|
+
* Isolation is the crux: git hooks are shared across all worktrees of a repo by
|
|
444
|
+
* default, so we must NOT write into the common hooks dir — that would impose
|
|
445
|
+
* tsc on the human's main-repo commits too. Instead we point THIS worktree's
|
|
446
|
+
* `core.hooksPath` at a worktree-local dir via the `--worktree` config scope
|
|
447
|
+
* (enabling `extensions.worktreeConfig`), which leaves the main repo's hook
|
|
448
|
+
* setup completely untouched and is torn down with the worktree.
|
|
449
|
+
*
|
|
450
|
+
* No-ops when the worktree has no `tsconfig.json`. Depends on pln#523 having
|
|
451
|
+
* linked `node_modules` so `tsc` resolves.
|
|
452
|
+
*/
|
|
453
|
+
export function installWorktreeTypecheckGate(mainWorktreePath, worktreePath) {
|
|
454
|
+
if (!fs.existsSync(path.join(worktreePath, 'tsconfig.json'))) {
|
|
455
|
+
return { installed: false, reason: 'no tsconfig.json — not a TypeScript worktree' };
|
|
456
|
+
}
|
|
457
|
+
try {
|
|
458
|
+
const hooksDir = path.join(worktreePath, WORKTREE_HOOKS_DIRNAME);
|
|
459
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
460
|
+
fs.writeFileSync(path.join(hooksDir, 'pre-commit'), buildTypecheckPreCommitScript(), {
|
|
461
|
+
encoding: 'utf-8',
|
|
462
|
+
mode: 0o755,
|
|
463
|
+
});
|
|
464
|
+
// Enable per-worktree config on the repo (idempotent, additive) so the
|
|
465
|
+
// hooksPath override stays scoped to THIS worktree only.
|
|
466
|
+
runGit(['config', 'extensions.worktreeConfig', 'true'], mainWorktreePath);
|
|
467
|
+
const set = runGit(['config', '--worktree', 'core.hooksPath', gitPath(hooksDir)], worktreePath);
|
|
468
|
+
if (!set.ok) {
|
|
469
|
+
return { installed: false, reason: `git config --worktree core.hooksPath failed: ${set.stderr.trim()}` };
|
|
470
|
+
}
|
|
471
|
+
return { installed: true };
|
|
472
|
+
}
|
|
473
|
+
catch (err) {
|
|
474
|
+
return { installed: false, reason: err instanceof Error ? err.message : String(err) };
|
|
475
|
+
}
|
|
476
|
+
}
|
|
396
477
|
/**
|
|
397
478
|
* Lists all git worktrees for the given repo and enriches them with
|
|
398
479
|
* brainclaw metadata if available.
|
package/dist/facts.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Generated by scripts/emit-site-facts.mjs at build time. Do not edit manually.
|
|
2
|
-
// Source: brainclaw v1.7.
|
|
2
|
+
// Source: brainclaw v1.7.4 on 2026-06-08T21:59:54.110Z
|
|
3
3
|
export const FACTS = {
|
|
4
|
-
"version": "1.7.
|
|
5
|
-
"generated_at": "2026-06-
|
|
4
|
+
"version": "1.7.4",
|
|
5
|
+
"generated_at": "2026-06-08T21:59:54.110Z",
|
|
6
6
|
"tools": {
|
|
7
7
|
"count": 62,
|
|
8
8
|
"published_count": 61,
|
package/dist/facts.json
CHANGED