brainclaw 1.26.2 → 1.27.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 +87 -14
- package/dist/commands/mcp-catalog.js +42 -18
- package/dist/commands/mcp-schemas.generated.js +44 -0
- package/dist/commands/mcp-write-claims.js +128 -1
- package/dist/commands/mcp-write-coordination.js +146 -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 +160 -14
- package/dist/core/execution-contract.js +345 -0
- package/dist/core/execution.js +130 -16
- 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 +207 -0
- package/dist/core/loops/artifact-contract.js +11 -0
- package/dist/core/loops/attempt-authority.js +476 -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/evidence.js +187 -0
- package/dist/core/loops/facade-schema.js +41 -10
- package/dist/core/loops/gate-policy.js +485 -0
- package/dist/core/loops/impl-bind.js +37 -79
- 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 +235 -18
- package/dist/core/loops/result-reducers.js +99 -10
- package/dist/core/loops/store.js +30 -3
- package/dist/core/loops/turn-execution.js +480 -0
- package/dist/core/loops/types.js +113 -2
- package/dist/core/loops/verbs.js +332 -99
- package/dist/core/loops/verify-command.js +31 -8
- 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 +35 -0
- package/dist/core/spawn-check.js +3 -2
- package/dist/core/upgrades/backup.js +27 -4
- package/dist/facts.js +7 -6
- package/dist/facts.json +6 -5
- 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 +154 -0
- package/docs/loops/research.md +136 -0
- package/docs/loops/review.md +200 -0
- package/docs/mcp-schema-changelog.md +14 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -282,6 +282,19 @@ per-phase memory filters. The shared controls are `open`, `turn`,
|
|
|
282
282
|
implementation also adds `bind` and `verify`. `request_input` /
|
|
283
283
|
`provide_input` are cross-cutting clarification primitives for any workflow.
|
|
284
284
|
|
|
285
|
+
Every worker-backed phase, across all five workflows, is launched from one
|
|
286
|
+
immutable [execution contract](docs/concepts/execution-contract.md): exact
|
|
287
|
+
identity, artifact expectations, workspace policy, capability snapshot and
|
|
288
|
+
evidence rules are persisted before the launch fence crosses. This reuses the
|
|
289
|
+
existing TurnReservation, Assignment and AgentRun records; it does not add a
|
|
290
|
+
second event journal.
|
|
291
|
+
|
|
292
|
+
Gate-driving artifacts are also bound to a server-sealed
|
|
293
|
+
[evidence envelope](docs/concepts/evidence-attestations.md). Review approval,
|
|
294
|
+
command verification, claim binding, and ordinary observations are separate
|
|
295
|
+
attestations rather than one confidence score; the same policy mechanism
|
|
296
|
+
protects every workflow above.
|
|
297
|
+
|
|
285
298
|
Review is a useful specialized path, not the definition of the engine. It has
|
|
286
299
|
asymmetric and symmetric modes and can auto-close on an approved verdict; the
|
|
287
300
|
other workflows use the same lifecycle to converge on a plan, synthesis,
|
|
Binary file
|
|
@@ -388,7 +388,7 @@ export function registerCoordinationCommands(program) {
|
|
|
388
388
|
// --- loop (drive loop turn verbs; pln#517 step 2) ---
|
|
389
389
|
const loopCmd = program
|
|
390
390
|
.command('loop')
|
|
391
|
-
.description('Drive
|
|
391
|
+
.description('Drive loop turns, fenced takeovers, phase advances, and artifacts');
|
|
392
392
|
loopCmd
|
|
393
393
|
.command('turn <loop_id>')
|
|
394
394
|
.description('Issue a turn assignment on a slot')
|
|
@@ -407,6 +407,13 @@ export function registerCoordinationCommands(program) {
|
|
|
407
407
|
.description('Complete a slot turn')
|
|
408
408
|
.requiredOption('--slot <slot_id>', 'Target slot id (lsl_...)')
|
|
409
409
|
.requiredOption('--outcome <outcome>', 'Turn outcome: done, failed, or cancelled')
|
|
410
|
+
.option('--assignment-id <id>', 'Attempt fence: stable assignment id')
|
|
411
|
+
.option('--turn-id <id>', 'Attempt fence: stable logical turn id')
|
|
412
|
+
.option('--run-id <id>', 'Attempt fence: physical run id')
|
|
413
|
+
.option('--nonce <value>', 'Attempt fence: generation launch nonce')
|
|
414
|
+
.option('--attempt-epoch <n>', 'Attempt fence: non-negative generation epoch')
|
|
415
|
+
.option('--execution-contract-hash <sha256>', 'Attempt fence: execution contract hash')
|
|
416
|
+
.option('--workspace-digest <sha256>', 'Attempt fence: canonical workspace digest')
|
|
410
417
|
.option('--failure-reason <text>', 'Reason when outcome is failed')
|
|
411
418
|
.option('--artifact <json>', 'JSON object payload for an artifact to attach')
|
|
412
419
|
.option('--json', 'Machine-readable output')
|
|
@@ -415,6 +422,24 @@ export function registerCoordinationCommands(program) {
|
|
|
415
422
|
const { runLoopCommand } = await import('../commands/loop.js');
|
|
416
423
|
await runLoopCommand('complete-turn', { loop_id }, options, globalOpts.cwd);
|
|
417
424
|
});
|
|
425
|
+
loopCmd
|
|
426
|
+
.command('takeover <loop_id>')
|
|
427
|
+
.description('Fence the active physical run and arm a fresh generation')
|
|
428
|
+
.requiredOption('--slot <slot_id>', 'Target slot id (lsl_...)')
|
|
429
|
+
.requiredOption('--turn-id <turn_id>', 'Stable logical turn id')
|
|
430
|
+
.requiredOption('--expected-epoch <n>', 'Currently active generation epoch')
|
|
431
|
+
.requiredOption('--cause <text>', 'Audited takeover cause')
|
|
432
|
+
.requiredOption('--liveness-evidence <text>', 'Evidence that the prior producer cannot safely continue')
|
|
433
|
+
.requiredOption('--external-effect-policy <policy>', 'none, idempotent, or externally_fenced')
|
|
434
|
+
.requiredOption('--next-workspace-path <path>', 'Existing isolated workspace for the new generation')
|
|
435
|
+
.requiredOption('--agent <agent>', 'Loop coordinator identity')
|
|
436
|
+
.option('--mode <mode>', 'takeover or retry', 'takeover')
|
|
437
|
+
.option('--json', 'Machine-readable output')
|
|
438
|
+
.action(async (loop_id, options) => {
|
|
439
|
+
const globalOpts = program.opts();
|
|
440
|
+
const { runLoopCommand } = await import('../commands/loop.js');
|
|
441
|
+
await runLoopCommand('takeover', { loop_id }, options, globalOpts.cwd);
|
|
442
|
+
});
|
|
418
443
|
loopCmd
|
|
419
444
|
.command('advance <loop_id>')
|
|
420
445
|
.description('Advance a loop to its next phase')
|
|
@@ -441,6 +466,45 @@ export function registerCoordinationCommands(program) {
|
|
|
441
466
|
const { runLoopCommand } = await import('../commands/loop.js');
|
|
442
467
|
await runLoopCommand('add-artifact', { loop_id }, options, globalOpts.cwd);
|
|
443
468
|
});
|
|
469
|
+
// --- attempt-authority (two-release writer guard; P4) ---
|
|
470
|
+
const attemptAuthorityCmd = program
|
|
471
|
+
.command('attempt-authority')
|
|
472
|
+
.description('Prepare, acknowledge and activate AttemptAuthority v2 writer compatibility');
|
|
473
|
+
attemptAuthorityCmd
|
|
474
|
+
.command('status')
|
|
475
|
+
.option('--json', 'Machine-readable output')
|
|
476
|
+
.action((options) => {
|
|
477
|
+
const globalOpts = program.opts();
|
|
478
|
+
return import('../commands/attempt-authority.js').then(({ runAttemptAuthorityCommand }) => runAttemptAuthorityCommand('status', { ...options, cwd: globalOpts.cwd }));
|
|
479
|
+
});
|
|
480
|
+
attemptAuthorityCmd
|
|
481
|
+
.command('prepare')
|
|
482
|
+
.requiredOption('--writers <agent_ids...>', 'Complete Release-A writer membership (registered agent ids)')
|
|
483
|
+
.option('--membership-epoch <n>', 'Membership epoch; defaults to active+1')
|
|
484
|
+
.option('--prepared-by <actor>', 'Audited operator/coordinator identity', 'operator')
|
|
485
|
+
.option('--json', 'Machine-readable output')
|
|
486
|
+
.action((options) => {
|
|
487
|
+
const globalOpts = program.opts();
|
|
488
|
+
return import('../commands/attempt-authority.js').then(({ runAttemptAuthorityCommand }) => runAttemptAuthorityCommand('prepare', { ...options, cwd: globalOpts.cwd }));
|
|
489
|
+
});
|
|
490
|
+
attemptAuthorityCmd
|
|
491
|
+
.command('ack')
|
|
492
|
+
.requiredOption('--membership-epoch <n>', 'Prepared membership epoch')
|
|
493
|
+
.requiredOption('--agent-id <agent_id>', 'Writer signing this ACK')
|
|
494
|
+
.option('--json', 'Machine-readable output')
|
|
495
|
+
.action((options) => {
|
|
496
|
+
const globalOpts = program.opts();
|
|
497
|
+
return import('../commands/attempt-authority.js').then(({ runAttemptAuthorityCommand }) => runAttemptAuthorityCommand('ack', { ...options, cwd: globalOpts.cwd }));
|
|
498
|
+
});
|
|
499
|
+
attemptAuthorityCmd
|
|
500
|
+
.command('activate')
|
|
501
|
+
.requiredOption('--membership-epoch <n>', 'Fully acknowledged membership epoch')
|
|
502
|
+
.option('--activated-by <actor>', 'Audited operator/coordinator identity', 'operator')
|
|
503
|
+
.option('--json', 'Machine-readable output')
|
|
504
|
+
.action((options) => {
|
|
505
|
+
const globalOpts = program.opts();
|
|
506
|
+
return import('../commands/attempt-authority.js').then(({ runAttemptAuthorityCommand }) => runAttemptAuthorityCommand('activate', { ...options, cwd: globalOpts.cwd }));
|
|
507
|
+
});
|
|
444
508
|
// --- reply (provide_input to an operator_question; pln#508 step 4) ---
|
|
445
509
|
program
|
|
446
510
|
.command('reply <qst_id>')
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { loadAgentIdentity, loadAgentSigningKey } from '../core/agent-registry.js';
|
|
2
|
+
import { loadConnectionState } from '../core/federation-state.js';
|
|
3
|
+
import { ATTEMPT_AUTHORITY_WRITER_VERSION, activateAttemptAuthorityV2, attemptRolloutActivationDigest, ensureLocalAuthorityHome, prepareAttemptAuthorityRollout, publishAttemptRolloutAck, readLocalAuthorityHome, resolveActiveAttemptRollout, } from '../core/loops/attempt-rollout.js';
|
|
4
|
+
function epoch(value, fallback = 1) {
|
|
5
|
+
const parsed = value === undefined ? fallback : Number(value);
|
|
6
|
+
if (!Number.isInteger(parsed) || parsed <= 0)
|
|
7
|
+
throw new Error('membership epoch must be a positive integer');
|
|
8
|
+
return parsed;
|
|
9
|
+
}
|
|
10
|
+
function print(value, json) {
|
|
11
|
+
if (json)
|
|
12
|
+
console.log(JSON.stringify(value, null, 2));
|
|
13
|
+
else
|
|
14
|
+
console.log(typeof value === 'string' ? value : JSON.stringify(value, null, 2));
|
|
15
|
+
}
|
|
16
|
+
export function runAttemptAuthorityCommand(subcommand, options = {}) {
|
|
17
|
+
const cwd = options.cwd ?? process.cwd();
|
|
18
|
+
if (subcommand === 'status') {
|
|
19
|
+
const active = resolveActiveAttemptRollout(cwd);
|
|
20
|
+
print({
|
|
21
|
+
writer_version: ATTEMPT_AUTHORITY_WRITER_VERSION,
|
|
22
|
+
local_authority_home: readLocalAuthorityHome(cwd),
|
|
23
|
+
active,
|
|
24
|
+
}, options.json);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (subcommand === 'prepare') {
|
|
28
|
+
const writerIds = options.writers ?? [];
|
|
29
|
+
if (writerIds.length === 0)
|
|
30
|
+
throw new Error('prepare requires at least one --writers <agent_id>');
|
|
31
|
+
const participants = writerIds.map((writerId) => {
|
|
32
|
+
const identity = loadAgentIdentity(writerId, cwd);
|
|
33
|
+
if (!identity.identity_key) {
|
|
34
|
+
throw new Error(`agent ${writerId} has no Ed25519 identity key; re-register it before preparing rollout`);
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
writer_id: writerId,
|
|
38
|
+
public_key_pem: identity.identity_key.public_key,
|
|
39
|
+
key_fingerprint: identity.identity_key.fingerprint,
|
|
40
|
+
status: 'active',
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
const active = resolveActiveAttemptRollout(cwd);
|
|
44
|
+
const membershipEpoch = epoch(options.membershipEpoch, (active?.guard.membership_epoch ?? 0) + 1);
|
|
45
|
+
const connection = loadConnectionState(cwd);
|
|
46
|
+
const home = ensureLocalAuthorityHome(cwd, { device_id: connection?.device.device_id });
|
|
47
|
+
const guard = prepareAttemptAuthorityRollout(cwd, {
|
|
48
|
+
membership_epoch: membershipEpoch,
|
|
49
|
+
authority_home: home,
|
|
50
|
+
participants,
|
|
51
|
+
previous_activation_digest: active ? attemptRolloutActivationDigest(active.activation) : undefined,
|
|
52
|
+
prepared_by: options.preparedBy ?? 'operator',
|
|
53
|
+
});
|
|
54
|
+
print(guard, options.json);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (subcommand === 'ack') {
|
|
58
|
+
const writerId = options.agentId;
|
|
59
|
+
if (!writerId)
|
|
60
|
+
throw new Error('ack requires --agent-id <agent_id>');
|
|
61
|
+
const signing = loadAgentSigningKey(writerId);
|
|
62
|
+
if (!signing)
|
|
63
|
+
throw new Error(`no local Ed25519 signing key for ${writerId}`);
|
|
64
|
+
const ack = publishAttemptRolloutAck(cwd, {
|
|
65
|
+
membership_epoch: epoch(options.membershipEpoch),
|
|
66
|
+
writer_id: writerId,
|
|
67
|
+
writer_version: ATTEMPT_AUTHORITY_WRITER_VERSION,
|
|
68
|
+
private_key_pem: signing.privateKeyPem,
|
|
69
|
+
});
|
|
70
|
+
print(ack, options.json);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (subcommand === 'activate') {
|
|
74
|
+
const activation = activateAttemptAuthorityV2(cwd, epoch(options.membershipEpoch), options.activatedBy ?? 'operator');
|
|
75
|
+
print(activation, options.json);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
throw new Error(`unknown attempt-authority subcommand: ${subcommand}`);
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=attempt-authority.js.map
|
package/dist/commands/harvest.js
CHANGED
|
@@ -24,54 +24,33 @@ import { getCapabilityProfile, dispatchCanCommit } from '../core/agent-capabilit
|
|
|
24
24
|
import { commitWorktreeOnBehalf, worktreesBaseDir, resolveGitToplevel } from '../core/worktree.js';
|
|
25
25
|
import { closeReviewLoopFromLaneResult } from '../core/review-loop-close.js';
|
|
26
26
|
import { closeIdeationLoopFromLaneResult } from '../core/ideation-loop-close.js';
|
|
27
|
-
import { dispatchReviewLoopTurn,
|
|
27
|
+
import { dispatchReviewLoopTurn, turnOwnedLoopEnabled } from '../core/review-loop-turn-dispatch.js';
|
|
28
28
|
import { reconcileTurn } from '../core/loops/reconcile-turn.js';
|
|
29
29
|
import { findReservationByAssignmentId } from '../core/loops/attempt-reservation.js';
|
|
30
|
+
import { resolveTurnGenerationChain } from '../core/loops/attempt-generations.js';
|
|
30
31
|
import { getLoop } from '../core/loops/store.js';
|
|
32
|
+
import { phasePolicy } from '../core/loops/kind-policies.js';
|
|
31
33
|
import { readCompletionSignals } from '../core/runtime-signals.js';
|
|
32
34
|
import { reconcileClaimConformity } from '../core/claim-conformity.js';
|
|
33
35
|
import { toWarningDetail } from '../core/warnings.js';
|
|
34
|
-
|
|
35
|
-
* pln#630 PR3a — finalize a TURN-OWNED review lane via the exactly-once `reconcileTurn`
|
|
36
|
-
* instead of the legacy `closeReviewLoopFromLaneResult`. Returns `undefined` for a legacy
|
|
37
|
-
* (non-reserved) lane so the caller runs the unchanged legacy path — this is the
|
|
38
|
-
* exactly-one-finalizer discriminator: a lane is turn-owned iff a reservation OWNS its
|
|
39
|
-
* assignment_id (only the turn-owned dispatch writes a reservation file).
|
|
40
|
-
*
|
|
41
|
-
* Evidence sourcing (the load-bearing subtlety): a real reviewer's LANE-RESULT.json is
|
|
42
|
-
* KEYLESS — the review brief never asks the worker to echo turn_id/run_id/nonce — so
|
|
43
|
-
* read-strict `reconcileTurn` (which matches lane.{turn_id,run_id,nonce} against the
|
|
44
|
-
* attempt) would REJECT it. We source the keys authoritatively: turn_id + run_id are
|
|
45
|
-
* deterministic from the reservation, and the NONCE — the non-derivable proof that THIS
|
|
46
|
-
* launch generation actually ran — comes from the coordinator's completion SENTINEL
|
|
47
|
-
* (written mechanically by the ack-wrapper with the launch-grant token). A caller/test
|
|
48
|
-
* that already supplies keyed lanes is honored (lane.* wins); a stale generation's
|
|
49
|
-
* sentinel carries the old token → still rejected, preserving the anti-stale guarantee.
|
|
50
|
-
*/
|
|
51
|
-
/**
|
|
52
|
-
* The turn-owned FINALIZATION discriminator (pln#630, review Finding 1). A lane finalizes via
|
|
53
|
-
* the exactly-once reconcileTurn ONLY if a committed reservation OWNS it AND turn-keyed evidence
|
|
54
|
-
* (the nonce) is available — from the lane or the coordinator's completion SENTINEL. Without the
|
|
55
|
-
* nonce, reconcileTurn's read-strict gate can NEVER converge: this is reachable in production
|
|
56
|
-
* when a turn-owned dispatch WON the fence but did not ack-wrap-spawn (inbox_only / IDE-only
|
|
57
|
-
* reviewer, command_ready_manual, capacity cap, BRAINCLAW_NO_SPAWN, worktree-creation failure) —
|
|
58
|
-
* it minted a reservation but no sentinel will ever be written. Returning undefined there routes
|
|
59
|
-
* the lane to the LEGACY presence-based closer so the loop still converges instead of stalling
|
|
60
|
-
* forever. This is SAFE: the exactly-once SPAWN guarantee is enforced at DISPATCH by the launch
|
|
61
|
-
* fence (already run), so using legacy FINALIZATION for a sentinel-less lane reintroduces no
|
|
62
|
-
* double-spawn; and a sentinel that lands after a legacy close makes a later reconcile a
|
|
63
|
-
* terminal-loop idempotent no-op.
|
|
64
|
-
*/
|
|
36
|
+
import { harvestHarnessObservation } from '../core/harness-adapters/index.js';
|
|
65
37
|
function turnOwnedLaneEvidence(lane, cwd) {
|
|
66
38
|
const reservation = findReservationByAssignmentId(lane.assignment_id, cwd);
|
|
67
39
|
if (!reservation)
|
|
68
40
|
return undefined; // legacy lane (no reservation)
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
41
|
+
const chain = resolveTurnGenerationChain(cwd, reservation.turn_id);
|
|
42
|
+
const completion = readCompletionSignals(cwd, reservation.child_ids.assignment_id, chain?.latest_generation.run_id).completed;
|
|
43
|
+
const nonce = lane.nonce ?? completion?.nonce;
|
|
44
|
+
if (!nonce && !reservation.execution_contract_ref)
|
|
45
|
+
return undefined;
|
|
46
|
+
return {
|
|
47
|
+
reservation,
|
|
48
|
+
nonce,
|
|
49
|
+
contract_hash: lane.execution_contract_hash ?? completion?.contract_hash,
|
|
50
|
+
capability_snapshot_hash: lane.capability_snapshot_hash ?? completion?.capability_snapshot_hash,
|
|
51
|
+
};
|
|
73
52
|
}
|
|
74
|
-
function
|
|
53
|
+
function reconcileTurnOwnedLane(lane, cwd, evidence) {
|
|
75
54
|
const ev = evidence ?? turnOwnedLaneEvidence(lane, cwd);
|
|
76
55
|
if (!ev)
|
|
77
56
|
return undefined; // legacy lane OR no turn-keyed evidence — caller runs the legacy path
|
|
@@ -81,8 +60,17 @@ function reconcileTurnOwnedReviewLane(lane, cwd, evidence) {
|
|
|
81
60
|
turn_id: lane.turn_id ?? reservation.turn_id,
|
|
82
61
|
run_id: lane.run_id ?? reservation.child_ids.run_id,
|
|
83
62
|
nonce,
|
|
63
|
+
execution_contract_hash: lane.execution_contract_hash ?? ev.contract_hash,
|
|
64
|
+
capability_snapshot_hash: lane.capability_snapshot_hash ?? ev.capability_snapshot_hash,
|
|
84
65
|
};
|
|
85
|
-
const
|
|
66
|
+
const loop = getLoop(reservation.loop_id, cwd);
|
|
67
|
+
const critiques = loop?.kind === 'ideation'
|
|
68
|
+
&& reservation.phase === 'critique'
|
|
69
|
+
&& lane.artifact_type === 'critique'
|
|
70
|
+
&& (lane.body ?? '').trim().length > 0
|
|
71
|
+
? [{ body: lane.body.trim() }]
|
|
72
|
+
: undefined;
|
|
73
|
+
const result = reconcileTurn({ turn_id: reservation.turn_id, lane: enrichedLane, cwd, critiques });
|
|
86
74
|
return { reservation, result };
|
|
87
75
|
}
|
|
88
76
|
/**
|
|
@@ -392,15 +380,31 @@ export function harvestLaneResults(options = {}) {
|
|
|
392
380
|
const worktreePaths = resolveLaneScanPaths(options, cwd);
|
|
393
381
|
for (const worktreePath of worktreePaths) {
|
|
394
382
|
const file = getLaneResultPath(worktreePath);
|
|
395
|
-
|
|
383
|
+
const fileExists = fs.existsSync(file);
|
|
384
|
+
let nativeObservation;
|
|
385
|
+
if (options.assignmentId) {
|
|
386
|
+
try {
|
|
387
|
+
nativeObservation = harvestHarnessObservation(options.assignmentId, cwd, !options.dryRun);
|
|
388
|
+
}
|
|
389
|
+
catch (err) {
|
|
390
|
+
result.errors.push(`Failed to harvest native harness output for ${options.assignmentId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (!fileExists && !nativeObservation)
|
|
396
395
|
continue;
|
|
397
396
|
let lane;
|
|
398
|
-
|
|
399
|
-
|
|
397
|
+
if (fileExists) {
|
|
398
|
+
try {
|
|
399
|
+
lane = LaneResultSchema.parse(JSON.parse(fs.readFileSync(file, 'utf-8')));
|
|
400
|
+
}
|
|
401
|
+
catch (err) {
|
|
402
|
+
result.errors.push(`Failed to parse ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
400
405
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
continue;
|
|
406
|
+
else {
|
|
407
|
+
lane = nativeObservation.lane;
|
|
404
408
|
}
|
|
405
409
|
// Assignment filter (when harvesting a specific lane).
|
|
406
410
|
if (options.assignmentId && lane.assignment_id !== options.assignmentId)
|
|
@@ -438,14 +442,66 @@ export function harvestLaneResults(options = {}) {
|
|
|
438
442
|
// Kill-switch (=0), a legacy lane (no reservation), OR a reservation WITHOUT evidence
|
|
439
443
|
// (review Finding 1: an inbox_only/non-ack-wrapped dispatch that never wrote a
|
|
440
444
|
// sentinel) → the lane takes the unchanged legacy close so it still converges.
|
|
441
|
-
//
|
|
442
|
-
|
|
443
|
-
|
|
445
|
+
// P0C: the same evidence gate now serves every LoopKind. Review keeps
|
|
446
|
+
// its report-only request_changes deferral because that path cannot
|
|
447
|
+
// re-dispatch a fix cycle; other kinds can safely record their result.
|
|
448
|
+
const candidateEvidence = turnOwnedLaneEvidence(lane, cwd);
|
|
449
|
+
// Set before reading the loop: a corrupt loop store may throw below,
|
|
450
|
+
// and the catch must still know this was a turn-owned lane to warn.
|
|
451
|
+
turnEvidenceForCatch = candidateEvidence;
|
|
452
|
+
const candidateLoop = candidateEvidence ? getLoop(candidateEvidence.reservation.loop_id, cwd) : undefined;
|
|
453
|
+
const laneTurnEvidence = candidateEvidence && candidateLoop && turnOwnedLoopEnabled(candidateLoop.kind)
|
|
454
|
+
? candidateEvidence
|
|
455
|
+
: undefined;
|
|
456
|
+
turnEvidenceForCatch = laneTurnEvidence ?? candidateEvidence;
|
|
457
|
+
const ownedLoop = laneTurnEvidence ? candidateLoop : undefined;
|
|
458
|
+
const ownedPhasePolicy = ownedLoop
|
|
459
|
+
? phasePolicy(ownedLoop.kind, laneTurnEvidence.reservation.phase)
|
|
460
|
+
: undefined;
|
|
444
461
|
if (!laneTurnEvidence) {
|
|
445
462
|
closeReviewLoopFromLaneResult(laneAssignment, lane, agent, cwd, { cycleOnRequestChanges: false });
|
|
446
463
|
}
|
|
464
|
+
else if (ownedPhasePolicy?.finalization === 'integrate') {
|
|
465
|
+
result.warnings.push(toWarningDetail({
|
|
466
|
+
code: 'loop_turn_not_converged',
|
|
467
|
+
message: `Turn-owned ${ownedLoop?.kind ?? 'loop'} lane ${lane.assignment_id} requires harvest --integrate before convergence; claim retained.`,
|
|
468
|
+
data: {
|
|
469
|
+
assignment_id: lane.assignment_id,
|
|
470
|
+
loop_id: laneTurnEvidence.reservation.loop_id,
|
|
471
|
+
turn_id: laneTurnEvidence.reservation.turn_id,
|
|
472
|
+
phase: laneTurnEvidence.reservation.phase,
|
|
473
|
+
},
|
|
474
|
+
}));
|
|
475
|
+
}
|
|
476
|
+
else if (ownedLoop?.kind !== 'review') {
|
|
477
|
+
const rr = reconcileTurnOwnedLane(lane, cwd, laneTurnEvidence);
|
|
478
|
+
if (ownedLoop?.kind === 'ideation'
|
|
479
|
+
&& laneTurnEvidence.reservation.phase === 'critique'
|
|
480
|
+
&& (lane.artifact_type !== 'critique' || !(lane.body ?? '').trim())) {
|
|
481
|
+
result.warnings.push(toWarningDetail({
|
|
482
|
+
code: 'loop_turn_not_converged',
|
|
483
|
+
message: `Ideation critique lane ${lane.assignment_id} did not provide artifact_type='critique' with a non-empty body; no critique artifact was accepted.`,
|
|
484
|
+
data: { assignment_id: lane.assignment_id, loop_id: laneTurnEvidence.reservation.loop_id, turn_id: laneTurnEvidence.reservation.turn_id },
|
|
485
|
+
}));
|
|
486
|
+
}
|
|
487
|
+
if (rr && !rr.result.reconciled && !/superseded/.test(rr.result.reason ?? '')) {
|
|
488
|
+
result.warnings.push(toWarningDetail({
|
|
489
|
+
code: 'loop_turn_not_converged',
|
|
490
|
+
message: `Turn-owned ${ownedLoop?.kind ?? 'loop'} lane ${lane.assignment_id} did not converge: ${rr.result.reason}.`,
|
|
491
|
+
data: { assignment_id: lane.assignment_id, loop_id: laneTurnEvidence.reservation.loop_id, turn_id: laneTurnEvidence.reservation.turn_id },
|
|
492
|
+
}));
|
|
493
|
+
}
|
|
494
|
+
if (ownedLoop?.kind === 'ideation' && rr) {
|
|
495
|
+
ideationLoop = {
|
|
496
|
+
loop_id: laneTurnEvidence.reservation.loop_id,
|
|
497
|
+
action: rr.result.auto_closed ? 'closed' : rr.result.reconciled ? 'advanced' : 'noop',
|
|
498
|
+
reason: rr.result.reason,
|
|
499
|
+
loop_status: rr.result.loop_status,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
}
|
|
447
503
|
else if (lane.review_verdict === 'approve') {
|
|
448
|
-
const rr =
|
|
504
|
+
const rr = reconcileTurnOwnedLane(lane, cwd, laneTurnEvidence);
|
|
449
505
|
// Reason-based quietness (PR #171 review P2-1 refinement): a terminal loop
|
|
450
506
|
// returns reconciled:true (idempotent no-op) and a superseded turn is the one
|
|
451
507
|
// healthy decline (`harvest --all` over a prior round's lane) — everything
|
|
@@ -465,7 +521,9 @@ export function harvestLaneResults(options = {}) {
|
|
|
465
521
|
}
|
|
466
522
|
// pln#521 P2-bis — the ideation analog: a critic lane records its critique +
|
|
467
523
|
// advances the ideation loop. Returns undefined for non-ideate scopes (no-op here).
|
|
468
|
-
|
|
524
|
+
if (!laneTurnEvidence) {
|
|
525
|
+
ideationLoop = closeIdeationLoopFromLaneResult(laneAssignment, lane, agent, cwd);
|
|
526
|
+
}
|
|
469
527
|
// pln#636 C2 (review F3) — the universal net's most important trigger.
|
|
470
528
|
// A file-fallback worker declares its own footprint in `files_changed`,
|
|
471
529
|
// which is BOTH cheaper and more reliable than a git diff here: by
|
|
@@ -522,6 +580,8 @@ export function harvestLaneResults(options = {}) {
|
|
|
522
580
|
ideation_loop: ideationLoop ?? null,
|
|
523
581
|
files_changed: lane.files_changed ?? [],
|
|
524
582
|
source_worktree: worktreePath,
|
|
583
|
+
harness_stdout_log: nativeObservation?.stdout_log ?? null,
|
|
584
|
+
harness_stderr_log: nativeObservation?.stderr_log ?? null,
|
|
525
585
|
},
|
|
526
586
|
}, cwd);
|
|
527
587
|
fs.mkdirSync(path.dirname(marker), { recursive: true });
|
|
@@ -684,27 +744,46 @@ export function integrateLaneResults(options = {}) {
|
|
|
684
744
|
// next_turn) unless the iteration cap is hit. This is the --integrate
|
|
685
745
|
// path, so it MAY cycle (it can re-dispatch AND retain the claim). No-op
|
|
686
746
|
// for non-review lanes / lanes without a verdict; never throws.
|
|
687
|
-
//
|
|
688
|
-
//
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
const
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
747
|
+
// Legacy ideation lanes still use the historical closer. A turn-owned
|
|
748
|
+
// lane of any kind is finalized exactly once by reconcileTurn below.
|
|
749
|
+
const candidateEvidence = turnOwnedLaneEvidence(lane, cwd);
|
|
750
|
+
const ownedLoop = candidateEvidence ? getLoop(candidateEvidence.reservation.loop_id, cwd) : undefined;
|
|
751
|
+
const turnOwnedEvidence = candidateEvidence && ownedLoop && turnOwnedLoopEnabled(ownedLoop.kind)
|
|
752
|
+
? candidateEvidence
|
|
753
|
+
: undefined;
|
|
754
|
+
if (!turnOwnedEvidence) {
|
|
755
|
+
const ideationClose = closeIdeationLoopFromLaneResult(assignment, lane, actor, cwd);
|
|
756
|
+
if (ideationClose) {
|
|
757
|
+
reasons.push(`ideate-loop ${ideationClose.loop_id}: ${ideationClose.action} — ${ideationClose.reason}`);
|
|
758
|
+
entry.ideation_loop = ideationClose;
|
|
759
|
+
}
|
|
695
760
|
}
|
|
696
761
|
// pln#630 PR3a — a TURN-OWNED review lane finalizes via the exactly-once
|
|
697
762
|
// reconcileTurn, which REPLACES the legacy closer + teardown gate for this lane
|
|
698
763
|
// (exactly-one finalizer per lane). Kill-switch (=0), a legacy (non-reserved) lane, OR a
|
|
699
764
|
// reservation with NO turn-keyed evidence (review Finding 1) → `turnOwned` is undefined
|
|
700
765
|
// and the unchanged legacy `else` block runs so the loop still converges.
|
|
701
|
-
const turnOwned =
|
|
702
|
-
?
|
|
766
|
+
const turnOwned = turnOwnedEvidence
|
|
767
|
+
? reconcileTurnOwnedLane(lane, cwd, turnOwnedEvidence)
|
|
703
768
|
: undefined;
|
|
704
769
|
if (turnOwned) {
|
|
705
770
|
const { reservation, result: rr } = turnOwned;
|
|
706
|
-
|
|
707
|
-
|
|
771
|
+
if (ownedLoop?.kind === 'review') {
|
|
772
|
+
entry.review_loop = reconcileToReviewLoopResult(reservation, rr, lane);
|
|
773
|
+
reasons.push(`turn-owned reconcile ${reservation.loop_id}: ${entry.review_loop.action} — ${rr.reason}${rr.conflict ? ' [CONFLICT — held]' : ''}`);
|
|
774
|
+
}
|
|
775
|
+
else {
|
|
776
|
+
const action = rr.auto_closed ? 'closed' : rr.reconciled ? 'advanced' : 'noop';
|
|
777
|
+
reasons.push(`turn-owned ${ownedLoop?.kind ?? 'loop'} reconcile ${reservation.loop_id}: ${action} — ${rr.reason}${rr.conflict ? ' [CONFLICT — held]' : ''}`);
|
|
778
|
+
if (ownedLoop?.kind === 'ideation') {
|
|
779
|
+
entry.ideation_loop = {
|
|
780
|
+
loop_id: reservation.loop_id,
|
|
781
|
+
action,
|
|
782
|
+
reason: rr.reason,
|
|
783
|
+
loop_status: rr.loop_status,
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
}
|
|
708
787
|
// pln#630 PR3b — a symmetric request_changes bumped the round + retained the claim
|
|
709
788
|
// and handed back the next fix-cycle turn. Push it exactly like the legacy path so
|
|
710
789
|
// the existing async re-dispatch loop spawns round N+1 into the reused worktree. The
|
package/dist/commands/loop.js
CHANGED
|
@@ -60,6 +60,14 @@ function parseOutcome(opts) {
|
|
|
60
60
|
}
|
|
61
61
|
return outcome;
|
|
62
62
|
}
|
|
63
|
+
function parseOptionalEpoch(value, opts) {
|
|
64
|
+
if (value === undefined)
|
|
65
|
+
return undefined;
|
|
66
|
+
const epoch = Number(value);
|
|
67
|
+
if (!Number.isInteger(epoch) || epoch < 0)
|
|
68
|
+
fail('--attempt-epoch must be a non-negative integer', 1, opts);
|
|
69
|
+
return epoch;
|
|
70
|
+
}
|
|
63
71
|
function formatNextExpected(hint) {
|
|
64
72
|
if (!hint)
|
|
65
73
|
return ' (loop has no further expected action)';
|
|
@@ -95,11 +103,37 @@ function buildRequest(subcommand, loopId, opts) {
|
|
|
95
103
|
intent: 'complete_turn',
|
|
96
104
|
loop_id: loopId,
|
|
97
105
|
slot_id: requireOption(opts.slot, '--slot <slot_id>', opts),
|
|
106
|
+
assignment_id: opts.assignmentId,
|
|
107
|
+
turn_id: opts.turnId,
|
|
108
|
+
run_id: opts.runId,
|
|
109
|
+
nonce: opts.nonce,
|
|
110
|
+
attempt_epoch: parseOptionalEpoch(opts.attemptEpoch, opts),
|
|
111
|
+
execution_contract_hash: opts.executionContractHash,
|
|
112
|
+
workspace_digest: opts.workspaceDigest,
|
|
98
113
|
outcome: parseOutcome(opts),
|
|
99
114
|
failure_reason: opts.failureReason,
|
|
100
115
|
artifact,
|
|
101
116
|
};
|
|
102
117
|
}
|
|
118
|
+
case 'takeover': {
|
|
119
|
+
const epoch = Number(opts.expectedEpoch);
|
|
120
|
+
if (!Number.isInteger(epoch) || epoch < 0)
|
|
121
|
+
fail('--expected-epoch must be a non-negative integer', 1, opts);
|
|
122
|
+
return {
|
|
123
|
+
intent: 'takeover',
|
|
124
|
+
loop_id: loopId,
|
|
125
|
+
slot_id: requireOption(opts.slot, '--slot <slot_id>', opts),
|
|
126
|
+
turn_id: requireOption(opts.turnId, '--turn-id <turn_id>', opts),
|
|
127
|
+
expected_epoch: epoch,
|
|
128
|
+
cause: requireOption(opts.cause, '--cause <text>', opts),
|
|
129
|
+
liveness_evidence: requireOption(opts.livenessEvidence, '--liveness-evidence <text>', opts),
|
|
130
|
+
external_effect_policy: requireOption(opts.externalEffectPolicy, '--external-effect-policy <policy>', opts),
|
|
131
|
+
next_workspace_path: requireOption(opts.nextWorkspacePath, '--next-workspace-path <path>', opts),
|
|
132
|
+
takeover_mode: opts.mode,
|
|
133
|
+
agent: opts.agent,
|
|
134
|
+
agentId: opts.agent,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
103
137
|
case 'advance':
|
|
104
138
|
return {
|
|
105
139
|
intent: 'advance',
|