brainclaw 1.26.2 → 1.28.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 +13 -0
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli/register-coordination.js +65 -1
- package/dist/commands/attempt-authority.js +80 -0
- package/dist/commands/harvest.js +140 -61
- package/dist/commands/loop.js +34 -0
- package/dist/commands/loops-handlers.js +143 -15
- package/dist/commands/mcp-catalog.js +52 -18
- package/dist/commands/mcp-schemas.generated.js +64 -0
- package/dist/commands/mcp-write-claims.js +128 -1
- package/dist/commands/mcp-write-coordination.js +149 -76
- package/dist/core/agent-capability.js +1 -1
- package/dist/core/agentrun-reconciler.js +148 -22
- package/dist/core/agentruns.js +254 -29
- package/dist/core/assignment-request-schema.js +7 -0
- package/dist/core/assignment-sweeper.js +5 -3
- package/dist/core/assignments.js +131 -33
- package/dist/core/claim-request-schema.js +7 -0
- package/dist/core/claims.js +53 -2
- package/dist/core/dispatch-status.js +16 -6
- package/dist/core/dispatcher.js +51 -51
- package/dist/core/entity-operations.js +20 -0
- package/dist/core/events.js +4 -0
- package/dist/core/execution-adapters.js +189 -14
- package/dist/core/execution-contract.js +345 -0
- package/dist/core/execution.js +130 -16
- package/dist/core/facade-schema.js +3 -0
- package/dist/core/harness-adapters/base.js +150 -0
- package/dist/core/harness-adapters/claude.js +39 -0
- package/dist/core/harness-adapters/codex.js +57 -0
- package/dist/core/harness-adapters/harvest.js +109 -0
- package/dist/core/harness-adapters/index.js +8 -0
- package/dist/core/harness-adapters/prompt-only.js +13 -0
- package/dist/core/harness-adapters/registry.js +48 -0
- package/dist/core/harness-adapters/result.js +33 -0
- package/dist/core/harness-adapters/types.js +2 -0
- package/dist/core/ideation-loop-close.js +25 -2
- package/dist/core/instruction-templates.js +3 -2
- package/dist/core/loop-turn-dispatch.js +235 -0
- package/dist/core/loops/artifact-contract.js +11 -0
- package/dist/core/loops/attempt-authority.js +496 -0
- package/dist/core/loops/attempt-generations.js +509 -0
- package/dist/core/loops/attempt-reservation.js +197 -35
- package/dist/core/loops/attempt-rollout.js +404 -0
- package/dist/core/loops/attempt-takeover.js +155 -0
- package/dist/core/loops/bootstrap-acquire.js +7 -3
- package/dist/core/loops/brief-assembly.js +21 -4
- package/dist/core/loops/evidence.js +188 -0
- package/dist/core/loops/facade-schema.js +75 -11
- package/dist/core/loops/gate-policy.js +533 -0
- package/dist/core/loops/impl-bind.js +91 -81
- package/dist/core/loops/index.js +9 -0
- package/dist/core/loops/iteration-engine.js +31 -19
- package/dist/core/loops/kind-policies.js +90 -0
- package/dist/core/loops/lock.js +71 -13
- package/dist/core/loops/reconcile-turn.js +237 -18
- package/dist/core/loops/result-reducers.js +113 -10
- package/dist/core/loops/store.js +34 -3
- package/dist/core/loops/turn-execution.js +480 -0
- package/dist/core/loops/types.js +127 -3
- package/dist/core/loops/verbs.js +335 -99
- package/dist/core/loops/verify-command.js +105 -20
- package/dist/core/loops/workspace-digest.js +54 -0
- package/dist/core/review-loop-close.js +25 -3
- package/dist/core/review-loop-turn-dispatch.js +210 -161
- package/dist/core/runtime-signals.js +62 -25
- package/dist/core/schema.js +40 -0
- package/dist/core/spawn-check.js +3 -2
- package/dist/core/upgrades/backup.js +27 -4
- package/dist/facts.js +9 -8
- package/dist/facts.json +8 -7
- package/docs/cli.md +49 -1
- package/docs/concepts/attempt-authority.md +407 -0
- package/docs/concepts/evidence-attestations.md +135 -0
- package/docs/concepts/execution-contract.md +166 -0
- package/docs/concepts/harness-adapters.md +166 -0
- package/docs/concepts/ideation-loop.md +5 -4
- package/docs/concepts/loop-engine.md +302 -113
- package/docs/index.md +4 -1
- package/docs/integrations/codex.md +3 -3
- package/docs/integrations/mcp.md +59 -5
- package/docs/loops/debug.md +144 -0
- package/docs/loops/ideation.md +158 -0
- package/docs/loops/implementation.md +174 -0
- package/docs/loops/research.md +136 -0
- package/docs/loops/review.md +200 -0
- package/docs/mcp-schema-changelog.md +18 -5
- package/package.json +1 -1
|
@@ -18,33 +18,38 @@
|
|
|
18
18
|
* command-level orchestrator that owns both and calls this to spawn. Nothing
|
|
19
19
|
* here imports harvest or review-loop-close, so no import cycle is introduced.
|
|
20
20
|
*/
|
|
21
|
-
import { createCoordinatorClaim, attachAssignmentMessageToClaim, linkClaimToAssignment } from './claims.js';
|
|
22
|
-
import { createAssignment, transitionAssignment, generateAssignmentId, patchAssignmentMessageId
|
|
21
|
+
import { createCoordinatorClaim, attachAssignmentMessageToClaim, ensureClaimAssignmentBinding, linkClaimToAssignment, loadClaim } from './claims.js';
|
|
22
|
+
import { createAssignment, loadAssignment, transitionAssignment, generateAssignmentId, patchAssignmentMessageId } from './assignments.js';
|
|
23
23
|
import { turn } from './loops/verbs.js';
|
|
24
24
|
import { getLoop } from './loops/store.js';
|
|
25
25
|
import { generateDispatchBrief } from './dispatcher.js';
|
|
26
26
|
import { sendMessage } from './messaging.js';
|
|
27
|
-
import {
|
|
27
|
+
import { resolveModel } from './agent-capability.js';
|
|
28
|
+
import { buildHarnessInvocation, resolveHarnessBinding } from './harness-adapters/index.js';
|
|
28
29
|
import { attemptExecution } from './execution.js';
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
30
|
+
import { transitionAgentRun } from './agentruns.js';
|
|
31
|
+
import { prepareTurnExecution } from './loops/turn-execution.js';
|
|
32
|
+
import { phasePolicy } from './loops/kind-policies.js';
|
|
33
|
+
import { deriveChildIds } from './loops/attempt-reservation.js';
|
|
34
|
+
import { readHeartbeat, signalExists } from './runtime-signals.js';
|
|
35
|
+
export function turnOwnedLoopMode() {
|
|
36
|
+
const configured = (process.env.BRAINCLAW_TURN_OWNED_LOOPS ?? '').trim().toLowerCase();
|
|
37
|
+
if (configured === 'off' || configured === 'review' || configured === 'all')
|
|
38
|
+
return configured;
|
|
39
|
+
// Backward-compatible alias: an explicitly configured legacy switch controls
|
|
40
|
+
// review only; an absent legacy switch adopts the new all-kinds default.
|
|
41
|
+
if (process.env.BRAINCLAW_TURN_OWNED_REVIEW !== undefined) {
|
|
42
|
+
const legacy = process.env.BRAINCLAW_TURN_OWNED_REVIEW.trim().toLowerCase();
|
|
43
|
+
return ['0', 'false', 'off', 'no'].includes(legacy) ? 'off' : 'review';
|
|
44
|
+
}
|
|
45
|
+
return 'all';
|
|
46
|
+
}
|
|
47
|
+
export function turnOwnedLoopEnabled(kind) {
|
|
48
|
+
const mode = turnOwnedLoopMode();
|
|
49
|
+
return mode === 'all' || (mode === 'review' && kind === 'review');
|
|
50
|
+
}
|
|
42
51
|
export function turnOwnedReviewEnabled() {
|
|
43
|
-
|
|
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);
|
|
52
|
+
return turnOwnedLoopEnabled('review');
|
|
48
53
|
}
|
|
49
54
|
/** GRANT lease: bounds ONE launch generation's reserve→arm→consume→spawn→run-`running`
|
|
50
55
|
* window. Long enough that a genuinely launching worker never expires under the PR2c-lease
|
|
@@ -59,6 +64,12 @@ const TURN_OWNED_LEASE_MS = 10 * 60_000;
|
|
|
59
64
|
* (with a single shared lease, the grant is revoked exactly when the dispatch lease is already
|
|
60
65
|
* expired, so re-arm was always denied). */
|
|
61
66
|
const TURN_OWNED_DISPATCH_LEASE_MS = 30 * 60_000;
|
|
67
|
+
function turnProjectionFaultPoint(input, point) {
|
|
68
|
+
input.faultInjector?.(point);
|
|
69
|
+
if (process.env.BRAINCLAW_FAULT_POINT === `turn_projection_${point}`) {
|
|
70
|
+
throw new Error(`fault-injection: turn_projection_${point}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
62
73
|
/**
|
|
63
74
|
* Prepare a turn-owned review dispatch (pln#630 PR2c-b, design dec#144). Runs the
|
|
64
75
|
* exactly-once machine INLINE in the coordinator (which has store access, unlike
|
|
@@ -73,149 +84,118 @@ const TURN_OWNED_DISPATCH_LEASE_MS = 30 * 60_000;
|
|
|
73
84
|
export function prepareTurnOwnedReviewDispatch(input) {
|
|
74
85
|
const { loopId, slotId, claimId, cwd } = input;
|
|
75
86
|
// ── Snapshot the loop BEFORE turn() bumps its version (dec#139 item 3). ──
|
|
76
|
-
let
|
|
77
|
-
let version;
|
|
87
|
+
let thread;
|
|
78
88
|
try {
|
|
79
|
-
|
|
80
|
-
if (!thread)
|
|
81
|
-
return { kind: 'legacy' }; // loop not found — pre-identity, safe to degrade
|
|
82
|
-
iteration = thread.iteration_count;
|
|
83
|
-
version = thread.version;
|
|
89
|
+
thread = getLoop(loopId, cwd);
|
|
84
90
|
}
|
|
85
|
-
catch {
|
|
86
|
-
return { kind: '
|
|
91
|
+
catch (error) {
|
|
92
|
+
return { kind: 'denied', reason: `launch_denied: indeterminate loop read; fail-closed (${error instanceof Error ? error.message : String(error)})` };
|
|
87
93
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
if (!thread)
|
|
95
|
+
return { kind: 'legacy' }; // proven absent before identity: safe legacy path
|
|
96
|
+
if (thread.kind !== 'review')
|
|
97
|
+
return { kind: 'denied', reason: `launch_denied: loop ${loopId} is ${thread.kind}, not review` };
|
|
98
|
+
if (thread.status !== 'open')
|
|
99
|
+
return { kind: 'denied', reason: `launch_denied: loop ${loopId} is ${thread.status}, not open` };
|
|
100
|
+
if (thread.current_phase !== input.phase) {
|
|
101
|
+
return { kind: 'denied', reason: `launch_denied: phase mismatch (${thread.current_phase} != ${input.phase})` };
|
|
102
|
+
}
|
|
103
|
+
const execution = phasePolicy('review', input.phase);
|
|
104
|
+
if (!execution || execution.execution !== 'worker') {
|
|
105
|
+
return { kind: 'denied', reason: `launch_denied: review.${input.phase} is ${execution?.execution ?? 'unknown'}, not a worker phase` };
|
|
106
|
+
}
|
|
107
|
+
const slot = thread.slots.find((candidate) => candidate.slot_id === slotId);
|
|
108
|
+
if (!slot)
|
|
109
|
+
return { kind: 'denied', reason: `launch_denied: slot ${slotId} not found` };
|
|
110
|
+
if (slot.agent !== undefined && slot.agent !== input.agent) {
|
|
111
|
+
return { kind: 'denied', reason: `launch_denied: slot agent mismatch (${slot.agent} != ${input.agent})` };
|
|
112
|
+
}
|
|
113
|
+
if (slot.agent_id !== undefined && slot.agent_id !== input.agentId) {
|
|
114
|
+
return { kind: 'denied', reason: `launch_denied: slot agent_id mismatch (${slot.agent_id} != ${input.agentId ?? 'none'})` };
|
|
115
|
+
}
|
|
116
|
+
if (slot.claim_id !== undefined && slot.claim_id !== claimId) {
|
|
117
|
+
return { kind: 'denied', reason: `launch_denied: slot claim mismatch (${slot.claim_id} != ${claimId})` };
|
|
118
|
+
}
|
|
119
|
+
let claim;
|
|
96
120
|
try {
|
|
97
|
-
|
|
98
|
-
|
|
121
|
+
claim = loadClaim(claimId, cwd);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
return { kind: 'denied', reason: `launch_denied: indeterminate claim read; fail-closed (${error instanceof Error ? error.message : String(error)})` };
|
|
125
|
+
}
|
|
126
|
+
if (!claim)
|
|
127
|
+
return { kind: 'denied', reason: `launch_denied: claim ${claimId} not found` };
|
|
128
|
+
if (claim.status !== 'active' || claim.agent !== input.agent || claim.scope !== input.scope) {
|
|
129
|
+
return { kind: 'denied', reason: `launch_denied: claim ${claimId} is incompatible with slot ${slotId}` };
|
|
130
|
+
}
|
|
131
|
+
// From the common adapter onward identity may exist, so every error is
|
|
132
|
+
// fail-closed. Review is a protocol adapter over the same contract and fence
|
|
133
|
+
// used by ideation, implementation, research and debug.
|
|
134
|
+
// Only the loop snapshot above can safely choose the legacy path.
|
|
135
|
+
try {
|
|
136
|
+
const harnessBinding = resolveHarnessBinding(input.agent, input.model);
|
|
137
|
+
const prepared = prepareTurnExecution({
|
|
138
|
+
kind: 'review',
|
|
99
139
|
loop_id: loopId,
|
|
100
140
|
slot_id: slotId,
|
|
101
|
-
|
|
102
|
-
loop_version_at_reserve: version,
|
|
141
|
+
phase: input.phase,
|
|
103
142
|
agent: input.agent,
|
|
104
143
|
agent_id: input.agentId,
|
|
105
144
|
claim_id: claimId,
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
145
|
+
dispatcher_agent: input.dispatcherAgent,
|
|
146
|
+
dispatcher_agent_id: input.dispatcherAgentId,
|
|
147
|
+
dispatcher_session_id: input.sessionId,
|
|
148
|
+
scope: input.scope,
|
|
149
|
+
description: input.description,
|
|
150
|
+
task: input.task,
|
|
109
151
|
cwd,
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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);
|
|
152
|
+
worktree_path: input.worktreePath,
|
|
153
|
+
dispatch_lease_ms: TURN_OWNED_DISPATCH_LEASE_MS,
|
|
154
|
+
grant_lease_ms: TURN_OWNED_LEASE_MS,
|
|
155
|
+
model: input.model,
|
|
156
|
+
harness_binding: harnessBinding,
|
|
157
|
+
assignment_tags: ['coordinate', 'review', 'loop', 'turn-owned', input.isReviewer ? 're-review' : 'author-fix'],
|
|
158
|
+
run_tags: ['turn-owned', 'review', 'loop'],
|
|
159
|
+
on_authority_stage: (stage) => turnProjectionFaultPoint(input, stage === 'reserved'
|
|
160
|
+
? 'after_reservation'
|
|
161
|
+
: stage === 'committed' ? 'after_commit' : 'after_arm'),
|
|
162
|
+
on_projection: (stage) => turnProjectionFaultPoint(input, stage === 'assignment' ? 'after_assignment'
|
|
163
|
+
: stage === 'run' ? 'after_run'
|
|
164
|
+
: stage === 'claim_binding' ? 'after_claim_binding'
|
|
165
|
+
: stage === 'slot_binding' ? 'after_slot_binding' : 'before_consume'),
|
|
166
|
+
});
|
|
167
|
+
if (prepared.kind !== 'won') {
|
|
168
|
+
const reason = /incompatible claim_id/.test(prepared.reason)
|
|
169
|
+
? `claim mismatch: ${prepared.reason}`
|
|
170
|
+
: /lock_timeout/.test(prepared.reason)
|
|
171
|
+
? `indeterminate reservation; fail-closed: ${prepared.reason}`
|
|
172
|
+
: prepared.reason;
|
|
173
|
+
return {
|
|
174
|
+
kind: 'denied',
|
|
175
|
+
reason: `launch_denied: ${reason}`,
|
|
176
|
+
turnId: prepared.turn_id,
|
|
177
|
+
alreadyCrossed: prepared.code === 'already_crossed',
|
|
178
|
+
};
|
|
204
179
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
180
|
+
turnProjectionFaultPoint(input, 'after_consume');
|
|
181
|
+
return {
|
|
182
|
+
kind: 'won',
|
|
183
|
+
assignmentId: prepared.assignment_id,
|
|
184
|
+
runId: prepared.run_id,
|
|
185
|
+
turnId: prepared.turn_id,
|
|
186
|
+
nonce: prepared.nonce,
|
|
187
|
+
attemptEpoch: prepared.attempt_epoch,
|
|
188
|
+
workspaceDigest: prepared.workspace_digest,
|
|
189
|
+
workspacePath: prepared.workspace_path,
|
|
190
|
+
executionContractRef: prepared.execution_contract_ref,
|
|
191
|
+
harnessBinding,
|
|
192
|
+
};
|
|
215
193
|
}
|
|
216
194
|
catch (err) {
|
|
217
195
|
// FAIL-CLOSED: identity reserved; degrade to denied, NEVER legacy.
|
|
218
|
-
|
|
196
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
197
|
+
const classified = /incompatible claim_id/.test(detail) ? `claim mismatch: ${detail}` : detail;
|
|
198
|
+
return { kind: 'denied', reason: `turn-owned prep fail-closed after reserve: ${classified}` };
|
|
219
199
|
}
|
|
220
200
|
}
|
|
221
201
|
/**
|
|
@@ -228,6 +208,20 @@ export const REVIEW_VERDICT_BRIEF_SUFFIX = '\n\n## Review verdict (required —
|
|
|
228
208
|
+ '"approve" (change is good to merge) or "request_changes" (needs fixes), plus '
|
|
229
209
|
+ '"review_summary":"<one-line rationale>". The coordinator reads review_verdict '
|
|
230
210
|
+ 'to close the review loop on approve, or continue the fix cycle on request_changes.';
|
|
211
|
+
export const AUTHOR_RESPONSE_BRIEF_SUFFIX = '\n\n## Author response (required — integrated before loop convergence)\n'
|
|
212
|
+
+ 'In your LANE-RESULT.json set "status":"completed", "artifact_type":"author_response", '
|
|
213
|
+
+ 'and put the applied-fix summary and validation evidence in "body". List modified files in '
|
|
214
|
+
+ '"files_changed". The coordinator integrates the worktree before accepting this turn.';
|
|
215
|
+
function hasTurnRuntimeSignal(cwd, turnId, worktreePath) {
|
|
216
|
+
const { assignment_id: assignmentId, run_id: runId } = deriveChildIds(turnId);
|
|
217
|
+
const signalPresent = (signal) => signalExists(cwd, assignmentId, signal, runId)
|
|
218
|
+
|| signalExists(cwd, assignmentId, signal);
|
|
219
|
+
return signalPresent('ack')
|
|
220
|
+
|| readHeartbeat(cwd, assignmentId, worktreePath, runId).exists
|
|
221
|
+
|| readHeartbeat(cwd, assignmentId, worktreePath).exists
|
|
222
|
+
|| signalPresent('completed')
|
|
223
|
+
|| signalPresent('failed');
|
|
224
|
+
}
|
|
231
225
|
/**
|
|
232
226
|
* Dispatch a single review-loop turn: create the coordinator claim + assignment,
|
|
233
227
|
* bind the slot to them (so harvest resolves the exact slot by assignment_id),
|
|
@@ -245,7 +239,11 @@ export async function dispatchReviewLoopTurn(input) {
|
|
|
245
239
|
// caller always supplies one — default defensively for direct callers/tests.
|
|
246
240
|
const cwd = input.cwd ?? process.cwd();
|
|
247
241
|
const agent = slot.agent ?? '';
|
|
248
|
-
|
|
242
|
+
// A claim owns one slot's execution lineage, not the whole loop. Using the
|
|
243
|
+
// loop-wide scope here made parallel reviewers reuse/conflict on one claim
|
|
244
|
+
// and its single assignment_id pointer. The suffix stays compatible with
|
|
245
|
+
// readers that parse the leading `review-loop:<loop_id>` namespace.
|
|
246
|
+
const scope = `review-loop:${loopId}:slot:${slot.slot_id}`;
|
|
249
247
|
const isReviewer = slot.role === 'reviewer';
|
|
250
248
|
const result = {
|
|
251
249
|
loop_id: loopId,
|
|
@@ -269,7 +267,9 @@ export async function dispatchReviewLoopTurn(input) {
|
|
|
269
267
|
result.worktree_path = claimResult.worktreePath;
|
|
270
268
|
let assignmentId;
|
|
271
269
|
let turnEcho;
|
|
270
|
+
let harnessBinding;
|
|
272
271
|
let runLegacyProjection = true;
|
|
272
|
+
let executionWorktreePath = claimResult.worktreePath;
|
|
273
273
|
// pln#630 — turn-owned (exactly-once) dispatch, now the DEFAULT + FAIL-CLOSED after
|
|
274
274
|
// reserve. Kill-switch off (BRAINCLAW_TURN_OWNED_REVIEW=0) → runLegacyProjection stays
|
|
275
275
|
// true and this branch is a byte-identical no-op (the legacy projection below runs).
|
|
@@ -288,8 +288,10 @@ export async function dispatchReviewLoopTurn(input) {
|
|
|
288
288
|
dispatcherAgent: input.dispatcherAgent,
|
|
289
289
|
dispatcherAgentId: input.dispatcherAgentId,
|
|
290
290
|
sessionId: input.sessionId,
|
|
291
|
+
model: resolveModel(agent, { override: input.model }),
|
|
291
292
|
isReviewer,
|
|
292
293
|
cwd,
|
|
294
|
+
faultInjector: input.faultInjector,
|
|
293
295
|
});
|
|
294
296
|
if (prep.kind === 'denied') {
|
|
295
297
|
// The exactly-once fence says this dispatch is NOT the spawner (adopted /
|
|
@@ -310,6 +312,12 @@ export async function dispatchReviewLoopTurn(input) {
|
|
|
310
312
|
// sabotaging a live winner is high-harm and not self-healing. So we leave it.
|
|
311
313
|
result.execution_status = 'inbox_only';
|
|
312
314
|
result.error = prep.reason;
|
|
315
|
+
if (prep.alreadyCrossed
|
|
316
|
+
&& prep.turnId
|
|
317
|
+
&& !hasTurnRuntimeSignal(cwd, prep.turnId, claimResult.worktreePath)) {
|
|
318
|
+
result.authority_state = 'crossed_unknown';
|
|
319
|
+
result.needs_operator = true;
|
|
320
|
+
}
|
|
313
321
|
return result;
|
|
314
322
|
}
|
|
315
323
|
if (prep.kind === 'won') {
|
|
@@ -318,7 +326,20 @@ export async function dispatchReviewLoopTurn(input) {
|
|
|
318
326
|
// ack-wrapper writes a turn-keyed completion sentinel.
|
|
319
327
|
assignmentId = prep.assignmentId;
|
|
320
328
|
result.assignment_id = prep.assignmentId;
|
|
321
|
-
turnEcho = {
|
|
329
|
+
turnEcho = {
|
|
330
|
+
turn_id: prep.turnId,
|
|
331
|
+
run_id: prep.runId,
|
|
332
|
+
nonce: prep.nonce,
|
|
333
|
+
...(prep.executionContractRef ? {
|
|
334
|
+
contract_hash: prep.executionContractRef.hash,
|
|
335
|
+
capability_snapshot_hash: prep.executionContractRef.snapshot_hash,
|
|
336
|
+
} : {}),
|
|
337
|
+
...(prep.attemptEpoch !== undefined ? { attempt_epoch: prep.attemptEpoch } : {}),
|
|
338
|
+
...(prep.workspaceDigest ? { workspace_digest: prep.workspaceDigest } : {}),
|
|
339
|
+
};
|
|
340
|
+
harnessBinding = prep.harnessBinding;
|
|
341
|
+
executionWorktreePath = prep.workspacePath;
|
|
342
|
+
result.worktree_path = executionWorktreePath;
|
|
322
343
|
runLegacyProjection = false;
|
|
323
344
|
}
|
|
324
345
|
// prep.kind === 'legacy' (fail-open BEFORE identity) → fall through unchanged.
|
|
@@ -358,14 +379,29 @@ export async function dispatchReviewLoopTurn(input) {
|
|
|
358
379
|
}
|
|
359
380
|
// Reviewer turns must carry the verdict contract; author-fix turns must not
|
|
360
381
|
// (an author lane has no verdict — it's mapped by scope+slot instead).
|
|
361
|
-
const briefTask = isReviewer
|
|
382
|
+
const briefTask = isReviewer
|
|
383
|
+
? input.task + REVIEW_VERDICT_BRIEF_SUFFIX
|
|
384
|
+
: input.task + AUTHOR_RESPONSE_BRIEF_SUFFIX;
|
|
362
385
|
const brief = generateDispatchBrief({
|
|
363
386
|
task: briefTask,
|
|
364
387
|
agent,
|
|
365
388
|
claimId: claimResult.claimId,
|
|
366
389
|
scope,
|
|
367
|
-
worktreePath:
|
|
390
|
+
worktreePath: executionWorktreePath,
|
|
368
391
|
assignmentId,
|
|
392
|
+
executionContractRef: turnEcho?.contract_hash && turnEcho.capability_snapshot_hash ? {
|
|
393
|
+
version: 1,
|
|
394
|
+
hash: turnEcho.contract_hash,
|
|
395
|
+
snapshot_hash: turnEcho.capability_snapshot_hash,
|
|
396
|
+
turn_id: turnEcho.turn_id,
|
|
397
|
+
} : undefined,
|
|
398
|
+
attemptFence: turnEcho?.attempt_epoch !== undefined && turnEcho.workspace_digest ? {
|
|
399
|
+
turn_id: turnEcho.turn_id,
|
|
400
|
+
run_id: turnEcho.run_id,
|
|
401
|
+
nonce: turnEcho.nonce,
|
|
402
|
+
attempt_epoch: turnEcho.attempt_epoch,
|
|
403
|
+
workspace_digest: turnEcho.workspace_digest,
|
|
404
|
+
} : undefined,
|
|
369
405
|
cwd, // pln#638 PR-6b — the context envelope reads the store
|
|
370
406
|
});
|
|
371
407
|
const msg = sendMessage({
|
|
@@ -389,29 +425,39 @@ export async function dispatchReviewLoopTurn(input) {
|
|
|
389
425
|
scope,
|
|
390
426
|
claim_id: claimResult.claimId,
|
|
391
427
|
...(assignmentId ? { assignment_id: assignmentId } : {}),
|
|
392
|
-
worktree_path:
|
|
428
|
+
worktree_path: executionWorktreePath,
|
|
393
429
|
},
|
|
394
430
|
}, cwd);
|
|
395
431
|
result.message_id = msg.id;
|
|
396
432
|
if (assignmentId) {
|
|
397
433
|
try {
|
|
398
434
|
attachAssignmentMessageToClaim(claimResult.claimId, msg.id, cwd);
|
|
399
|
-
|
|
400
|
-
|
|
435
|
+
if (turnEcho)
|
|
436
|
+
ensureClaimAssignmentBinding(claimResult.claimId, assignmentId, cwd);
|
|
437
|
+
else
|
|
438
|
+
linkClaimToAssignment(claimResult.claimId, assignmentId, cwd);
|
|
439
|
+
const logicalAssignment = loadAssignment(assignmentId, cwd);
|
|
440
|
+
if (logicalAssignment?.status === 'created' || logicalAssignment?.status === 'retrying') {
|
|
441
|
+
transitionAssignment(assignmentId, 'offered', { actor: input.dispatcherAgent }, cwd);
|
|
442
|
+
}
|
|
443
|
+
else if (turnEcho?.attempt_epoch === undefined || logicalAssignment?.status !== 'started') {
|
|
444
|
+
transitionAssignment(assignmentId, 'offered', { actor: input.dispatcherAgent }, cwd);
|
|
445
|
+
}
|
|
401
446
|
patchAssignmentMessageId(assignmentId, msg.id, cwd);
|
|
402
447
|
}
|
|
403
448
|
catch (linkErr) {
|
|
404
449
|
result.error = `assignment linkage failed: ${linkErr instanceof Error ? linkErr.message : String(linkErr)}`;
|
|
405
450
|
}
|
|
406
451
|
}
|
|
407
|
-
const invoke =
|
|
452
|
+
const invoke = buildHarnessInvocation(agent, brief, {
|
|
408
453
|
mode: 'worker',
|
|
409
454
|
model: resolveModel(agent, { override: input.model }),
|
|
410
|
-
|
|
411
|
-
|
|
455
|
+
binding: harnessBinding,
|
|
456
|
+
})?.invoke;
|
|
457
|
+
const execResult = await (input.executionAttempt ?? attemptExecution)(invoke, {
|
|
412
458
|
agent,
|
|
413
459
|
autoExecute: true,
|
|
414
|
-
worktreePath:
|
|
460
|
+
worktreePath: executionWorktreePath,
|
|
415
461
|
claimId: claimResult.claimId,
|
|
416
462
|
assignmentId,
|
|
417
463
|
dispatcherAgent: input.dispatcherAgent,
|
|
@@ -425,6 +471,9 @@ export async function dispatchReviewLoopTurn(input) {
|
|
|
425
471
|
result.shell = execResult.shell;
|
|
426
472
|
if (execResult.error && !result.error)
|
|
427
473
|
result.error = execResult.error;
|
|
474
|
+
if (execResult.execution_status === 'delivered_and_started') {
|
|
475
|
+
turnProjectionFaultPoint(input, 'after_spawn');
|
|
476
|
+
}
|
|
428
477
|
// pln#630 PR2c-b — a turn-owned run was preallocated `created`; once the
|
|
429
478
|
// worker actually spawned, move it to `running` so it leaves the PR2c-lease
|
|
430
479
|
// pre-run lease scope (created/launching) and is governed by the heartbeat
|