brainclaw 1.17.0 → 1.19.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 -5
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/commands/code-map.js +4 -1
- package/dist/commands/codev.js +61 -30
- package/dist/commands/doctor.js +14 -1
- package/dist/commands/harvest.js +223 -43
- package/dist/commands/inbox.js +10 -4
- package/dist/commands/install-hooks.js +184 -27
- package/dist/commands/loop.js +2 -2
- package/dist/commands/loops-handlers.js +82 -1
- package/dist/commands/mcp-catalog.js +12 -4
- package/dist/commands/mcp-read-handlers.js +90 -7
- package/dist/commands/mcp-schemas.generated.js +3 -0
- package/dist/commands/mcp-write-claims.js +57 -0
- package/dist/commands/mcp-write-coordination.js +216 -57
- package/dist/commands/mcp-write-entities.js +11 -0
- package/dist/commands/mcp.js +29 -2
- package/dist/commands/session-end.js +15 -0
- package/dist/commands/session-start.js +19 -0
- package/dist/core/agentrun-reconciler.js +171 -7
- package/dist/core/agentruns.js +6 -1
- package/dist/core/claim-conformity.js +193 -0
- package/dist/core/claim-scope.js +155 -0
- package/dist/core/claims.js +127 -2
- package/dist/core/code-map/aggregate.js +473 -0
- package/dist/core/code-map/backend.js +36 -10
- package/dist/core/code-map/freshness.js +36 -1
- package/dist/core/code-map/lang/c/imports.scm +12 -0
- package/dist/core/code-map/lang/c/index.js +150 -0
- package/dist/core/code-map/lang/c/tags.scm +68 -0
- package/dist/core/code-map/lang/cpp/imports.scm +14 -0
- package/dist/core/code-map/lang/cpp/index.js +149 -0
- package/dist/core/code-map/lang/cpp/tags.scm +87 -0
- package/dist/core/code-map/lang/csharp/imports.scm +20 -0
- package/dist/core/code-map/lang/csharp/index.js +224 -0
- package/dist/core/code-map/lang/csharp/tags.scm +63 -0
- package/dist/core/code-map/lang/go/imports.scm +13 -0
- package/dist/core/code-map/lang/go/index.js +139 -0
- package/dist/core/code-map/lang/go/tags.scm +36 -0
- package/dist/core/code-map/lang/providers.js +12 -1
- package/dist/core/code-map/lang/ruby/imports.scm +24 -0
- package/dist/core/code-map/lang/ruby/index.js +198 -0
- package/dist/core/code-map/lang/ruby/tags.scm +49 -0
- package/dist/core/code-map/lang/rust/imports.scm +44 -0
- package/dist/core/code-map/lang/rust/index.js +136 -0
- package/dist/core/code-map/lang/rust/tags.scm +47 -0
- package/dist/core/code-map/query.js +229 -80
- package/dist/core/code-map/types.js +18 -0
- package/dist/core/code-map/work-section.js +8 -7
- package/dist/core/codev-responses.js +16 -0
- package/dist/core/dispatcher.js +176 -22
- package/dist/core/execution-adapters.js +29 -3
- package/dist/core/facade-schema.js +32 -0
- package/dist/core/guidance-telemetry.js +197 -0
- package/dist/core/ideation-loop-close.js +152 -0
- package/dist/core/instruction-templates.js +11 -3
- package/dist/core/loops/artifact-resolver.js +197 -0
- package/dist/core/loops/attempt-reservation.js +576 -0
- package/dist/core/loops/commit-intent.js +494 -0
- package/dist/core/loops/facade-schema.js +48 -0
- package/dist/core/loops/impl-bind.js +144 -0
- package/dist/core/loops/index.js +1 -1
- package/dist/core/loops/iteration-engine.js +29 -0
- package/dist/core/loops/lock.js +14 -0
- package/dist/core/loops/project-resolution.js +157 -0
- package/dist/core/loops/reconcile-turn.js +369 -0
- package/dist/core/loops/result-reducers.js +88 -0
- package/dist/core/loops/store.js +46 -7
- package/dist/core/loops/types.js +139 -11
- package/dist/core/loops/verbs.js +49 -4
- package/dist/core/loops/verify-command.js +209 -0
- package/dist/core/messaging.js +58 -5
- package/dist/core/next-actions.js +157 -0
- package/dist/core/review-loop-close.js +27 -6
- package/dist/core/review-loop-turn-dispatch.js +290 -28
- package/dist/core/runtime-signals.js +68 -0
- package/dist/core/schema.js +64 -0
- package/dist/core/surface-freshness.js +150 -0
- package/dist/core/warnings.js +98 -0
- package/dist/core/worktree.js +24 -0
- package/dist/facts.js +9 -9
- package/dist/facts.json +8 -8
- package/dist/wasm/tree-sitter-c.wasm +0 -0
- package/dist/wasm/tree-sitter-c_sharp.wasm +0 -0
- package/dist/wasm/tree-sitter-cpp.wasm +0 -0
- package/dist/wasm/tree-sitter-go.wasm +0 -0
- package/dist/wasm/tree-sitter-ruby.wasm +0 -0
- package/dist/wasm/tree-sitter-rust.wasm +0 -0
- package/docs/cli.md +1 -1
- package/docs/code-map.md +22 -6
- package/docs/concepts/loop-engine.md +24 -0
- package/docs/concepts/observer-protocol.md +22 -0
- package/docs/concepts/plans-and-claims.md +57 -0
- package/docs/integrations/claude-code.md +53 -0
- package/docs/integrations/mcp.md +45 -0
- package/docs/mcp-schema-changelog.md +118 -2
- package/package.json +1 -1
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pln#636 C0-a — claim scope grammar + conformity verdict.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS IS SMALL. The design originally called for a fresh classifier. It is
|
|
5
|
+
* not needed: `resolveScopeToPathspecs` (core/dirty-scope.ts) already resolves a
|
|
6
|
+
* free-string scope to git pathspecs or `unknown`, and its own header has
|
|
7
|
+
* documented the bifurcation since pln#520 ("~60% are not resolvable to paths at
|
|
8
|
+
* all"). Writing a second classifier would have been duplicated truth. This
|
|
9
|
+
* module adds only the two things that were genuinely missing.
|
|
10
|
+
*
|
|
11
|
+
* MISSING PIECE 1 — a DECLARED grammar for the reserved semantic prefixes.
|
|
12
|
+
* dirty-scope hardcodes `review-loop:` alone (line ~149), but production carries
|
|
13
|
+
* three variants. Census over the 613 live claims in this store:
|
|
14
|
+
*
|
|
15
|
+
* review-loop 133
|
|
16
|
+
* ideate-loop 5
|
|
17
|
+
* ideation-loop 2
|
|
18
|
+
* C 1 ← a WINDOWS DRIVE LETTER, not a prefix
|
|
19
|
+
* project-resolution 1 ← prose that happens to contain a colon
|
|
20
|
+
* worktree-as-contract 1 ← ditto
|
|
21
|
+
*
|
|
22
|
+
* Two traps fall straight out of that data. A naive `/^[a-z-]+:/i` would read
|
|
23
|
+
* `C:/Users/...` as a semantic scope and stop treating an absolute Windows path
|
|
24
|
+
* as a path. And an unknown `word:` prefix is prose, not a loop reference — so
|
|
25
|
+
* the reserved set is ENUMERATED, never inferred from shape.
|
|
26
|
+
*
|
|
27
|
+
* MISSING PIECE 2 — the verdict's default on `unknown` must be INVERTED relative
|
|
28
|
+
* to the dirty guard, and this is the load-bearing insight of C0:
|
|
29
|
+
*
|
|
30
|
+
* - The dirty guard BLOCKS on unknown. Its cardinal rule is that a noisy,
|
|
31
|
+
* visible false-positive beats a silent false-negative, because letting a
|
|
32
|
+
* worker edit stale code is worse than refusing a legitimate dispatch.
|
|
33
|
+
* - A conformity advisory must be SILENT on unknown. Accusing an agent of
|
|
34
|
+
* writing outside its scope when we cannot tell teaches it to ignore the
|
|
35
|
+
* channel (the pln#634 failure mode) — and a channel an agent has learned to
|
|
36
|
+
* skip is worse than no channel. Saying nothing costs nothing.
|
|
37
|
+
*
|
|
38
|
+
* Same classification, opposite correct default, because the cost of being wrong
|
|
39
|
+
* points the other way. Hence `unverifiable` is a first-class verdict every
|
|
40
|
+
* consumer must render as silence.
|
|
41
|
+
*
|
|
42
|
+
* @module
|
|
43
|
+
*/
|
|
44
|
+
import path from 'node:path';
|
|
45
|
+
import { resolveScopeToPathspecs } from './dirty-scope.js';
|
|
46
|
+
/**
|
|
47
|
+
* Reserved semantic prefixes, enumerated from production usage. A scope starting
|
|
48
|
+
* with one of these refers to a loop lane, never to files.
|
|
49
|
+
*
|
|
50
|
+
* `ideate-loop` and `ideation-loop` BOTH appear in the live store — an
|
|
51
|
+
* inconsistency in the emitting code, not here. Both are accepted so
|
|
52
|
+
* classification is correct today; unifying the emitters is a separate cleanup.
|
|
53
|
+
*/
|
|
54
|
+
export const RESERVED_SCOPE_PREFIXES = ['review-loop', 'ideate-loop', 'ideation-loop'];
|
|
55
|
+
/** True when the token is an absolute Windows path (`C:/…`), not a prefixed scope. */
|
|
56
|
+
function looksLikeWindowsDrive(scope) {
|
|
57
|
+
return /^[A-Za-z]:[\\/]/.test(scope);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Classify a claim scope.
|
|
61
|
+
*
|
|
62
|
+
* Order matters: the drive-letter check runs BEFORE the prefix check, because
|
|
63
|
+
* `C:` satisfies a naive prefix pattern while being a path.
|
|
64
|
+
*/
|
|
65
|
+
export function classifyClaimScope(scope, cwd) {
|
|
66
|
+
const trimmed = scope?.trim();
|
|
67
|
+
if (!trimmed)
|
|
68
|
+
return { kind: 'empty', reason: 'no scope recorded on the claim' };
|
|
69
|
+
if (!looksLikeWindowsDrive(trimmed)) {
|
|
70
|
+
for (const prefix of RESERVED_SCOPE_PREFIXES) {
|
|
71
|
+
if (!trimmed.toLowerCase().startsWith(`${prefix}:`))
|
|
72
|
+
continue;
|
|
73
|
+
const rest = trimmed.slice(prefix.length + 1);
|
|
74
|
+
const [loopId, slotId] = rest.split(':');
|
|
75
|
+
return {
|
|
76
|
+
kind: 'loop_ref',
|
|
77
|
+
loopRef: {
|
|
78
|
+
prefix,
|
|
79
|
+
loopId: (loopId ?? '').trim(),
|
|
80
|
+
...(slotId?.trim() ? { slotId: slotId.trim() } : {}),
|
|
81
|
+
},
|
|
82
|
+
reason: `scope refers to a ${prefix} lane, not to files`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const resolved = resolveScopeToPathspecs(trimmed, cwd);
|
|
87
|
+
if (resolved.kind === 'pathspecs')
|
|
88
|
+
return { kind: 'paths', pathspecs: resolved.pathspecs };
|
|
89
|
+
return { kind: 'prose', reason: resolved.reason };
|
|
90
|
+
}
|
|
91
|
+
function normalise(p) {
|
|
92
|
+
return p.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
93
|
+
}
|
|
94
|
+
/** A touched file is in scope when it equals or sits under one declared pathspec. */
|
|
95
|
+
function matchesPathspec(file, pathspec) {
|
|
96
|
+
const f = normalise(file);
|
|
97
|
+
const spec = normalise(pathspec.replace(/^:\(glob\)/, '')).replace(/\/$/, '');
|
|
98
|
+
if (spec.includes('*') || spec.includes('?')) {
|
|
99
|
+
// Delegating real globs to git is the resolver's job; here a glob scope is
|
|
100
|
+
// deliberately unverifiable rather than approximated with a hand-rolled matcher.
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
return f === spec || f.startsWith(`${spec}/`);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Compare the files a claim actually touched against the scope it declared.
|
|
107
|
+
*
|
|
108
|
+
* SILENT ON DOUBT, by construction. A loop-ref, prose, empty or glob scope
|
|
109
|
+
* yields `unverifiable`, and so does an empty touched-file list — there is
|
|
110
|
+
* nothing to accuse anyone of. Only a path-resolvable scope with concrete
|
|
111
|
+
* touched files can ever produce `out_of_scope`.
|
|
112
|
+
*/
|
|
113
|
+
export function assessScopeConformity(input) {
|
|
114
|
+
const classified = classifyClaimScope(input.scope, input.cwd);
|
|
115
|
+
if (classified.kind !== 'paths' || !classified.pathspecs?.length) {
|
|
116
|
+
return { kind: 'unverifiable', reason: classified.reason ?? 'scope is not path-resolvable' };
|
|
117
|
+
}
|
|
118
|
+
if (input.touchedPaths.length === 0) {
|
|
119
|
+
return { kind: 'unverifiable', reason: 'no touched files to compare' };
|
|
120
|
+
}
|
|
121
|
+
if (classified.pathspecs.some((spec) => spec.includes('*') || spec.includes('?'))) {
|
|
122
|
+
return { kind: 'unverifiable', reason: 'glob scope — left to git rather than approximated here' };
|
|
123
|
+
}
|
|
124
|
+
const specs = classified.pathspecs;
|
|
125
|
+
const matched = [];
|
|
126
|
+
const unexpected = [];
|
|
127
|
+
for (const file of input.touchedPaths) {
|
|
128
|
+
// The coordination store and git internals are never "outside scope": every
|
|
129
|
+
// brainclaw call rewrites them, so counting them would accuse every agent.
|
|
130
|
+
const n = normalise(file);
|
|
131
|
+
if (n.startsWith('.brainclaw/') || n.startsWith('.git/'))
|
|
132
|
+
continue;
|
|
133
|
+
if (specs.some((spec) => matchesPathspec(file, spec)))
|
|
134
|
+
matched.push(n);
|
|
135
|
+
else
|
|
136
|
+
unexpected.push(n);
|
|
137
|
+
}
|
|
138
|
+
if (unexpected.length === 0) {
|
|
139
|
+
return matched.length > 0
|
|
140
|
+
? { kind: 'in_scope', matched }
|
|
141
|
+
: { kind: 'unverifiable', reason: 'every touched file was a system path' };
|
|
142
|
+
}
|
|
143
|
+
return { kind: 'out_of_scope', unexpected, pathspecs: specs };
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Absolute→relative helper for callers holding worktree-absolute paths (a git
|
|
147
|
+
* diff run inside a lane worktree returns repo-relative already, but a hook sees
|
|
148
|
+
* absolute `tool_input.file_path`).
|
|
149
|
+
*/
|
|
150
|
+
export function toRepoRelative(absoluteOrRelative, repoRoot) {
|
|
151
|
+
if (!path.isAbsolute(absoluteOrRelative))
|
|
152
|
+
return normalise(absoluteOrRelative);
|
|
153
|
+
return normalise(path.relative(repoRoot, absoluteOrRelative));
|
|
154
|
+
}
|
|
155
|
+
//# sourceMappingURL=claim-scope.js.map
|
package/dist/core/claims.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
1
2
|
import crypto from 'node:crypto';
|
|
2
3
|
import fs from 'node:fs';
|
|
3
4
|
import path from 'node:path';
|
|
@@ -13,6 +14,7 @@ import { refreshLiveCompanions } from '../commands/export.js';
|
|
|
13
14
|
import { loadSessionById } from './identity.js';
|
|
14
15
|
import { loadState, persistState } from './state.js';
|
|
15
16
|
import { createRuntimeEvent } from './events.js';
|
|
17
|
+
import { latestActivityMs, readHeartbeat } from './runtime-signals.js';
|
|
16
18
|
import { emitRegistryPostImage, registryFaultPoint } from './events/registry-post-image.js';
|
|
17
19
|
import { maybeEnqueueClaimTransition, isFederationEnqueueActive } from './federation-outbox.js';
|
|
18
20
|
/** Parse duration string like '4h', '30m' to ms. */
|
|
@@ -119,6 +121,35 @@ export function saveClaim(claim, cwd) {
|
|
|
119
121
|
saveClaimUnlocked(claim, cwd);
|
|
120
122
|
});
|
|
121
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* pln#636 C0-b — resolve the commit a claim starts from.
|
|
126
|
+
*
|
|
127
|
+
* Recorded once, at creation, and never updated: it is the fixed point a later
|
|
128
|
+
* "what did this claim actually touch?" comparison needs. The design review
|
|
129
|
+
* rejected both alternatives — neither HEAD-at-read-time nor the worktree dirty
|
|
130
|
+
* set is authoritative once a lane commits mid-work.
|
|
131
|
+
*
|
|
132
|
+
* BEST-EFFORT BY CONSTRUCTION. A non-git project, a detached state, or a missing
|
|
133
|
+
* git binary yields `undefined`, and a claim without a baseline is simply
|
|
134
|
+
* `unverifiable` downstream. Claim acquisition must never fail because a
|
|
135
|
+
* conformity nicety could not be computed.
|
|
136
|
+
*/
|
|
137
|
+
export function resolveClaimBaseSha(cwd) {
|
|
138
|
+
try {
|
|
139
|
+
const result = spawnSync('git', ['rev-parse', 'HEAD'], {
|
|
140
|
+
cwd: cwd ?? process.cwd(),
|
|
141
|
+
encoding: 'utf-8',
|
|
142
|
+
windowsHide: true,
|
|
143
|
+
});
|
|
144
|
+
if (result.status !== 0)
|
|
145
|
+
return undefined;
|
|
146
|
+
const sha = result.stdout.trim();
|
|
147
|
+
return /^[0-9a-f]{7,40}$/i.test(sha) ? sha : undefined;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
122
153
|
/**
|
|
123
154
|
* Atomically check for an active claim on `scope` and save a new one if absent.
|
|
124
155
|
*
|
|
@@ -126,6 +157,10 @@ export function saveClaim(claim, cwd) {
|
|
|
126
157
|
* the mutation-pipeline mutex serializes filesystem writes on the claims store.
|
|
127
158
|
*/
|
|
128
159
|
export function acquireClaimScope(input, cwd) {
|
|
160
|
+
// Resolved OUTSIDE the mutate callback: one git call per acquisition, and it
|
|
161
|
+
// stays off the critical section (mutate serializes filesystem writes on the
|
|
162
|
+
// claims store, so a subprocess spawn inside it would widen the lock window).
|
|
163
|
+
const baseSha = resolveClaimBaseSha(cwd);
|
|
129
164
|
return mutate({ cwd }, () => {
|
|
130
165
|
const conflictingClaim = listClaims(cwd).find((claim) => claim.status === 'active' && claim.scope === input.scope);
|
|
131
166
|
if (conflictingClaim) {
|
|
@@ -143,6 +178,10 @@ export function acquireClaimScope(input, cwd) {
|
|
|
143
178
|
status: 'active',
|
|
144
179
|
plan_id: input.plan_id,
|
|
145
180
|
model: input.model,
|
|
181
|
+
// pln#636 C0-b — capture the baseline while we know it. Absent when the
|
|
182
|
+
// project is not a git repo; downstream treats that as unverifiable.
|
|
183
|
+
...(baseSha ? { base_sha: baseSha } : {}),
|
|
184
|
+
...(input.paths?.length ? { paths: input.paths } : {}),
|
|
146
185
|
};
|
|
147
186
|
saveClaimUnlocked(claim, cwd);
|
|
148
187
|
return { acquired: true, claim };
|
|
@@ -502,11 +541,82 @@ const DEFAULT_STALE_HOURS = 24;
|
|
|
502
541
|
* even if it has no session yet (coordinator claims are created before the worker session starts).
|
|
503
542
|
*/
|
|
504
543
|
const YOUNG_CLAIM_THRESHOLD_MS = 30 * 60_000; // 30 minutes
|
|
544
|
+
/** Default freshness window for file evidence — matches the default `heartbeat_ttl_ms`. */
|
|
545
|
+
const DEFAULT_EVIDENCE_TTL_MS = 30 * 60_000;
|
|
505
546
|
/**
|
|
506
|
-
*
|
|
547
|
+
* How far into the future a file timestamp may sit before we stop trusting it.
|
|
548
|
+
*
|
|
549
|
+
* WHY THIS TOLERANCE EXISTS, and why the naive `age < 0 → ignore` was wrong.
|
|
550
|
+
* `fs.stat().mtimeMs` is sub-millisecond on NTFS while `Date.now()` is coarser,
|
|
551
|
+
* so a heartbeat written microseconds ago routinely stats as *newer than now* —
|
|
552
|
+
* i.e. the freshest evidence possible was the evidence most likely to be thrown
|
|
553
|
+
* away. That reproduced as a nondeterministic liveness verdict: the same claim
|
|
554
|
+
* read `live` on an idle machine and `never-adopted` under load.
|
|
555
|
+
*
|
|
556
|
+
* A file dated slightly ahead is therefore clamped to age 0 (maximally fresh),
|
|
557
|
+
* while one dated grossly ahead is discarded — that is a genuinely wrong clock or
|
|
558
|
+
* a hand-forged timestamp, and inventing liveness from it would let a dead
|
|
559
|
+
* worker hold a claim forever.
|
|
560
|
+
*/
|
|
561
|
+
const FUTURE_EVIDENCE_TOLERANCE_MS = 5 * 60_000;
|
|
562
|
+
/**
|
|
563
|
+
* pln#636 — age of the freshest FILE evidence that this claim's worker is alive.
|
|
564
|
+
*
|
|
565
|
+
* WHY FILE EVIDENCE AND NOT A SESSION. A sandboxed spawned worker cannot reach
|
|
566
|
+
* MCP, so it cannot maintain any server-side liveness record — which is exactly
|
|
567
|
+
* why the project moved proof-of-life to filesystem sentinels: the dispatcher
|
|
568
|
+
* injects a "Liveness — DO THIS FIRST" step into every brief, and the worker
|
|
569
|
+
* writes/refreshes a heartbeat in the ONE location a sandbox can write (its own
|
|
570
|
+
* worktree). `assignment-sweeper` already honours that evidence; claims did not,
|
|
571
|
+
* which meant a demonstrably-alive sandboxed worker kept its assignment but had
|
|
572
|
+
* its CLAIM aged out on wall-clock alone (trp_4d0fc2ef). This closes that
|
|
573
|
+
* asymmetry by reading the same signals.
|
|
574
|
+
*
|
|
575
|
+
* Deliberately reads the leaf `runtime-signals` module rather than
|
|
576
|
+
* `collectEvidence`: agentrun-reconciler imports `loadClaim` from here, so
|
|
577
|
+
* importing it back would create a cycle.
|
|
578
|
+
*
|
|
579
|
+
* Returns undefined when there is nothing to read — no assignment, no signals —
|
|
580
|
+
* and never throws.
|
|
581
|
+
*/
|
|
582
|
+
function freshestEvidenceAgeMs(claim, nowMs, cwd) {
|
|
583
|
+
if (!claim.assignment_id)
|
|
584
|
+
return undefined;
|
|
585
|
+
const root = cwd ?? process.cwd();
|
|
586
|
+
let freshest;
|
|
587
|
+
const consider = (ms) => {
|
|
588
|
+
if (ms === undefined)
|
|
589
|
+
return;
|
|
590
|
+
const age = nowMs - ms;
|
|
591
|
+
// Slightly-future timestamps are a clock-granularity artefact, not skew —
|
|
592
|
+
// clamp them to "just now". Grossly-future ones are untrustworthy: ignore
|
|
593
|
+
// rather than invent liveness. See FUTURE_EVIDENCE_TOLERANCE_MS.
|
|
594
|
+
if (age < -FUTURE_EVIDENCE_TOLERANCE_MS)
|
|
595
|
+
return;
|
|
596
|
+
const normalised = age < 0 ? 0 : age;
|
|
597
|
+
if (freshest === undefined || normalised < freshest)
|
|
598
|
+
freshest = normalised;
|
|
599
|
+
};
|
|
600
|
+
try {
|
|
601
|
+
const hb = readHeartbeat(root, claim.assignment_id, claim.worktree_path);
|
|
602
|
+
if (hb.exists)
|
|
603
|
+
consider(hb.mtimeMs);
|
|
604
|
+
}
|
|
605
|
+
catch { /* evidence is best-effort */ }
|
|
606
|
+
try {
|
|
607
|
+
consider(latestActivityMs(root, claim.assignment_id, claim.worktree_path));
|
|
608
|
+
}
|
|
609
|
+
catch { /* evidence is best-effort */ }
|
|
610
|
+
return freshest;
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Assess the liveness of an active claim.
|
|
507
614
|
*
|
|
508
615
|
* Decision tree:
|
|
509
|
-
*
|
|
616
|
+
* 0. Young (< 30 min) → never auto-release — dispatcher may not have sent the worker yet.
|
|
617
|
+
* 1. FRESH FILE EVIDENCE → 'live', whatever the session says. This branch comes
|
|
618
|
+
* first because it is the only proof a sandboxed, MCP-less worker can
|
|
619
|
+
* produce, and it is the same evidence the assignment sweeper trusts.
|
|
510
620
|
* 2. Has session_id + session alive → 'live' — long-running work; do NOT release.
|
|
511
621
|
* 3. Has session_id + adopted_at + session dead → 'orphaned' — crash recovery scenario.
|
|
512
622
|
* 4. Has session_id + no adopted_at + session dead → 'stale' — direct agent claim, session ended.
|
|
@@ -525,6 +635,21 @@ export function assessClaimLiveness(claim, options = {}) {
|
|
|
525
635
|
ageMs,
|
|
526
636
|
};
|
|
527
637
|
}
|
|
638
|
+
// 1. FILE EVIDENCE FIRST (pln#636, trp_4d0fc2ef). A sandboxed worker proves
|
|
639
|
+
// life by writing a heartbeat into its worktree — the only place it can
|
|
640
|
+
// write — and by touching files there. That evidence outranks any session
|
|
641
|
+
// reasoning: a worker actively committing is alive whether or not a session
|
|
642
|
+
// record exists, and a coordinator-created claim has no session_id at all.
|
|
643
|
+
const evidenceAgeMs = freshestEvidenceAgeMs(claim, nowMs, options.cwd);
|
|
644
|
+
const evidenceTtlMs = options.evidenceTtlMs ?? DEFAULT_EVIDENCE_TTL_MS;
|
|
645
|
+
if (evidenceAgeMs !== undefined && evidenceAgeMs < evidenceTtlMs) {
|
|
646
|
+
return {
|
|
647
|
+
status: 'live',
|
|
648
|
+
reason: `File evidence is fresh (${Math.round(evidenceAgeMs / 60_000)}min ago) — the worker is demonstrably active`,
|
|
649
|
+
ageMs,
|
|
650
|
+
evidenceAgeMs,
|
|
651
|
+
};
|
|
652
|
+
}
|
|
528
653
|
// 2–4. Has a session_id — check session liveness
|
|
529
654
|
if (claim.session_id) {
|
|
530
655
|
let sessionAgeMs;
|