brainclaw 1.28.1 → 1.28.3
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/dist/brainclaw-vscode.vsix +0 -0
- package/dist/commands/code-map.js +2 -0
- package/dist/commands/doctor.js +1 -0
- package/dist/commands/harvest.js +32 -43
- package/dist/commands/loops-handlers.js +66 -3
- package/dist/commands/mcp-catalog.js +2 -2
- package/dist/commands/mcp-write-coordination.js +426 -141
- package/dist/commands/mcp-write-entities.js +5 -2
- package/dist/commands/mcp.js +57 -11
- package/dist/core/agentrun-reconciler.js +138 -4
- package/dist/core/claims.js +4 -1
- package/dist/core/code-map/aggregate.js +20 -7
- package/dist/core/code-map/backend.js +25 -7
- package/dist/core/code-map/cascade-jobs.js +174 -0
- package/dist/core/code-map/cascade-worker.js +15 -0
- package/dist/core/code-map/cascade.js +63 -26
- package/dist/core/code-map/query.js +6 -3
- package/dist/core/entity-operations.js +18 -4
- package/dist/core/execution-adapters.js +23 -8
- package/dist/core/hygiene-policy.js +2 -1
- package/dist/core/loop-turn-dispatch.js +18 -1
- package/dist/core/loops/attempt-authority.js +22 -4
- package/dist/core/loops/attempt-generations.js +17 -4
- package/dist/core/loops/attempt-reservation.js +14 -1
- package/dist/core/loops/attempt-takeover.js +173 -76
- package/dist/core/loops/reconcile-turn.js +224 -26
- package/dist/core/loops/result-reducers.js +8 -8
- package/dist/core/loops/turn-execution.js +38 -19
- package/dist/core/loops/types.js +3 -0
- package/dist/core/loops/verbs.js +1 -1
- package/dist/core/spawn-check.js +9 -1
- package/dist/facts.js +8 -8
- package/dist/facts.json +7 -7
- package/docs/cli.md +2 -2
- package/docs/code-map.md +30 -9
- package/docs/concepts/loop-engine.md +5 -0
- package/docs/integrations/mcp.md +2 -2
- package/docs/mcp-schema-changelog.md +30 -0
- package/package.json +1 -1
|
@@ -479,9 +479,12 @@ export function handleBclawCreate(payload, ctx) {
|
|
|
479
479
|
const result = createEntity(entity, data, targetCwd);
|
|
480
480
|
appendAuditEntry({ actor: actor ?? 'unknown', ...(actorId ? { actor_id: actorId } : {}), action: 'create', item_id: result.id, item_type: entity }, targetCwd);
|
|
481
481
|
const createText = `✔ created ${entity} ${result.id}${autoSwitched ? ` (auto-switched → ${targetScope.resolved_project.name ?? targetScope.resolved_project.path})` : ''}`;
|
|
482
|
+
const proximityText = result.nearby_items?.length
|
|
483
|
+
? `Nearby existing ${entity} item(s): ${result.nearby_items.map((item) => `${item.id} (${item.reason})`).join(', ')}. Creation was kept; review before adding another duplicate.`
|
|
484
|
+
: undefined;
|
|
482
485
|
const createContent = autoRepair
|
|
483
|
-
? [{ type: 'text', text: createText }, { type: 'text', text: renderAutoRepairWarning(autoRepair, actor ?? 'unknown') }]
|
|
484
|
-
: [{ type: 'text', text: createText }];
|
|
486
|
+
? [{ type: 'text', text: createText }, { type: 'text', text: renderAutoRepairWarning(autoRepair, actor ?? 'unknown') }, ...(proximityText ? [{ type: 'text', text: proximityText }] : [])]
|
|
487
|
+
: [{ type: 'text', text: createText }, ...(proximityText ? [{ type: 'text', text: proximityText }] : [])];
|
|
485
488
|
// pln#634 — a freshly created plan whose steps are never added is the most
|
|
486
489
|
// common half-finished shape in the store; a sequence with no readiness
|
|
487
490
|
// check is the second. Only those two emit a follow-up.
|
package/dist/commands/mcp.js
CHANGED
|
@@ -39,7 +39,7 @@ import { ensureTrust, resolveMutationIdentity, explicitSessionIdFromEnv, project
|
|
|
39
39
|
// point: it imports the extracted modules for its own use and re-exports the
|
|
40
40
|
// full historical surface below so external importers are unaffected.
|
|
41
41
|
// ---------------------------------------------------------------------------
|
|
42
|
-
import {
|
|
42
|
+
import { MCP_PROTOCOL_VERSIONS, MCP_SERVER_NOT_INITIALIZED, toolResponse, createToolErrorResponse, } from './mcp-contract.js';
|
|
43
43
|
import { MCP_READ_TOOLS, PUBLISHED_TOOLS, DEFAULT_PUBLISHED_TOOLS, UNINITIALIZED_TOOL_NAMES, UNINITIALIZED_PUBLISHED_TOOLS, buildUninitializedStateMessage, LEGACY_READ_TOOL_HANDLERS, } from './mcp-catalog.js';
|
|
44
44
|
// Claim/assignment write handlers extracted in pln#622 PR3b. The shared write
|
|
45
45
|
// helpers (ensureTrust, resolveExecutionWriteTarget, …) stay here and are
|
|
@@ -227,7 +227,10 @@ export function createInitializeResult(protocolVersion, options) {
|
|
|
227
227
|
const uninitialized = options?.uninitialized === true;
|
|
228
228
|
return {
|
|
229
229
|
protocolVersion,
|
|
230
|
-
serverInfo
|
|
230
|
+
// MCP serverInfo.version is the running PRODUCT version. SCHEMA_VERSION
|
|
231
|
+
// belongs to tool envelopes and made stale processes impossible to
|
|
232
|
+
// distinguish during the DGX 1.28.0→1.28.1 dogfood.
|
|
233
|
+
serverInfo: { name: 'brainclaw', version: getInstalledBrainclawVersion() },
|
|
231
234
|
// listChanged is only advertised in setup mode, where the catalog flips
|
|
232
235
|
// to the full set once the project memory is initialized.
|
|
233
236
|
capabilities: { tools: { listChanged: uninitialized } },
|
|
@@ -1093,18 +1096,47 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
1093
1096
|
if (name === 'bclaw_code_status' || name === 'bclaw_code_find' || name === 'bclaw_code_brief' || name === 'bclaw_code_impact' || name === 'bclaw_code_export' || name === 'bclaw_code_outline' || name === 'bclaw_code_refresh') {
|
|
1094
1097
|
const { JsonlBackend } = await import('../core/code-map/backend.js');
|
|
1095
1098
|
const be = new JsonlBackend();
|
|
1099
|
+
// Session-scoped project selection is authoritative for Code Map too.
|
|
1100
|
+
const codeCwd = scopeInfo.cwd;
|
|
1096
1101
|
if (name === 'bclaw_code_status') {
|
|
1097
|
-
const status = await be.status({ cwd, cascade: args.cascade === true });
|
|
1102
|
+
const status = await be.status({ cwd: codeCwd, cascade: args.cascade === true });
|
|
1103
|
+
const diskVersion = readDiskBrainclawVersion();
|
|
1098
1104
|
return {
|
|
1099
1105
|
response: toolResponse({
|
|
1100
1106
|
content: [{ type: 'text', text: `Code Map: ${status.store_exists ? 'store present' : 'no store'} — freshness=${status.freshness_badge.freshness}` }],
|
|
1101
|
-
structuredContent: {
|
|
1107
|
+
structuredContent: {
|
|
1108
|
+
...status,
|
|
1109
|
+
freshness_badge: status.freshness_badge,
|
|
1110
|
+
mcp_resolution: {
|
|
1111
|
+
active_source: scopeInfo.active_source,
|
|
1112
|
+
resolved_project: scopeInfo.resolved_project,
|
|
1113
|
+
server_version: getInstalledBrainclawVersion(),
|
|
1114
|
+
disk_version: diskVersion,
|
|
1115
|
+
restart_required: diskVersion !== '0.0.0' && diskVersion !== getInstalledBrainclawVersion(),
|
|
1116
|
+
},
|
|
1117
|
+
},
|
|
1102
1118
|
}),
|
|
1103
1119
|
};
|
|
1104
1120
|
}
|
|
1105
1121
|
if (name === 'bclaw_code_refresh') {
|
|
1106
1122
|
const scope = args.scope === 'all' ? 'all' : 'changed';
|
|
1107
|
-
|
|
1123
|
+
if (args.cascade === true) {
|
|
1124
|
+
const { startCascadeRefreshJob, summarizeCascadeRefreshJob } = await import('../core/code-map/cascade-jobs.js');
|
|
1125
|
+
const job = startCascadeRefreshJob(codeCwd, scope);
|
|
1126
|
+
if (job) {
|
|
1127
|
+
return {
|
|
1128
|
+
response: toolResponse({
|
|
1129
|
+
content: [{ type: 'text', text: `Code Map cascade started: job=${job.job_id}, projects=${job.projects_total}. Follow with bclaw_code_status(cascade=true).` }],
|
|
1130
|
+
structuredContent: {
|
|
1131
|
+
started: true,
|
|
1132
|
+
...summarizeCascadeRefreshJob(job),
|
|
1133
|
+
next_actions: [{ tool: 'bclaw_code_status', args: { cascade: true }, when: 'follow progress and terminal per-project diagnostics' }],
|
|
1134
|
+
},
|
|
1135
|
+
}),
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
const result = await be.refresh({ scope, cwd: codeCwd, cascade: args.cascade === true });
|
|
1108
1140
|
const cascadeNote = result.cascade ? ` cascade=${result.cascade.children_refreshed} child(ren)+root` : '';
|
|
1109
1141
|
return {
|
|
1110
1142
|
response: toolResponse({
|
|
@@ -1119,7 +1151,7 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
1119
1151
|
return { response: createToolErrorResponse('validation_error', 'bclaw_code_find requires a non-empty query.') };
|
|
1120
1152
|
}
|
|
1121
1153
|
const limit = typeof args.limit === 'number' ? args.limit : undefined;
|
|
1122
|
-
const result = await be.find({ query, limit, cwd });
|
|
1154
|
+
const result = await be.find({ query, limit, cwd: codeCwd });
|
|
1123
1155
|
return {
|
|
1124
1156
|
response: toolResponse({
|
|
1125
1157
|
content: [{ type: 'text', text: `Code Map find "${result.query}": ${result.matches.length} match(es), freshness=${result.freshness_badge.freshness}` }],
|
|
@@ -1140,7 +1172,7 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
1140
1172
|
const maxEdges = typeof args.maxEdges === 'number' ? args.maxEdges : undefined;
|
|
1141
1173
|
const minConfidence = typeof args.minConfidence === 'number' ? args.minConfidence : undefined;
|
|
1142
1174
|
const format = args.format === 'mermaid' ? 'mermaid' : args.format === 'json' ? 'json' : undefined;
|
|
1143
|
-
const result = await be.exportGraph({ target, targetKind, direction, depth, maxNodes, maxEdges, minConfidence, format, cwd });
|
|
1175
|
+
const result = await be.exportGraph({ target, targetKind, direction, depth, maxNodes, maxEdges, minConfidence, format, cwd: codeCwd });
|
|
1144
1176
|
return {
|
|
1145
1177
|
response: toolResponse({
|
|
1146
1178
|
content: [{ type: 'text', text: `Code Map export "${result.target}": ${result.nodes.length} node(s), ${result.edges.length} edge(s), depth=${result.limits.max_depth}, freshness=${result.freshness_badge.freshness}` }],
|
|
@@ -1156,7 +1188,7 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
1156
1188
|
}
|
|
1157
1189
|
const depth = typeof args.depth === 'number' ? args.depth : undefined;
|
|
1158
1190
|
const limit = typeof args.limit === 'number' ? args.limit : undefined;
|
|
1159
|
-
const result = await be.impact({ target, depth, limit, cwd });
|
|
1191
|
+
const result = await be.impact({ target, depth, limit, cwd: codeCwd });
|
|
1160
1192
|
return {
|
|
1161
1193
|
response: toolResponse({
|
|
1162
1194
|
content: [{ type: 'text', text: `Code Map impact "${result.target}": ${result.risk.counters.direct_dependents} direct, ${result.risk.counters.transitive_dependents} transitive dependent(s), risk=${result.risk.score}, freshness=${result.freshness_badge.freshness}` }],
|
|
@@ -1171,7 +1203,7 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
1171
1203
|
return { response: createToolErrorResponse('validation_error', 'bclaw_code_outline requires a non-empty path.') };
|
|
1172
1204
|
}
|
|
1173
1205
|
const limit = typeof args.limit === 'number' ? args.limit : undefined;
|
|
1174
|
-
const result = await be.outline({ path: outlinePath, limit, cwd });
|
|
1206
|
+
const result = await be.outline({ path: outlinePath, limit, cwd: codeCwd });
|
|
1175
1207
|
return {
|
|
1176
1208
|
response: toolResponse({
|
|
1177
1209
|
content: [{ type: 'text', text: `Code Map outline "${result.path}": ${result.symbols.length}/${result.symbol_count} symbol(s), index=${result.index_status}, freshness=${result.freshness_badge.freshness}` }],
|
|
@@ -1185,7 +1217,7 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
1185
1217
|
return { response: createToolErrorResponse('validation_error', 'bclaw_code_brief requires a non-empty target.') };
|
|
1186
1218
|
}
|
|
1187
1219
|
const limit = typeof args.limit === 'number' ? args.limit : undefined;
|
|
1188
|
-
const result = await be.brief({ target, limit, cwd });
|
|
1220
|
+
const result = await be.brief({ target, limit, cwd: codeCwd });
|
|
1189
1221
|
return {
|
|
1190
1222
|
response: toolResponse({
|
|
1191
1223
|
content: [{ type: 'text', text: `Code Map brief "${result.target}": ${result.suggested_files_to_read.length} file(s) to read, freshness=${result.freshness_badge.freshness}` }],
|
|
@@ -1457,10 +1489,24 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
1457
1489
|
const full = candidateIds
|
|
1458
1490
|
.map((id) => loadAssignment(id, targetCwd))
|
|
1459
1491
|
.filter((a) => a !== undefined);
|
|
1460
|
-
sweepAssignmentsAtReadPath(full, targetCwd, {
|
|
1492
|
+
const sweep = sweepAssignmentsAtReadPath(full, targetCwd, {
|
|
1461
1493
|
actor: 'bclaw_work-readpath',
|
|
1462
1494
|
policy,
|
|
1463
1495
|
});
|
|
1496
|
+
// The context snapshot was built before the lazy sweep. Reflect
|
|
1497
|
+
// the same-pass mutations so bclaw_work never serves a fossil as
|
|
1498
|
+
// active once it has just expired/timed it out on disk.
|
|
1499
|
+
const parked = new Set([
|
|
1500
|
+
...sweep.expired.map((item) => item.assignment_id),
|
|
1501
|
+
...sweep.timed_out.map((item) => item.assignment_id),
|
|
1502
|
+
]);
|
|
1503
|
+
const advanced = new Map(sweep.implicitly_advanced.map((item) => [item.assignment_id, item.to]));
|
|
1504
|
+
contextResult.open_work.active_assignments = openAssignments
|
|
1505
|
+
.filter((assignment) => !parked.has(assignment.id))
|
|
1506
|
+
.map((assignment) => {
|
|
1507
|
+
const status = advanced.get(assignment.id);
|
|
1508
|
+
return status ? { ...assignment, status } : assignment;
|
|
1509
|
+
});
|
|
1464
1510
|
}
|
|
1465
1511
|
const registry = loadServeRegistry(targetCwd);
|
|
1466
1512
|
const aged = ageStaleWarnings(contextResult.stale_warnings ?? [], targetCwd, { policy, registry });
|
|
@@ -36,7 +36,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
36
36
|
import fs from 'node:fs';
|
|
37
37
|
import { loadAgentRun, recordExecutionContractAnomaly, transitionAgentRun, listAgentRuns } from './agentruns.js';
|
|
38
38
|
import { loadClaim, releaseClaim } from './claims.js';
|
|
39
|
-
import { loadAssignment } from './assignments.js';
|
|
39
|
+
import { convergeAssignmentToTerminal, loadAssignment, transitionAssignment } from './assignments.js';
|
|
40
40
|
import { createRuntimeEvent } from './events.js';
|
|
41
41
|
import { nowISO } from './ids.js';
|
|
42
42
|
import { readContractAck, readHeartbeat, readLogTail, signalExists, latestActivityMs, readCompletionSignals } from './runtime-signals.js';
|
|
@@ -44,7 +44,7 @@ import { validateWorkerContractAcceptance } from './execution-contract.js';
|
|
|
44
44
|
import { findReservationByRunId, evidenceMatchesAttempt, launchGrant, revokeLaunchGrant } from './loops/attempt-reservation.js';
|
|
45
45
|
import { executionContractForGeneration } from './loops/attempt-authority.js';
|
|
46
46
|
import { readLaunchDecision, resolveTurnGenerationChain } from './loops/attempt-generations.js';
|
|
47
|
-
import { reconcileFailedTurn } from './loops/reconcile-turn.js';
|
|
47
|
+
import { reconcileFailedTurn, reconcileLaneResultForRun } from './loops/reconcile-turn.js';
|
|
48
48
|
// ── Constants ──────────────────────────────────────────────────────────────
|
|
49
49
|
/**
|
|
50
50
|
* Minimum age before a run is eligible for reconciliation. Below this, the
|
|
@@ -76,6 +76,47 @@ export const DEFAULT_HEARTBEAT_STALE_MS = 10 * 60_000;
|
|
|
76
76
|
const TERMINAL_STATUSES = new Set([
|
|
77
77
|
'completed', 'failed', 'cancelled', 'timed_out', 'interrupted',
|
|
78
78
|
]);
|
|
79
|
+
/** Shared business-result bridge for every lazy AgentRun read path. */
|
|
80
|
+
function reconcileOwnedLaneResultAtRead(run, evidence, cwd, actor) {
|
|
81
|
+
if (!evidence.turn_owned)
|
|
82
|
+
return undefined;
|
|
83
|
+
const lane = reconcileLaneResultForRun(run, cwd ?? process.cwd(), actor);
|
|
84
|
+
if (!lane.found)
|
|
85
|
+
return undefined;
|
|
86
|
+
if (!lane.valid || !lane.result?.reconciled) {
|
|
87
|
+
return { withheld_reason: `lane_result_withheld: ${lane.reason}` };
|
|
88
|
+
}
|
|
89
|
+
const after = loadAgentRun(run.id, cwd) ?? run;
|
|
90
|
+
return { result: {
|
|
91
|
+
run_id: run.id,
|
|
92
|
+
action: 'reconciled_turn',
|
|
93
|
+
reason: `lane_result_reconciled: ${lane.reason}`,
|
|
94
|
+
evidence,
|
|
95
|
+
previous_status: run.status,
|
|
96
|
+
current_status: after.status,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** A malformed/foreign business result withholds success, but it must not mask
|
|
101
|
+
* independent, conclusive failure evidence forever. */
|
|
102
|
+
function settleWithheldLaneFailure(run, evidence, actor, cwd, reason) {
|
|
103
|
+
try {
|
|
104
|
+
transitionAgentRun(run.id, 'failed', { actor, status_reason: reason }, cwd);
|
|
105
|
+
convergeAssignmentFromRun(run, 'failed', evidence, actor, cwd);
|
|
106
|
+
cascadeReleaseOnFailure(run, actor, cwd, 'failed', reason);
|
|
107
|
+
return { run_id: run.id, action: 'inferred_failed', reason, evidence, previous_status: run.status, current_status: 'failed' };
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
return {
|
|
111
|
+
run_id: run.id,
|
|
112
|
+
action: 'no_op',
|
|
113
|
+
reason: `failure transition rejected: ${err instanceof Error ? err.message : String(err)}`,
|
|
114
|
+
evidence,
|
|
115
|
+
previous_status: run.status,
|
|
116
|
+
current_status: run.status,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
79
120
|
// ── Process liveness ───────────────────────────────────────────────────────
|
|
80
121
|
/**
|
|
81
122
|
* Cross-platform "is this PID alive" check. `process.kill(pid, 0)` does NOT
|
|
@@ -309,6 +350,32 @@ export function collectEvidence(run, cwd, options) {
|
|
|
309
350
|
function fsActiveWithin(evidence, windowMs) {
|
|
310
351
|
return evidence.fs_activity_age_ms !== undefined && evidence.fs_activity_age_ms < windowMs;
|
|
311
352
|
}
|
|
353
|
+
/** Align a non-turn-owned Assignment with conclusive runtime evidence. */
|
|
354
|
+
function convergeAssignmentFromRun(run, outcome, evidence, actor, cwd) {
|
|
355
|
+
if (findReservationByRunId(run.id, cwd))
|
|
356
|
+
return false;
|
|
357
|
+
const assignment = loadAssignment(run.assignment_id, cwd);
|
|
358
|
+
if (!assignment || ['completed', 'failed', 'blocked', 'timed_out', 'cancelled', 'expired', 'rerouted'].includes(assignment.status)) {
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
if (outcome === 'completed') {
|
|
362
|
+
// A released claim alone is not business proof of successful work.
|
|
363
|
+
if (!evidence.completed_signal && !evidence.has_post_start_commit && !evidence.assignment_completed)
|
|
364
|
+
return false;
|
|
365
|
+
return convergeAssignmentToTerminal(assignment.id, 'completed', `agent_run ${run.id} completed from authoritative runtime evidence`, cwd);
|
|
366
|
+
}
|
|
367
|
+
try {
|
|
368
|
+
transitionAssignment(assignment.id, 'failed', {
|
|
369
|
+
actor,
|
|
370
|
+
syncAgentRun: false,
|
|
371
|
+
status_reason: `agent_run ${run.id} failed from liveness reconciliation`,
|
|
372
|
+
}, cwd);
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
312
379
|
/**
|
|
313
380
|
* trp#433 — when a run is reconciled to `failed` (silent_death / stalled), release
|
|
314
381
|
* its linked claim so dead runs stop leaving active claims (and their worktrees)
|
|
@@ -542,6 +609,15 @@ export function reconcileAgentRun(runId, cwd, options = {}) {
|
|
|
542
609
|
}
|
|
543
610
|
const previous_status = run.status;
|
|
544
611
|
const evidence = collectEvidence(run, cwd, { nowMs: options.nowMs });
|
|
612
|
+
const actor = options.actor ?? 'reconciler';
|
|
613
|
+
// pln#692 P0 — LANE-RESULT is the worker's business outcome, not merely a
|
|
614
|
+
// process-liveness hint. Reconcile it before the terminal-run short circuit
|
|
615
|
+
// and before generic sentinel completion so a replay heals every projection
|
|
616
|
+
// (slot, assignment, run, claim) and malformed/foreign results cannot leave
|
|
617
|
+
// only the run marked completed.
|
|
618
|
+
const laneResult = reconcileOwnedLaneResultAtRead(run, evidence, cwd, actor);
|
|
619
|
+
if (laneResult?.result)
|
|
620
|
+
return laneResult.result;
|
|
545
621
|
// Never touch terminal runs — they already converged.
|
|
546
622
|
if (TERMINAL_STATUSES.has(run.status)) {
|
|
547
623
|
return {
|
|
@@ -549,6 +625,33 @@ export function reconcileAgentRun(runId, cwd, options = {}) {
|
|
|
549
625
|
evidence, previous_status, current_status: run.status,
|
|
550
626
|
};
|
|
551
627
|
}
|
|
628
|
+
if (laneResult?.withheld_reason) {
|
|
629
|
+
const stale = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
630
|
+
const heartbeatStale = options.heartbeatStaleMs ?? DEFAULT_HEARTBEAT_STALE_MS;
|
|
631
|
+
const conclusiveFailure = evidence.failed_signal
|
|
632
|
+
? 'wrapper reported non-zero exit'
|
|
633
|
+
: evidence.heartbeat_exists
|
|
634
|
+
&& evidence.heartbeat_age_ms !== undefined
|
|
635
|
+
&& evidence.heartbeat_age_ms >= heartbeatStale
|
|
636
|
+
&& !fsActiveWithin(evidence, heartbeatStale)
|
|
637
|
+
? `heartbeat stalled for ${Math.round(evidence.heartbeat_age_ms / 1000)}s`
|
|
638
|
+
: evidence.process_alive === false
|
|
639
|
+
&& evidence.age_ms >= stale
|
|
640
|
+
&& !fsActiveWithin(evidence, heartbeatStale)
|
|
641
|
+
? 'silent termination past stale window'
|
|
642
|
+
: undefined;
|
|
643
|
+
if (conclusiveFailure) {
|
|
644
|
+
return settleWithheldLaneFailure(run, evidence, actor, cwd, `invalid_lane_result_with_conclusive_failure: ${conclusiveFailure}; ${laneResult.withheld_reason}`);
|
|
645
|
+
}
|
|
646
|
+
return {
|
|
647
|
+
run_id: run.id,
|
|
648
|
+
action: 'health_check_unverified',
|
|
649
|
+
reason: laneResult.withheld_reason,
|
|
650
|
+
evidence,
|
|
651
|
+
previous_status: run.status,
|
|
652
|
+
current_status: run.status,
|
|
653
|
+
};
|
|
654
|
+
}
|
|
552
655
|
if (evidence.contract_acceptance_anomaly) {
|
|
553
656
|
try {
|
|
554
657
|
recordExecutionContractAnomaly(runId, {
|
|
@@ -580,7 +683,6 @@ export function reconcileAgentRun(runId, cwd, options = {}) {
|
|
|
580
683
|
}
|
|
581
684
|
const grace = options.healthCheckGraceMs ?? DEFAULT_HEALTH_CHECK_GRACE_MS;
|
|
582
685
|
const stale = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
583
|
-
const actor = options.actor ?? 'reconciler';
|
|
584
686
|
// Below grace window: too early to draw any conclusion. Caller should
|
|
585
687
|
// re-poll later.
|
|
586
688
|
if (evidence.age_ms < grace) {
|
|
@@ -596,6 +698,7 @@ export function reconcileAgentRun(runId, cwd, options = {}) {
|
|
|
596
698
|
actor,
|
|
597
699
|
status_reason: `inferred=true; evidence: ${describeEvidence(evidence)}`,
|
|
598
700
|
}, cwd);
|
|
701
|
+
convergeAssignmentFromRun(run, 'completed', evidence, actor, cwd);
|
|
599
702
|
return {
|
|
600
703
|
run_id: runId, action: 'inferred_completed',
|
|
601
704
|
reason: `inferred=true; ${describeEvidence(evidence)}`,
|
|
@@ -617,6 +720,7 @@ export function reconcileAgentRun(runId, cwd, options = {}) {
|
|
|
617
720
|
const failHere = (reason) => {
|
|
618
721
|
try {
|
|
619
722
|
transitionAgentRun(runId, 'failed', { actor, status_reason: reason }, cwd);
|
|
723
|
+
convergeAssignmentFromRun(run, 'failed', evidence, actor, cwd);
|
|
620
724
|
cascadeReleaseOnFailure(run, actor, cwd, 'failed', reason);
|
|
621
725
|
return { run_id: runId, action: 'inferred_failed', reason, evidence, previous_status, current_status: 'failed' };
|
|
622
726
|
}
|
|
@@ -814,18 +918,47 @@ export function reconcileDeadPidRunningAgentRunAtRead(runId, cwd, options = {})
|
|
|
814
918
|
};
|
|
815
919
|
}
|
|
816
920
|
const evidence = collectEvidence(run, cwd, { nowMs: options.nowMs });
|
|
921
|
+
const actor = options.actor ?? 'reconciler';
|
|
922
|
+
const laneResult = reconcileOwnedLaneResultAtRead(run, evidence, cwd, actor);
|
|
923
|
+
if (laneResult?.result)
|
|
924
|
+
return laneResult.result;
|
|
817
925
|
if (run.status !== 'running') {
|
|
818
926
|
return {
|
|
819
927
|
run_id: run.id, action: 'no_op', reason: `run status is ${run.status}, not running`,
|
|
820
928
|
evidence, previous_status: run.status, current_status: run.status,
|
|
821
929
|
};
|
|
822
930
|
}
|
|
823
|
-
const actor = options.actor ?? 'reconciler';
|
|
824
931
|
const stale = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
825
932
|
const heartbeatStale = options.heartbeatStaleMs ?? DEFAULT_HEARTBEAT_STALE_MS;
|
|
933
|
+
if (laneResult?.withheld_reason) {
|
|
934
|
+
const conclusiveFailure = evidence.failed_signal
|
|
935
|
+
? 'wrapper reported non-zero exit'
|
|
936
|
+
: evidence.heartbeat_exists
|
|
937
|
+
&& evidence.heartbeat_age_ms !== undefined
|
|
938
|
+
&& evidence.heartbeat_age_ms >= heartbeatStale
|
|
939
|
+
&& !fsActiveWithin(evidence, heartbeatStale)
|
|
940
|
+
? `heartbeat stalled for ${Math.round(evidence.heartbeat_age_ms / 1000)}s`
|
|
941
|
+
: evidence.process_alive === false
|
|
942
|
+
&& evidence.age_ms >= stale
|
|
943
|
+
&& !fsActiveWithin(evidence, heartbeatStale)
|
|
944
|
+
? 'silent termination past stale window'
|
|
945
|
+
: undefined;
|
|
946
|
+
if (conclusiveFailure) {
|
|
947
|
+
return settleWithheldLaneFailure(run, evidence, actor, cwd, `invalid_lane_result_with_conclusive_failure: ${conclusiveFailure}; ${laneResult.withheld_reason}`);
|
|
948
|
+
}
|
|
949
|
+
return {
|
|
950
|
+
run_id: run.id,
|
|
951
|
+
action: 'health_check_unverified',
|
|
952
|
+
reason: laneResult.withheld_reason,
|
|
953
|
+
evidence,
|
|
954
|
+
previous_status: run.status,
|
|
955
|
+
current_status: run.status,
|
|
956
|
+
};
|
|
957
|
+
}
|
|
826
958
|
const failRun = (reason) => {
|
|
827
959
|
try {
|
|
828
960
|
transitionAgentRun(run.id, 'failed', { actor, status_reason: reason }, cwd);
|
|
961
|
+
convergeAssignmentFromRun(run, 'failed', evidence, actor, cwd);
|
|
829
962
|
cascadeReleaseOnFailure(run, actor, cwd, 'failed', reason);
|
|
830
963
|
return { run_id: run.id, action: 'inferred_failed', reason, evidence, previous_status: run.status, current_status: 'failed' };
|
|
831
964
|
}
|
|
@@ -846,6 +979,7 @@ export function reconcileDeadPidRunningAgentRunAtRead(runId, cwd, options = {})
|
|
|
846
979
|
actor,
|
|
847
980
|
status_reason: `inferred=true; evidence: ${describeEvidence(evidence)}`,
|
|
848
981
|
}, cwd);
|
|
982
|
+
convergeAssignmentFromRun(run, 'completed', evidence, actor, cwd);
|
|
849
983
|
return {
|
|
850
984
|
run_id: run.id, action: 'inferred_completed',
|
|
851
985
|
reason: `inferred=true; ${describeEvidence(evidence)}`,
|
package/dist/core/claims.js
CHANGED
|
@@ -963,7 +963,10 @@ export function createCoordinatorClaim(options) {
|
|
|
963
963
|
// `.github/workflows` previously produced `feat/.github-…` (leading dot),
|
|
964
964
|
// which git rejects and the whole spawn failed.
|
|
965
965
|
const branchSlug = sanitizeBranchComponent(options.scope);
|
|
966
|
-
const
|
|
966
|
+
const branchSuffix = options.worktreeBranchSuffix
|
|
967
|
+
? `-${sanitizeBranchComponent(options.worktreeBranchSuffix)}`
|
|
968
|
+
: '';
|
|
969
|
+
const worktreeBranch = `feat/${branchSlug}${branchSuffix}`;
|
|
967
970
|
try {
|
|
968
971
|
worktreePath = createWorktree(options.cwd, worktreeBranch, {
|
|
969
972
|
sessionId: options.sessionId,
|
|
@@ -183,10 +183,9 @@ function statusRank(s) {
|
|
|
183
183
|
}
|
|
184
184
|
}
|
|
185
185
|
/**
|
|
186
|
-
* Merge per-store badges into one workspace badge
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* un-indexed is the whole workspace `missing_index`.
|
|
186
|
+
* Merge per-store badges into one workspace badge. Missing child indexes are a
|
|
187
|
+
* PARTIAL workspace, never a fresh one: serving the indexed subset is useful,
|
|
188
|
+
* but the top-line signal must describe the coverage actually searched.
|
|
190
189
|
*/
|
|
191
190
|
function mergeBadges(perStore) {
|
|
192
191
|
const total = perStore.length;
|
|
@@ -202,14 +201,28 @@ function mergeBadges(perStore) {
|
|
|
202
201
|
if (statusRank(p.badge.status) > statusRank(worst))
|
|
203
202
|
worst = p.badge.status;
|
|
204
203
|
}
|
|
204
|
+
if (unindexed.length > 0)
|
|
205
|
+
worst = 'partial';
|
|
206
|
+
const statusCounts = {};
|
|
207
|
+
for (const p of perStore) {
|
|
208
|
+
const status = p.hasIndex ? p.badge.status : 'missing_index';
|
|
209
|
+
statusCounts[status] = (statusCounts[status] ?? 0) + 1;
|
|
210
|
+
}
|
|
211
|
+
const exceptionalProjects = perStore
|
|
212
|
+
.filter((p) => p.hasIndex && p.badge.status !== 'fresh')
|
|
213
|
+
.map((p) => ({ path: p.ref.relPath || '.', status: p.badge.status }));
|
|
205
214
|
const details = {
|
|
206
215
|
traversal: 'workspace',
|
|
207
216
|
projects_indexed: indexed.length,
|
|
208
217
|
projects_total: total,
|
|
209
|
-
|
|
218
|
+
project_status_counts: statusCounts,
|
|
210
219
|
};
|
|
211
|
-
if (unindexed.length)
|
|
220
|
+
if (unindexed.length) {
|
|
221
|
+
details.unindexed_project_count = unindexed.length;
|
|
212
222
|
details.unindexed_projects = unindexed;
|
|
223
|
+
}
|
|
224
|
+
if (exceptionalProjects.length)
|
|
225
|
+
details.non_fresh_projects = exceptionalProjects;
|
|
213
226
|
const prefixMerge = (key) => {
|
|
214
227
|
const out = [];
|
|
215
228
|
for (const p of indexed) {
|
|
@@ -259,7 +272,7 @@ export function aggregateFind(query, limit, resolved, currentHead) {
|
|
|
259
272
|
const r = findInStore(query, { cwd: ref.cwd }, checker, acc);
|
|
260
273
|
// Per-store badge: drive `partial` from THIS store's own budget-skips (review F2),
|
|
261
274
|
// NOT the shared checker.exhausted flag — else an early store spending the budget
|
|
262
|
-
// would mislabel every fully-fresh later store as `partial` in
|
|
275
|
+
// would mislabel every fully-fresh later store as `partial` in diagnostics. Then
|
|
263
276
|
// apply per-store HEAD drift against the one workspace HEAD (review F3) so a child
|
|
264
277
|
// whose index lags the working tree is flagged even under an otherwise-fresh root.
|
|
265
278
|
let badge = deriveBadge(r.base, acc, false, r.matches.length > 0, r.emptyCandidates);
|
|
@@ -19,8 +19,10 @@ import { exportSubgraph } from './export.js';
|
|
|
19
19
|
import { fileId } from './ids.js';
|
|
20
20
|
import { resolveTraversal, aggregateFind, aggregateBrief } from './aggregate.js';
|
|
21
21
|
import { defaultMemoryReader } from './memory-reader.js';
|
|
22
|
-
import {
|
|
22
|
+
import { inspectNestedProjects, refreshWorkspaceCascade } from './cascade.js';
|
|
23
|
+
import { latestCascadeRefreshJob, summarizeCascadeRefreshJob } from './cascade-jobs.js';
|
|
23
24
|
import { loadConfig } from '../config.js';
|
|
25
|
+
import { codeMapDir } from './paths.js';
|
|
24
26
|
/** spec §9 caps the brief reading list at 12 files. */
|
|
25
27
|
export const BRIEF_FILE_CAP = 12;
|
|
26
28
|
/**
|
|
@@ -111,17 +113,26 @@ function isMultiProjectWorkspace(cwd) {
|
|
|
111
113
|
/** Per-child store recap for `status(cascade)` in a multi-project workspace. */
|
|
112
114
|
function buildCascadeStatus(rootCwd) {
|
|
113
115
|
const root = rootCwd ?? process.cwd();
|
|
114
|
-
const
|
|
116
|
+
const discovery = inspectNestedProjects(root);
|
|
117
|
+
const children = discovery.projects.map((abs) => {
|
|
115
118
|
const m = readManifest(abs);
|
|
116
119
|
return {
|
|
117
120
|
path: path.relative(root, abs).replace(/\\/g, '/') || '.',
|
|
118
121
|
store_exists: m ? true : storeExists(abs),
|
|
119
122
|
freshness: m ? m.freshness.status : 'missing_index',
|
|
120
123
|
files_indexed: m ? m.stats.files_indexed : null,
|
|
124
|
+
...(m && m.stats.files_indexed === 0 ? { reason: 'no_eligible_files' } : {}),
|
|
121
125
|
};
|
|
122
126
|
});
|
|
123
127
|
const indexed = children.filter((c) => c.freshness !== 'missing_index').length;
|
|
124
|
-
|
|
128
|
+
const latestJob = latestCascadeRefreshJob(root);
|
|
129
|
+
return {
|
|
130
|
+
children,
|
|
131
|
+
indexed_children: indexed,
|
|
132
|
+
total_children: children.length,
|
|
133
|
+
discovery_truncated: discovery.truncated,
|
|
134
|
+
...(latestJob ? { refresh_job: summarizeCascadeRefreshJob(latestJob) } : {}),
|
|
135
|
+
};
|
|
125
136
|
}
|
|
126
137
|
/**
|
|
127
138
|
* P0 JSONL-backed query backend. Reads the durable file store (manifest +
|
|
@@ -140,10 +151,16 @@ export class JsonlBackend {
|
|
|
140
151
|
this.gitHeadReader = opts.gitHeadReader ?? readCurrentGitHead;
|
|
141
152
|
}
|
|
142
153
|
async status(input) {
|
|
154
|
+
const projectRoot = path.resolve(input.cwd ?? process.cwd());
|
|
155
|
+
const resolution = {
|
|
156
|
+
project_root: projectRoot,
|
|
157
|
+
store_path: codeMapDir(projectRoot, input.preferredDirName),
|
|
158
|
+
};
|
|
143
159
|
const manifest = readManifest(input.cwd, input.preferredDirName);
|
|
144
160
|
const result = manifest
|
|
145
161
|
? {
|
|
146
162
|
store_exists: true,
|
|
163
|
+
resolution,
|
|
147
164
|
freshness_badge: this.withHeadDrift(badge(manifest.freshness.status, {
|
|
148
165
|
stale_file_count: manifest.freshness.stale_file_count,
|
|
149
166
|
partial_reason: manifest.freshness.partial_reason,
|
|
@@ -156,6 +173,7 @@ export class JsonlBackend {
|
|
|
156
173
|
}
|
|
157
174
|
: {
|
|
158
175
|
store_exists: storeExists(input.cwd, input.preferredDirName),
|
|
176
|
+
resolution,
|
|
159
177
|
freshness_badge: badge('missing_index'),
|
|
160
178
|
stats: null,
|
|
161
179
|
};
|
|
@@ -190,17 +208,17 @@ export class JsonlBackend {
|
|
|
190
208
|
// A cascade is only fully "acquired" when EVERY project got its lock; if a
|
|
191
209
|
// child or the root was skipped under a live writer, surface that instead
|
|
192
210
|
// of reporting a clean lock_acquired=true over a partial cascade (codex review).
|
|
193
|
-
const
|
|
211
|
+
const incomplete = allProjects.filter((p) => p.outcome === 'locked' || p.outcome === 'failed');
|
|
194
212
|
return {
|
|
195
213
|
ran: allProjects.some((p) => p.ran),
|
|
196
214
|
scope,
|
|
197
|
-
lock_acquired:
|
|
215
|
+
lock_acquired: incomplete.length === 0,
|
|
198
216
|
freshness_badge: badge(root.freshness, {
|
|
199
217
|
files_parsed: root.files_parsed,
|
|
200
218
|
children_refreshed: cascade.children_refreshed,
|
|
201
219
|
}),
|
|
202
|
-
...(
|
|
203
|
-
? { lock_status: `${
|
|
220
|
+
...(incomplete.length > 0
|
|
221
|
+
? { lock_status: `${incomplete.length} project(s) incomplete: ${incomplete.map((p) => `${p.path} (${p.outcome})`).join(', ')}` }
|
|
204
222
|
: {}),
|
|
205
223
|
cascade,
|
|
206
224
|
};
|