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.
Files changed (39) hide show
  1. package/dist/brainclaw-vscode.vsix +0 -0
  2. package/dist/commands/code-map.js +2 -0
  3. package/dist/commands/doctor.js +1 -0
  4. package/dist/commands/harvest.js +32 -43
  5. package/dist/commands/loops-handlers.js +66 -3
  6. package/dist/commands/mcp-catalog.js +2 -2
  7. package/dist/commands/mcp-write-coordination.js +426 -141
  8. package/dist/commands/mcp-write-entities.js +5 -2
  9. package/dist/commands/mcp.js +57 -11
  10. package/dist/core/agentrun-reconciler.js +138 -4
  11. package/dist/core/claims.js +4 -1
  12. package/dist/core/code-map/aggregate.js +20 -7
  13. package/dist/core/code-map/backend.js +25 -7
  14. package/dist/core/code-map/cascade-jobs.js +174 -0
  15. package/dist/core/code-map/cascade-worker.js +15 -0
  16. package/dist/core/code-map/cascade.js +63 -26
  17. package/dist/core/code-map/query.js +6 -3
  18. package/dist/core/entity-operations.js +18 -4
  19. package/dist/core/execution-adapters.js +23 -8
  20. package/dist/core/hygiene-policy.js +2 -1
  21. package/dist/core/loop-turn-dispatch.js +18 -1
  22. package/dist/core/loops/attempt-authority.js +22 -4
  23. package/dist/core/loops/attempt-generations.js +17 -4
  24. package/dist/core/loops/attempt-reservation.js +14 -1
  25. package/dist/core/loops/attempt-takeover.js +173 -76
  26. package/dist/core/loops/reconcile-turn.js +224 -26
  27. package/dist/core/loops/result-reducers.js +8 -8
  28. package/dist/core/loops/turn-execution.js +38 -19
  29. package/dist/core/loops/types.js +3 -0
  30. package/dist/core/loops/verbs.js +1 -1
  31. package/dist/core/spawn-check.js +9 -1
  32. package/dist/facts.js +8 -8
  33. package/dist/facts.json +7 -7
  34. package/docs/cli.md +2 -2
  35. package/docs/code-map.md +30 -9
  36. package/docs/concepts/loop-engine.md +5 -0
  37. package/docs/integrations/mcp.md +2 -2
  38. package/docs/mcp-schema-changelog.md +30 -0
  39. 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.
@@ -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 { SCHEMA_VERSION, MCP_PROTOCOL_VERSIONS, MCP_SERVER_NOT_INITIALIZED, toolResponse, createToolErrorResponse, } from './mcp-contract.js';
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: { name: 'brainclaw', version: SCHEMA_VERSION },
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: { ...status, freshness_badge: status.freshness_badge },
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
- const result = await be.refresh({ scope, cwd, cascade: args.cascade === true });
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)}`,
@@ -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 worktreeBranch = `feat/${branchSlug}`;
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: worst status among the INDEXED
187
- * stores (a missing-index child contributes to coverage, never drags the top-line),
188
- * plus coverage + workspace-relative detail path-sets. Only when EVERY store is
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
- per_project: Object.fromEntries(perStore.map((p) => [p.ref.relPath || '.', p.badge.status])),
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 per_project. Then
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 { listNestedProjects, refreshWorkspaceCascade } from './cascade.js';
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 children = listNestedProjects(root).map((abs) => {
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
- return { children, indexed_children: indexed, total_children: children.length };
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 skipped = allProjects.filter((p) => !p.lock_acquired);
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: skipped.length === 0,
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
- ...(skipped.length > 0
203
- ? { lock_status: `${skipped.length} project(s) skipped (lock held): ${skipped.map((p) => p.path).join(', ')}` }
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
  };