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.
- package/README.md +22 -8
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli/register-review.js +2 -2
- package/dist/commands/code-map.js +4 -1
- package/dist/commands/codev.js +61 -30
- package/dist/commands/doctor.js +14 -1
- package/dist/commands/harvest.js +241 -25
- package/dist/commands/inbox.js +10 -4
- package/dist/commands/loop.js +2 -2
- package/dist/commands/loops-handlers.js +82 -1
- package/dist/commands/mcp-catalog.js +12 -4
- package/dist/commands/mcp-read-handlers.js +90 -7
- package/dist/commands/mcp-schemas.generated.js +3 -0
- package/dist/commands/mcp-write-coordination.js +159 -40
- package/dist/commands/mcp.js +11 -2
- package/dist/core/agent-capability.js +7 -2
- package/dist/core/agent-files.js +53 -2
- package/dist/core/agent-integrations.js +1 -0
- package/dist/core/agentrun-reconciler.js +171 -7
- package/dist/core/agentruns.js +6 -1
- package/dist/core/code-map/aggregate.js +473 -0
- package/dist/core/code-map/backend.js +36 -10
- package/dist/core/code-map/freshness.js +36 -1
- package/dist/core/code-map/lang/c/imports.scm +12 -0
- package/dist/core/code-map/lang/c/index.js +150 -0
- package/dist/core/code-map/lang/c/tags.scm +68 -0
- package/dist/core/code-map/lang/cpp/imports.scm +14 -0
- package/dist/core/code-map/lang/cpp/index.js +149 -0
- package/dist/core/code-map/lang/cpp/tags.scm +87 -0
- package/dist/core/code-map/lang/csharp/imports.scm +20 -0
- package/dist/core/code-map/lang/csharp/index.js +224 -0
- package/dist/core/code-map/lang/csharp/tags.scm +63 -0
- package/dist/core/code-map/lang/go/imports.scm +13 -0
- package/dist/core/code-map/lang/go/index.js +139 -0
- package/dist/core/code-map/lang/go/tags.scm +36 -0
- package/dist/core/code-map/lang/providers.js +12 -1
- package/dist/core/code-map/lang/ruby/imports.scm +24 -0
- package/dist/core/code-map/lang/ruby/index.js +198 -0
- package/dist/core/code-map/lang/ruby/tags.scm +49 -0
- package/dist/core/code-map/lang/rust/imports.scm +44 -0
- package/dist/core/code-map/lang/rust/index.js +136 -0
- package/dist/core/code-map/lang/rust/tags.scm +47 -0
- package/dist/core/code-map/query.js +229 -80
- package/dist/core/code-map/types.js +18 -0
- package/dist/core/code-map/work-section.js +8 -7
- package/dist/core/codev-responses.js +16 -0
- package/dist/core/dispatcher.js +209 -29
- package/dist/core/execution-adapters.js +29 -3
- package/dist/core/ideation-loop-close.js +124 -0
- package/dist/core/loops/artifact-resolver.js +197 -0
- package/dist/core/loops/attempt-reservation.js +576 -0
- package/dist/core/loops/commit-intent.js +494 -0
- package/dist/core/loops/facade-schema.js +48 -0
- package/dist/core/loops/impl-bind.js +144 -0
- package/dist/core/loops/index.js +1 -1
- package/dist/core/loops/iteration-engine.js +29 -0
- package/dist/core/loops/lock.js +14 -0
- package/dist/core/loops/project-resolution.js +157 -0
- package/dist/core/loops/reconcile-turn.js +369 -0
- package/dist/core/loops/result-reducers.js +88 -0
- package/dist/core/loops/store.js +46 -7
- package/dist/core/loops/types.js +139 -11
- package/dist/core/loops/verbs.js +9 -3
- package/dist/core/loops/verify-command.js +209 -0
- package/dist/core/messaging.js +58 -5
- package/dist/core/review-loop-close.js +106 -34
- package/dist/core/review-loop-turn-dispatch.js +445 -0
- package/dist/core/runtime-signals.js +68 -0
- package/dist/core/schema.js +34 -0
- package/dist/core/worktree.js +240 -22
- package/dist/facts.js +10 -10
- package/dist/facts.json +9 -9
- package/dist/wasm/tree-sitter-c.wasm +0 -0
- package/dist/wasm/tree-sitter-c_sharp.wasm +0 -0
- package/dist/wasm/tree-sitter-cpp.wasm +0 -0
- package/dist/wasm/tree-sitter-go.wasm +0 -0
- package/dist/wasm/tree-sitter-ruby.wasm +0 -0
- package/dist/wasm/tree-sitter-rust.wasm +0 -0
- package/docs/cli.md +1 -1
- package/docs/code-map.md +22 -6
- package/docs/concepts/loop-engine.md +28 -2
- package/docs/concepts/observer-protocol.md +22 -0
- package/docs/integrations/codex.md +19 -3
- package/docs/mcp-schema-changelog.md +43 -1
- 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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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:
|
|
103
|
-
reason: advanced.
|
|
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
|