brainclaw 1.17.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +5 -5
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/commands/code-map.js +4 -1
  4. package/dist/commands/codev.js +61 -30
  5. package/dist/commands/doctor.js +14 -1
  6. package/dist/commands/harvest.js +223 -43
  7. package/dist/commands/inbox.js +10 -4
  8. package/dist/commands/install-hooks.js +184 -27
  9. package/dist/commands/loop.js +2 -2
  10. package/dist/commands/loops-handlers.js +82 -1
  11. package/dist/commands/mcp-catalog.js +12 -4
  12. package/dist/commands/mcp-read-handlers.js +90 -7
  13. package/dist/commands/mcp-schemas.generated.js +3 -0
  14. package/dist/commands/mcp-write-claims.js +57 -0
  15. package/dist/commands/mcp-write-coordination.js +216 -57
  16. package/dist/commands/mcp-write-entities.js +11 -0
  17. package/dist/commands/mcp.js +29 -2
  18. package/dist/commands/session-end.js +15 -0
  19. package/dist/commands/session-start.js +19 -0
  20. package/dist/core/agentrun-reconciler.js +171 -7
  21. package/dist/core/agentruns.js +6 -1
  22. package/dist/core/claim-conformity.js +193 -0
  23. package/dist/core/claim-scope.js +155 -0
  24. package/dist/core/claims.js +127 -2
  25. package/dist/core/code-map/aggregate.js +473 -0
  26. package/dist/core/code-map/backend.js +36 -10
  27. package/dist/core/code-map/freshness.js +36 -1
  28. package/dist/core/code-map/lang/c/imports.scm +12 -0
  29. package/dist/core/code-map/lang/c/index.js +150 -0
  30. package/dist/core/code-map/lang/c/tags.scm +68 -0
  31. package/dist/core/code-map/lang/cpp/imports.scm +14 -0
  32. package/dist/core/code-map/lang/cpp/index.js +149 -0
  33. package/dist/core/code-map/lang/cpp/tags.scm +87 -0
  34. package/dist/core/code-map/lang/csharp/imports.scm +20 -0
  35. package/dist/core/code-map/lang/csharp/index.js +224 -0
  36. package/dist/core/code-map/lang/csharp/tags.scm +63 -0
  37. package/dist/core/code-map/lang/go/imports.scm +13 -0
  38. package/dist/core/code-map/lang/go/index.js +139 -0
  39. package/dist/core/code-map/lang/go/tags.scm +36 -0
  40. package/dist/core/code-map/lang/providers.js +12 -1
  41. package/dist/core/code-map/lang/ruby/imports.scm +24 -0
  42. package/dist/core/code-map/lang/ruby/index.js +198 -0
  43. package/dist/core/code-map/lang/ruby/tags.scm +49 -0
  44. package/dist/core/code-map/lang/rust/imports.scm +44 -0
  45. package/dist/core/code-map/lang/rust/index.js +136 -0
  46. package/dist/core/code-map/lang/rust/tags.scm +47 -0
  47. package/dist/core/code-map/query.js +229 -80
  48. package/dist/core/code-map/types.js +18 -0
  49. package/dist/core/code-map/work-section.js +8 -7
  50. package/dist/core/codev-responses.js +16 -0
  51. package/dist/core/dispatcher.js +176 -22
  52. package/dist/core/execution-adapters.js +29 -3
  53. package/dist/core/facade-schema.js +32 -0
  54. package/dist/core/guidance-telemetry.js +197 -0
  55. package/dist/core/ideation-loop-close.js +152 -0
  56. package/dist/core/instruction-templates.js +11 -3
  57. package/dist/core/loops/artifact-resolver.js +197 -0
  58. package/dist/core/loops/attempt-reservation.js +576 -0
  59. package/dist/core/loops/commit-intent.js +494 -0
  60. package/dist/core/loops/facade-schema.js +48 -0
  61. package/dist/core/loops/impl-bind.js +144 -0
  62. package/dist/core/loops/index.js +1 -1
  63. package/dist/core/loops/iteration-engine.js +29 -0
  64. package/dist/core/loops/lock.js +14 -0
  65. package/dist/core/loops/project-resolution.js +157 -0
  66. package/dist/core/loops/reconcile-turn.js +369 -0
  67. package/dist/core/loops/result-reducers.js +88 -0
  68. package/dist/core/loops/store.js +46 -7
  69. package/dist/core/loops/types.js +139 -11
  70. package/dist/core/loops/verbs.js +49 -4
  71. package/dist/core/loops/verify-command.js +209 -0
  72. package/dist/core/messaging.js +58 -5
  73. package/dist/core/next-actions.js +157 -0
  74. package/dist/core/review-loop-close.js +27 -6
  75. package/dist/core/review-loop-turn-dispatch.js +290 -28
  76. package/dist/core/runtime-signals.js +68 -0
  77. package/dist/core/schema.js +64 -0
  78. package/dist/core/surface-freshness.js +150 -0
  79. package/dist/core/warnings.js +98 -0
  80. package/dist/core/worktree.js +24 -0
  81. package/dist/facts.js +9 -9
  82. package/dist/facts.json +8 -8
  83. package/dist/wasm/tree-sitter-c.wasm +0 -0
  84. package/dist/wasm/tree-sitter-c_sharp.wasm +0 -0
  85. package/dist/wasm/tree-sitter-cpp.wasm +0 -0
  86. package/dist/wasm/tree-sitter-go.wasm +0 -0
  87. package/dist/wasm/tree-sitter-ruby.wasm +0 -0
  88. package/dist/wasm/tree-sitter-rust.wasm +0 -0
  89. package/docs/cli.md +1 -1
  90. package/docs/code-map.md +22 -6
  91. package/docs/concepts/loop-engine.md +24 -0
  92. package/docs/concepts/observer-protocol.md +22 -0
  93. package/docs/concepts/plans-and-claims.md +57 -0
  94. package/docs/integrations/claude-code.md +53 -0
  95. package/docs/integrations/mcp.md +45 -0
  96. package/docs/mcp-schema-changelog.md +118 -2
  97. package/package.json +1 -1
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Verifying a spawned worker: always `bclaw_dispatch_status`, never
3
+ * `bclaw_find(agent_run)` + a pid check. On Windows an ack-wrapped spawn runs
4
+ * under cmd.exe, so `agent_run.pid` is the wrapper (which exits by design) and
5
+ * reads dead while the worker is alive (trp_7fc3e3c4). `dispatch_status`
6
+ * returns a sentinel-based verdict instead.
7
+ */
8
+ export function verifyDispatchAction(targetId, note) {
9
+ return {
10
+ tool: 'bclaw_dispatch_status',
11
+ args: { target_id: targetId },
12
+ when: note
13
+ ? `${note} — sentinel-based liveness verdict (do NOT judge from agent_run.pid)`
14
+ : 'verify the spawned worker is actually alive — sentinel-based verdict (do NOT judge from agent_run.pid)',
15
+ };
16
+ }
17
+ /** Cap on repeated per-target actions, so a wide fan-out cannot flood the field. */
18
+ const FANOUT_CAP = 3;
19
+ function verifyActions(targetIds, note) {
20
+ const shown = targetIds.slice(0, FANOUT_CAP);
21
+ const actions = shown.map((id) => verifyDispatchAction(id, note));
22
+ if (targetIds.length > shown.length) {
23
+ // Say what was dropped rather than silently truncating.
24
+ actions.push({
25
+ tool: 'bclaw_dispatch_status',
26
+ args: { target_id: '<one of the remaining targets>' },
27
+ when: `${targetIds.length - shown.length} further target(s) were dispatched — verify each one the same way`,
28
+ });
29
+ }
30
+ return actions;
31
+ }
32
+ /**
33
+ * After a release, the follow-up depends entirely on what the cascade decided:
34
+ * a blocked plan transition needs the other claim holders inspected, a
35
+ * completed plan is ready for review, and a plain release has no next step at
36
+ * all.
37
+ */
38
+ export function releaseClaimNextActions(outcome) {
39
+ const actions = [];
40
+ if (outcome.planWarning && outcome.planId) {
41
+ // The cascade refused: other claims still hold the plan. Both the diagnosis
42
+ // and the eventual manual transition are real MCP calls.
43
+ actions.push({
44
+ tool: 'bclaw_find',
45
+ args: { entity: 'claim', filter: { plan_id: outcome.planId, status: 'active' } },
46
+ when: 'the plan was NOT transitioned because other claims are still active — see who else holds it',
47
+ });
48
+ actions.push({
49
+ tool: 'bclaw_transition',
50
+ args: { entity: 'plan', id: outcome.planId, to: outcome.requestedPlanStatus ?? 'done' },
51
+ when: 'once the other claims are released, transition the plan yourself',
52
+ });
53
+ return actions;
54
+ }
55
+ if (outcome.planTransitioned && outcome.planId) {
56
+ // Documented workflow: implement → release → review.
57
+ actions.push({
58
+ tool: 'bclaw_coordinate',
59
+ args: {
60
+ intent: 'review',
61
+ task: `Review the work delivered under plan ${outcome.planId}`,
62
+ open_loop: true,
63
+ },
64
+ when: 'the plan is done — the next workflow stage is review',
65
+ });
66
+ }
67
+ return actions;
68
+ }
69
+ /**
70
+ * Only two transitions imply an unambiguous next call. Everything else
71
+ * (candidate accepted, plan done, trap retired, …) is terminal for the caller,
72
+ * so it returns nothing rather than inventing busywork.
73
+ */
74
+ export function transitionNextActions(outcome) {
75
+ if (outcome.entity === 'plan' && outcome.to === 'in_progress') {
76
+ return [{
77
+ tool: 'bclaw_work',
78
+ args: { intent: 'execute', planId: outcome.id, scope: '<scope you are about to edit>' },
79
+ when: 'the plan is in progress — claim the scope before editing',
80
+ }];
81
+ }
82
+ if (outcome.entity === 'plan' && outcome.to === 'blocked') {
83
+ return [{
84
+ tool: 'bclaw_quick_capture',
85
+ args: { text: '<what blocks this plan>', type: 'trap' },
86
+ when: 'record WHY it is blocked so the next agent does not rediscover it',
87
+ }];
88
+ }
89
+ return [];
90
+ }
91
+ /**
92
+ * Coordinate's follow-up is driven by whether anything actually spawned, not by
93
+ * the intent alone: the same `intent='assign'` needs verification when it
94
+ * spawned and nothing MCP-callable when it produced manual commands.
95
+ */
96
+ export function coordinateNextActions(outcome) {
97
+ const actions = [];
98
+ const spawned = outcome.executionStatus === 'delivered_and_started';
99
+ if (spawned && outcome.assignmentIds.length > 0) {
100
+ actions.push(...verifyActions(outcome.assignmentIds));
101
+ }
102
+ if (outcome.loopId) {
103
+ actions.push({
104
+ tool: 'bclaw_loop',
105
+ args: { intent: 'get', loop_id: outcome.loopId },
106
+ when: spawned
107
+ ? 'inspect loop state — its `next_expected` names the turn the loop is waiting on'
108
+ : 'the loop is open but nothing spawned — inspect it and drive the turn yourself',
109
+ });
110
+ }
111
+ // Manual-handoff spawning intents: the launch commands are in the text body
112
+ // (not MCP-callable), so the only real MCP follow-up is verification AFTER
113
+ // the operator runs them.
114
+ if (!spawned && outcome.executionStatus === 'command_ready_manual' && outcome.assignmentIds.length > 0) {
115
+ actions.push(verifyActions(outcome.assignmentIds, 'once you have run the launch command(s) printed above')[0]);
116
+ }
117
+ return actions;
118
+ }
119
+ export function dispatchNextActions(outcome) {
120
+ if (outcome.dryRun) {
121
+ return [{
122
+ tool: 'bclaw_dispatch',
123
+ args: { intent: 'execute' },
124
+ when: 'this was a dry run — nothing was dispatched; re-run without dryRun to actually spawn',
125
+ }];
126
+ }
127
+ const actions = [];
128
+ if (outcome.spawnedTargets.length > 0) {
129
+ actions.push(...verifyActions(outcome.spawnedTargets));
130
+ }
131
+ if (outcome.blockedCount > 0) {
132
+ actions.push({
133
+ tool: 'bclaw_dispatch',
134
+ args: { intent: 'analysis' },
135
+ when: `${outcome.blockedCount} lane(s) are blocked — analysis explains which gate holds each one`,
136
+ });
137
+ }
138
+ return actions;
139
+ }
140
+ export function createEntityNextActions(outcome) {
141
+ if (outcome.entity === 'plan') {
142
+ return [{
143
+ tool: 'bclaw_add_step',
144
+ args: { planId: outcome.id, data: { text: '<first unit of work>' } },
145
+ when: 'break the plan into steps so progress is trackable',
146
+ }];
147
+ }
148
+ if (outcome.entity === 'sequence') {
149
+ return [{
150
+ tool: 'bclaw_dispatch',
151
+ args: { intent: 'analysis' },
152
+ when: 'inspect lane readiness before dispatching the sequence',
153
+ }];
154
+ }
155
+ return [];
156
+ }
157
+ //# sourceMappingURL=next-actions.js.map
@@ -1,11 +1,24 @@
1
1
  import { getLoop } from './loops/store.js';
2
2
  import { complete_turn, advance } from './loops/verbs.js';
3
3
  import { withLoopLock } from './loops/lock.js';
4
+ import { LOOP_ARTIFACT_BODY_MAX_BYTES } from './loops/types.js';
4
5
  /** review-loop:lop_xxx → the loop id (mirrors assignment-reconciler.ts). */
5
6
  const REVIEW_LOOP_SCOPE_RE = /^review-loop:(lop_[0-9a-z]+)/;
6
7
  const LOOP_TERMINAL = new Set(['completed', 'cancelled', 'blocked']);
7
- /** Build the fix+re-review brief for a request_changes cycle turn (symmetric). */
8
- function buildFixCycleTask(summary, iteration) {
8
+ /** Keep the loop-facing verdict valid while the full worker body stays durable in harvest metadata. */
9
+ function capVerdictBody(prefix, detail) {
10
+ const full = `${prefix}${detail ? `: ${detail}` : ''}`;
11
+ if (Buffer.byteLength(full, 'utf8') <= LOOP_ARTIFACT_BODY_MAX_BYTES)
12
+ return full;
13
+ const marker = '…[truncated; full body retained in lane harvest event]';
14
+ const room = LOOP_ARTIFACT_BODY_MAX_BYTES - Buffer.byteLength(prefix, 'utf8') - Buffer.byteLength(': ', 'utf8') - Buffer.byteLength(marker, 'utf8');
15
+ return `${prefix}: ${Buffer.from(detail, 'utf8').subarray(0, Math.max(0, room)).toString('utf8').replace(/�+$/, '')}${marker}`;
16
+ }
17
+ /** Build the fix+re-review brief for a request_changes cycle turn (symmetric).
18
+ * Exported so the turn-owned reconcile path (pln#630 PR3b) reuses the identical
19
+ * wording — the reviewer contract must not drift between the legacy and turn-owned
20
+ * fix cycles. */
21
+ export function buildFixCycleTask(summary, iteration) {
9
22
  return (`The reviewer requested changes (fix cycle round ${iteration}). `
10
23
  + 'Apply the requested changes DIRECTLY in this worktree (it is the same '
11
24
  + 'checkout, kept across turns so your commits accumulate), then RE-REVIEW '
@@ -75,14 +88,21 @@ export function closeReviewLoopFromLaneResult(assignment, lane, actor, cwd, opti
75
88
  return noop(`loop already ${loop.status}`, loop.status);
76
89
  const slot = resolveReviewerSlot(loop, assignment);
77
90
  const acceptedVerdictExists = loop.artifacts.some(isAcceptedVerdict);
78
- const summary = (lane.review_summary ?? '').trim();
91
+ const detail = (lane.body ?? lane.review_summary ?? '').trim();
79
92
  // ── approve → close on reviewer_green ───────────────────────────────
80
93
  if (verdict === 'approve') {
81
94
  if (slot) {
82
95
  // isVerdictAccepted fires reviewer_green ONLY on an "accepted…" body.
83
96
  complete_turn({
84
97
  id: loopId, slot_id: slot.slot_id, actor,
85
- artifact: { phase: loop.current_phase, type: 'verdict', body: `accepted${summary ? `: ${summary}` : ''}` },
98
+ // pln#639 BUG-2 — the phase the slot was DISPATCHED in, not the
99
+ // loop's phase at close time. Same defect as the ideation closer;
100
+ // fixed here too because this is the far more travelled path.
101
+ // Safe for the approve flow: `reviewer_green` scans every artifact
102
+ // via isVerdictAccepted regardless of phase, and no gate in the
103
+ // engine keys on `type: 'verdict'` — so this changes attribution
104
+ // truth without changing a single gate outcome.
105
+ artifact: { phase: slot.phase ?? loop.current_phase, type: 'verdict', body: capVerdictBody('accepted', detail) },
86
106
  }, cwd);
87
107
  }
88
108
  else if (!acceptedVerdictExists) {
@@ -127,7 +147,8 @@ export function closeReviewLoopFromLaneResult(assignment, lane, actor, cwd, opti
127
147
  const symmetric = loop.protocol?.review_mode === 'symmetric';
128
148
  complete_turn({
129
149
  id: loopId, slot_id: slot.slot_id, actor,
130
- artifact: { phase: loop.current_phase, type: 'verdict', body: `changes-requested${summary ? `: ${summary}` : ''}` },
150
+ // pln#639 BUG-2 — dispatch phase, not close-time phase (see above).
151
+ artifact: { phase: slot.phase ?? loop.current_phase, type: 'verdict', body: capVerdictBody('changes-requested', detail) },
131
152
  }, cwd);
132
153
  if (!symmetric) {
133
154
  const advancedAsym = advance({ id: loopId, actor }, cwd);
@@ -171,7 +192,7 @@ export function closeReviewLoopFromLaneResult(assignment, lane, actor, cwd, opti
171
192
  agent_id: slot.agent_id,
172
193
  phase: advanced.loop.current_phase,
173
194
  iteration: advanced.loop.iteration_count,
174
- task: buildFixCycleTask(summary, advanced.loop.iteration_count),
195
+ task: buildFixCycleTask(detail, advanced.loop.iteration_count),
175
196
  },
176
197
  };
177
198
  },
@@ -19,12 +19,205 @@
19
19
  * here imports harvest or review-loop-close, so no import cycle is introduced.
20
20
  */
21
21
  import { createCoordinatorClaim, attachAssignmentMessageToClaim, linkClaimToAssignment } from './claims.js';
22
- import { createAssignment, transitionAssignment, generateAssignmentId, patchAssignmentMessageId } from './assignments.js';
22
+ import { createAssignment, transitionAssignment, generateAssignmentId, patchAssignmentMessageId, loadAssignment } from './assignments.js';
23
23
  import { turn } from './loops/verbs.js';
24
+ import { getLoop } from './loops/store.js';
24
25
  import { generateDispatchBrief } from './dispatcher.js';
25
26
  import { sendMessage } from './messaging.js';
26
27
  import { buildInvokeCommand, resolveModel } from './agent-capability.js';
27
28
  import { attemptExecution } from './execution.js';
29
+ import { createAgentRun, loadAgentRun, transitionAgentRun } from './agentruns.js';
30
+ import { reserve, commitReservation, armLaunch, consumeLaunchGrant, launchGrant, deriveTurnId, deriveChildIds, ReservationStateError, LaunchFenceError, } from './loops/attempt-reservation.js';
31
+ /**
32
+ * pln#630 — gate for the turn-owned (exactly-once) review dispatch path.
33
+ *
34
+ * NOW DEFAULT ON (the shipped default): the finalization (PR3a), autonomous fix-cycle (PR3b),
35
+ * strand self-heal (PR4), and revoked-grant recovery (R1) are all merged; the §9 conformance
36
+ * harness + a live end-to-end (real spawn → turn-keyed sentinel → harvest → reconcileTurn →
37
+ * close-on-approve) validated the path. `BRAINCLAW_TURN_OWNED_REVIEW=0` is the explicit
38
+ * KILL-SWITCH that reverts to the legacy closer (byte-identical) if a problem surfaces in prod.
39
+ * Note the routing is doubly-gated: even ON, harvest only reconcile-turns a lane that OWNS a
40
+ * turn reservation — a legacy-dispatched lane (no reservation) still uses the legacy path.
41
+ */
42
+ export function turnOwnedReviewEnabled() {
43
+ // Normalized kill-switch (review Finding 4): any of 0/false/off/no (case/space-insensitive)
44
+ // disables — so an operator reaching for it under pressure can't mis-set it. Anything else
45
+ // (including unset) keeps the shipped default ON.
46
+ const v = (process.env.BRAINCLAW_TURN_OWNED_REVIEW ?? '').trim().toLowerCase();
47
+ return !['0', 'false', 'off', 'no'].includes(v);
48
+ }
49
+ /** GRANT lease: bounds ONE launch generation's reserve→arm→consume→spawn→run-`running`
50
+ * window. Long enough that a genuinely launching worker never expires under the PR2c-lease
51
+ * pre-run reconciler; a live `running` run is out of that reconciler's scope. */
52
+ const TURN_OWNED_LEASE_MS = 10 * 60_000;
53
+ /** DISPATCH lease: how long the committed RESERVATION stays re-dispatchable. Strictly LONGER
54
+ * than the grant lease (pln#630 dec#149 R1 / review Finding 1): a reserved_never_launched
55
+ * round (grant lease expired → the reconciler revokes the grant) then still has a RECOVERY
56
+ * WINDOW to re-arm a fresh generation before the reservation itself expires. armLaunch
57
+ * enforces this bound, so a re-dispatch past it refuses arm and stays correctly stranded —
58
+ * no phantom-spawn-after-lease. Decoupling the two is what makes R1 recovery actually reachable
59
+ * (with a single shared lease, the grant is revoked exactly when the dispatch lease is already
60
+ * expired, so re-arm was always denied). */
61
+ const TURN_OWNED_DISPATCH_LEASE_MS = 30 * 60_000;
62
+ /**
63
+ * Prepare a turn-owned review dispatch (pln#630 PR2c-b, design dec#144). Runs the
64
+ * exactly-once machine INLINE in the coordinator (which has store access, unlike
65
+ * the sandboxed worktree — trp_26e9634b): deterministic turn_id → reserve/adopt →
66
+ * commit → arm/adopt → consume, spawning ONLY on the winning consume.
67
+ *
68
+ * FAIL-CLOSED after `reserve`: once identity is claimed, any error aborts as
69
+ * `denied` (never legacy) so an ungated legacy worker can never spawn beside a
70
+ * live reservation (the adversarial double-spawn hole, dec#144 MUST-FIX 1). Only
71
+ * a failure BEFORE identity is reserved degrades to `legacy`.
72
+ */
73
+ export function prepareTurnOwnedReviewDispatch(input) {
74
+ const { loopId, slotId, claimId, cwd } = input;
75
+ // ── Snapshot the loop BEFORE turn() bumps its version (dec#139 item 3). ──
76
+ let iteration;
77
+ let version;
78
+ try {
79
+ const thread = getLoop(loopId, cwd);
80
+ if (!thread)
81
+ return { kind: 'legacy' }; // loop not found — pre-identity, safe to degrade
82
+ iteration = thread.iteration_count;
83
+ version = thread.version;
84
+ }
85
+ catch {
86
+ return { kind: 'legacy' };
87
+ }
88
+ const turnId = deriveTurnId(loopId, slotId, iteration);
89
+ const { assignment_id: assignmentId, run_id: runId } = deriveChildIds(turnId);
90
+ // Decoupled leases (dec#149 R1): the reservation dispatch lease is longer than each grant's
91
+ // lease, giving a revoked (reserved_never_launched) round a window to re-arm. reserve() adopts
92
+ // on a re-dispatch, so the ORIGINAL (longer) dispatch lease governs re-arm eligibility.
93
+ const dispatchLease = new Date(Date.now() + TURN_OWNED_DISPATCH_LEASE_MS).toISOString();
94
+ const grantLease = new Date(Date.now() + TURN_OWNED_LEASE_MS).toISOString();
95
+ // ── Phase 1: claim identity. Fail-OPEN allowed ONLY here (nothing reserved yet). ──
96
+ try {
97
+ reserve({
98
+ turn_id: turnId,
99
+ loop_id: loopId,
100
+ slot_id: slotId,
101
+ target_slot_generation: iteration, // LoopSlot has no generation field — observational proxy (dec#144 #8)
102
+ loop_version_at_reserve: version,
103
+ agent: input.agent,
104
+ agent_id: input.agentId,
105
+ claim_id: claimId,
106
+ phase: input.phase,
107
+ iteration,
108
+ store_root: cwd,
109
+ cwd,
110
+ lease_deadline: dispatchLease,
111
+ }, cwd);
112
+ }
113
+ catch (err) {
114
+ if (err instanceof ReservationStateError && err.code === 'reservation_exists') {
115
+ // A concurrent dispatch already OWNS this turn_id — adopt it and fall
116
+ // through to the fail-closed consume path (we may still legitimately win
117
+ // the fence if the owner reserved-but-never-crossed; otherwise denied).
118
+ }
119
+ else {
120
+ // FAIL-CLOSED (review Finding 1): any other reserve outcome is
121
+ // INDETERMINATE — a lock timeout (a live holder mid-critical-section),
122
+ // lock-lost, or unknown error does NOT prove that no identity was claimed.
123
+ // Degrading to `legacy` here would spawn an ungated worker beside a
124
+ // reservation that may well exist — the exact concurrent-dispatch
125
+ // double-spawn MUST-FIX 1 closes. (A definitively pre-identity error is
126
+ // unreachable here: the lease we build is always parseable, and
127
+ // loop-not-found is handled before reserve.)
128
+ return { kind: 'denied', reason: `reserve indeterminate — fail-closed (no legacy fallback): ${err instanceof Error ? err.message : String(err)}` };
129
+ }
130
+ }
131
+ // ── Phase 2: FAIL-CLOSED. Identity is reserved; never legacy-spawn from here. ──
132
+ try {
133
+ commitReservation(turnId, cwd);
134
+ // Arm-or-adopt the launch grant. Arm when none exists OR when a prior generation was
135
+ // REVOKED (reserved_never_launched — a crash between arm and consume, then the expiry
136
+ // sweep): re-arm a FRESH generation at a strictly-higher epoch so a revoked round becomes
137
+ // re-dispatchable (pln#630 dec#149 R1 strand recovery). armLaunch permits re-arming a
138
+ // revoked grant and still enforces the dispatch lease, so a lease-expired reservation
139
+ // refuses arm and stays correctly stranded (never a phantom-spawn-after-lease). A
140
+ // concurrent arm surfaces as `already_armed` → adopt the incumbent grant.
141
+ let grant = launchGrant(turnId, cwd);
142
+ if (!grant || grant.status === 'revoked') {
143
+ const priorEpoch = grant?.epoch ?? -1; // fresh → epoch 0 (unchanged); revoked → epoch+1
144
+ try {
145
+ armLaunch(turnId, { epoch: priorEpoch + 1, lease_deadline: grantLease }, cwd);
146
+ }
147
+ catch (err) {
148
+ if (!(err instanceof LaunchFenceError && err.code === 'already_armed')) {
149
+ // dispatch_lease_expired / lease_invalid / not_committed / epoch_mismatch → do-not-spawn.
150
+ return { kind: 'denied', reason: `arm_refused: ${err instanceof Error ? err.message : String(err)}` };
151
+ }
152
+ }
153
+ grant = launchGrant(turnId, cwd);
154
+ }
155
+ // A crossed grant = launch_attempted_unknown (worker already invoked, never re-spawn);
156
+ // still-revoked = re-arm refused (lease expired) → never-launch; absent → all do-not-spawn.
157
+ if (!grant || grant.status !== 'armed') {
158
+ return { kind: 'denied', reason: `launch_denied: grant is ${grant?.status ?? 'absent'} (not armed)` };
159
+ }
160
+ // Consume the grant — the atomic exactly-once SPAWN authority.
161
+ let wonTransition;
162
+ try {
163
+ ({ wonTransition } = consumeLaunchGrant(turnId, grant.token, grant.epoch, cwd));
164
+ }
165
+ catch (err) {
166
+ return { kind: 'denied', reason: `launch_denied: consume refused (${err instanceof Error ? err.message : String(err)})` };
167
+ }
168
+ if (!wonTransition) {
169
+ // Adopted — another invocation crossed the fence. MUST NOT spawn.
170
+ return { kind: 'denied', reason: 'launch_denied: grant already crossed by a concurrent dispatch' };
171
+ }
172
+ // ── WON: this dispatch is the SOLE spawner. Bind slot + run to MY live claim
173
+ // (claimId), NOT the reservation's first-reserver claim (dec#144 #3) — else a
174
+ // recovery-winner would bind the slot to a dead claim and break complete_turn
175
+ // auth. Mints are idempotent (save overwrites, so guard on load). ──
176
+ if (!loadAssignment(assignmentId, cwd)) {
177
+ createAssignment({
178
+ id: assignmentId,
179
+ short_label: assignmentId,
180
+ claim_id: claimId,
181
+ agent: input.agent,
182
+ dispatcher_agent: input.dispatcherAgent,
183
+ dispatcher_session_id: input.sessionId,
184
+ scope: input.scope,
185
+ description: input.description,
186
+ tags: ['coordinate', 'review', 'loop', 'turn-owned', input.isReviewer ? 're-review' : 'author-fix'],
187
+ }, cwd);
188
+ }
189
+ if (!loadAgentRun(runId, cwd)) {
190
+ createAgentRun({
191
+ id: runId,
192
+ short_label: runId,
193
+ assignment_id: assignmentId,
194
+ claim_id: claimId,
195
+ agent: input.agent,
196
+ agent_id: input.agentId,
197
+ transport: 'cli_spawn',
198
+ status: 'created',
199
+ scope: input.scope,
200
+ description: input.description,
201
+ worktree_path: input.worktreePath,
202
+ tags: ['turn-owned', 'review', 'loop'],
203
+ }, cwd);
204
+ }
205
+ turn({
206
+ id: loopId,
207
+ slot_id: slotId,
208
+ actor: input.dispatcherAgentId ?? input.dispatcherAgent,
209
+ input: input.task,
210
+ turn_id: turnId,
211
+ assignment_id: assignmentId,
212
+ claim_id: claimId,
213
+ }, cwd);
214
+ return { kind: 'won', assignmentId, runId, turnId, nonce: grant.token };
215
+ }
216
+ catch (err) {
217
+ // FAIL-CLOSED: identity reserved; degrade to denied, NEVER legacy.
218
+ return { kind: 'denied', reason: `turn-owned prep aborted after reserve: ${err instanceof Error ? err.message : String(err)}` };
219
+ }
220
+ }
28
221
  /**
29
222
  * The structured signal a reviewer must emit in LANE-RESULT.json so harvest can
30
223
  * map its lane back onto the loop. Shared by the initial dispatch and every
@@ -75,37 +268,94 @@ export async function dispatchReviewLoopTurn(input) {
75
268
  result.claim_id = claimResult.claimId;
76
269
  result.worktree_path = claimResult.worktreePath;
77
270
  let assignmentId;
78
- try {
79
- const preId = generateAssignmentId(cwd);
80
- const assignment = createAssignment({
81
- id: preId.id,
82
- short_label: preId.short_label,
83
- claim_id: claimResult.claimId,
271
+ let turnEcho;
272
+ let runLegacyProjection = true;
273
+ // pln#630 — turn-owned (exactly-once) dispatch, now the DEFAULT + FAIL-CLOSED after
274
+ // reserve. Kill-switch off (BRAINCLAW_TURN_OWNED_REVIEW=0) → runLegacyProjection stays
275
+ // true and this branch is a byte-identical no-op (the legacy projection below runs).
276
+ if (turnOwnedReviewEnabled()) {
277
+ const prep = prepareTurnOwnedReviewDispatch({
278
+ loopId,
279
+ slotId: slot.slot_id,
84
280
  agent,
85
- dispatcher_agent: input.dispatcherAgent,
86
- dispatcher_session_id: input.sessionId,
87
- scope,
281
+ agentId: slot.agent_id,
282
+ phase,
283
+ task: input.task,
88
284
  description,
89
- tags: ['coordinate', 'review', 'loop', isReviewer ? 're-review' : 'author-fix'],
90
- }, cwd);
91
- assignmentId = assignment.id;
92
- result.assignment_id = assignment.id;
285
+ scope,
286
+ claimId: claimResult.claimId,
287
+ worktreePath: claimResult.worktreePath,
288
+ dispatcherAgent: input.dispatcherAgent,
289
+ dispatcherAgentId: input.dispatcherAgentId,
290
+ sessionId: input.sessionId,
291
+ isReviewer,
292
+ cwd,
293
+ });
294
+ if (prep.kind === 'denied') {
295
+ // The exactly-once fence says this dispatch is NOT the spawner (adopted /
296
+ // crossed / revoked / lease-expired). MUST NOT spawn AND MUST NOT fall back
297
+ // to legacy — a legacy spawn beside the live reservation is the double-spawn
298
+ // hole the fence exists to close (dec#144 MUST-FIX 1).
299
+ //
300
+ // Do NOT release the coordinator claim here (review Finding 2, round 2):
301
+ // createCoordinatorClaim dedups on scope+agent, so a same-turn concurrent
302
+ // dispatch SHARES one claim C1 — and claim-creation order is uncoupled from
303
+ // fence-crossing order (different locks), so the loser can be C1's creator
304
+ // while the WINNER merely reused it and bound its slot/run/assignment to C1.
305
+ // releaseClaim() runs unauthenticated with no active-binding guard, so ANY
306
+ // release in the denied path can flip a claim a live winner depends on to
307
+ // `released` (re-opening the slot.claim_id divergence MUST-FIX 3 closed). A
308
+ // genuine orphan — the rare double-failure where no dispatch wins — is
309
+ // low-harm and reaped by the claim staleness sweep (auto_release_after_hours);
310
+ // sabotaging a live winner is high-harm and not self-healing. So we leave it.
311
+ result.execution_status = 'inbox_only';
312
+ result.error = prep.reason;
313
+ return result;
314
+ }
315
+ if (prep.kind === 'won') {
316
+ // Deterministic assignment mint + slot binding already happened inside
317
+ // prepare; skip the legacy projection and carry the turn-keyed echo so the
318
+ // ack-wrapper writes a turn-keyed completion sentinel.
319
+ assignmentId = prep.assignmentId;
320
+ result.assignment_id = prep.assignmentId;
321
+ turnEcho = { turn_id: prep.turnId, run_id: prep.runId, nonce: prep.nonce };
322
+ runLegacyProjection = false;
323
+ }
324
+ // prep.kind === 'legacy' (fail-open BEFORE identity) → fall through unchanged.
93
325
  }
94
- catch (asgErr) {
95
- result.error = `assignment creation failed: ${asgErr instanceof Error ? asgErr.message : String(asgErr)}`;
326
+ if (runLegacyProjection) {
327
+ try {
328
+ const preId = generateAssignmentId(cwd);
329
+ const assignment = createAssignment({
330
+ id: preId.id,
331
+ short_label: preId.short_label,
332
+ claim_id: claimResult.claimId,
333
+ agent,
334
+ dispatcher_agent: input.dispatcherAgent,
335
+ dispatcher_session_id: input.sessionId,
336
+ scope,
337
+ description,
338
+ tags: ['coordinate', 'review', 'loop', isReviewer ? 're-review' : 'author-fix'],
339
+ }, cwd);
340
+ assignmentId = assignment.id;
341
+ result.assignment_id = assignment.id;
342
+ }
343
+ catch (asgErr) {
344
+ result.error = `assignment creation failed: ${asgErr instanceof Error ? asgErr.message : String(asgErr)}`;
345
+ }
346
+ // Bind the slot to the new claim/assignment (PR1 BLOCKING 2 invariant): a
347
+ // later harvest must resolve THIS slot by assignment_id, not by agent name
348
+ // (which is ambiguous under symmetric multi-reviewer loops). Runs even if
349
+ // assignment creation failed (undefined id → legacy agent-match fallback).
350
+ turn({
351
+ id: loopId,
352
+ slot_id: slot.slot_id,
353
+ actor: input.dispatcherAgentId ?? input.dispatcherAgent,
354
+ input: input.task,
355
+ assignment_id: assignmentId,
356
+ claim_id: claimResult.claimId,
357
+ }, cwd);
96
358
  }
97
- // Bind the slot to the new claim/assignment (PR1 BLOCKING 2 invariant): a
98
- // later harvest must resolve THIS slot by assignment_id, not by agent name
99
- // (which is ambiguous under symmetric multi-reviewer loops). Runs even if
100
- // assignment creation failed (undefined id → legacy agent-match fallback).
101
- turn({
102
- id: loopId,
103
- slot_id: slot.slot_id,
104
- actor: input.dispatcherAgentId ?? input.dispatcherAgent,
105
- input: input.task,
106
- assignment_id: assignmentId,
107
- claim_id: claimResult.claimId,
108
- }, cwd);
109
359
  // Reviewer turns must carry the verdict contract; author-fix turns must not
110
360
  // (an author lane has no verdict — it's mapped by scope+slot instead).
111
361
  const briefTask = isReviewer ? input.task + REVIEW_VERDICT_BRIEF_SUFFIX : input.task;
@@ -167,12 +417,24 @@ export async function dispatchReviewLoopTurn(input) {
167
417
  dispatcherAgentId: input.dispatcherAgentId,
168
418
  cwd,
169
419
  requireWorktree: true, // never spawn a worker in the integration repo (pln#531)
420
+ turnEcho, // pln#630 PR2c-b — undefined on the legacy path (wrapper unchanged)
170
421
  });
171
422
  result.execution_status = execResult.execution_status;
172
423
  result.command = execResult.command;
173
424
  result.shell = execResult.shell;
174
425
  if (execResult.error && !result.error)
175
426
  result.error = execResult.error;
427
+ // pln#630 PR2c-b — a turn-owned run was preallocated `created`; once the
428
+ // worker actually spawned, move it to `running` so it leaves the PR2c-lease
429
+ // pre-run lease scope (created/launching) and is governed by the heartbeat
430
+ // reconciler instead. If it did NOT start, leave it `created` → the pre-run
431
+ // reconciler converges it (crossed → launch_attempted_unknown) at lease.
432
+ if (turnEcho && execResult.execution_status === 'delivered_and_started') {
433
+ try {
434
+ transitionAgentRun(turnEcho.run_id, 'running', { actor: input.dispatcherAgent, status_reason: 'turn-owned worker spawned' }, cwd);
435
+ }
436
+ catch { /* best-effort — the reconciler converges if this races */ }
437
+ }
176
438
  return result;
177
439
  }
178
440
  catch (err) {
@@ -114,6 +114,74 @@ export function readHeartbeat(root, assignmentId, worktreePath) {
114
114
  return projectInfo;
115
115
  return (worktreeInfo.mtimeMs ?? 0) > (projectInfo.mtimeMs ?? 0) ? worktreeInfo : projectInfo;
116
116
  }
117
+ /**
118
+ * Write a turn-keyed completion/failed sentinel body. Used when brainclaw itself
119
+ * (wrapper/reconcile) writes the sentinel; the shell `&& completed` fallback
120
+ * still produces a legacy presence-only marker, which stays a valid life-sign
121
+ * via signalExists but is NOT accepted as turn-owned evidence (PR2b-c).
122
+ */
123
+ export function writeCompletionSignal(root, assignmentId, body) {
124
+ const p = getRuntimeSignalPath(root, assignmentId, body.status);
125
+ fs.mkdirSync(path.dirname(p), { recursive: true });
126
+ fs.writeFileSync(p, JSON.stringify(body), 'utf-8');
127
+ }
128
+ /** Parse ONE turn-keyed sentinel body, or undefined if absent / legacy
129
+ * presence-only / non-JSON / missing correlation keys. Never throws. */
130
+ function readOneCompletionSignal(root, assignmentId, status) {
131
+ let raw;
132
+ try {
133
+ raw = fs.readFileSync(getRuntimeSignalPath(root, assignmentId, status), 'utf-8').trim();
134
+ }
135
+ catch {
136
+ return undefined; // sentinel absent
137
+ }
138
+ if (!raw)
139
+ return undefined; // legacy presence-only (empty) marker
140
+ try {
141
+ const parsed = JSON.parse(raw);
142
+ if (typeof parsed.turn_id === 'string' &&
143
+ typeof parsed.run_id === 'string' &&
144
+ typeof parsed.nonce === 'string' &&
145
+ (parsed.status === 'completed' || parsed.status === 'failed')) {
146
+ return {
147
+ turn_id: parsed.turn_id,
148
+ run_id: parsed.run_id,
149
+ nonce: parsed.nonce,
150
+ status: parsed.status,
151
+ at: typeof parsed.at === 'string' ? parsed.at : '',
152
+ };
153
+ }
154
+ }
155
+ catch { /* non-JSON legacy body */ }
156
+ return undefined;
157
+ }
158
+ /**
159
+ * Read BOTH turn-keyed completion sentinels for an attempt. This is the
160
+ * authoritative reader for the read-strict acceptance path: it surfaces a
161
+ * `completed`+`failed` contradiction so the caller can raise a conflict event
162
+ * and WITHHOLD an irreversible auto-stop (spec §13 R4), rather than silently
163
+ * collapsing to one. Legacy presence-only markers read as absent here.
164
+ */
165
+ export function readCompletionSignals(root, assignmentId) {
166
+ const out = {};
167
+ const completed = readOneCompletionSignal(root, assignmentId, 'completed');
168
+ const failed = readOneCompletionSignal(root, assignmentId, 'failed');
169
+ if (completed)
170
+ out.completed = completed;
171
+ if (failed)
172
+ out.failed = failed;
173
+ return out;
174
+ }
175
+ /**
176
+ * Convenience single-body reader (`completed` preferred over `failed`). Returns
177
+ * undefined for absent / legacy presence-only / non-JSON / missing-keys.
178
+ * CALLERS THAT ACT IRREVERSIBLY must use {@link readCompletionSignals} instead
179
+ * so a completed+failed contradiction is not hidden (spec §13 R4).
180
+ */
181
+ export function readCompletionSignal(root, assignmentId) {
182
+ const both = readCompletionSignals(root, assignmentId);
183
+ return both.completed ?? both.failed;
184
+ }
117
185
  /**
118
186
  * can_c39f0961 — CP850 high-byte table (0x80–0xFF). Windows-native console
119
187
  * tools write redirected stdout/stderr in the OEM codepage (cp850 on western