brainclaw 1.16.0 → 1.18.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 (85) hide show
  1. package/README.md +22 -8
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli/register-review.js +2 -2
  4. package/dist/commands/code-map.js +4 -1
  5. package/dist/commands/codev.js +61 -30
  6. package/dist/commands/doctor.js +14 -1
  7. package/dist/commands/harvest.js +241 -25
  8. package/dist/commands/inbox.js +10 -4
  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-coordination.js +159 -40
  15. package/dist/commands/mcp.js +11 -2
  16. package/dist/core/agent-capability.js +7 -2
  17. package/dist/core/agent-files.js +53 -2
  18. package/dist/core/agent-integrations.js +1 -0
  19. package/dist/core/agentrun-reconciler.js +171 -7
  20. package/dist/core/agentruns.js +6 -1
  21. package/dist/core/code-map/aggregate.js +473 -0
  22. package/dist/core/code-map/backend.js +36 -10
  23. package/dist/core/code-map/freshness.js +36 -1
  24. package/dist/core/code-map/lang/c/imports.scm +12 -0
  25. package/dist/core/code-map/lang/c/index.js +150 -0
  26. package/dist/core/code-map/lang/c/tags.scm +68 -0
  27. package/dist/core/code-map/lang/cpp/imports.scm +14 -0
  28. package/dist/core/code-map/lang/cpp/index.js +149 -0
  29. package/dist/core/code-map/lang/cpp/tags.scm +87 -0
  30. package/dist/core/code-map/lang/csharp/imports.scm +20 -0
  31. package/dist/core/code-map/lang/csharp/index.js +224 -0
  32. package/dist/core/code-map/lang/csharp/tags.scm +63 -0
  33. package/dist/core/code-map/lang/go/imports.scm +13 -0
  34. package/dist/core/code-map/lang/go/index.js +139 -0
  35. package/dist/core/code-map/lang/go/tags.scm +36 -0
  36. package/dist/core/code-map/lang/providers.js +12 -1
  37. package/dist/core/code-map/lang/ruby/imports.scm +24 -0
  38. package/dist/core/code-map/lang/ruby/index.js +198 -0
  39. package/dist/core/code-map/lang/ruby/tags.scm +49 -0
  40. package/dist/core/code-map/lang/rust/imports.scm +44 -0
  41. package/dist/core/code-map/lang/rust/index.js +136 -0
  42. package/dist/core/code-map/lang/rust/tags.scm +47 -0
  43. package/dist/core/code-map/query.js +229 -80
  44. package/dist/core/code-map/types.js +18 -0
  45. package/dist/core/code-map/work-section.js +8 -7
  46. package/dist/core/codev-responses.js +16 -0
  47. package/dist/core/dispatcher.js +209 -29
  48. package/dist/core/execution-adapters.js +29 -3
  49. package/dist/core/ideation-loop-close.js +124 -0
  50. package/dist/core/loops/artifact-resolver.js +197 -0
  51. package/dist/core/loops/attempt-reservation.js +576 -0
  52. package/dist/core/loops/commit-intent.js +494 -0
  53. package/dist/core/loops/facade-schema.js +48 -0
  54. package/dist/core/loops/impl-bind.js +144 -0
  55. package/dist/core/loops/index.js +1 -1
  56. package/dist/core/loops/iteration-engine.js +29 -0
  57. package/dist/core/loops/lock.js +14 -0
  58. package/dist/core/loops/project-resolution.js +157 -0
  59. package/dist/core/loops/reconcile-turn.js +369 -0
  60. package/dist/core/loops/result-reducers.js +88 -0
  61. package/dist/core/loops/store.js +46 -7
  62. package/dist/core/loops/types.js +139 -11
  63. package/dist/core/loops/verbs.js +9 -3
  64. package/dist/core/loops/verify-command.js +209 -0
  65. package/dist/core/messaging.js +58 -5
  66. package/dist/core/review-loop-close.js +106 -34
  67. package/dist/core/review-loop-turn-dispatch.js +445 -0
  68. package/dist/core/runtime-signals.js +68 -0
  69. package/dist/core/schema.js +34 -0
  70. package/dist/core/worktree.js +240 -22
  71. package/dist/facts.js +10 -10
  72. package/dist/facts.json +9 -9
  73. package/dist/wasm/tree-sitter-c.wasm +0 -0
  74. package/dist/wasm/tree-sitter-c_sharp.wasm +0 -0
  75. package/dist/wasm/tree-sitter-cpp.wasm +0 -0
  76. package/dist/wasm/tree-sitter-go.wasm +0 -0
  77. package/dist/wasm/tree-sitter-ruby.wasm +0 -0
  78. package/dist/wasm/tree-sitter-rust.wasm +0 -0
  79. package/docs/cli.md +1 -1
  80. package/docs/code-map.md +22 -6
  81. package/docs/concepts/loop-engine.md +28 -2
  82. package/docs/concepts/observer-protocol.md +22 -0
  83. package/docs/integrations/codex.md +19 -3
  84. package/docs/mcp-schema-changelog.md +43 -1
  85. package/package.json +1 -1
@@ -4,6 +4,18 @@ import { withLoopLock } from './loops/lock.js';
4
4
  /** review-loop:lop_xxx → the loop id (mirrors assignment-reconciler.ts). */
5
5
  const REVIEW_LOOP_SCOPE_RE = /^review-loop:(lop_[0-9a-z]+)/;
6
6
  const LOOP_TERMINAL = new Set(['completed', 'cancelled', 'blocked']);
7
+ /** Build the fix+re-review brief for a request_changes cycle turn (symmetric).
8
+ * Exported so the turn-owned reconcile path (pln#630 PR3b) reuses the identical
9
+ * wording — the reviewer contract must not drift between the legacy and turn-owned
10
+ * fix cycles. */
11
+ export function buildFixCycleTask(summary, iteration) {
12
+ return (`The reviewer requested changes (fix cycle round ${iteration}). `
13
+ + 'Apply the requested changes DIRECTLY in this worktree (it is the same '
14
+ + 'checkout, kept across turns so your commits accumulate), then RE-REVIEW '
15
+ + 'the result. Set review_verdict="approve" once the change is correct and '
16
+ + 'complete, or "request_changes" to take another pass.'
17
+ + (summary ? `\n\nRequested changes: ${summary}` : ''));
18
+ }
7
19
  /** Mirrors verbs.ts:isVerdictAccepted — reviewer_green fires only on a `verdict`
8
20
  * artifact whose body starts with "accepted". */
9
21
  function isAcceptedVerdict(artifact) {
@@ -38,22 +50,13 @@ function resolveReviewerSlot(loop, assignment) {
38
50
  const byAgent = assignment.agent ? active.find((s) => s.agent === assignment.agent) : undefined;
39
51
  return byAgent ?? active[0];
40
52
  }
41
- /**
42
- * Map a harvested review lane onto its loop and close/advance it.
43
- *
44
- * Fires ONLY when the assignment scope is a review-loop (`review-loop:lop_…`)
45
- * AND the lane carries a `review_verdict` — otherwise returns undefined and the
46
- * caller (harvest) proceeds unchanged. Idempotent, convergent, and defensive:
47
- * a terminal loop is a no-op, a partial prior pass is resumed, and any
48
- * loop-verb / lock error is swallowed into a `noop` result so a loop-close
49
- * failure never breaks harvest (mirrors convergeSlotAssignmentsForClosedLoop).
50
- */
51
- export function closeReviewLoopFromLaneResult(assignment, lane, actor, cwd) {
53
+ export function closeReviewLoopFromLaneResult(assignment, lane, actor, cwd, options) {
52
54
  const scopeMatch = assignment.scope?.match(REVIEW_LOOP_SCOPE_RE);
53
55
  if (!scopeMatch)
54
56
  return undefined;
55
57
  if (!lane.review_verdict)
56
58
  return undefined;
59
+ const cycleOnRequestChanges = options?.cycleOnRequestChanges ?? true;
57
60
  const loopId = scopeMatch[1];
58
61
  const verdict = lane.review_verdict;
59
62
  const noop = (reason, loop_status) => ({
@@ -75,35 +78,104 @@ export function closeReviewLoopFromLaneResult(assignment, lane, actor, cwd) {
75
78
  return noop(`loop already ${loop.status}`, loop.status);
76
79
  const slot = resolveReviewerSlot(loop, assignment);
77
80
  const acceptedVerdictExists = loop.artifacts.some(isAcceptedVerdict);
78
- if (slot) {
79
- // Active reviewer slot → record the verdict on it. isVerdictAccepted
80
- // fires reviewer_green ONLY on an "accepted…" body, so approve MUST
81
- // start with "accepted" and request_changes must NOT.
82
- const summary = (lane.review_summary ?? '').trim();
83
- const body = verdict === 'approve'
84
- ? `accepted${summary ? `: ${summary}` : ''}`
85
- : `changes-requested${summary ? `: ${summary}` : ''}`;
86
- complete_turn({ id: loopId, slot_id: slot.slot_id, actor, artifact: { phase: loop.current_phase, type: 'verdict', body } }, cwd);
81
+ const summary = (lane.review_summary ?? '').trim();
82
+ // ── approve → close on reviewer_green ───────────────────────────────
83
+ if (verdict === 'approve') {
84
+ if (slot) {
85
+ // isVerdictAccepted fires reviewer_green ONLY on an "accepted…" body.
86
+ complete_turn({
87
+ id: loopId, slot_id: slot.slot_id, actor,
88
+ artifact: { phase: loop.current_phase, type: 'verdict', body: `accepted${summary ? `: ${summary}` : ''}` },
89
+ }, cwd);
90
+ }
91
+ else if (!acceptedVerdictExists) {
92
+ // No slot to complete and no accepted verdict recorded → a prior pass
93
+ // already processed this (idempotent no-op).
94
+ return noop('already processed (no active reviewer slot; no accepted verdict to resume)', loop.status);
95
+ }
96
+ // Advance: closes on reviewer_green. Convergent — safe whether we just
97
+ // recorded the verdict or are resuming an interrupted approve.
98
+ const advanced = advance({ id: loopId, actor }, cwd);
99
+ return {
100
+ loop_id: loopId,
101
+ verdict,
102
+ action: advanced.auto_closed ? 'closed' : 'advanced',
103
+ reason: advanced.auto_closed
104
+ ? `reviewer_green → loop ${advanced.loop.status}`
105
+ : `accepted verdict recorded → advanced to "${advanced.loop.current_phase}"`,
106
+ loop_status: advanced.loop.status,
107
+ };
108
+ }
109
+ // ── request_changes → autonomous fix cycle (PR2) ────────────────────
110
+ if (!slot) {
111
+ // The cycle already advanced + re-dispatched on the first pass (the
112
+ // re-dispatched slot is now bound to a NEWER assignment, so
113
+ // resolveReviewerSlot returned undefined here → idempotent no-op).
114
+ return noop('already processed (no active reviewer slot to cycle)', loop.status);
115
+ }
116
+ if (!cycleOnRequestChanges) {
117
+ // Report-only path: never advance a cycle it can't follow through on
118
+ // (no re-dispatch, no claim retention). Defer to `harvest --integrate`.
119
+ return noop('request_changes deferred to --integrate (report path does not cycle)', loop.status);
120
+ }
121
+ // Codex review P1 — the autonomous fix cycle is SYMMETRIC-only in v1: it
122
+ // asks the SAME reviewer slot to modify AND re-review in the reused
123
+ // worktree, which is only sound when both roles are the same coding agent
124
+ // (mode='symmetric'). Review loops DEFAULT to asymmetric, where the
125
+ // reviewer must NOT self-fix. For asymmetric, fall back to the PR1
126
+ // behavior: record the verdict, advance linearly to `author_response`,
127
+ // and DO NOT keep the claim / emit a next_turn — the author-fix dispatch
128
+ // is a planned follow-up, so a human drives it. (No re-dispatch means no
129
+ // worktree reuse, so the claim is released by harvest as usual.)
130
+ const symmetric = loop.protocol?.review_mode === 'symmetric';
131
+ complete_turn({
132
+ id: loopId, slot_id: slot.slot_id, actor,
133
+ artifact: { phase: loop.current_phase, type: 'verdict', body: `changes-requested${summary ? `: ${summary}` : ''}` },
134
+ }, cwd);
135
+ if (!symmetric) {
136
+ const advancedAsym = advance({ id: loopId, actor }, cwd);
137
+ return {
138
+ loop_id: loopId,
139
+ verdict,
140
+ action: advancedAsym.auto_closed ? 'closed' : 'advanced',
141
+ reason: advancedAsym.auto_closed
142
+ ? `request_changes → loop ${advancedAsym.loop.status}`
143
+ : `request_changes (asymmetric) → advanced to "${advancedAsym.loop.current_phase}"; author-fix dispatch is a follow-up (drive manually)`,
144
+ loop_status: advancedAsym.loop.status,
145
+ };
87
146
  }
88
- else if (!(verdict === 'approve' && acceptedVerdictExists)) {
89
- // No reviewer slot is ours to complete. Resume ONLY the approve→close
90
- // case: a prior pass recorded an accepted verdict but died before
91
- // advancing. For request_changes (or no accepted verdict), the single
92
- // advance already happened on the first pass — do not re-advance.
93
- return noop('already processed (no active reviewer slot to (re)advance)', loop.status);
147
+ // Symmetric: bump the round counter by advancing to the SAME phase
148
+ // (advance treats to_phase <= current as a backward iteration →
149
+ // iteration_count += 1). The post-advance stop_condition (max_iterations
150
+ // n=3) auto-closes the loop as `blocked` once the cap is hit; otherwise
151
+ // the loop stays open and we hand harvest a `next_turn` to re-dispatch
152
+ // into the SAME (kept) worktree so fixes accumulate on one branch.
153
+ const advanced = advance({ id: loopId, to_phase: loop.current_phase, actor }, cwd);
154
+ if (advanced.auto_closed) {
155
+ return {
156
+ loop_id: loopId,
157
+ verdict,
158
+ action: 'closed',
159
+ reason: `request_changes hit iteration cap → loop ${advanced.loop.status} (needs human)`,
160
+ loop_status: advanced.loop.status,
161
+ };
94
162
  }
95
- // Advance: closes on reviewer_green (approve), else moves one phase.
96
- // Convergent — safe whether we just recorded the verdict or are resuming
97
- // an interrupted approve.
98
- const advanced = advance({ id: loopId, actor }, cwd);
99
163
  return {
100
164
  loop_id: loopId,
101
165
  verdict,
102
- action: advanced.auto_closed ? 'closed' : 'advanced',
103
- reason: advanced.auto_closed
104
- ? `reviewer_green → loop ${advanced.loop.status}`
105
- : `verdict recorded → advanced to phase "${advanced.loop.current_phase}" (awaiting fix cycle — PR2)`,
166
+ action: 'advanced',
167
+ reason: `request_changes (round ${advanced.loop.iteration_count}) → re-dispatch same reviewer into kept worktree`,
106
168
  loop_status: advanced.loop.status,
169
+ keep_claim: true,
170
+ next_turn: {
171
+ slot_id: slot.slot_id,
172
+ role: slot.role,
173
+ agent: slot.agent ?? '',
174
+ agent_id: slot.agent_id,
175
+ phase: advanced.loop.current_phase,
176
+ iteration: advanced.loop.iteration_count,
177
+ task: buildFixCycleTask(summary, advanced.loop.iteration_count),
178
+ },
107
179
  };
108
180
  },
109
181
  });
@@ -0,0 +1,445 @@
1
+ /**
2
+ * pln#628 Focus 4B PR2 — reusable "dispatch one review-loop turn".
3
+ *
4
+ * PR1 wired the harvest→loop direction (a reviewer verdict advances/closes the
5
+ * loop). PR2 makes the request_changes→fix→re-review cycle autonomous, which
6
+ * means the harvest close path must be able to SPAWN the next turn's worker
7
+ * (the author to apply fixes, then the reviewer to re-review). The full spawn
8
+ * chain — coordinator claim + assignment + slot binding + brief + queued inbox
9
+ * message + CLI spawn — used to live only as closures inside the
10
+ * bclaw_coordinate review handler (mcp-write-coordination.ts). This module
11
+ * lifts that chain into a standalone, closure-free function the harvest path
12
+ * can call.
13
+ *
14
+ * Layering (mirrors review-loop-close.ts's cycle-avoidance note): this is a
15
+ * core module that imports the heavy dispatch primitives (execution, dispatcher,
16
+ * claims, messaging, assignments). review-loop-close.ts stays PURE (loops +
17
+ * schema only) and merely RETURNS a `NextTurn` descriptor; harvest.ts is the
18
+ * command-level orchestrator that owns both and calls this to spawn. Nothing
19
+ * here imports harvest or review-loop-close, so no import cycle is introduced.
20
+ */
21
+ import { createCoordinatorClaim, attachAssignmentMessageToClaim, linkClaimToAssignment } from './claims.js';
22
+ import { createAssignment, transitionAssignment, generateAssignmentId, patchAssignmentMessageId, loadAssignment } from './assignments.js';
23
+ import { turn } from './loops/verbs.js';
24
+ import { getLoop } from './loops/store.js';
25
+ import { generateDispatchBrief } from './dispatcher.js';
26
+ import { sendMessage } from './messaging.js';
27
+ import { buildInvokeCommand, resolveModel } from './agent-capability.js';
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
+ }
221
+ /**
222
+ * The structured signal a reviewer must emit in LANE-RESULT.json so harvest can
223
+ * map its lane back onto the loop. Shared by the initial dispatch and every
224
+ * re-review turn (identical wording keeps the reviewer contract stable).
225
+ */
226
+ export const REVIEW_VERDICT_BRIEF_SUFFIX = '\n\n## Review verdict (required — drives autonomous loop convergence)\n'
227
+ + 'In your LANE-RESULT.json set "status":"completed" AND add "review_verdict": '
228
+ + '"approve" (change is good to merge) or "request_changes" (needs fixes), plus '
229
+ + '"review_summary":"<one-line rationale>". The coordinator reads review_verdict '
230
+ + 'to close the review loop on approve, or continue the fix cycle on request_changes.';
231
+ /**
232
+ * Dispatch a single review-loop turn: create the coordinator claim + assignment,
233
+ * bind the slot to them (so harvest resolves the exact slot by assignment_id),
234
+ * build + queue the brief, and spawn the worker CLI.
235
+ *
236
+ * Best-effort and non-throwing: any failure is returned in `.error` so the
237
+ * caller (harvest) can record it as a warning without aborting the harvest —
238
+ * the loop simply stays open awaiting a manual turn. Mirrors the resilience of
239
+ * the initial reviewer dispatch (which pushes a warning and leaves the loop open
240
+ * on failure) rather than the fail-fast style of a user-facing command.
241
+ */
242
+ export async function dispatchReviewLoopTurn(input) {
243
+ const { loopId, slot, phase } = input;
244
+ // createCoordinatorClaim / sendMessage require a concrete cwd; the harvest
245
+ // caller always supplies one — default defensively for direct callers/tests.
246
+ const cwd = input.cwd ?? process.cwd();
247
+ const agent = slot.agent ?? '';
248
+ const scope = `review-loop:${loopId}`;
249
+ const isReviewer = slot.role === 'reviewer';
250
+ const result = {
251
+ loop_id: loopId,
252
+ slot_id: slot.slot_id,
253
+ role: slot.role,
254
+ agent,
255
+ phase,
256
+ };
257
+ try {
258
+ const description = `Review loop turn for ${loopId} slot ${slot.slot_id} phase ${phase}. ${input.task}`;
259
+ const claimResult = createCoordinatorClaim({
260
+ agent,
261
+ scope,
262
+ description,
263
+ dispatcherAgent: input.dispatcherAgent,
264
+ sessionId: input.sessionId,
265
+ cwd,
266
+ worktreeBaseRef: input.worktreeBaseRef,
267
+ });
268
+ result.claim_id = claimResult.claimId;
269
+ result.worktree_path = claimResult.worktreePath;
270
+ let assignmentId;
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,
280
+ agent,
281
+ agentId: slot.agent_id,
282
+ phase,
283
+ task: input.task,
284
+ description,
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.
325
+ }
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);
358
+ }
359
+ // Reviewer turns must carry the verdict contract; author-fix turns must not
360
+ // (an author lane has no verdict — it's mapped by scope+slot instead).
361
+ const briefTask = isReviewer ? input.task + REVIEW_VERDICT_BRIEF_SUFFIX : input.task;
362
+ const brief = generateDispatchBrief({
363
+ task: briefTask,
364
+ agent,
365
+ claimId: claimResult.claimId,
366
+ scope,
367
+ worktreePath: claimResult.worktreePath,
368
+ assignmentId,
369
+ });
370
+ const msg = sendMessage({
371
+ from: input.dispatcherAgent,
372
+ to: agent,
373
+ type: 'review',
374
+ text: brief,
375
+ ref: loopId,
376
+ scope,
377
+ requires_ack: true,
378
+ claim_id: claimResult.claimId,
379
+ assignment_id: assignmentId,
380
+ tags: ['coordinate', 'review', 'loop', isReviewer ? 're-review' : 'author-fix'],
381
+ author_id: input.dispatcherAgentId,
382
+ session_id: input.sessionId,
383
+ payload: {
384
+ intent: 'review',
385
+ loop_id: loopId,
386
+ slot_id: slot.slot_id,
387
+ phase,
388
+ scope,
389
+ claim_id: claimResult.claimId,
390
+ ...(assignmentId ? { assignment_id: assignmentId } : {}),
391
+ worktree_path: claimResult.worktreePath,
392
+ },
393
+ }, cwd);
394
+ result.message_id = msg.id;
395
+ if (assignmentId) {
396
+ try {
397
+ attachAssignmentMessageToClaim(claimResult.claimId, msg.id, cwd);
398
+ linkClaimToAssignment(claimResult.claimId, assignmentId, cwd);
399
+ transitionAssignment(assignmentId, 'offered', { actor: input.dispatcherAgent }, cwd);
400
+ patchAssignmentMessageId(assignmentId, msg.id, cwd);
401
+ }
402
+ catch (linkErr) {
403
+ result.error = `assignment linkage failed: ${linkErr instanceof Error ? linkErr.message : String(linkErr)}`;
404
+ }
405
+ }
406
+ const invoke = buildInvokeCommand(agent, brief, {
407
+ mode: 'worker',
408
+ model: resolveModel(agent, { override: input.model }),
409
+ });
410
+ const execResult = await attemptExecution(invoke, {
411
+ agent,
412
+ autoExecute: true,
413
+ worktreePath: claimResult.worktreePath,
414
+ claimId: claimResult.claimId,
415
+ assignmentId,
416
+ dispatcherAgent: input.dispatcherAgent,
417
+ dispatcherAgentId: input.dispatcherAgentId,
418
+ cwd,
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)
421
+ });
422
+ result.execution_status = execResult.execution_status;
423
+ result.command = execResult.command;
424
+ result.shell = execResult.shell;
425
+ if (execResult.error && !result.error)
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
+ }
438
+ return result;
439
+ }
440
+ catch (err) {
441
+ result.error = `review-loop turn dispatch failed: ${err instanceof Error ? err.message : String(err)}`;
442
+ return result;
443
+ }
444
+ }
445
+ //# sourceMappingURL=review-loop-turn-dispatch.js.map