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
|
@@ -1,32 +1,68 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* The implementation protocol declares `bind` as "bind plan+sequence and dispatch" (see
|
|
5
|
-
* LOOP_PROTOCOLS.implementation in types.ts). This is that action: read the loop's linked
|
|
6
|
-
* sequence, dispatch its ready lanes via the EXISTING sequence spawner, then advance
|
|
7
|
-
* `bind → execute` so the loop enters its execute↔verify cycle.
|
|
8
|
-
*
|
|
9
|
-
* Reuses `dispatch()` ADDITIVELY via its `sequenceId` option (pln#632) so the loop drives
|
|
10
|
-
* its OWN linked sequence WITHOUT touching the project's global active-sequence pointer —
|
|
11
|
-
* no hijack of whatever sequence other bclaw_dispatch work is using. Mirrors the
|
|
12
|
-
* async-handler-spawns pattern already used by bclaw_coordinate(open_loop=true) for review
|
|
13
|
-
* loops; touches NO review/ideation dispatch. The live spawn happens only when this runs
|
|
14
|
-
* on a real loop (exactly like bclaw_dispatch) — `dryRun` previews with no spawn and no
|
|
15
|
-
* phase mutation, so the flow is unit-testable.
|
|
16
|
-
*/
|
|
17
|
-
import { dispatch } from '../dispatcher.js';
|
|
18
|
-
import { listSequences } from '../sequence.js';
|
|
1
|
+
import { loadSequence } from '../sequence.js';
|
|
2
|
+
import { loadState } from '../state.js';
|
|
3
|
+
import { withLoopLock } from './lock.js';
|
|
19
4
|
import { getLoop } from './store.js';
|
|
20
5
|
import { advance } from './verbs.js';
|
|
21
|
-
|
|
6
|
+
function deriveBindings(loop, sequenceId, cwd) {
|
|
7
|
+
if (!loop)
|
|
8
|
+
throw new Error('implementation loop disappeared during bind');
|
|
9
|
+
const sequence = loadSequence(sequenceId, cwd);
|
|
10
|
+
if (sequence.items.length === 0)
|
|
11
|
+
throw new Error(`linked sequence ${sequenceId} has no items`);
|
|
12
|
+
const linkedPlans = new Set(loop.linked?.plan_ids ?? []);
|
|
13
|
+
if (linkedPlans.size === 0) {
|
|
14
|
+
throw new Error(`impl-bind requires linked.plan_ids in addition to linked.sequence_ids`);
|
|
15
|
+
}
|
|
16
|
+
const plans = new Map(loadState(cwd).plan_items.map((plan) => [plan.id, plan]));
|
|
17
|
+
for (const item of sequence.items) {
|
|
18
|
+
if (!linkedPlans.has(item.planId)) {
|
|
19
|
+
throw new Error(`sequence item rank ${item.rank} references unlinked plan ${item.planId}`);
|
|
20
|
+
}
|
|
21
|
+
const plan = plans.get(item.planId);
|
|
22
|
+
if (!plan)
|
|
23
|
+
throw new Error(`linked sequence ${sequenceId} references missing plan ${item.planId}`);
|
|
24
|
+
if (item.stepId && !(plan.steps ?? []).some((step) => step.id === item.stepId)) {
|
|
25
|
+
throw new Error(`sequence item rank ${item.rank} references missing step ${item.stepId} on plan ${item.planId}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const grouped = new Map();
|
|
29
|
+
for (const item of sequence.items) {
|
|
30
|
+
const lane = item.lane?.trim() || 'default';
|
|
31
|
+
grouped.set(lane, [...(grouped.get(lane) ?? []), item]);
|
|
32
|
+
}
|
|
33
|
+
const lanes = [...grouped.keys()].sort();
|
|
34
|
+
if (loop.slots.length !== lanes.length) {
|
|
35
|
+
throw new Error(`impl-bind lane/slot mismatch: sequence ${sequenceId} has ${lanes.length} lane(s) (${lanes.join(', ')}) but loop has ${loop.slots.length} slot(s); open one worker slot per lane`);
|
|
36
|
+
}
|
|
37
|
+
const bindings = {};
|
|
38
|
+
loop.slots.forEach((slot, index) => {
|
|
39
|
+
const lane = lanes[index];
|
|
40
|
+
const items = grouped.get(lane);
|
|
41
|
+
const scopes = [...new Set(items.map((item) => item.scope_hint?.trim()).filter((value) => Boolean(value)))];
|
|
42
|
+
bindings[slot.slot_id] = {
|
|
43
|
+
lane,
|
|
44
|
+
scope_hint: scopes.length > 0 ? scopes.join(', ') : undefined,
|
|
45
|
+
plan_ids: [...new Set(items.map((item) => item.planId))],
|
|
46
|
+
step_ids: [...new Set(items.flatMap((item) => item.stepId ? [item.stepId] : []))],
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
return bindings;
|
|
50
|
+
}
|
|
51
|
+
const ENGINE_ONLY_WARNING = 'implementation bind is engine-only and does not dispatch workers; use bclaw_loop(intent="turn", dispatch=true, slot_id=...) in execute';
|
|
52
|
+
function compatibilityWarnings(input) {
|
|
53
|
+
const usedLaunchOption = input.lanes !== undefined
|
|
54
|
+
|| input.autoExecute !== undefined
|
|
55
|
+
|| input.model !== undefined
|
|
56
|
+
|| input.maxAssignments !== undefined;
|
|
57
|
+
return usedLaunchOption
|
|
58
|
+
? [`${ENGINE_ONLY_WARNING}; bind launch options are retained but ignored`]
|
|
59
|
+
: [ENGINE_ONLY_WARNING];
|
|
60
|
+
}
|
|
22
61
|
/**
|
|
23
|
-
*
|
|
24
|
-
* underlying spawn is async (the only awaited work; `dryRun` resolves synchronously).
|
|
62
|
+
* Validate an implementation loop's linked sequence and advance to execute.
|
|
25
63
|
*
|
|
26
|
-
* Idempotent: a loop already past `bind` returns
|
|
27
|
-
*
|
|
28
|
-
* so a retry re-dispatches only the still-unassigned lanes (dispatch skips lanes with an
|
|
29
|
-
* active assignment) and re-attempts the advance — safe crash-recovery.
|
|
64
|
+
* Idempotent: a loop already past `bind` returns `noop`. The phase re-check and
|
|
65
|
+
* advance happen under the loop lock, so racing bind calls cannot advance twice.
|
|
30
66
|
*/
|
|
31
67
|
export async function runImplBind(input, cwd) {
|
|
32
68
|
const { loop_id, dispatcherAgent } = input;
|
|
@@ -36,16 +72,10 @@ export async function runImplBind(input, cwd) {
|
|
|
36
72
|
if (loop.kind !== 'implementation') {
|
|
37
73
|
throw new Error(`bind is only valid for implementation loops (loop ${loop_id} is kind='${loop.kind}'); review/ideation loops dispatch via bclaw_coordinate`);
|
|
38
74
|
}
|
|
39
|
-
// bind SPAWNS real workers via dispatch() — never do that on a loop that is not open.
|
|
40
|
-
// A paused loop keeps current_phase='bind' (pause only flips status), and open→close
|
|
41
|
-
// leaves the phase at 'bind' too, so the phase-idempotency check below is NOT sufficient
|
|
42
|
-
// to stop a spawn on a held/terminal loop (review Finding 1). Gate on status FIRST, so
|
|
43
|
-
// no dispatch fires and we don't spawn then throw at the advance.
|
|
44
75
|
if (loop.status !== 'open') {
|
|
45
76
|
throw new Error(`bind requires an open loop; loop ${loop_id} is ${loop.status} (resume a paused loop, or reopen a terminal one, before binding)`);
|
|
46
77
|
}
|
|
47
78
|
const sequenceId = loop.linked?.sequence_ids?.[0];
|
|
48
|
-
// Idempotency: `bind` is the loop's FIRST phase. A loop already past it was bound before.
|
|
49
79
|
if (loop.current_phase !== 'bind') {
|
|
50
80
|
return {
|
|
51
81
|
loop_id,
|
|
@@ -53,58 +83,32 @@ export async function runImplBind(input, cwd) {
|
|
|
53
83
|
action: 'noop',
|
|
54
84
|
dispatch: null,
|
|
55
85
|
messages_sent: 0,
|
|
56
|
-
|
|
86
|
+
warnings: compatibilityWarnings(input),
|
|
87
|
+
reason: `loop is in phase '${loop.current_phase}', not 'bind' - already bound (idempotent)`,
|
|
57
88
|
};
|
|
58
89
|
}
|
|
59
90
|
if (!sequenceId) {
|
|
60
|
-
throw new Error(`impl-bind requires a linked sequence: open the implementation loop with linked.sequence_ids=[
|
|
91
|
+
throw new Error(`impl-bind requires a linked sequence: open the implementation loop with linked.sequence_ids=[...] (the sequence whose lanes it executes). None found on ${loop_id}.`);
|
|
61
92
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
sequenceId,
|
|
69
|
-
dispatcherAgent,
|
|
70
|
-
dispatcherAgentId: input.dispatcherAgentId,
|
|
71
|
-
sessionId: input.sessionId,
|
|
72
|
-
dryRun: input.dryRun,
|
|
73
|
-
lanes: input.lanes,
|
|
74
|
-
autoExecute: input.autoExecute,
|
|
75
|
-
model: input.model,
|
|
76
|
-
maxAssignments: input.maxAssignments,
|
|
77
|
-
}, cwd ?? process.cwd());
|
|
78
|
-
const result = dispatched?.result ?? null;
|
|
79
|
-
const messages_sent = result?.messages_sent.length ?? 0;
|
|
80
|
-
// Guard a race: the sequence disappeared between validation and dispatch → don't advance.
|
|
81
|
-
if (!dispatched) {
|
|
82
|
-
return {
|
|
83
|
-
loop_id,
|
|
84
|
-
sequence_id: sequenceId,
|
|
85
|
-
action: 'noop',
|
|
86
|
-
dispatch: null,
|
|
87
|
-
messages_sent: 0,
|
|
88
|
-
reason: `sequence ${sequenceId} yielded no dispatch analysis (unavailable); loop stays in 'bind'`,
|
|
89
|
-
};
|
|
93
|
+
let bindings;
|
|
94
|
+
try {
|
|
95
|
+
bindings = deriveBindings(loop, sequenceId, cwd);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
throw new Error(`impl-bind validation failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
90
99
|
}
|
|
91
100
|
if (input.dryRun) {
|
|
92
101
|
return {
|
|
93
102
|
loop_id,
|
|
94
103
|
sequence_id: sequenceId,
|
|
95
104
|
action: 'preview',
|
|
96
|
-
dispatch:
|
|
97
|
-
messages_sent,
|
|
98
|
-
|
|
105
|
+
dispatch: null,
|
|
106
|
+
messages_sent: 0,
|
|
107
|
+
warnings: compatibilityWarnings(input),
|
|
108
|
+
reason: `dry run: linked sequence ${sequenceId} is valid; loop stays in 'bind' and no worker is dispatched`,
|
|
109
|
+
lanes: Object.entries(bindings).map(([slot_id, binding]) => ({ slot_id, lane: binding.lane, scope_hint: binding.scope_hint })),
|
|
99
110
|
};
|
|
100
111
|
}
|
|
101
|
-
// Real bind: advance bind → execute so the loop enters the execute↔verify cycle. The
|
|
102
|
-
// implementation protocol's `bind` phase carries no advance_gate, so the advance is
|
|
103
|
-
// unconditional FROM bind — but the phase-check and the advance must be ATOMIC or two
|
|
104
|
-
// racing binds each advance once and push the loop bind→execute→verify with no execute
|
|
105
|
-
// work done (review Finding 2). advance() does not self-lock (its callers — the facade's
|
|
106
|
-
// withLockedLoopMutation and the ideation closer — provide the lock), so we take the loop
|
|
107
|
-
// lock here and re-check the phase under it: only the bind that still sees 'bind' advances.
|
|
108
112
|
const advanced = withLoopLock({
|
|
109
113
|
cwd,
|
|
110
114
|
intent: 'impl-bind-advance',
|
|
@@ -112,12 +116,15 @@ export async function runImplBind(input, cwd) {
|
|
|
112
116
|
scope: { kind: 'loop', loopId: loop_id },
|
|
113
117
|
work: () => {
|
|
114
118
|
const fresh = getLoop(loop_id, cwd);
|
|
115
|
-
// Raced: a concurrent bind (or a pause/close) moved the loop out of an open 'bind'
|
|
116
|
-
// state → do NOT advance again. Idempotent by construction.
|
|
117
119
|
if (!fresh || fresh.status !== 'open' || fresh.current_phase !== 'bind')
|
|
118
120
|
return null;
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
+
const freshSequenceId = fresh.linked?.sequence_ids?.[0];
|
|
122
|
+
if (freshSequenceId !== sequenceId) {
|
|
123
|
+
throw new Error(`linked sequence ${sequenceId} changed or disappeared before bind could advance`);
|
|
124
|
+
}
|
|
125
|
+
const freshBindings = deriveBindings(fresh, sequenceId, cwd);
|
|
126
|
+
const result = advance({ id: loop_id, actor: dispatcherAgent, slot_bindings: freshBindings }, cwd);
|
|
127
|
+
return { phase: result.loop.current_phase, auto_closed: result.auto_closed };
|
|
121
128
|
},
|
|
122
129
|
});
|
|
123
130
|
if (!advanced) {
|
|
@@ -125,9 +132,10 @@ export async function runImplBind(input, cwd) {
|
|
|
125
132
|
loop_id,
|
|
126
133
|
sequence_id: sequenceId,
|
|
127
134
|
action: 'noop',
|
|
128
|
-
dispatch:
|
|
129
|
-
messages_sent,
|
|
130
|
-
|
|
135
|
+
dispatch: null,
|
|
136
|
+
messages_sent: 0,
|
|
137
|
+
warnings: compatibilityWarnings(input),
|
|
138
|
+
reason: `phase already advanced out of 'bind' under a concurrent bind (idempotent - not re-advanced)`,
|
|
131
139
|
};
|
|
132
140
|
}
|
|
133
141
|
return {
|
|
@@ -136,9 +144,11 @@ export async function runImplBind(input, cwd) {
|
|
|
136
144
|
action: 'bound',
|
|
137
145
|
advanced_to: advanced.phase,
|
|
138
146
|
auto_closed: advanced.auto_closed,
|
|
139
|
-
dispatch:
|
|
140
|
-
messages_sent,
|
|
141
|
-
|
|
147
|
+
dispatch: null,
|
|
148
|
+
messages_sent: 0,
|
|
149
|
+
warnings: compatibilityWarnings(input),
|
|
150
|
+
reason: `validated linked sequence ${sequenceId}; advanced bind -> ${advanced.phase}; dispatch worker slots with turn(dispatch=true)`,
|
|
151
|
+
lanes: Object.entries(bindings).map(([slot_id, binding]) => ({ slot_id, lane: binding.lane, scope_hint: binding.scope_hint })),
|
|
142
152
|
};
|
|
143
153
|
}
|
|
144
154
|
//# sourceMappingURL=impl-bind.js.map
|
package/dist/core/loops/index.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
export * from './types.js';
|
|
2
|
+
export * from './attempt-generations.js';
|
|
3
|
+
export * from './attempt-rollout.js';
|
|
4
|
+
export * from './attempt-takeover.js';
|
|
5
|
+
export { GATE_POLICY_VERSION, artifactEvidenceProvenance, artifactEvidenceDigest, evidenceDigest, evidencePolicyForNewLoop, evidenceWriterEnabled, validateArtifactEvidence, validateThreadEvidence, } from './evidence.js';
|
|
6
|
+
export { GATE_POLICIES, eligibleArtifactsForPurpose, evaluateCommandGreen, evaluateCriticSignal, evaluateGateCondition, evaluateNoNewCritique, } from './gate-policy.js';
|
|
7
|
+
export { EXECUTION_CONTRACT_PROTOCOL_VERSION, EXECUTION_CONTRACT_VERSION, CapabilityRequirementSchema, CapabilityResolutionReasonSchema, CapabilitySnapshotSchema, HarnessCapabilityBindingSchema, ExecutionContractRefSchema, ExecutionContractSchema, RuntimeCapabilityObservationSchema, assertExecutionContractIntegrity, capabilitySnapshotHash, canonicalExecutionContract, executionContractHash, executionContractRef, resolveCapabilitySnapshot, validateWorkerContractAcceptance, } from '../execution-contract.js';
|
|
2
8
|
export { AwaitingFileApplyApprovalError, closeLoop, ensureLoopsDir, generateLoopId, generateMutationId, generateSlotId, getLoop, listLoopEvents, listLoops, openLoop, writeThreadFile, } from './store.js';
|
|
3
9
|
export { add_artifact, advance, complete_turn, evaluatePhaseAdvanceGate, evaluateStopCondition, pause, provideInput, reconcileOpenQuestions, requestInput, resume, sweepPauseTimeouts, turn, } from './verbs.js';
|
|
4
10
|
export { decideNextPhase, artifactsInIteration, noNewCritiqueInIteration, hasCriticSignalInIteration, hasPassingVerifyReportInIteration, } from './iteration-engine.js';
|
|
@@ -12,4 +18,7 @@ export { buildSurveySignalsBaseline, } from './hooks/survey-signals-baseline.js'
|
|
|
12
18
|
export { acquireLock, hashRequest, recordConflict, withLoopLock, DEFAULT_MAX_MUTATION_DURATION_MS, IDEMPOTENCY_TTL_MS, LEASE_GRACE_MS, LEASE_WINDOW_MS, IdempotencyKeyReusedError, IdempotencyOwnerMismatchError, LockLostError, LockTimeoutError, VersionConflictError, } from './lock.js';
|
|
13
19
|
export { acquireBootstrapLoop, findExistingBootstrapLoop, BootstrapCoordinationInProgressError, } from './bootstrap-acquire.js';
|
|
14
20
|
export { deriveWorkerReplyContract, renderWorkerReplyProse, workerReplyNextAction, } from './worker-reply-contract.js';
|
|
21
|
+
export { abortAttempt, inspectAttempt, matchEvidence, prepareAttempt, projectAndCross, revokeAttempt, } from './attempt-authority.js';
|
|
22
|
+
export { LOOP_KIND_POLICIES, assertLoopKindPoliciesComplete, isWorkerPhase, phasePolicy, policyForKind, } from './kind-policies.js';
|
|
23
|
+
export { ensureTurnExecutionProjections, prepareTurnExecution, } from './turn-execution.js';
|
|
15
24
|
//# sourceMappingURL=index.js.map
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* transitions = exit_when conditions + cycle membership, guards = phase
|
|
13
13
|
* advance_gate, actions = system event emissions.
|
|
14
14
|
*/
|
|
15
|
+
import { evaluateCommandGreen, evaluateCriticSignal, evaluateNoNewCritique, } from './gate-policy.js';
|
|
15
16
|
/**
|
|
16
17
|
* Decide the next phase given the current thread state and the protocol.
|
|
17
18
|
*
|
|
@@ -19,7 +20,7 @@
|
|
|
19
20
|
* is no successor (last phase + no iteration block). Callers that want
|
|
20
21
|
* to handle "already at end" should check beforehand.
|
|
21
22
|
*/
|
|
22
|
-
export function decideNextPhase(thread, protocol) {
|
|
23
|
+
export function decideNextPhase(thread, protocol, cwd) {
|
|
23
24
|
const phaseNames = protocol.phases.map((p) => p.name);
|
|
24
25
|
const currentIndex = phaseNames.indexOf(thread.current_phase);
|
|
25
26
|
if (currentIndex < 0) {
|
|
@@ -44,6 +45,26 @@ export function decideNextPhase(thread, protocol) {
|
|
|
44
45
|
// exit by max_iterations.
|
|
45
46
|
const cycleIndex = cycle.indexOf(thread.current_phase);
|
|
46
47
|
const atCycleEnd = cycleIndex === cycle.length - 1;
|
|
48
|
+
// A saturation exit is a negative observation: after at least one full
|
|
49
|
+
// cycle, a settled critique phase with no eligible new critique exits
|
|
50
|
+
// directly. Waiting until the revision boundary made this condition
|
|
51
|
+
// unreachable because the critique phase's quantitative gate refused the
|
|
52
|
+
// very zero-artifact round the exit condition needs to observe.
|
|
53
|
+
if (cycleIndex === 0 &&
|
|
54
|
+
thread.iteration_count > 0 &&
|
|
55
|
+
protocol.iteration?.exit_when === 'no_new_critique_artifacts' &&
|
|
56
|
+
noNewCritiqueInIteration(thread, thread.iteration_count, cwd)) {
|
|
57
|
+
const lastCyclePhaseIndex = phaseNames.indexOf(cycle[cycle.length - 1]);
|
|
58
|
+
if (lastCyclePhaseIndex < 0 || lastCyclePhaseIndex + 1 >= phaseNames.length) {
|
|
59
|
+
throw new Error(`decideNextPhase: cycle's last phase "${cycle[cycle.length - 1]}" has no post-cycle successor`);
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
kind: 'exit_cycle',
|
|
63
|
+
target: phaseNames[lastCyclePhaseIndex + 1],
|
|
64
|
+
iteration: thread.iteration_count,
|
|
65
|
+
reason: 'no_new_critique_artifacts',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
47
68
|
if (!atCycleEnd) {
|
|
48
69
|
return {
|
|
49
70
|
kind: 'advance_to',
|
|
@@ -75,7 +96,7 @@ export function decideNextPhase(thread, protocol) {
|
|
|
75
96
|
// iteration_count is the one that just completed (the engine has not
|
|
76
97
|
// yet incremented).
|
|
77
98
|
if (iterationBlock.exit_when === 'critic_signal' &&
|
|
78
|
-
hasCriticSignalInIteration(thread, thread.iteration_count)) {
|
|
99
|
+
hasCriticSignalInIteration(thread, thread.iteration_count, cwd)) {
|
|
79
100
|
return {
|
|
80
101
|
kind: 'exit_cycle',
|
|
81
102
|
target: postCycleTarget,
|
|
@@ -84,7 +105,7 @@ export function decideNextPhase(thread, protocol) {
|
|
|
84
105
|
};
|
|
85
106
|
}
|
|
86
107
|
if (iterationBlock.exit_when === 'no_new_critique_artifacts' &&
|
|
87
|
-
noNewCritiqueInIteration(thread, thread.iteration_count)) {
|
|
108
|
+
noNewCritiqueInIteration(thread, thread.iteration_count, cwd)) {
|
|
88
109
|
return {
|
|
89
110
|
kind: 'exit_cycle',
|
|
90
111
|
target: postCycleTarget,
|
|
@@ -93,7 +114,7 @@ export function decideNextPhase(thread, protocol) {
|
|
|
93
114
|
};
|
|
94
115
|
}
|
|
95
116
|
if (iterationBlock.exit_when === 'command_green' &&
|
|
96
|
-
hasPassingVerifyReportInIteration(thread, thread.iteration_count)) {
|
|
117
|
+
hasPassingVerifyReportInIteration(thread, thread.iteration_count, cwd)) {
|
|
97
118
|
return {
|
|
98
119
|
kind: 'exit_cycle',
|
|
99
120
|
target: postCycleTarget,
|
|
@@ -134,16 +155,16 @@ export function artifactsInIteration(thread, iteration) {
|
|
|
134
155
|
* just-completed iteration produced no critique-typed artifacts. Used
|
|
135
156
|
* by `decideNextPhase` at the cycle boundary.
|
|
136
157
|
*/
|
|
137
|
-
export function noNewCritiqueInIteration(thread, iteration) {
|
|
138
|
-
return
|
|
158
|
+
export function noNewCritiqueInIteration(thread, iteration, cwd) {
|
|
159
|
+
return evaluateNoNewCritique(thread, iteration, cwd).passed;
|
|
139
160
|
}
|
|
140
161
|
/**
|
|
141
162
|
* `exit_when='critic_signal'` predicate: true when the iteration
|
|
142
163
|
* contains a `type='critic_signal'` artifact (any subtype/body). The
|
|
143
164
|
* critic emits this when it judges the proposal sufficient.
|
|
144
165
|
*/
|
|
145
|
-
export function hasCriticSignalInIteration(thread, iteration) {
|
|
146
|
-
return thread
|
|
166
|
+
export function hasCriticSignalInIteration(thread, iteration, cwd) {
|
|
167
|
+
return evaluateCriticSignal(thread, iteration, cwd).passed;
|
|
147
168
|
}
|
|
148
169
|
/**
|
|
149
170
|
* `exit_when='command_green'` predicate (pln#609): true when the just-finished
|
|
@@ -153,16 +174,7 @@ export function hasCriticSignalInIteration(thread, iteration) {
|
|
|
153
174
|
* Increment 2). Iteration-window scoped so a green from a prior iteration can
|
|
154
175
|
* never satisfy the current one; absence reads as false.
|
|
155
176
|
*/
|
|
156
|
-
export function hasPassingVerifyReportInIteration(thread, iteration) {
|
|
157
|
-
return
|
|
158
|
-
if (a.type !== 'verify_report')
|
|
159
|
-
return false;
|
|
160
|
-
try {
|
|
161
|
-
return JSON.parse(a.body ?? '{}').passed === true;
|
|
162
|
-
}
|
|
163
|
-
catch {
|
|
164
|
-
return false;
|
|
165
|
-
}
|
|
166
|
-
});
|
|
177
|
+
export function hasPassingVerifyReportInIteration(thread, iteration, cwd) {
|
|
178
|
+
return evaluateCommandGreen(thread, iteration, cwd).passed;
|
|
167
179
|
}
|
|
168
180
|
//# sourceMappingURL=iteration-engine.js.map
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { DEFAULT_PROTOCOLS, LOOP_KINDS } from './types.js';
|
|
2
|
+
const expected = (logicalName, loopArtifactType = logicalName) => ({
|
|
3
|
+
logical_name: logicalName,
|
|
4
|
+
worker_path: 'LANE-RESULT.json',
|
|
5
|
+
loop_artifact_type: loopArtifactType,
|
|
6
|
+
completion_policy: 'required',
|
|
7
|
+
});
|
|
8
|
+
/**
|
|
9
|
+
* Execution metadata only. DEFAULT_PROTOCOLS remains canonical for phase
|
|
10
|
+
* graphs, advance gates, iteration and stop conditions.
|
|
11
|
+
*/
|
|
12
|
+
export const LOOP_KIND_POLICIES = {
|
|
13
|
+
review: {
|
|
14
|
+
phases: {
|
|
15
|
+
change_summary: { execution: 'manual' },
|
|
16
|
+
findings: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('review_verdict', 'verdict')], finalization: 'report' },
|
|
17
|
+
author_response: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('author_response')], finalization: 'integrate' },
|
|
18
|
+
followup_review: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('review_verdict', 'verdict')], finalization: 'report' },
|
|
19
|
+
verdict: { execution: 'engine' },
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
ideation: {
|
|
23
|
+
phases: {
|
|
24
|
+
proposal: { execution: 'manual' },
|
|
25
|
+
critique: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('critique')], finalization: 'report' },
|
|
26
|
+
// Critic and champion work use the same generic production driver. The
|
|
27
|
+
// coordinator still chooses when to cross each phase; the worker only
|
|
28
|
+
// produces the declared artifact and never advances the loop.
|
|
29
|
+
revision: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('revision')], finalization: 'report' },
|
|
30
|
+
synthesis: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('plan_draft')], finalization: 'report' },
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
implementation: {
|
|
34
|
+
phases: {
|
|
35
|
+
bind: { execution: 'engine' },
|
|
36
|
+
execute: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('execute_report')], finalization: 'integrate' },
|
|
37
|
+
verify: { execution: 'engine' },
|
|
38
|
+
handoff_ready: { execution: 'manual' },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
research: {
|
|
42
|
+
phases: {
|
|
43
|
+
investigate: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('finding')], finalization: 'report' },
|
|
44
|
+
synthesize: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('synthesis')], finalization: 'report' },
|
|
45
|
+
conclude: { execution: 'engine' },
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
debug: {
|
|
49
|
+
phases: {
|
|
50
|
+
reproduce: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('repro')], finalization: 'report' },
|
|
51
|
+
hypothesize: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('hypothesis')], finalization: 'report' },
|
|
52
|
+
isolate: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('isolation_report')], finalization: 'report' },
|
|
53
|
+
fix: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('verify_report')], finalization: 'integrate' },
|
|
54
|
+
handoff: { execution: 'manual' },
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
export function policyForKind(kind) {
|
|
59
|
+
return LOOP_KIND_POLICIES[kind];
|
|
60
|
+
}
|
|
61
|
+
export function phasePolicy(kind, phase) {
|
|
62
|
+
return LOOP_KIND_POLICIES[kind].phases[phase];
|
|
63
|
+
}
|
|
64
|
+
export function isWorkerPhase(kind, phase) {
|
|
65
|
+
return phasePolicy(kind, phase)?.execution === 'worker';
|
|
66
|
+
}
|
|
67
|
+
/** Runtime assertion used by conformance tests and startup diagnostics. */
|
|
68
|
+
export function assertLoopKindPoliciesComplete() {
|
|
69
|
+
for (const kind of LOOP_KINDS) {
|
|
70
|
+
const policy = LOOP_KIND_POLICIES[kind];
|
|
71
|
+
if (!policy)
|
|
72
|
+
throw new Error(`missing LoopKindPolicy for ${kind}`);
|
|
73
|
+
const protocolPhases = DEFAULT_PROTOCOLS[kind].phases.map((phase) => phase.name).sort();
|
|
74
|
+
const policyPhases = Object.keys(policy.phases).sort();
|
|
75
|
+
if (JSON.stringify(protocolPhases) !== JSON.stringify(policyPhases)) {
|
|
76
|
+
throw new Error(`${kind} policy phases diverge from DEFAULT_PROTOCOLS: policy=${policyPhases.join(',')} protocol=${protocolPhases.join(',')}`);
|
|
77
|
+
}
|
|
78
|
+
for (const [phase, phaseExecution] of Object.entries(policy.phases)) {
|
|
79
|
+
if (phaseExecution.execution === 'worker') {
|
|
80
|
+
if (!phaseExecution.completion_mode || !phaseExecution.expected_artifacts?.length || !phaseExecution.finalization) {
|
|
81
|
+
throw new Error(`${kind}.${phase} worker policy is incomplete`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else if (phaseExecution.completion_mode || phaseExecution.expected_artifacts?.length || phaseExecution.finalization) {
|
|
85
|
+
throw new Error(`${kind}.${phase} ${phaseExecution.execution} policy must not declare worker execution metadata`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=kind-policies.js.map
|
package/dist/core/loops/lock.js
CHANGED
|
@@ -9,9 +9,11 @@ import { recoverPendingIntents } from './commit-intent.js';
|
|
|
9
9
|
* Per-loop exclusive lock + idempotency + fencing helpers.
|
|
10
10
|
*
|
|
11
11
|
* Implements the commit protocol from docs/concepts/loop-engine.md §Persistence.
|
|
12
|
-
* For synchronous
|
|
13
|
-
* lease renewal via an internal heartbeat is not
|
|
14
|
-
*
|
|
12
|
+
* For synchronous mutations, the lock window is short (< 100ms typically), so
|
|
13
|
+
* lease renewal via an internal heartbeat is not wired up. `lease_until` and
|
|
14
|
+
* `hard_deadline` remain durable diagnostics, but elapsed time alone never
|
|
15
|
+
* authorizes automatic takeover: without per-write fencing, a suspended live
|
|
16
|
+
* process could otherwise resume after takeover and commit as a second owner.
|
|
15
17
|
*/
|
|
16
18
|
export const LOCK_BACKOFF_BASE_MS = 10;
|
|
17
19
|
export const LOCK_BACKOFF_TOTAL_MS = 500;
|
|
@@ -179,14 +181,70 @@ function processIsAlive(pid) {
|
|
|
179
181
|
return code === 'EPERM';
|
|
180
182
|
}
|
|
181
183
|
}
|
|
182
|
-
function lockIsStale(blob,
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
184
|
+
function lockIsStale(blob, _now) {
|
|
185
|
+
// Automatic reaping is deliberately proof-based. On the local host we can
|
|
186
|
+
// prove the owning process is gone. A deadline or lease expiry only proves
|
|
187
|
+
// that a process was delayed; on Windows a suspended live process can resume.
|
|
188
|
+
// Cross-host liveness is likewise unknowable from this file alone, so those
|
|
189
|
+
// locks fail closed and require explicit operator recovery.
|
|
190
|
+
return blob.host_id === os.hostname() && !processIsAlive(blob.pid);
|
|
191
|
+
}
|
|
192
|
+
function takeoverClaimPath(lockPath, observedMutationId) {
|
|
193
|
+
// Lock blobs are read from disk and may predate schema validation. Hash the
|
|
194
|
+
// token before using it as a filename so a corrupt value cannot escape the
|
|
195
|
+
// takeover directory or create a Windows-invalid path.
|
|
196
|
+
const generationKey = crypto.createHash('sha256').update(observedMutationId).digest('hex');
|
|
197
|
+
return path.join(`${lockPath}.takeovers`, `${generationKey}.lock`);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Remove exactly the stale generation that was observed.
|
|
201
|
+
*
|
|
202
|
+
* A plain `read stale -> unlink(path)` has an ABA race on Windows: another
|
|
203
|
+
* reaper can replace generation X with Y between the read and unlink, after
|
|
204
|
+
* which the late reaper deletes Y. The immutable, generation-keyed takeover
|
|
205
|
+
* claim elects one reaper for X. Losers never unlink the shared path.
|
|
206
|
+
*
|
|
207
|
+
* If the elected reaper crashes before unlinking, the generation fails closed
|
|
208
|
+
* (the tiny takeover claim is retained for operator recovery). If it crashes
|
|
209
|
+
* after unlinking, contenders can safely acquire the now-empty main path.
|
|
210
|
+
*/
|
|
211
|
+
function reapObservedLock(lockPath, observed, agentId) {
|
|
212
|
+
const nowMs = Date.now();
|
|
213
|
+
const claimPath = takeoverClaimPath(lockPath, observed.mutation_id);
|
|
214
|
+
const claim = {
|
|
215
|
+
pid: process.pid,
|
|
216
|
+
host_id: os.hostname(),
|
|
217
|
+
agent_id: agentId,
|
|
218
|
+
acquired_at: new Date(nowMs).toISOString(),
|
|
219
|
+
lease_until: new Date(nowMs + LEASE_WINDOW_MS).toISOString(),
|
|
220
|
+
hard_deadline: new Date(nowMs + 5_000).toISOString(),
|
|
221
|
+
mutation_id: crypto.randomUUID().replace(/-/g, ''),
|
|
222
|
+
};
|
|
223
|
+
if (!acquireRaw(claimPath, claim))
|
|
224
|
+
return false;
|
|
225
|
+
let removed = false;
|
|
226
|
+
try {
|
|
227
|
+
const current = readLockBlob(lockPath);
|
|
228
|
+
if (current
|
|
229
|
+
&& current.mutation_id === observed.mutation_id
|
|
230
|
+
&& lockIsStale(current, Date.now())) {
|
|
231
|
+
fs.unlinkSync(lockPath);
|
|
232
|
+
removed = true;
|
|
233
|
+
}
|
|
234
|
+
return removed;
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
// Delete only our own claim. A crash before this point intentionally leaves
|
|
238
|
+
// a fail-closed marker instead of guessing that a different process is dead.
|
|
239
|
+
try {
|
|
240
|
+
const currentClaim = readLockBlob(claimPath);
|
|
241
|
+
if (currentClaim?.mutation_id === claim.mutation_id)
|
|
242
|
+
fs.unlinkSync(claimPath);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
/* best-effort; a retained claim fails closed for this stale generation */
|
|
246
|
+
}
|
|
247
|
+
}
|
|
190
248
|
}
|
|
191
249
|
function acquireRaw(lockPath, blob) {
|
|
192
250
|
ensureDirFor(lockPath);
|
|
@@ -221,10 +279,10 @@ export function acquireLock(options) {
|
|
|
221
279
|
if (existing) {
|
|
222
280
|
if (lockIsStale(existing, Date.now())) {
|
|
223
281
|
try {
|
|
224
|
-
|
|
282
|
+
reapObservedLock(options.lockPath, existing, options.agentId);
|
|
225
283
|
}
|
|
226
284
|
catch {
|
|
227
|
-
/*
|
|
285
|
+
/* another reaper won or I/O failed; exclusive acquire below retries */
|
|
228
286
|
}
|
|
229
287
|
}
|
|
230
288
|
}
|