brainclaw 1.5.5 → 1.7.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 +5 -4
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +124 -7
- package/dist/commands/bootstrap-loop.js +206 -0
- package/dist/commands/loop.js +156 -0
- package/dist/commands/loops-handlers.js +110 -55
- package/dist/commands/mcp-read-handlers.js +37 -0
- package/dist/commands/mcp-schemas.generated.js +33 -0
- package/dist/commands/mcp.js +661 -207
- package/dist/commands/questions.js +180 -0
- package/dist/commands/reply.js +190 -0
- package/dist/commands/session-end.js +105 -3
- package/dist/commands/session-start.js +32 -53
- package/dist/commands/setup.js +64 -13
- package/dist/commands/switch.js +17 -1
- package/dist/core/agent-capability.js +19 -0
- package/dist/core/agent-files.js +86 -0
- package/dist/core/agent-integrations.js +34 -0
- package/dist/core/agent-inventory.js +27 -0
- package/dist/core/agentrun-reconciler.js +130 -0
- package/dist/core/ai-agent-detection.js +17 -1
- package/dist/core/claims.js +54 -3
- package/dist/core/dirty-scope.js +242 -0
- package/dist/core/dispatch-status.js +219 -0
- package/dist/core/entity-operations.js +128 -9
- package/dist/core/execution-adapters.js +38 -2
- package/dist/core/facade-schema.js +71 -0
- package/dist/core/federation-cloud.js +27 -12
- package/dist/core/federation-materialize.js +57 -0
- package/dist/core/instruction-templates.js +2 -0
- package/dist/core/loops/bootstrap-acquire.js +195 -0
- package/dist/core/loops/facade-schema.js +68 -1
- package/dist/core/loops/hooks/bootstrap-write.js +144 -0
- package/dist/core/loops/hooks/notify-operator.js +148 -0
- package/dist/core/loops/hooks/survey-source-reader.js +256 -0
- package/dist/core/loops/index.js +8 -2
- package/dist/core/loops/next-expected.js +63 -0
- package/dist/core/loops/presets/bootstrap.js +75 -0
- package/dist/core/loops/presets/index.js +16 -0
- package/dist/core/loops/store.js +224 -4
- package/dist/core/loops/types.js +346 -1
- package/dist/core/loops/verbs.js +739 -6
- package/dist/core/schema.js +30 -2
- package/dist/core/state.js +62 -0
- package/dist/core/worktree.js +58 -0
- package/dist/facts.js +360 -5
- package/dist/facts.json +359 -4
- package/docs/cli.md +10 -7
- package/docs/concepts/dispatch-lifecycle.md +228 -0
- package/docs/concepts/loop-engine.md +55 -0
- package/docs/concepts/multi-agent-workflows.md +167 -166
- package/docs/concepts/troubleshooting.md +36 -2
- package/docs/index.md +4 -3
- package/docs/integrations/hermes.md +78 -0
- package/docs/integrations/overview.md +22 -18
- package/docs/mcp-schema-changelog.md +8 -1
- package/docs/quickstart-existing-project.md +1 -1
- package/package.json +5 -4
package/dist/core/claims.js
CHANGED
|
@@ -6,7 +6,7 @@ import { mutate } from './mutation-pipeline.js';
|
|
|
6
6
|
import { nowISO } from './ids.js';
|
|
7
7
|
import { JsonStore } from './json-store.js';
|
|
8
8
|
import { loadConfig } from './config.js';
|
|
9
|
-
import { createWorktree } from './worktree.js';
|
|
9
|
+
import { createWorktree, resetWorktreeToRef } from './worktree.js';
|
|
10
10
|
import { appendAuditEntry } from './audit.js';
|
|
11
11
|
import { refreshLiveCompanions } from '../commands/export.js';
|
|
12
12
|
import { loadSessionById } from './identity.js';
|
|
@@ -59,6 +59,35 @@ export function saveClaim(claim, cwd) {
|
|
|
59
59
|
catch { /* best-effort */ }
|
|
60
60
|
});
|
|
61
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Atomically check for an active claim on `scope` and save a new one if absent.
|
|
64
|
+
*
|
|
65
|
+
* Atomicity is provided by running both operations inside a single mutate() call;
|
|
66
|
+
* the mutation-pipeline mutex serializes filesystem writes on the claims store.
|
|
67
|
+
*/
|
|
68
|
+
export function acquireClaimScope(input, cwd) {
|
|
69
|
+
return mutate({ cwd }, () => {
|
|
70
|
+
const conflictingClaim = listClaims(cwd).find((claim) => claim.status === 'active' && claim.scope === input.scope);
|
|
71
|
+
if (conflictingClaim) {
|
|
72
|
+
return { acquired: false, conflicting_claim: conflictingClaim };
|
|
73
|
+
}
|
|
74
|
+
const claim = {
|
|
75
|
+
id: generateClaimId(),
|
|
76
|
+
agent: input.agent,
|
|
77
|
+
agent_id: input.agent_id,
|
|
78
|
+
user: input.user,
|
|
79
|
+
session_id: input.session_id,
|
|
80
|
+
scope: input.scope,
|
|
81
|
+
description: input.description,
|
|
82
|
+
created_at: nowISO(),
|
|
83
|
+
status: 'active',
|
|
84
|
+
plan_id: input.plan_id,
|
|
85
|
+
model: input.model,
|
|
86
|
+
};
|
|
87
|
+
saveClaim(claim, cwd);
|
|
88
|
+
return { acquired: true, claim };
|
|
89
|
+
});
|
|
90
|
+
}
|
|
62
91
|
export function loadClaim(id, cwd) {
|
|
63
92
|
return claimStore(cwd).load(id);
|
|
64
93
|
}
|
|
@@ -378,10 +407,28 @@ export function createCoordinatorClaim(options) {
|
|
|
378
407
|
const existingScopeClaim = listClaims(options.cwd).find((claim) => claim.status === 'active' && claim.scope === options.scope);
|
|
379
408
|
if (existingScopeClaim) {
|
|
380
409
|
if (existingScopeClaim.agent === options.agent) {
|
|
381
|
-
// Same agent already has this scope — reuse the claim (backward compat,
|
|
410
|
+
// Same agent already has this scope — reuse the claim (backward compat,
|
|
411
|
+
// same-agent multi-call). If the caller pinned a base ref, the reused
|
|
412
|
+
// worktree MUST be re-pointed to it; otherwise a dispatch that relied on
|
|
413
|
+
// the ref (e.g. the dirty-guard bypass) would run the worker on a stale
|
|
414
|
+
// worktree — the same silent false-negative the guard exists to prevent
|
|
415
|
+
// (pln#520 Tier 2 / codex r2).
|
|
416
|
+
let reuseWarning;
|
|
417
|
+
if (options.worktreeBaseRef) {
|
|
418
|
+
if (existingScopeClaim.worktree_path) {
|
|
419
|
+
const reset = resetWorktreeToRef(existingScopeClaim.worktree_path, options.worktreeBaseRef);
|
|
420
|
+
if (!reset.ok) {
|
|
421
|
+
reuseWarning = `Reused claim ${existingScopeClaim.id} pinned to ref "${options.worktreeBaseRef}": ${reset.stderr.trim()}`;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
reuseWarning = `Reused claim ${existingScopeClaim.id} has no worktree to pin to ref "${options.worktreeBaseRef}".`;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
382
428
|
return {
|
|
383
429
|
claimId: existingScopeClaim.id,
|
|
384
430
|
worktreePath: existingScopeClaim.worktree_path,
|
|
431
|
+
worktreeWarning: reuseWarning,
|
|
385
432
|
reusedExisting: true,
|
|
386
433
|
};
|
|
387
434
|
}
|
|
@@ -406,7 +453,11 @@ export function createCoordinatorClaim(options) {
|
|
|
406
453
|
sessionId: options.sessionId,
|
|
407
454
|
agent: options.agent,
|
|
408
455
|
baseRef: options.worktreeBaseRef,
|
|
409
|
-
|
|
456
|
+
// A pinned base ref implies the branch must be reset to it: createWorktree
|
|
457
|
+
// otherwise reuses a pre-existing feat/<scope> branch and ignores baseRef.
|
|
458
|
+
// Deriving it here (not only at the call site) keeps the invariant — "a
|
|
459
|
+
// pinned ref ⇒ the worktree reflects that ref" — owned by this chokepoint.
|
|
460
|
+
resetExistingBranch: options.resetExistingWorktreeBranch || Boolean(options.worktreeBaseRef),
|
|
410
461
|
});
|
|
411
462
|
}
|
|
412
463
|
catch (err) {
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scope-aware dirty-working-tree guard for dispatch (pln#520 Tier 2, trp#371).
|
|
3
|
+
*
|
|
4
|
+
* The original guard (can_30c295b4) refused review/assign/consult/ideate the
|
|
5
|
+
* moment ANY uncommitted file existed in the source repo — repo-global, no
|
|
6
|
+
* comparison to the dispatch scope. In multi-agent use (codex/claude leave
|
|
7
|
+
* uncommitted files around) that hard-blocks legitimate dispatches, and the
|
|
8
|
+
* coordination store itself (`.brainclaw/`) is rewritten on every dispatch so
|
|
9
|
+
* a second `bclaw_coordinate` in the same session ALWAYS saw a dirty tree.
|
|
10
|
+
*
|
|
11
|
+
* This helper makes the decision scope-aware. The cardinal rule (converged
|
|
12
|
+
* across the Tier 2 ideation loop lop_5fc24cc8707992ea): never turn a noisy,
|
|
13
|
+
* visible false-positive (block a legitimate dispatch) into a SILENT
|
|
14
|
+
* false-negative (let a worker review/edit stale code with no signal). So
|
|
15
|
+
* when the scope cannot be proven disjoint from the dirty files, we still
|
|
16
|
+
* block — `allow_dirty=true` remains the explicit, auditable override.
|
|
17
|
+
*
|
|
18
|
+
* Empirical grounding: of ~450 real claim scopes in the store, only 4 contain
|
|
19
|
+
* a glob and ~60% are not resolvable to paths at all (plan-ids, loop-refs,
|
|
20
|
+
* prose). So the resolver optimises for the path-prefix case and treats
|
|
21
|
+
* anything ambiguous as `unknown` (→ conservative block when dirty), rather
|
|
22
|
+
* than building a glob engine. The rare real globs are delegated to git.
|
|
23
|
+
*/
|
|
24
|
+
import { spawnSync } from 'node:child_process';
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
/** Top-level directories that read as code paths even when not yet on disk. */
|
|
28
|
+
const KNOWN_TOP_LEVEL = new Set([
|
|
29
|
+
'src', 'lib', 'docs', 'doc', 'test', 'tests', 'packages', 'app', 'apps',
|
|
30
|
+
'scripts', 'bin', 'public', 'assets', 'examples', 'config',
|
|
31
|
+
]);
|
|
32
|
+
/** Entity-id / loop-ref prefixes that are never file paths. */
|
|
33
|
+
const ENTITY_ID_RE = /^(pln|clm|asg|asgn|run|lop|dec|cst|con|trp|han|can|rtn|rn|seq|sess|agt|art|lsl)[_#]/i;
|
|
34
|
+
function defaultRunGit(cwd, args) {
|
|
35
|
+
try {
|
|
36
|
+
const probe = spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8', timeout: 5000 });
|
|
37
|
+
if (probe.status !== 0 || typeof probe.stdout !== 'string') {
|
|
38
|
+
return { ok: false, stdout: '' };
|
|
39
|
+
}
|
|
40
|
+
return { ok: true, stdout: probe.stdout };
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Never block a dispatch because git hiccuped — surface as "not probeable".
|
|
44
|
+
return { ok: false, stdout: '' };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** True for coordination/store paths that are dirty as a side effect of dispatching. */
|
|
48
|
+
export function isSystemDirtyPath(p) {
|
|
49
|
+
const norm = p.replace(/\\/g, '/');
|
|
50
|
+
return norm === '.brainclaw'
|
|
51
|
+
|| norm.startsWith('.brainclaw/')
|
|
52
|
+
|| norm === '.git'
|
|
53
|
+
|| norm.startsWith('.git/');
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Parse `git status --porcelain=v1 -z` output into a flat list of paths.
|
|
57
|
+
*
|
|
58
|
+
* NUL-separated entries avoid the quoting/newline pitfalls of line parsing.
|
|
59
|
+
* Rename/copy entries (`R`/`C`) carry the NEW path in the status field and the
|
|
60
|
+
* ORIGINAL path in the following NUL field — we keep BOTH, because a move out
|
|
61
|
+
* of scope that creates a file in scope (or vice versa) is a relevant change.
|
|
62
|
+
*/
|
|
63
|
+
export function parsePorcelainZ(stdout) {
|
|
64
|
+
const parts = stdout.split('\0').filter((p) => p.length > 0);
|
|
65
|
+
const paths = [];
|
|
66
|
+
for (let i = 0; i < parts.length; i++) {
|
|
67
|
+
const entry = parts[i];
|
|
68
|
+
if (entry.length < 4)
|
|
69
|
+
continue; // malformed; "XY p" is the minimum
|
|
70
|
+
const status = entry.slice(0, 2);
|
|
71
|
+
const newPath = entry.slice(3); // skip "XY " (2 status chars + 1 separator)
|
|
72
|
+
paths.push(newPath);
|
|
73
|
+
if (status[0] === 'R' || status[0] === 'C') {
|
|
74
|
+
const origPath = parts[i + 1];
|
|
75
|
+
if (origPath !== undefined) {
|
|
76
|
+
paths.push(origPath);
|
|
77
|
+
i++; // consume the original-path field
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return paths;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Resolve a free-string scope into git pathspecs, or `unknown` when it cannot
|
|
85
|
+
* be proven to denote file paths. All-or-nothing across comma-separated
|
|
86
|
+
* tokens: a single unresolvable token marks the whole scope unknown, so the
|
|
87
|
+
* guard never blocks on a partial (and therefore misleading) view.
|
|
88
|
+
*/
|
|
89
|
+
export function resolveScopeToPathspecs(scope, cwd) {
|
|
90
|
+
if (!scope || !scope.trim()) {
|
|
91
|
+
return { kind: 'unknown', reason: 'no scope provided' };
|
|
92
|
+
}
|
|
93
|
+
const tokens = scope.split(',').map((t) => t.trim()).filter(Boolean);
|
|
94
|
+
if (tokens.length === 0) {
|
|
95
|
+
return { kind: 'unknown', reason: 'empty scope' };
|
|
96
|
+
}
|
|
97
|
+
const pathspecs = [];
|
|
98
|
+
for (const token of tokens) {
|
|
99
|
+
if (ENTITY_ID_RE.test(token) || /^review-loop:/i.test(token)) {
|
|
100
|
+
return { kind: 'unknown', reason: `token "${token}" is an entity id, not a path` };
|
|
101
|
+
}
|
|
102
|
+
if (/\s/.test(token)) {
|
|
103
|
+
return { kind: 'unknown', reason: `token "${token}" contains whitespace (prose, not a path)` };
|
|
104
|
+
}
|
|
105
|
+
const normalized = token.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
106
|
+
if (/[*?[\]]/.test(normalized)) {
|
|
107
|
+
// Delegate the rare real glob to git's native pathspec matcher.
|
|
108
|
+
pathspecs.push(`:(glob)${normalized}`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const exists = fs.existsSync(path.resolve(cwd, normalized));
|
|
112
|
+
const topLevel = normalized.split('/')[0];
|
|
113
|
+
if (exists || KNOWN_TOP_LEVEL.has(topLevel)) {
|
|
114
|
+
pathspecs.push(normalized);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
kind: 'unknown',
|
|
119
|
+
reason: `token "${token}" is not a resolvable path (no such file/dir and not a known top-level)`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return { kind: 'pathspecs', pathspecs };
|
|
123
|
+
}
|
|
124
|
+
/** Cap a file list for human-readable messages. */
|
|
125
|
+
function summariseFiles(files, max = 10) {
|
|
126
|
+
if (files.length <= max)
|
|
127
|
+
return files.join(', ');
|
|
128
|
+
return `${files.slice(0, max).join(', ')} (and ${files.length - max} more)`;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Decide whether a dispatch may proceed given the source repo's dirty state and
|
|
132
|
+
* the dispatch scope. See the module header for the cardinal rule.
|
|
133
|
+
*/
|
|
134
|
+
export function assessDirtyDispatchGuard(input) {
|
|
135
|
+
const runGit = input.runGit ?? defaultRunGit;
|
|
136
|
+
const resolution = resolveScopeToPathspecs(input.scope, input.cwd);
|
|
137
|
+
// 1. Global probe — is the tree dirty at all (ignoring the coordination store)?
|
|
138
|
+
const global = runGit(input.cwd, ['status', '--porcelain=v1', '-z', '--untracked-files=normal']);
|
|
139
|
+
if (!global.ok) {
|
|
140
|
+
return {
|
|
141
|
+
decision: 'allow',
|
|
142
|
+
reason: 'source cwd is not a git repo (or git is unavailable) — dirty guard skipped',
|
|
143
|
+
dirtyCount: 0,
|
|
144
|
+
overlapping: [],
|
|
145
|
+
ignoredSystemDirty: [],
|
|
146
|
+
scopeResolution: resolution.kind,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const allPaths = parsePorcelainZ(global.stdout);
|
|
150
|
+
const ignoredSystemDirty = allPaths.filter(isSystemDirtyPath);
|
|
151
|
+
const realDirty = allPaths.filter((p) => !isSystemDirtyPath(p));
|
|
152
|
+
if (realDirty.length === 0) {
|
|
153
|
+
return {
|
|
154
|
+
decision: 'allow',
|
|
155
|
+
reason: ignoredSystemDirty.length > 0
|
|
156
|
+
? `working tree clean apart from ${ignoredSystemDirty.length} coordination-store file(s) (.brainclaw/, .git/) which the worker rebuilds itself`
|
|
157
|
+
: 'working tree clean',
|
|
158
|
+
dirtyCount: 0,
|
|
159
|
+
overlapping: [],
|
|
160
|
+
ignoredSystemDirty,
|
|
161
|
+
scopeResolution: resolution.kind,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
// 2. Explicit ref → the worktree is built from that ref (the dispatch path
|
|
165
|
+
// forces resetExistingWorktreeBranch so a pre-existing branch is reset to
|
|
166
|
+
// the ref, not silently reused), so uncommitted working-tree changes are
|
|
167
|
+
// intentionally out of scope. Only bypass when the ref actually resolves —
|
|
168
|
+
// a bogus/unresolvable ref must NOT widen the allow; fall through to the
|
|
169
|
+
// scope-aware check below so a dirty in-scope file still blocks.
|
|
170
|
+
if (input.checkoutRef) {
|
|
171
|
+
const refResolves = runGit(input.cwd, ['rev-parse', '--verify', '--quiet', `${input.checkoutRef}^{commit}`]).ok;
|
|
172
|
+
if (refResolves) {
|
|
173
|
+
return {
|
|
174
|
+
decision: 'allow',
|
|
175
|
+
reason: `dispatch builds its worktree from explicit ref "${input.checkoutRef}"; ${realDirty.length} uncommitted working-tree file(s) are intentionally out of scope`,
|
|
176
|
+
dirtyCount: realDirty.length,
|
|
177
|
+
overlapping: [],
|
|
178
|
+
ignoredSystemDirty,
|
|
179
|
+
scopeResolution: resolution.kind,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
// else: ref does not resolve → continue to the scope-aware evaluation.
|
|
183
|
+
}
|
|
184
|
+
// 3. Scope-aware intersection (delegated to git for glob + segment-boundary correctness).
|
|
185
|
+
if (resolution.kind === 'pathspecs') {
|
|
186
|
+
const scoped = runGit(input.cwd, [
|
|
187
|
+
'status', '--porcelain=v1', '-z', '--untracked-files=normal',
|
|
188
|
+
'--', ...resolution.pathspecs, ':(exclude).brainclaw/', ':(exclude).git/',
|
|
189
|
+
]);
|
|
190
|
+
// If the scoped probe fails for any reason, fall back conservatively:
|
|
191
|
+
// treat the whole real-dirty set as potentially overlapping.
|
|
192
|
+
const overlapping = scoped.ok ? parsePorcelainZ(scoped.stdout).filter((p) => !isSystemDirtyPath(p)) : realDirty;
|
|
193
|
+
if (overlapping.length === 0) {
|
|
194
|
+
return {
|
|
195
|
+
decision: 'allow',
|
|
196
|
+
reason: `${realDirty.length} uncommitted file(s) but none overlap the dispatch scope`,
|
|
197
|
+
dirtyCount: realDirty.length,
|
|
198
|
+
overlapping: [],
|
|
199
|
+
ignoredSystemDirty,
|
|
200
|
+
scopeResolution: resolution.kind,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
if (input.allowDirty) {
|
|
204
|
+
return {
|
|
205
|
+
decision: 'warn',
|
|
206
|
+
reason: `allow_dirty=true: proceeding despite ${overlapping.length} dirty file(s) overlapping the dispatch scope: ${summariseFiles(overlapping)}`,
|
|
207
|
+
dirtyCount: realDirty.length,
|
|
208
|
+
overlapping,
|
|
209
|
+
ignoredSystemDirty,
|
|
210
|
+
scopeResolution: resolution.kind,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
decision: 'block',
|
|
215
|
+
reason: `${overlapping.length} uncommitted file(s) overlap the dispatch scope and the worker spawns from HEAD, so it will not see them: ${summariseFiles(overlapping)}. Commit or stash these, or pass allow_dirty=true to override.`,
|
|
216
|
+
dirtyCount: realDirty.length,
|
|
217
|
+
overlapping,
|
|
218
|
+
ignoredSystemDirty,
|
|
219
|
+
scopeResolution: resolution.kind,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
// 4. Scope not resolvable to paths → cannot prove disjointness → conservative.
|
|
223
|
+
if (input.allowDirty) {
|
|
224
|
+
return {
|
|
225
|
+
decision: 'warn',
|
|
226
|
+
reason: `allow_dirty=true: proceeding despite ${realDirty.length} uncommitted file(s); dispatch scope is not resolvable to paths (${resolution.reason}) so overlap could not be ruled out`,
|
|
227
|
+
dirtyCount: realDirty.length,
|
|
228
|
+
overlapping: [],
|
|
229
|
+
ignoredSystemDirty,
|
|
230
|
+
scopeResolution: resolution.kind,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
decision: 'block',
|
|
235
|
+
reason: `${realDirty.length} uncommitted file(s) and the dispatch scope is not resolvable to file paths (${resolution.reason}), so overlap cannot be ruled out; the worker spawns from HEAD and will not see them: ${summariseFiles(realDirty)}. Commit or stash, pass a resolvable file scope, or pass allow_dirty=true to override.`,
|
|
236
|
+
dirtyCount: realDirty.length,
|
|
237
|
+
overlapping: [],
|
|
238
|
+
ignoredSystemDirty,
|
|
239
|
+
scopeResolution: resolution.kind,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
//# sourceMappingURL=dirty-scope.js.map
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consolidated dispatch status (pln#503 phase 3.1).
|
|
3
|
+
*
|
|
4
|
+
* Resolves a single dispatch reference (`asgn_…`, `clm_…`, `lop_…`, `run_…`)
|
|
5
|
+
* into the full set of linked entities — assignment, claim, loop, agent_run —
|
|
6
|
+
* plus on-disk artefacts (brief-ack sentinel, stdout/stderr logs) and a
|
|
7
|
+
* pid-liveness check, then computes a health verdict + a recommended next
|
|
8
|
+
* action for the caller.
|
|
9
|
+
*
|
|
10
|
+
* The motivating use case: an agent who just called `bclaw_coordinate` and
|
|
11
|
+
* got `execution_status: "delivered_and_started"` should be able to verify
|
|
12
|
+
* the dispatch is alive without running five separate `bclaw_find` calls.
|
|
13
|
+
* The `verify_with` hint added in phase 3.3 already points callers at this
|
|
14
|
+
* tool by name.
|
|
15
|
+
*
|
|
16
|
+
* See docs/concepts/dispatch-lifecycle.md for the full entity-relationship
|
|
17
|
+
* and FSM model.
|
|
18
|
+
*
|
|
19
|
+
* @module
|
|
20
|
+
*/
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { loadAssignment, listAssignments } from './assignments.js';
|
|
24
|
+
import { loadAgentRun, listAgentRuns } from './agentruns.js';
|
|
25
|
+
import { loadClaim } from './claims.js';
|
|
26
|
+
import { getLoop, listLoops } from './loops/store.js';
|
|
27
|
+
import { isProcessAlive } from './agentrun-reconciler.js';
|
|
28
|
+
const DEFAULT_TAIL = 20;
|
|
29
|
+
const DEFAULT_STALL_MS = 5 * 60_000;
|
|
30
|
+
// ── Internal helpers ──────────────────────────────────────────────────────
|
|
31
|
+
function readLogTail(filePath, lines) {
|
|
32
|
+
try {
|
|
33
|
+
const stat = fs.statSync(filePath);
|
|
34
|
+
if (lines <= 0) {
|
|
35
|
+
return { path: filePath, exists: true, size_bytes: stat.size };
|
|
36
|
+
}
|
|
37
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
38
|
+
const all = content.split(/\r?\n/);
|
|
39
|
+
// Strip trailing empty line from final \n
|
|
40
|
+
if (all.length > 0 && all[all.length - 1] === '')
|
|
41
|
+
all.pop();
|
|
42
|
+
return {
|
|
43
|
+
path: filePath,
|
|
44
|
+
exists: true,
|
|
45
|
+
size_bytes: stat.size,
|
|
46
|
+
tail: all.slice(-lines),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return { path: filePath, exists: false, size_bytes: 0 };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function findLoopByAssignmentId(assignmentId, cwd) {
|
|
54
|
+
for (const loop of listLoops({}, cwd)) {
|
|
55
|
+
if (loop.slots.some((s) => s.assignment_id === assignmentId))
|
|
56
|
+
return loop;
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
function resolveTarget(targetId, cwd) {
|
|
61
|
+
if (targetId.startsWith('asgn_')) {
|
|
62
|
+
return { resolved_from: 'assignment_id', assignment_id: targetId };
|
|
63
|
+
}
|
|
64
|
+
if (targetId.startsWith('run_')) {
|
|
65
|
+
const run = loadAgentRun(targetId, cwd);
|
|
66
|
+
if (run)
|
|
67
|
+
return { resolved_from: 'run_id', assignment_id: run.assignment_id, agent_run: run };
|
|
68
|
+
return { resolved_from: 'unresolved' };
|
|
69
|
+
}
|
|
70
|
+
if (targetId.startsWith('clm_')) {
|
|
71
|
+
const assignments = listAssignments(cwd, { claim_id: targetId });
|
|
72
|
+
if (assignments.length > 0) {
|
|
73
|
+
// Pick the most recent assignment for this claim (latest created_at).
|
|
74
|
+
const recent = [...assignments].sort((a, b) => b.created_at.localeCompare(a.created_at))[0];
|
|
75
|
+
return { resolved_from: 'claim_id', assignment_id: recent.id };
|
|
76
|
+
}
|
|
77
|
+
return { resolved_from: 'claim_id' };
|
|
78
|
+
}
|
|
79
|
+
if (targetId.startsWith('lop_')) {
|
|
80
|
+
const loop = getLoop(targetId, cwd);
|
|
81
|
+
if (loop) {
|
|
82
|
+
// Prefer the slot in the current_phase with an assignment_id; fall back
|
|
83
|
+
// to any slot's assignment_id.
|
|
84
|
+
const phaseSlot = loop.slots.find((s) => s.phase === loop.current_phase && s.assignment_id);
|
|
85
|
+
const anySlot = loop.slots.find((s) => s.assignment_id);
|
|
86
|
+
const slot = phaseSlot ?? anySlot;
|
|
87
|
+
if (slot?.assignment_id) {
|
|
88
|
+
return { resolved_from: 'loop_id', assignment_id: slot.assignment_id };
|
|
89
|
+
}
|
|
90
|
+
return { resolved_from: 'loop_id' };
|
|
91
|
+
}
|
|
92
|
+
return { resolved_from: 'unresolved' };
|
|
93
|
+
}
|
|
94
|
+
return { resolved_from: 'unresolved' };
|
|
95
|
+
}
|
|
96
|
+
const TERMINAL_RUN_STATUSES = new Set([
|
|
97
|
+
'completed', 'failed', 'cancelled', 'timed_out', 'interrupted',
|
|
98
|
+
]);
|
|
99
|
+
function computeDiagnosis(assignment, agentRun, runtime, options) {
|
|
100
|
+
if (!assignment && !agentRun) {
|
|
101
|
+
return {
|
|
102
|
+
health: 'unknown',
|
|
103
|
+
summary: 'target_id did not resolve to any assignment or agent_run',
|
|
104
|
+
recommended_next_action: 'Verify the target_id is correct (asgn_/clm_/lop_/run_). Use bclaw_find(entity="assignment") to list available assignments.',
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (!agentRun) {
|
|
108
|
+
return {
|
|
109
|
+
health: 'not_dispatched',
|
|
110
|
+
summary: `assignment exists (status=${assignment?.status}) but no agent_run record — the spawn never produced a process, or the assignment is still waiting to be picked up`,
|
|
111
|
+
recommended_next_action: assignment?.status === 'offered'
|
|
112
|
+
? 'Wait for the target agent to accept, or reroute via bclaw_coordinate(intent="reroute", scope=…).'
|
|
113
|
+
: 'Re-dispatch with bclaw_coordinate or check for an earlier spawn error.',
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
if (TERMINAL_RUN_STATUSES.has(agentRun.status)) {
|
|
117
|
+
const isSuccess = agentRun.status === 'completed';
|
|
118
|
+
return {
|
|
119
|
+
health: 'terminal',
|
|
120
|
+
summary: `agent_run already terminal (status=${agentRun.status})${agentRun.status_reason ? `: ${agentRun.status_reason}` : ''}`,
|
|
121
|
+
recommended_next_action: isSuccess
|
|
122
|
+
? 'Harvest artifacts and move on; if the assignment is still open, transition it to completed.'
|
|
123
|
+
: 'Read stderr log (path in runtime.log_files.stderr) for the failure detail; reroute or retry if appropriate.',
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// status is running / launching / waiting_input / blocked → check liveness
|
|
127
|
+
const lastEventMs = new Date(agentRun.last_event_at ?? agentRun.started_at ?? agentRun.created_at).getTime();
|
|
128
|
+
const stallAge = options.nowMs - lastEventMs;
|
|
129
|
+
if (runtime.pid_alive === false) {
|
|
130
|
+
return {
|
|
131
|
+
health: 'silent_death',
|
|
132
|
+
summary: `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)`,
|
|
133
|
+
recommended_next_action: 'Read .stderr.log for the exit reason; then trigger reconciliation by calling bclaw_find(entity="agent_run") again, or cancel + reroute.',
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (runtime.pid_alive === true && stallAge > options.stallMs) {
|
|
137
|
+
return {
|
|
138
|
+
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: 'Tail the stdout/stderr log to see whether the worker is doing useful work; if truly hung, kill the pid and reroute.',
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (runtime.pid_alive === true) {
|
|
144
|
+
return {
|
|
145
|
+
health: 'healthy',
|
|
146
|
+
summary: `agent_run.status="${agentRun.status}", pid ${runtime.pid} alive, last activity ${Math.round(stallAge / 1000)}s ago`,
|
|
147
|
+
recommended_next_action: 'No action — the dispatch is alive and recent. Re-check periodically until terminal.',
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
// pid_alive undefined → cannot determine (no pid tracked, or signal failed)
|
|
151
|
+
return {
|
|
152
|
+
health: 'unknown',
|
|
153
|
+
summary: `agent_run.status="${agentRun.status}" but pid liveness could not be determined (pid=${agentRun.pid ?? '(none)'})`,
|
|
154
|
+
recommended_next_action: 'Read the stdout/stderr log for life signs; or wait for the lazy reconciler to converge based on commit / claim / assignment evidence.',
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
// ── Public API ─────────────────────────────────────────────────────────────
|
|
158
|
+
export function getDispatchStatus(options) {
|
|
159
|
+
const cwd = options.cwd;
|
|
160
|
+
const tailLines = options.tail_log_lines ?? DEFAULT_TAIL;
|
|
161
|
+
const stallMs = options.stall_threshold_ms ?? DEFAULT_STALL_MS;
|
|
162
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
163
|
+
const resolved = resolveTarget(options.target_id, cwd);
|
|
164
|
+
const assignmentId = resolved.assignment_id;
|
|
165
|
+
const assignment = assignmentId ? loadAssignment(assignmentId, cwd) : undefined;
|
|
166
|
+
const claim = assignment?.claim_id ? loadClaim(assignment.claim_id, cwd) : undefined;
|
|
167
|
+
// Prefer the pre-resolved agent_run (when target_id was a run_…); otherwise
|
|
168
|
+
// look up by assignment_id and pick the most recent attempt.
|
|
169
|
+
let agentRun = resolved.agent_run;
|
|
170
|
+
if (!agentRun && assignmentId) {
|
|
171
|
+
const runs = listAgentRuns(cwd, { assignment_id: assignmentId });
|
|
172
|
+
agentRun = [...runs].sort((a, b) => b.created_at.localeCompare(a.created_at))[0];
|
|
173
|
+
}
|
|
174
|
+
let loop;
|
|
175
|
+
if (resolved.resolved_from === 'loop_id') {
|
|
176
|
+
loop = getLoop(options.target_id, cwd);
|
|
177
|
+
}
|
|
178
|
+
else if (assignmentId) {
|
|
179
|
+
loop = findLoopByAssignmentId(assignmentId, cwd);
|
|
180
|
+
}
|
|
181
|
+
// Runtime artefacts (ack file + log files) — all under the project's
|
|
182
|
+
// coordination root. Use the cwd or the runtime cwd as the anchor; the
|
|
183
|
+
// dispatcher writes them under cwd/.brainclaw/coordination/runtime/...
|
|
184
|
+
const projectRoot = cwd ?? process.cwd();
|
|
185
|
+
const runtimeRoot = path.join(projectRoot, '.brainclaw', 'coordination', 'runtime');
|
|
186
|
+
const ackPath = assignmentId ? path.join(runtimeRoot, 'ack', `${assignmentId}.ack`) : undefined;
|
|
187
|
+
const stdoutPath = assignmentId ? path.join(runtimeRoot, 'log', `${assignmentId}.stdout.log`) : undefined;
|
|
188
|
+
const stderrPath = assignmentId ? path.join(runtimeRoot, 'log', `${assignmentId}.stderr.log`) : undefined;
|
|
189
|
+
const runtime = {
|
|
190
|
+
pid: agentRun?.pid,
|
|
191
|
+
pid_alive: isProcessAlive(agentRun?.pid),
|
|
192
|
+
ack_file: {
|
|
193
|
+
exists: ackPath ? fs.existsSync(ackPath) : false,
|
|
194
|
+
path: ackPath,
|
|
195
|
+
},
|
|
196
|
+
log_files: {
|
|
197
|
+
stdout: stdoutPath ? readLogTail(stdoutPath, tailLines) : undefined,
|
|
198
|
+
stderr: stderrPath ? readLogTail(stderrPath, tailLines) : undefined,
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
const diagnosis = computeDiagnosis(assignment, agentRun, runtime, { stallMs, nowMs });
|
|
202
|
+
return {
|
|
203
|
+
target_id: options.target_id,
|
|
204
|
+
resolved_from: resolved.resolved_from,
|
|
205
|
+
entities: {
|
|
206
|
+
assignment_id: assignmentId,
|
|
207
|
+
claim_id: assignment?.claim_id,
|
|
208
|
+
loop_id: loop?.id,
|
|
209
|
+
run_id: agentRun?.id,
|
|
210
|
+
},
|
|
211
|
+
assignment,
|
|
212
|
+
claim,
|
|
213
|
+
loop,
|
|
214
|
+
agent_run: agentRun,
|
|
215
|
+
runtime,
|
|
216
|
+
diagnosis,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
//# sourceMappingURL=dispatch-status.js.map
|