brainclaw 1.7.5 → 1.9.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 +28 -11
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +139 -13
- package/dist/commands/add-step.js +1 -1
- package/dist/commands/bootstrap.js +2 -26
- package/dist/commands/check-security-mcp.js +50 -33
- package/dist/commands/check-security.js +86 -43
- package/dist/commands/claim.js +22 -21
- package/dist/commands/confirm.js +26 -0
- package/dist/commands/context-diff.js +1 -1
- package/dist/commands/dispatch-watch.js +142 -0
- package/dist/commands/doctor.js +113 -2
- package/dist/commands/estimation-report.js +115 -16
- package/dist/commands/harvest.js +502 -16
- package/dist/commands/init.js +123 -21
- package/dist/commands/loops-handlers.js +4 -0
- package/dist/commands/mcp-read-handlers.js +198 -29
- package/dist/commands/mcp.js +615 -92
- package/dist/commands/memory.js +21 -17
- package/dist/commands/migrate.js +81 -17
- package/dist/commands/prune.js +78 -4
- package/dist/commands/reflect.js +26 -20
- package/dist/commands/register-agent.js +57 -1
- package/dist/commands/repair.js +20 -0
- package/dist/commands/session-end.js +15 -6
- package/dist/commands/session-start.js +18 -1
- package/dist/commands/setup-security.js +39 -18
- package/dist/commands/setup.js +26 -27
- package/dist/commands/stale.js +16 -2
- package/dist/commands/uninstall.js +126 -34
- package/dist/commands/update-step.js +6 -0
- package/dist/commands/worktree.js +60 -0
- package/dist/core/actions.js +12 -3
- package/dist/core/agent-capability.js +11 -13
- package/dist/core/agent-files.js +844 -547
- package/dist/core/agent-integrations.js +0 -3
- package/dist/core/agent-inventory.js +67 -0
- package/dist/core/agent-registry.js +163 -29
- package/dist/core/agentrun-reconciler.js +33 -2
- package/dist/core/agentruns.js +7 -1
- package/dist/core/ai-agent-detection.js +31 -44
- package/dist/core/archival.js +15 -9
- package/dist/core/assignment-reconciler.js +56 -0
- package/dist/core/assignment-sweeper.js +127 -4
- package/dist/core/assignments.js +69 -11
- package/dist/core/bootstrap.js +233 -67
- package/dist/core/brainclaw-version.js +22 -0
- package/dist/core/candidates.js +21 -1
- package/dist/core/claims.js +313 -150
- package/dist/core/config.js +6 -1
- package/dist/core/context-diff.js +148 -20
- package/dist/core/context.js +129 -8
- package/dist/core/coordination.js +22 -3
- package/dist/core/dispatch-status.js +109 -5
- package/dist/core/dispatcher.js +65 -11
- package/dist/core/entity-operations.js +45 -24
- package/dist/core/entity-registry.js +31 -5
- package/dist/core/event-log.js +138 -21
- package/dist/core/events/checkpoint.js +258 -0
- package/dist/core/events/genesis.js +220 -0
- package/dist/core/events/journal.js +507 -0
- package/dist/core/events/materialize.js +126 -0
- package/dist/core/events/registry-post-image.js +110 -0
- package/dist/core/events/verify.js +109 -0
- package/dist/core/execution-adapters.js +23 -0
- package/dist/core/execution.js +25 -0
- package/dist/core/facade-schema.js +48 -0
- package/dist/core/gc-semantic.js +130 -5
- package/dist/core/handoff-snapshot.js +68 -0
- package/dist/core/ids.js +19 -8
- package/dist/core/instruction-templates.js +34 -115
- package/dist/core/io.js +39 -3
- package/dist/core/json-store.js +10 -1
- package/dist/core/lock.js +153 -28
- package/dist/core/loops/bootstrap-acquire.js +25 -1
- package/dist/core/loops/facade-schema.js +2 -0
- package/dist/core/loops/hooks/survey-signals-baseline.js +36 -0
- package/dist/core/loops/index.js +1 -0
- package/dist/core/loops/presets/bootstrap.js +7 -0
- package/dist/core/loops/store.js +17 -0
- package/dist/core/loops/verbs.js +24 -1
- package/dist/core/markdown.js +8 -76
- package/dist/core/mcp-command-resolution.js +245 -0
- package/dist/core/memory-compactor.js +5 -3
- package/dist/core/memory-lifecycle.js +282 -0
- package/dist/core/merge-risk.js +150 -0
- package/dist/core/messaging.js +8 -1
- package/dist/core/migration.js +11 -1
- package/dist/core/observer-mode.js +26 -0
- package/dist/core/operations/memory-mutation.js +90 -65
- package/dist/core/operations/plan.js +27 -1
- package/dist/core/protocol-skills.js +210 -0
- package/dist/core/reflection-safety.js +6 -7
- package/dist/core/reputation.js +84 -2
- package/dist/core/runtime-signals.js +71 -9
- package/dist/core/runtime.js +84 -1
- package/dist/core/schema.js +125 -0
- package/dist/core/security-detectors.js +125 -0
- package/dist/core/security-extract.js +189 -0
- package/dist/core/security-guard.js +107 -29
- package/dist/core/security-packages.js +121 -0
- package/dist/core/security-scoring.js +76 -9
- package/dist/core/security.js +34 -2
- package/dist/core/sequence.js +11 -2
- package/dist/core/setup-flow.js +141 -13
- package/dist/core/spawn-check.js +110 -4
- package/dist/core/staleness.js +109 -1
- package/dist/core/state.js +250 -54
- package/dist/core/store-resolution.js +19 -5
- package/dist/core/worktree.js +169 -7
- package/dist/facts.js +8 -8
- package/dist/facts.json +7 -7
- package/docs/PROTOCOL.md +223 -0
- package/docs/cli.md +11 -10
- package/docs/concepts/coordinator-runbook.md +129 -0
- package/docs/concepts/dispatch-lifecycle.md +17 -0
- package/docs/concepts/event-log-store-critique-A.md +333 -0
- package/docs/concepts/event-log-store-critique-B.md +353 -0
- package/docs/concepts/event-log-store-phase0-measurements.md +58 -0
- package/docs/concepts/event-log-store-proposal-A.md +365 -0
- package/docs/concepts/event-log-store-proposal-B.md +404 -0
- package/docs/concepts/event-log-store.md +928 -0
- package/docs/concepts/identity-model-proposal.md +371 -0
- package/docs/concepts/memory.md +5 -4
- package/docs/concepts/observer-protocol.md +361 -0
- package/docs/concepts/parallel-merge-protocol.md +71 -0
- package/docs/concepts/plans-and-claims.md +43 -0
- package/docs/concepts/skills.md +78 -0
- package/docs/concepts/workspace-bootstrapping.md +61 -0
- package/docs/integrations/agents.md +4 -4
- package/docs/integrations/cline.md +10 -11
- package/docs/integrations/codex.md +2 -2
- package/docs/integrations/continue.md +5 -5
- package/docs/integrations/copilot.md +14 -12
- package/docs/integrations/openclaw.md +7 -6
- package/docs/integrations/overview.md +7 -7
- package/docs/integrations/roo.md +3 -3
- package/docs/integrations/windsurf.md +6 -6
- package/docs/mcp-schema-changelog.md +51 -20
- package/docs/quickstart.md +48 -47
- package/docs/security.md +174 -15
- package/docs/storage.md +4 -2
- package/package.json +8 -6
package/dist/core/archival.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { resolveEntityDir } from './io.js';
|
|
4
4
|
import { logger } from './logger.js';
|
|
5
|
+
import { mutate } from './mutation-pipeline.js';
|
|
5
6
|
/** Default age threshold: items older than 30 days are eligible for archival. */
|
|
6
7
|
const DEFAULT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
7
8
|
/**
|
|
@@ -11,15 +12,20 @@ const DEFAULT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
|
11
12
|
* This is lossless — all data is preserved in the archive.
|
|
12
13
|
*/
|
|
13
14
|
export function archiveStalePlansAndHandoffs(cwd, maxAgeMs = DEFAULT_MAX_AGE_MS) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
15
|
+
// Review follow-up O4 (lop_e2d566765b8b4ce3): the append+unlink pairs must
|
|
16
|
+
// run inside the store mutation lock — outside it, a concurrent stale-snapshot
|
|
17
|
+
// persistState could RECREATE the just-archived files (resurrection).
|
|
18
|
+
return mutate({ cwd }, () => {
|
|
19
|
+
const results = [];
|
|
20
|
+
const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
|
|
21
|
+
results.push(archiveEntity('plans', cutoff, (item) => {
|
|
22
|
+
return item.status === 'done' || item.status === 'dropped';
|
|
23
|
+
}, cwd));
|
|
24
|
+
results.push(archiveEntity('handoffs', cutoff, (item) => {
|
|
25
|
+
return item.status === 'closed';
|
|
26
|
+
}, cwd));
|
|
27
|
+
return results.filter(r => r.archived > 0);
|
|
28
|
+
});
|
|
23
29
|
}
|
|
24
30
|
function archiveEntity(entity, cutoffDate, isEligible, cwd) {
|
|
25
31
|
const dir = resolveEntityDir(entity, cwd ?? process.cwd(), 'read');
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazy reconciler for orphaned review-loop assignments (pln#563, layer B).
|
|
3
|
+
*
|
|
4
|
+
* A review-loop assignment whose loop already reached a terminal status but
|
|
5
|
+
* which is itself still stuck in `offered`/`accepted`/`started` (the file-based
|
|
6
|
+
* worker never reported terminal; the coordinator couldn't cross-update it —
|
|
7
|
+
* trp#547) is converged on read. This is the house lazy-reconcile-at-read
|
|
8
|
+
* pattern (feedback_lazy_reconcile_pattern / pln#496): no daemon, convergence
|
|
9
|
+
* happens the next time open_work is computed.
|
|
10
|
+
*
|
|
11
|
+
* Layer A (the closeLoop cascade) handles the common path going forward; this
|
|
12
|
+
* backstop cleans the existing backlog and any future close that didn't cascade
|
|
13
|
+
* (e.g. a loop force-closed out of band). Kept in its own module to avoid an
|
|
14
|
+
* import cycle (loops/store → assignments for the cascade; this → both).
|
|
15
|
+
*/
|
|
16
|
+
import { listAssignments, convergeAssignmentToTerminal } from './assignments.js';
|
|
17
|
+
import { getLoop } from './loops/store.js';
|
|
18
|
+
/** review-loop:lop_xxx → the loop id. */
|
|
19
|
+
const LOOP_SCOPE_RE = /^review-loop:(lop_[0-9a-z]+)/;
|
|
20
|
+
const LOOP_TERMINAL = new Set(['completed', 'cancelled', 'blocked']);
|
|
21
|
+
/**
|
|
22
|
+
* Converge any review-loop assignment whose loop is terminal. Returns the
|
|
23
|
+
* count converged. Pure best-effort and cheap: it only does a loop lookup for
|
|
24
|
+
* assignments whose scope is a review-loop, and only writes when a stuck one is
|
|
25
|
+
* found (the steady state is zero writes).
|
|
26
|
+
*/
|
|
27
|
+
export function reconcileOrphanedLoopAssignments(cwd) {
|
|
28
|
+
return reconcileOrphanedLoopAssignmentsFromList(listAssignments(cwd), cwd).length;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Same reconciliation as reconcileOrphanedLoopAssignments, but reuses an
|
|
32
|
+
* already-loaded assignment list. This keeps open_work context building from
|
|
33
|
+
* doing a second full assignment directory scan just to clean loop orphans.
|
|
34
|
+
*/
|
|
35
|
+
export function reconcileOrphanedLoopAssignmentsFromList(assignments, cwd) {
|
|
36
|
+
const convergedIds = [];
|
|
37
|
+
const loopCache = new Map();
|
|
38
|
+
for (const a of assignments) {
|
|
39
|
+
const match = a.scope?.match(LOOP_SCOPE_RE);
|
|
40
|
+
if (!match)
|
|
41
|
+
continue;
|
|
42
|
+
let loop = loopCache.get(match[1]);
|
|
43
|
+
if (!loopCache.has(match[1])) {
|
|
44
|
+
loop = getLoop(match[1], cwd);
|
|
45
|
+
loopCache.set(match[1], loop);
|
|
46
|
+
}
|
|
47
|
+
if (!loop || !LOOP_TERMINAL.has(loop.status))
|
|
48
|
+
continue;
|
|
49
|
+
const terminal = loop.status === 'completed' ? 'completed' : 'cancelled';
|
|
50
|
+
if (convergeAssignmentToTerminal(a.id, terminal, `loop ${match[1]} ${loop.status} (lazy reconcile)`, cwd)) {
|
|
51
|
+
convergedIds.push(a.id);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return convergedIds;
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=assignment-reconciler.js.map
|
|
@@ -4,15 +4,100 @@
|
|
|
4
4
|
* Runs opportunistically (no daemon): integrated into dispatch().
|
|
5
5
|
* Future: integrate into session_start() and expose as CLI `brainclaw sweep`.
|
|
6
6
|
*
|
|
7
|
+
* can_948acfd6 (sprint 1.5): the sweep consults IMPLICIT worker evidence
|
|
8
|
+
* before declaring an administrative death. Three live workers were expired
|
|
9
|
+
* by the acceptance-TTL in a single sprint because they could not call
|
|
10
|
+
* bclaw_assignment_update (sandboxed / no MCP) — yet their ack sentinel,
|
|
11
|
+
* heartbeat, filesystem activity and commits were all observable. This is the
|
|
12
|
+
* acceptance-sweep counterpart of the pln#527 no-heartbeat veto.
|
|
13
|
+
*
|
|
7
14
|
* @module
|
|
8
15
|
*/
|
|
16
|
+
import { spawnSync } from 'node:child_process';
|
|
9
17
|
import { listAssignments, transitionAssignment } from './assignments.js';
|
|
18
|
+
import { signalExists, readHeartbeat, latestActivityMs } from './runtime-signals.js';
|
|
19
|
+
function lastCommitAgeMs(worktreePath, nowMs) {
|
|
20
|
+
if (!worktreePath)
|
|
21
|
+
return undefined;
|
|
22
|
+
try {
|
|
23
|
+
const res = spawnSync('git', ['log', '-1', '--format=%ct'], {
|
|
24
|
+
cwd: worktreePath, encoding: 'utf-8', windowsHide: true, timeout: 10_000,
|
|
25
|
+
});
|
|
26
|
+
if (res.status !== 0)
|
|
27
|
+
return undefined;
|
|
28
|
+
const epochSec = parseInt((res.stdout ?? '').trim(), 10);
|
|
29
|
+
if (!Number.isFinite(epochSec))
|
|
30
|
+
return undefined;
|
|
31
|
+
return nowMs - epochSec * 1000;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Collect implicit life-signs for an assignment: ack sentinel, heartbeat
|
|
39
|
+
* (project-root OR worktree-local), filesystem activity (logs + worktree),
|
|
40
|
+
* and a post-dispatch commit on the worktree branch.
|
|
41
|
+
*
|
|
42
|
+
* `sinceMs` anchors the commit check (a commit older than the offer is not
|
|
43
|
+
* evidence of THIS assignment's worker). `freshTtlMs` bounds what counts as
|
|
44
|
+
* "currently active" for the accepted/started branches.
|
|
45
|
+
*/
|
|
46
|
+
function collectImplicitEvidence(assignment, cwd, nowMs, sinceMs, freshTtlMs) {
|
|
47
|
+
const root = cwd ?? process.cwd();
|
|
48
|
+
const parts = [];
|
|
49
|
+
let freshest;
|
|
50
|
+
const bump = (ageMs) => {
|
|
51
|
+
if (ageMs === undefined)
|
|
52
|
+
return;
|
|
53
|
+
if (freshest === undefined || ageMs < freshest)
|
|
54
|
+
freshest = ageMs;
|
|
55
|
+
};
|
|
56
|
+
try {
|
|
57
|
+
if (signalExists(root, assignment.id, 'ack'))
|
|
58
|
+
parts.push('ack sentinel');
|
|
59
|
+
}
|
|
60
|
+
catch { /* defensive */ }
|
|
61
|
+
try {
|
|
62
|
+
const hb = readHeartbeat(root, assignment.id, assignment.worktree_path);
|
|
63
|
+
if (hb.exists && hb.mtimeMs !== undefined) {
|
|
64
|
+
const age = nowMs - hb.mtimeMs;
|
|
65
|
+
parts.push(`heartbeat ${Math.round(age / 1000)}s old`);
|
|
66
|
+
bump(age);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch { /* defensive */ }
|
|
70
|
+
try {
|
|
71
|
+
const lastFs = latestActivityMs(root, assignment.id, assignment.worktree_path);
|
|
72
|
+
if (lastFs !== undefined) {
|
|
73
|
+
const age = nowMs - lastFs;
|
|
74
|
+
parts.push(`fs activity ${Math.round(age / 1000)}s old`);
|
|
75
|
+
bump(age);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch { /* defensive */ }
|
|
79
|
+
const commitAge = lastCommitAgeMs(assignment.worktree_path, nowMs);
|
|
80
|
+
if (commitAge !== undefined && nowMs - commitAge >= sinceMs) {
|
|
81
|
+
parts.push(`post-dispatch commit ${Math.round(commitAge / 1000)}s old`);
|
|
82
|
+
bump(commitAge);
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
any: parts.length > 0,
|
|
86
|
+
fresh: freshest !== undefined && freshest <= freshTtlMs,
|
|
87
|
+
description: parts.join(' + ') || 'none',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
10
90
|
// ── Sweeper ──────────────────────────────────────────────────
|
|
11
91
|
/**
|
|
12
92
|
* Scan all active assignments and timeout those past their TTL.
|
|
13
93
|
*
|
|
14
94
|
* - `started` assignments with no heartbeat within `heartbeat_ttl_ms` → `timed_out`
|
|
95
|
+
* UNLESS file evidence (heartbeat sentinel / fs activity / commit) is fresh.
|
|
96
|
+
* - `accepted` assignments not started within `acceptance_ttl_ms` → `timed_out`
|
|
97
|
+
* UNLESS fresh evidence ⇒ implicit `started`.
|
|
15
98
|
* - `offered` assignments not accepted within `acceptance_ttl_ms` → `expired`
|
|
99
|
+
* UNLESS any evidence ⇒ implicit `accepted` (ack/heartbeat/fs/commit are
|
|
100
|
+
* acceptance, just delivered by a worker that cannot reach MCP).
|
|
16
101
|
*
|
|
17
102
|
* @param cwd - Project root
|
|
18
103
|
* @param options.nowMs - Override current time for testing
|
|
@@ -21,7 +106,7 @@ import { listAssignments, transitionAssignment } from './assignments.js';
|
|
|
21
106
|
export function sweepAssignments(cwd, options) {
|
|
22
107
|
const now = options?.nowMs ?? Date.now();
|
|
23
108
|
const actor = options?.actor ?? 'sweeper';
|
|
24
|
-
const result = { timed_out: [], expired: [] };
|
|
109
|
+
const result = { timed_out: [], expired: [], implicitly_advanced: [] };
|
|
25
110
|
const all = listAssignments(cwd);
|
|
26
111
|
for (const assignment of all) {
|
|
27
112
|
// Check started assignments for heartbeat timeout
|
|
@@ -31,9 +116,16 @@ export function sweepAssignments(cwd, options) {
|
|
|
31
116
|
continue;
|
|
32
117
|
const ageMs = now - new Date(lastBeat).getTime();
|
|
33
118
|
if (ageMs > assignment.heartbeat_ttl_ms) {
|
|
119
|
+
// can_948acfd6: a worker without MCP cannot bump last_heartbeat_at —
|
|
120
|
+
// its file evidence is the heartbeat. Fresh file activity vetoes the
|
|
121
|
+
// administrative timeout.
|
|
122
|
+
const sinceMs = new Date(assignment.started_at ?? assignment.created_at).getTime();
|
|
123
|
+
const evidence = collectImplicitEvidence(assignment, cwd, now, sinceMs, assignment.heartbeat_ttl_ms);
|
|
124
|
+
if (evidence.fresh)
|
|
125
|
+
continue;
|
|
34
126
|
try {
|
|
35
127
|
transitionAssignment(assignment.id, 'timed_out', {
|
|
36
|
-
status_reason: `No heartbeat for ${Math.round(ageMs / 60_000)} minutes (TTL: ${Math.round(assignment.heartbeat_ttl_ms / 60_000)}min)`,
|
|
128
|
+
status_reason: `No heartbeat for ${Math.round(ageMs / 60_000)} minutes (TTL: ${Math.round(assignment.heartbeat_ttl_ms / 60_000)}min); implicit evidence: ${evidence.description}`,
|
|
37
129
|
actor,
|
|
38
130
|
}, cwd);
|
|
39
131
|
result.timed_out.push({ assignment_id: assignment.id, agent: assignment.agent, age_ms: ageMs });
|
|
@@ -49,9 +141,23 @@ export function sweepAssignments(cwd, options) {
|
|
|
49
141
|
const ageMs = now - new Date(acceptedAt).getTime();
|
|
50
142
|
// Use acceptance_ttl for accepted→timed_out (same window: agent should start quickly after accepting)
|
|
51
143
|
if (ageMs > assignment.acceptance_ttl_ms) {
|
|
144
|
+
const sinceMs = new Date(acceptedAt).getTime();
|
|
145
|
+
const evidence = collectImplicitEvidence(assignment, cwd, now, sinceMs, assignment.acceptance_ttl_ms);
|
|
146
|
+
if (evidence.fresh) {
|
|
147
|
+
// Working without MCP — record the implicit start so the FSM matches reality.
|
|
148
|
+
try {
|
|
149
|
+
transitionAssignment(assignment.id, 'started', {
|
|
150
|
+
status_reason: `Implicit start inferred by sweeper: ${evidence.description}`,
|
|
151
|
+
actor,
|
|
152
|
+
}, cwd);
|
|
153
|
+
result.implicitly_advanced.push({ assignment_id: assignment.id, agent: assignment.agent, to: 'started', evidence: evidence.description });
|
|
154
|
+
}
|
|
155
|
+
catch { /* skip */ }
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
52
158
|
try {
|
|
53
159
|
transitionAssignment(assignment.id, 'timed_out', {
|
|
54
|
-
status_reason: `Accepted but not started within ${Math.round(ageMs / 60_000)} minutes`,
|
|
160
|
+
status_reason: `Accepted but not started within ${Math.round(ageMs / 60_000)} minutes; implicit evidence: ${evidence.description}`,
|
|
55
161
|
actor,
|
|
56
162
|
}, cwd);
|
|
57
163
|
result.timed_out.push({ assignment_id: assignment.id, agent: assignment.agent, age_ms: ageMs });
|
|
@@ -66,9 +172,26 @@ export function sweepAssignments(cwd, options) {
|
|
|
66
172
|
continue;
|
|
67
173
|
const ageMs = now - new Date(offeredAt).getTime();
|
|
68
174
|
if (ageMs > assignment.acceptance_ttl_ms) {
|
|
175
|
+
// can_948acfd6: ANY worker evidence (ack sentinel touched pre-exec,
|
|
176
|
+
// heartbeat written, files edited, commit landed) is an implicit
|
|
177
|
+
// acceptance — the worker just couldn't say so via MCP. Expiring it
|
|
178
|
+
// is the false-administrative-death observed three times in sprint 1.
|
|
179
|
+
const sinceMs = new Date(offeredAt).getTime();
|
|
180
|
+
const evidence = collectImplicitEvidence(assignment, cwd, now, sinceMs, assignment.acceptance_ttl_ms);
|
|
181
|
+
if (evidence.any) {
|
|
182
|
+
try {
|
|
183
|
+
transitionAssignment(assignment.id, 'accepted', {
|
|
184
|
+
status_reason: `Implicit acceptance inferred by sweeper: ${evidence.description}`,
|
|
185
|
+
actor,
|
|
186
|
+
}, cwd);
|
|
187
|
+
result.implicitly_advanced.push({ assignment_id: assignment.id, agent: assignment.agent, to: 'accepted', evidence: evidence.description });
|
|
188
|
+
}
|
|
189
|
+
catch { /* skip */ }
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
69
192
|
try {
|
|
70
193
|
transitionAssignment(assignment.id, 'expired', {
|
|
71
|
-
status_reason: `Not accepted within ${Math.round(ageMs / 60_000)} minutes (TTL: ${Math.round(assignment.acceptance_ttl_ms / 60_000)}min)`,
|
|
194
|
+
status_reason: `Not accepted within ${Math.round(ageMs / 60_000)} minutes (TTL: ${Math.round(assignment.acceptance_ttl_ms / 60_000)}min); no implicit evidence`,
|
|
72
195
|
actor,
|
|
73
196
|
}, cwd);
|
|
74
197
|
result.expired.push({ assignment_id: assignment.id, agent: assignment.agent, age_ms: ageMs });
|
package/dist/core/assignments.js
CHANGED
|
@@ -7,6 +7,7 @@ import { JsonStore } from './json-store.js';
|
|
|
7
7
|
import { appendAuditEntry } from './audit.js';
|
|
8
8
|
import { appendEvent } from './event-log.js';
|
|
9
9
|
import { createRuntimeEvent } from './events.js';
|
|
10
|
+
import { emitRegistryPostImage, emitRegistryTombstone, registryFaultPoint } from './events/registry-post-image.js';
|
|
10
11
|
import { findLatestAgentRunForAssignment, recordAgentRunProgress, syncAgentRunFromAssignmentTransition } from './agentruns.js';
|
|
11
12
|
// ── Directory / Store ────────────────────────────────────────
|
|
12
13
|
function assignmentsDir(cwd, mode = 'read') {
|
|
@@ -36,7 +37,12 @@ export function saveAssignment(assignment, cwd) {
|
|
|
36
37
|
getId: (a) => a.id,
|
|
37
38
|
sort: (a, b) => a.created_at.localeCompare(b.created_at),
|
|
38
39
|
});
|
|
39
|
-
|
|
40
|
+
const parsed = AssignmentSchema.parse(assignment);
|
|
41
|
+
// pln#568 (I2): journal the post-image BEFORE the projection write.
|
|
42
|
+
const created = !store.exists(parsed.id);
|
|
43
|
+
emitRegistryPostImage('assignment', parsed, { created, agent: parsed.agent, agent_id: parsed.agent_id, session_id: parsed.session_id, cwd });
|
|
44
|
+
registryFaultPoint('after_registry_journal');
|
|
45
|
+
store.save(parsed);
|
|
40
46
|
});
|
|
41
47
|
}
|
|
42
48
|
export function loadAssignment(id, cwd) {
|
|
@@ -65,20 +71,22 @@ export function listAssignments(cwd, filter) {
|
|
|
65
71
|
return items;
|
|
66
72
|
}
|
|
67
73
|
export function deleteAssignment(id, cwd) {
|
|
68
|
-
|
|
69
|
-
if (!store.exists(id)) {
|
|
70
|
-
return false;
|
|
71
|
-
}
|
|
72
|
-
mutate({ cwd }, () => {
|
|
74
|
+
return mutate({ cwd }, () => {
|
|
73
75
|
const writableStore = new JsonStore({
|
|
74
76
|
dirPath: assignmentsDir(cwd, 'write'),
|
|
75
77
|
documentType: 'assignment',
|
|
76
78
|
getId: (a) => a.id,
|
|
77
79
|
sort: (a, b) => a.created_at.localeCompare(b.created_at),
|
|
78
80
|
});
|
|
81
|
+
if (!writableStore.exists(id)) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
const assignment = writableStore.load(id);
|
|
85
|
+
emitRegistryTombstone('assignment', assignment.id, { agent: assignment.agent, agent_id: assignment.agent_id, session_id: assignment.session_id, cwd });
|
|
86
|
+
registryFaultPoint('after_registry_journal');
|
|
79
87
|
writableStore.delete(id);
|
|
88
|
+
return true;
|
|
80
89
|
});
|
|
81
|
-
return true;
|
|
82
90
|
}
|
|
83
91
|
// ── ID Generation ────────────────────────────────────────────
|
|
84
92
|
export function generateAssignmentId(cwd) {
|
|
@@ -102,7 +110,12 @@ const VALID_TRANSITIONS = new Map([
|
|
|
102
110
|
['timed_out', new Set(['retrying', 'rerouted', 'cancelled'])],
|
|
103
111
|
['retrying', new Set(['offered', 'rerouted', 'cancelled'])],
|
|
104
112
|
['blocked', new Set(['rerouted', 'started', 'failed', 'cancelled'])],
|
|
105
|
-
//
|
|
113
|
+
// can_948acfd6: evidence can arrive AFTER an administrative expiry — the
|
|
114
|
+
// worker was alive all along but never acked (sandboxed, no MCP), and its
|
|
115
|
+
// commit / LANE-RESULT surfaced later. Allow the late convergence so
|
|
116
|
+
// harvest/reconcile can record the truth instead of being FSM-blocked.
|
|
117
|
+
['expired', new Set(['completed'])],
|
|
118
|
+
// Terminal: completed, cancelled, rerouted (no outgoing transitions)
|
|
106
119
|
]);
|
|
107
120
|
export function validateTransition(from, to) {
|
|
108
121
|
const allowed = VALID_TRANSITIONS.get(from);
|
|
@@ -119,6 +132,9 @@ export function validateTransition(from, to) {
|
|
|
119
132
|
* Updates relevant timestamps, emits event and audit entry.
|
|
120
133
|
*/
|
|
121
134
|
export function transitionAssignment(id, newStatus, options, cwd) {
|
|
135
|
+
return transitionAssignmentInternal(id, newStatus, options, cwd);
|
|
136
|
+
}
|
|
137
|
+
function transitionAssignmentInternal(id, newStatus, options, cwd) {
|
|
122
138
|
const assignment = loadAssignment(id, cwd);
|
|
123
139
|
if (!assignment) {
|
|
124
140
|
throw new Error(`Assignment not found: ${id}`);
|
|
@@ -131,9 +147,17 @@ export function transitionAssignment(id, newStatus, options, cwd) {
|
|
|
131
147
|
saveAssignment(assignment, cwd);
|
|
132
148
|
return { assignment, previous_status: newStatus, idempotent: true };
|
|
133
149
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
150
|
+
if (options.systemConvergence) {
|
|
151
|
+
const validation = validateSystemConvergence(assignment.status, newStatus, options.actor);
|
|
152
|
+
if (!validation.valid) {
|
|
153
|
+
throw new Error(validation.reason);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
const validation = validateTransition(assignment.status, newStatus);
|
|
158
|
+
if (!validation.valid) {
|
|
159
|
+
throw new Error(validation.reason);
|
|
160
|
+
}
|
|
137
161
|
}
|
|
138
162
|
const previous_status = assignment.status;
|
|
139
163
|
const now = nowISO();
|
|
@@ -300,6 +324,40 @@ export function createAssignment(options, cwd) {
|
|
|
300
324
|
// ── Active Assignment Lookup ─────────────────────────────────
|
|
301
325
|
/** Statuses that indicate a finished assignment (no longer active). */
|
|
302
326
|
const TERMINAL_STATUSES = new Set(['completed', 'cancelled', 'expired', 'rerouted']);
|
|
327
|
+
/**
|
|
328
|
+
* Statuses a file-based worker leaves an assignment stuck in — it never calls
|
|
329
|
+
* bclaw_assignment_update, so the assignment never advances past these (pln#563).
|
|
330
|
+
* These are the only states a system convergence (loop-close cascade / lazy
|
|
331
|
+
* reconciler) fast-forwards; failed/blocked/timed_out carry real signal and are
|
|
332
|
+
* left alone.
|
|
333
|
+
*/
|
|
334
|
+
const CONVERGEABLE_STATUSES = new Set(['offered', 'accepted', 'started']);
|
|
335
|
+
function validateSystemConvergence(from, to, actor) {
|
|
336
|
+
if (actor !== 'system') {
|
|
337
|
+
return { valid: false, reason: 'System convergence must be performed by actor=system' };
|
|
338
|
+
}
|
|
339
|
+
if (!CONVERGEABLE_STATUSES.has(from) || (to !== 'completed' && to !== 'cancelled')) {
|
|
340
|
+
return { valid: false, reason: `Invalid system convergence: ${from} → ${to}` };
|
|
341
|
+
}
|
|
342
|
+
return { valid: true };
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Force a stuck assignment to a terminal status as a SYSTEM convergence
|
|
346
|
+
* (pln#563). No-op (returns false) if the assignment is missing or not in a
|
|
347
|
+
* convergeable state, so callers can fire it best-effort. Used by the loop-close
|
|
348
|
+
* cascade and the lazy orphan reconciler.
|
|
349
|
+
*/
|
|
350
|
+
export function convergeAssignmentToTerminal(id, terminal, reason, cwd) {
|
|
351
|
+
const assignment = loadAssignment(id, cwd);
|
|
352
|
+
if (!assignment || !CONVERGEABLE_STATUSES.has(assignment.status))
|
|
353
|
+
return false;
|
|
354
|
+
transitionAssignmentInternal(id, terminal, {
|
|
355
|
+
actor: 'system',
|
|
356
|
+
status_reason: reason,
|
|
357
|
+
systemConvergence: true,
|
|
358
|
+
}, cwd);
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
303
361
|
/**
|
|
304
362
|
* Return the most recently created non-terminal assignment for the given agent.
|
|
305
363
|
* When `claimId` is provided, it is used as a fast-path lookup before falling
|