brainclaw 1.28.0 → 1.28.2

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/cli/register-coordination.js +12 -0
  3. package/dist/commands/code-map.js +2 -0
  4. package/dist/commands/doctor.js +1 -0
  5. package/dist/commands/harvest.js +32 -43
  6. package/dist/commands/loop.js +12 -0
  7. package/dist/commands/loops-handlers.js +284 -17
  8. package/dist/commands/mcp-catalog.js +6 -3
  9. package/dist/commands/mcp-write-claims.js +55 -8
  10. package/dist/commands/mcp-write-coordination.js +413 -137
  11. package/dist/commands/mcp.js +32 -4
  12. package/dist/core/actions.js +17 -3
  13. package/dist/core/agentrun-reconciler.js +138 -4
  14. package/dist/core/claims.js +4 -1
  15. package/dist/core/code-map/backend.js +8 -0
  16. package/dist/core/execution-adapters.js +15 -7
  17. package/dist/core/hygiene-policy.js +2 -1
  18. package/dist/core/loop-turn-dispatch.js +18 -1
  19. package/dist/core/loops/attempt-authority.js +22 -4
  20. package/dist/core/loops/attempt-generations.js +17 -4
  21. package/dist/core/loops/attempt-reservation.js +14 -1
  22. package/dist/core/loops/attempt-takeover.js +173 -76
  23. package/dist/core/loops/continuation.js +337 -0
  24. package/dist/core/loops/facade-schema.js +15 -0
  25. package/dist/core/loops/index.js +1 -0
  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 +9 -0
  30. package/dist/core/loops/verbs.js +1 -1
  31. package/dist/core/reviewer-policy.js +39 -0
  32. package/dist/core/schema.js +16 -1
  33. package/dist/facts.js +8 -8
  34. package/dist/facts.json +7 -7
  35. package/docs/cli.md +4 -2
  36. package/docs/code-map.md +10 -0
  37. package/docs/concepts/loop-engine.md +30 -0
  38. package/docs/mcp-schema-changelog.md +6 -1
  39. package/package.json +1 -1
@@ -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 } },
@@ -1095,10 +1098,21 @@ async function _executeMcpToolCallInner(payload) {
1095
1098
  const be = new JsonlBackend();
1096
1099
  if (name === 'bclaw_code_status') {
1097
1100
  const status = await be.status({ cwd, cascade: args.cascade === true });
1101
+ const diskVersion = readDiskBrainclawVersion();
1098
1102
  return {
1099
1103
  response: toolResponse({
1100
1104
  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 },
1105
+ structuredContent: {
1106
+ ...status,
1107
+ freshness_badge: status.freshness_badge,
1108
+ mcp_resolution: {
1109
+ active_source: scopeInfo.active_source,
1110
+ resolved_project: scopeInfo.resolved_project,
1111
+ server_version: getInstalledBrainclawVersion(),
1112
+ disk_version: diskVersion,
1113
+ restart_required: diskVersion !== '0.0.0' && diskVersion !== getInstalledBrainclawVersion(),
1114
+ },
1115
+ },
1102
1116
  }),
1103
1117
  };
1104
1118
  }
@@ -1457,10 +1471,24 @@ async function _executeMcpToolCallInner(payload) {
1457
1471
  const full = candidateIds
1458
1472
  .map((id) => loadAssignment(id, targetCwd))
1459
1473
  .filter((a) => a !== undefined);
1460
- sweepAssignmentsAtReadPath(full, targetCwd, {
1474
+ const sweep = sweepAssignmentsAtReadPath(full, targetCwd, {
1461
1475
  actor: 'bclaw_work-readpath',
1462
1476
  policy,
1463
1477
  });
1478
+ // The context snapshot was built before the lazy sweep. Reflect
1479
+ // the same-pass mutations so bclaw_work never serves a fossil as
1480
+ // active once it has just expired/timed it out on disk.
1481
+ const parked = new Set([
1482
+ ...sweep.expired.map((item) => item.assignment_id),
1483
+ ...sweep.timed_out.map((item) => item.assignment_id),
1484
+ ]);
1485
+ const advanced = new Map(sweep.implicitly_advanced.map((item) => [item.assignment_id, item.to]));
1486
+ contextResult.open_work.active_assignments = openAssignments
1487
+ .filter((assignment) => !parked.has(assignment.id))
1488
+ .map((assignment) => {
1489
+ const status = advanced.get(assignment.id);
1490
+ return status ? { ...assignment, status } : assignment;
1491
+ });
1464
1492
  }
1465
1493
  const registry = loadServeRegistry(targetCwd);
1466
1494
  const aged = ageStaleWarnings(contextResult.stale_warnings ?? [], targetCwd, { policy, registry });
@@ -11,6 +11,7 @@ import { emitRegistryPostImage, registryFaultPoint } from './events/registry-pos
11
11
  import { createRuntimeEvent } from './events.js';
12
12
  import { loadAssignment, transitionAssignment } from './assignments.js';
13
13
  import { loadAgentRun, transitionAgentRun } from './agentruns.js';
14
+ import { denyContinuation } from './loops/continuation.js';
14
15
  function actionsDir(cwd, mode = 'read') {
15
16
  return resolveEntityDir('actions', cwd ?? process.cwd(), mode);
16
17
  }
@@ -90,7 +91,7 @@ function expireStaleActions(actions, cwd) {
90
91
  }
91
92
  catch { /* best-effort */ }
92
93
  try {
93
- const assignment = loadAssignment(action.assignment_id, cwd);
94
+ const assignment = action.assignment_id ? loadAssignment(action.assignment_id, cwd) : undefined;
94
95
  if (assignment && assignment.status === 'blocked') {
95
96
  transitionAssignment(assignment.id, 'failed', {
96
97
  actor: action.agent,
@@ -102,6 +103,12 @@ function expireStaleActions(actions, cwd) {
102
103
  }
103
104
  }
104
105
  catch { /* best-effort */ }
106
+ try {
107
+ if (action.target?.kind === 'continuation') {
108
+ denyContinuation(action.target.continuation_id, `approval ${action.id} expired`, action.agent, action.agent_id, cwd);
109
+ }
110
+ }
111
+ catch { /* best-effort */ }
105
112
  try {
106
113
  appendAuditEntry({
107
114
  actor: action.agent,
@@ -184,11 +191,15 @@ function saveActionRequired(action, cwd) {
184
191
  export function createActionRequired(options, cwd) {
185
192
  const generated = generateIdWithLabel('actions', cwd);
186
193
  const now = nowISO();
194
+ const target = options.target ?? (options.assignment_id
195
+ ? { kind: 'assignment', assignment_id: options.assignment_id }
196
+ : undefined);
187
197
  const action = ActionRequiredSchema.parse({
188
198
  schema_version: 1,
189
199
  id: generated.id,
190
200
  short_label: generated.short_label,
191
201
  assignment_id: options.assignment_id,
202
+ target,
192
203
  run_id: options.run_id,
193
204
  claim_id: options.claim_id,
194
205
  message_id: options.message_id,
@@ -216,7 +227,7 @@ export function createActionRequired(options, cwd) {
216
227
  action: 'create',
217
228
  item_id: action.id,
218
229
  item_type: 'state',
219
- after: { kind: action.kind, assignment_id: action.assignment_id, run_id: action.run_id },
230
+ after: { kind: action.kind, target: action.target, assignment_id: action.assignment_id, run_id: action.run_id },
220
231
  scope: action.scope,
221
232
  session_id: action.session_id,
222
233
  }, cwd);
@@ -268,6 +279,9 @@ export function resolveActionRequired(id, options, cwd) {
268
279
  responded_at: now,
269
280
  };
270
281
  saveActionRequired(action, cwd);
282
+ if (action.target?.kind === 'continuation' && options.outcome !== 'resolved') {
283
+ denyContinuation(action.target.continuation_id, `approval ${action.id} ${options.outcome}`, options.responded_by, options.responded_by_id, cwd);
284
+ }
271
285
  appendAuditEntry({
272
286
  actor: options.responded_by,
273
287
  actor_id: options.responded_by_id,
@@ -301,7 +315,7 @@ export function resolveActionRequired(id, options, cwd) {
301
315
  }
302
316
  }
303
317
  }
304
- const assignment = loadAssignment(action.assignment_id, cwd);
318
+ const assignment = action.assignment_id ? loadAssignment(action.assignment_id, cwd) : undefined;
305
319
  if (assignment) {
306
320
  if (options.outcome === 'resolved' && assignment.status === 'blocked') {
307
321
  transitionAssignment(assignment.id, 'started', {
@@ -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,
@@ -21,6 +21,7 @@ import { resolveTraversal, aggregateFind, aggregateBrief } from './aggregate.js'
21
21
  import { defaultMemoryReader } from './memory-reader.js';
22
22
  import { listNestedProjects, refreshWorkspaceCascade } from './cascade.js';
23
23
  import { loadConfig } from '../config.js';
24
+ import { codeMapDir } from './paths.js';
24
25
  /** spec §9 caps the brief reading list at 12 files. */
25
26
  export const BRIEF_FILE_CAP = 12;
26
27
  /**
@@ -140,10 +141,16 @@ export class JsonlBackend {
140
141
  this.gitHeadReader = opts.gitHeadReader ?? readCurrentGitHead;
141
142
  }
142
143
  async status(input) {
144
+ const projectRoot = path.resolve(input.cwd ?? process.cwd());
145
+ const resolution = {
146
+ project_root: projectRoot,
147
+ store_path: codeMapDir(projectRoot, input.preferredDirName),
148
+ };
143
149
  const manifest = readManifest(input.cwd, input.preferredDirName);
144
150
  const result = manifest
145
151
  ? {
146
152
  store_exists: true,
153
+ resolution,
147
154
  freshness_badge: this.withHeadDrift(badge(manifest.freshness.status, {
148
155
  stale_file_count: manifest.freshness.stale_file_count,
149
156
  partial_reason: manifest.freshness.partial_reason,
@@ -156,6 +163,7 @@ export class JsonlBackend {
156
163
  }
157
164
  : {
158
165
  store_exists: storeExists(input.cwd, input.preferredDirName),
166
+ resolution,
159
167
  freshness_badge: badge('missing_index'),
160
168
  stats: null,
161
169
  };
@@ -197,13 +197,21 @@ export function withCodexWorkspaceRoot(invoke, agent, worktreePath, isWin32 = pr
197
197
  args.splice(insertAt, 0, '--cd', worktreePath);
198
198
  const quote = (value) => isWin32
199
199
  ? `"${value.replace(/"/g, '""')}"`
200
- : `'${value.replace(/'/g, `'\\''`)}'`;
201
- const flags = `--cd ${quote(worktreePath)}`;
202
- const prefix = invoke.executable;
203
- const suffix = invoke.bashCommand.startsWith(`${prefix} `)
204
- ? invoke.bashCommand.slice(prefix.length + 1)
205
- : invoke.bashCommand;
206
- return { ...invoke, args, bashCommand: `${prefix} ${flags} ${suffix}` };
200
+ : `"${value.replace(/\\/g, '\\\\').replace(/\$/g, '\\$').replace(/`/g, '\\`').replace(/"/g, '\\"')}"`;
201
+ // Re-render from structured argv. bashCommand also contains the model-authored
202
+ // prompt, so searching it for "| codex" can mistake prompt data for the real
203
+ // executable boundary and splice --cd inside quoted content.
204
+ const command = [invoke.executable, ...args.map(quote)].join(' ');
205
+ const prompt = invoke.promptText ?? '';
206
+ const escapedPrompt = prompt.replace(/'/g, "'\\''");
207
+ let bashCommand = command;
208
+ if (!isWin32 && invoke.promptDelivery === 'stdin_pipe') {
209
+ bashCommand = `printf '%s' '${escapedPrompt}' | ${command}`;
210
+ }
211
+ else if (!isWin32 && invoke.promptDelivery === 'temp_file' && invoke.tempFilePath) {
212
+ bashCommand = `printf '%s' '${escapedPrompt}' > ${quote(invoke.tempFilePath)} && ${command}`;
213
+ }
214
+ return { ...invoke, args, bashCommand };
207
215
  }
208
216
  export class CliExecutionAdapter {
209
217
  id = 'cli';
@@ -25,7 +25,8 @@ import { logger } from './logger.js';
25
25
  const DAY_MS = 24 * 60 * 60 * 1000;
26
26
  export const DEFAULT_HYGIENE_POLICY = {
27
27
  disabled: false,
28
- assignment_offered_ttl_ms: 3 * DAY_MS,
28
+ // A never-accepted dispatch should not survive into the next day's dogfood.
29
+ assignment_offered_ttl_ms: 1 * DAY_MS,
29
30
  assignment_accepted_ttl_ms: 1 * DAY_MS,
30
31
  assignment_started_ttl_ms: 1 * DAY_MS,
31
32
  handoff_closed_ttl_ms: 30 * DAY_MS,
@@ -10,7 +10,7 @@ import { resolveModel } from './agent-capability.js';
10
10
  import { listAgentIdentities } from './agent-registry.js';
11
11
  import { transitionAgentRun } from './agentruns.js';
12
12
  import { loadAssignment, patchAssignmentMessageId, transitionAssignment } from './assignments.js';
13
- import { attachAssignmentMessageToClaim, createCoordinatorClaim, ensureClaimAssignmentBinding, } from './claims.js';
13
+ import { attachAssignmentMessageToClaim, createCoordinatorClaim, ensureClaimAssignmentBinding, releaseClaimIfActive, } from './claims.js';
14
14
  import { generateDispatchBrief } from './dispatcher.js';
15
15
  import { search } from './search.js';
16
16
  import { attemptExecution } from './execution.js';
@@ -21,6 +21,7 @@ import { buildIdeationBrief } from './loops/brief-assembly.js';
21
21
  import { getLoop } from './loops/store.js';
22
22
  import { prepareTurnExecution } from './loops/turn-execution.js';
23
23
  import { sendMessage } from './messaging.js';
24
+ import { removeWorktree } from './worktree.js';
24
25
  export async function dispatchLoopTurn(input) {
25
26
  const loop = getLoop(input.loop_id, input.cwd);
26
27
  if (!loop)
@@ -131,6 +132,22 @@ export async function dispatchLoopTurn(input) {
131
132
  if (prepared.kind !== 'won') {
132
133
  result.execution_status = 'inbox_only';
133
134
  result.error = prepared.reason;
135
+ if (prepared.claim_disposition === 'release' && !claim.reusedExisting) {
136
+ try {
137
+ const released = releaseClaimIfActive(claim.claimId, input.cwd);
138
+ if (released.released && claim.worktreePath) {
139
+ try {
140
+ removeWorktree(input.cwd, claim.worktreePath, { force: true });
141
+ }
142
+ catch (cleanupError) {
143
+ result.error += `; denied-claim worktree cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`;
144
+ }
145
+ }
146
+ }
147
+ catch (cleanupError) {
148
+ result.error += `; denied-claim cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`;
149
+ }
150
+ }
134
151
  return result;
135
152
  }
136
153
  result.assignment_id = prepared.assignment_id;
@@ -219,6 +219,7 @@ export function executionContractForGeneration(reservation, generation) {
219
219
  }
220
220
  return { contract, ref };
221
221
  }
222
+ const snapshot = generation.executor?.capability_snapshot ?? reservation.capability_snapshot;
222
223
  const contract = ExecutionContractSchema.parse({
223
224
  ...reservation.execution_contract,
224
225
  identity: {
@@ -234,7 +235,7 @@ export function executionContractForGeneration(reservation, generation) {
234
235
  isolation: 'worktree',
235
236
  },
236
237
  });
237
- const ref = executionContractRef(contract, reservation.capability_snapshot);
238
+ const ref = executionContractRef(contract, snapshot);
238
239
  if (ref.hash !== generation.contract_hash) {
239
240
  throw new AttemptGenerationError('fenced', `generation ${generation.attempt_epoch} contract hash ${generation.contract_hash} does not match derived ${ref.hash}`);
240
241
  }
@@ -269,6 +270,12 @@ export function bootstrapAttemptAuthorityV2(input) {
269
270
  workspace_path: workspacePath,
270
271
  workspace_digest: attemptWorkspaceDigest(workspacePath, reservation.turn_id, 0),
271
272
  launch_nonce: grant.token,
273
+ executor: {
274
+ agent: reservation.agent,
275
+ agent_id: reservation.agent_id,
276
+ claim_id: reservation.claim_id,
277
+ capability_snapshot: reservation.capability_snapshot,
278
+ },
272
279
  });
273
280
  if (initial.assignment_id !== reservation.child_ids.assignment_id
274
281
  || initial.run_id !== reservation.child_ids.run_id
@@ -331,7 +338,8 @@ export function prepareAttemptTakeoverV2(input) {
331
338
  && close.decision === expectedMode
332
339
  && close.cause === requestedCause
333
340
  && canonicalWorkspacePath(close.next_generation.workspace_path) === canonicalWorkspacePath(input.next_workspace_path)
334
- && JSON.stringify(close.next_generation.authority_home) === JSON.stringify(input.authority_home)) {
341
+ && JSON.stringify(close.next_generation.authority_home) === JSON.stringify(input.authority_home)
342
+ && (!input.next_executor || JSON.stringify(close.next_generation.executor) === JSON.stringify(input.next_executor))) {
335
343
  const generationContract = executionContractForGeneration(reservation, close.next_generation);
336
344
  return {
337
345
  won: false,
@@ -376,9 +384,10 @@ export function prepareAttemptTakeoverV2(input) {
376
384
  contract_hash: '0'.repeat(64),
377
385
  workspace_path: nextWorkspacePath,
378
386
  workspace_digest: attemptWorkspaceDigest(nextWorkspacePath, input.turn_id, nextEpoch),
387
+ executor: input.next_executor,
379
388
  });
380
389
  const baseContract = reservation.execution_contract;
381
- const snapshot = reservation.capability_snapshot;
390
+ const snapshot = input.next_executor?.capability_snapshot ?? reservation.capability_snapshot;
382
391
  if (!baseContract || !snapshot) {
383
392
  throw new AttemptGenerationError('invalid_transition', `turn ${input.turn_id} lacks a contracted capability snapshot`);
384
393
  }
@@ -402,6 +411,7 @@ export function prepareAttemptTakeoverV2(input) {
402
411
  contract_hash: nextRef.hash,
403
412
  workspace_path: nextWorkspacePath,
404
413
  workspace_digest: attemptWorkspaceDigest(nextWorkspacePath, input.turn_id, nextEpoch),
414
+ executor: input.next_executor,
405
415
  launch_nonce: provisional.launch_nonce,
406
416
  created_at: provisional.created_at,
407
417
  });
@@ -419,7 +429,15 @@ export function prepareAttemptTakeoverV2(input) {
419
429
  if (generationDigest(incumbent.next_generation) !== generationDigest(next)) {
420
430
  throw new AttemptGenerationError('fenced', `a different successor already won generation ${current.attempt_epoch}`);
421
431
  }
422
- rebuildAttemptGenerationHead(input.cwd, current);
432
+ // The close cell above is the immutable authority boundary. `head.json` is
433
+ // only a rebuildable read cache, so a projection failure after a winning
434
+ // close must never escape as a pre-commit takeover failure: callers could
435
+ // otherwise roll back claims while the successor generation already owns
436
+ // the turn. Readers repair the cache from immutable cells on replay.
437
+ try {
438
+ rebuildAttemptGenerationHead(input.cwd, current);
439
+ }
440
+ catch { /* immutable close(epoch) remains authoritative */ }
423
441
  return {
424
442
  won: published.won,
425
443
  rollout,
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { z } from 'zod';
5
+ import { CapabilitySnapshotSchema } from '../execution-contract.js';
5
6
  import { memoryDir, writeFileAtomic } from '../io.js';
6
7
  import { nowISO } from '../ids.js';
7
8
  /**
@@ -31,9 +32,17 @@ export const GenerationFenceSchema = z.object({
31
32
  workspace_digest: z.string().min(1),
32
33
  authority_home: AuthorityHomeSchema,
33
34
  }).strict();
35
+ export const GenerationExecutorSchema = z.object({
36
+ agent: z.string().min(1),
37
+ agent_id: z.string().min(1).optional(),
38
+ claim_id: z.string().min(1),
39
+ capability_snapshot: CapabilitySnapshotSchema,
40
+ }).strict();
34
41
  export const AttemptGenerationSchema = GenerationFenceSchema.extend({
35
42
  schema_version: z.literal(ATTEMPT_AUTHORITY_VERSION),
36
43
  generation_kind: z.literal('attempt_generation'),
44
+ /** Executor ownership is generation-scoped so a reroute never inherits stale provenance. */
45
+ executor: GenerationExecutorSchema.optional(),
37
46
  created_at: z.string().min(1),
38
47
  }).strict();
39
48
  export const LaunchDecisionCellSchema = z.object({
@@ -145,8 +154,9 @@ function stableId(prefix, material) {
145
154
  return `${prefix}_${digest}`;
146
155
  }
147
156
  /** Compatible with the existing logical Assignment derivation. */
148
- export function deriveGenerationAssignmentId(turnId) {
149
- return stableId('asgn', `${turnId}:assignment`);
157
+ export function deriveGenerationAssignmentId(turnId, attemptEpoch = 0) {
158
+ const salt = attemptEpoch === 0 ? 'assignment' : `assignment:${attemptEpoch}`;
159
+ return stableId('asgn', `${turnId}:${salt}`);
150
160
  }
151
161
  /** Epoch zero preserves the existing run id; every later epoch is fresh. */
152
162
  export function deriveGenerationRunId(turnId, attemptEpoch) {
@@ -161,7 +171,7 @@ export function prepareInitialGeneration(input) {
161
171
  schema_version: ATTEMPT_AUTHORITY_VERSION,
162
172
  generation_kind: 'attempt_generation',
163
173
  turn_id: input.turn_id,
164
- assignment_id: deriveGenerationAssignmentId(input.turn_id),
174
+ assignment_id: deriveGenerationAssignmentId(input.turn_id, 0),
165
175
  attempt_epoch: 0,
166
176
  run_id: deriveGenerationRunId(input.turn_id, 0),
167
177
  launch_nonce: input.launch_nonce ?? crypto.randomUUID(),
@@ -170,6 +180,7 @@ export function prepareInitialGeneration(input) {
170
180
  workspace_path: input.workspace_path,
171
181
  workspace_digest: input.workspace_digest,
172
182
  authority_home: AuthorityHomeSchema.parse(input.authority_home),
183
+ executor: input.executor,
173
184
  created_at: input.created_at ?? nowISO(),
174
185
  });
175
186
  }
@@ -178,6 +189,7 @@ export function prepareNextGeneration(current, input) {
178
189
  const attemptEpoch = parsed.attempt_epoch + 1;
179
190
  return AttemptGenerationSchema.parse({
180
191
  ...parsed,
192
+ assignment_id: deriveGenerationAssignmentId(parsed.turn_id, attemptEpoch),
181
193
  attempt_epoch: attemptEpoch,
182
194
  run_id: deriveGenerationRunId(parsed.turn_id, attemptEpoch),
183
195
  launch_nonce: input.launch_nonce ?? crypto.randomUUID(),
@@ -185,6 +197,7 @@ export function prepareNextGeneration(current, input) {
185
197
  workspace_id: deriveGenerationWorkspaceId(parsed.turn_id, attemptEpoch),
186
198
  workspace_path: input.workspace_path,
187
199
  workspace_digest: input.workspace_digest,
200
+ executor: input.executor ?? parsed.executor,
188
201
  created_at: input.created_at ?? nowISO(),
189
202
  });
190
203
  }
@@ -231,7 +244,7 @@ function assertValidSuccessor(current, next) {
231
244
  }
232
245
  const invalid = [
233
246
  current.turn_id !== next.turn_id && 'turn_id',
234
- current.assignment_id !== next.assignment_id && 'assignment_id',
247
+ next.assignment_id === current.assignment_id && 'assignment_id',
235
248
  next.attempt_epoch !== current.attempt_epoch + 1 && 'attempt_epoch',
236
249
  next.run_id === current.run_id && 'run_id',
237
250
  next.launch_nonce === current.launch_nonce && 'launch_nonce',