zelari-code 2.12.0 → 2.14.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/dist/cli/headless/policyGate.js +101 -0
- package/dist/cli/headless/policyGate.js.map +1 -0
- package/dist/cli/headless.js.map +1 -1
- package/dist/cli/kraken/completionProof.js +89 -41
- package/dist/cli/kraken/completionProof.js.map +1 -1
- package/dist/cli/kraken/completionProofAttestation.js +157 -0
- package/dist/cli/kraken/completionProofAttestation.js.map +1 -0
- package/dist/cli/kraken/completionProofPersist.js +219 -0
- package/dist/cli/kraken/completionProofPersist.js.map +1 -0
- package/dist/cli/kraken/completionProofProbe.js +135 -0
- package/dist/cli/kraken/completionProofProbe.js.map +1 -0
- package/dist/cli/kraken/contractCompiler.js +142 -0
- package/dist/cli/kraken/contractCompiler.js.map +1 -0
- package/dist/cli/kraken/delegationPolicy.js +40 -3
- package/dist/cli/kraken/delegationPolicy.js.map +1 -1
- package/dist/cli/kraken/executor.js +462 -9
- package/dist/cli/kraken/executor.js.map +1 -1
- package/dist/cli/kraken/fileOwnership.js +190 -0
- package/dist/cli/kraken/fileOwnership.js.map +1 -0
- package/dist/cli/kraken/modelReputation.js +208 -0
- package/dist/cli/kraken/modelReputation.js.map +1 -0
- package/dist/cli/kraken/nativeVerification.js +53 -11
- package/dist/cli/kraken/nativeVerification.js.map +1 -1
- package/dist/cli/kraken/planner.js +2 -0
- package/dist/cli/kraken/planner.js.map +1 -1
- package/dist/cli/kraken/reputationStore.js +139 -0
- package/dist/cli/kraken/reputationStore.js.map +1 -0
- package/dist/cli/kraken/semanticOwnership.js +184 -0
- package/dist/cli/kraken/semanticOwnership.js.map +1 -0
- package/dist/cli/kraken/spawnRoi.js +209 -0
- package/dist/cli/kraken/spawnRoi.js.map +1 -0
- package/dist/cli/kraken/transactional.js +104 -0
- package/dist/cli/kraken/transactional.js.map +1 -0
- package/dist/cli/kraken/verificationAdapters/dotnet.js +49 -0
- package/dist/cli/kraken/verificationAdapters/dotnet.js.map +1 -0
- package/dist/cli/kraken/verificationAdapters/go.js +36 -0
- package/dist/cli/kraken/verificationAdapters/go.js.map +1 -0
- package/dist/cli/kraken/verificationAdapters/index.js +47 -0
- package/dist/cli/kraken/verificationAdapters/index.js.map +1 -0
- package/dist/cli/kraken/verificationAdapters/java.js +102 -0
- package/dist/cli/kraken/verificationAdapters/java.js.map +1 -0
- package/dist/cli/kraken/verificationAdapters/node.js +90 -0
- package/dist/cli/kraken/verificationAdapters/node.js.map +1 -0
- package/dist/cli/kraken/verificationAdapters/python.js +86 -0
- package/dist/cli/kraken/verificationAdapters/python.js.map +1 -0
- package/dist/cli/kraken/verificationAdapters/rust.js +39 -0
- package/dist/cli/kraken/verificationAdapters/rust.js.map +1 -0
- package/dist/cli/kraken/verificationAdapters/types.js +2 -0
- package/dist/cli/kraken/verificationAdapters/types.js.map +1 -0
- package/dist/cli/kraken/verificationBridge.js +40 -3
- package/dist/cli/kraken/verificationBridge.js.map +1 -1
- package/dist/cli/kraken/verifierLifecycle.js +51 -36
- package/dist/cli/kraken/verifierLifecycle.js.map +1 -1
- package/dist/cli/kraken/verifierRouting.js +264 -0
- package/dist/cli/kraken/verifierRouting.js.map +1 -0
- package/dist/cli/kraken/worktreeScheduling.js +201 -0
- package/dist/cli/kraken/worktreeScheduling.js.map +1 -0
- package/dist/cli/main.bundled.js +4085 -939
- package/dist/cli/main.bundled.js.map +4 -4
- package/dist/cli/orchestration/facts.js +127 -0
- package/dist/cli/orchestration/facts.js.map +1 -0
- package/dist/cli/orchestration/policy.js +128 -75
- package/dist/cli/orchestration/policy.js.map +1 -1
- package/dist/cli/orchestration/signals.js +96 -0
- package/dist/cli/orchestration/signals.js.map +1 -0
- package/dist/cli/runHeadless.js +92 -19
- package/dist/cli/runHeadless.js.map +1 -1
- package/dist/cli/safety/policyEngine.js +217 -43
- package/dist/cli/safety/policyEngine.js.map +1 -1
- package/dist/cli/safety/policyLayers.js +55 -0
- package/dist/cli/safety/policyLayers.js.map +1 -0
- package/dist/cli/safety/policyLoadMode.js +59 -0
- package/dist/cli/safety/policyLoadMode.js.map +1 -0
- package/dist/cli/safety/resourceClaims.js +373 -0
- package/dist/cli/safety/resourceClaims.js.map +1 -0
- package/dist/cli/safety/sandboxPath.js +160 -17
- package/dist/cli/safety/sandboxPath.js.map +1 -1
- package/dist/cli/toolRegistry.js +114 -15
- package/dist/cli/toolRegistry.js.map +1 -1
- package/dist/cli/tools/execProcess.js +121 -0
- package/dist/cli/tools/execProcess.js.map +1 -0
- package/dist/cli/tools/krakenCsvFanout.js +4 -4
- package/dist/cli/tools/krakenCsvFanout.js.map +1 -1
- package/dist/cli/tools/krakenRadio.js +9 -0
- package/dist/cli/tools/krakenRadio.js.map +1 -1
- package/dist/cli/tools/taskTool.js +12 -6
- package/dist/cli/tools/taskTool.js.map +1 -1
- package/package.json +2 -2
|
@@ -38,12 +38,39 @@ import { getReadyNodes, isSettled, isConverged, failedNodeIds, countByStatus, ca
|
|
|
38
38
|
import { runTentacle, TASK_TOOL_TIMEOUT_MS, } from './tentacle.js';
|
|
39
39
|
import { mergeKrakenWorktree, } from '../tools/krakenWorktree.js';
|
|
40
40
|
import { appendKrakenRadio } from '../tools/krakenRadio.js';
|
|
41
|
+
import { runTransactional } from './transactional.js';
|
|
41
42
|
import { runBacktest } from '../workspace/worldModel.js';
|
|
42
43
|
import { startKrakenGraphLive, updateKrakenGraphLive, endKrakenGraphLive, } from './graphStatus.js';
|
|
43
44
|
import { existsSync } from 'node:fs';
|
|
44
45
|
import path from 'node:path';
|
|
45
46
|
import { saveGraphSnapshot, toGraphSnapshot } from './graphMemory.js';
|
|
46
47
|
import { WorkbenchWriter } from './workbench.js';
|
|
48
|
+
import { arbitrateAdmission, caseInsensitiveFs, hasWriteOverlap } from './fileOwnership.js';
|
|
49
|
+
import { isWorktreeCapableKind, resolveWorktreeMode, worktreeSchedulingDecision, } from './worktreeScheduling.js';
|
|
50
|
+
import { semanticConflictDecision, } from './semanticOwnership.js';
|
|
51
|
+
import { isAstSupported, parseFileSymbolsDiag } from '../ast/engine.js';
|
|
52
|
+
// t29 (§15–16): fail-open reputation recording + routing source refresh.
|
|
53
|
+
import { calculateCost } from '../modelPricing.js';
|
|
54
|
+
import { aggregate, REPUTATION_MIN_SAMPLE, reputationRecordFromNodeRun, } from './modelReputation.js';
|
|
55
|
+
// t30 (§17): spawn-ROI gate — deterministic score, threshold parsing and the
|
|
56
|
+
// duplication-risk heuristic; the veto itself lives in this class (fail-open).
|
|
57
|
+
import { computeSpawnScore, duplicationRiskFor, parseRoiThreshold, shouldSpawn, } from './spawnRoi.js';
|
|
58
|
+
import { appendRecord, DEFAULT_MAX_RECORDS, loadRecords, pruneStore, resolveReputationStorePath, } from './reputationStore.js';
|
|
59
|
+
import { setReputationSource } from './verifierRouting.js';
|
|
60
|
+
/**
|
|
61
|
+
* P2.B default symbol extractor behind semantic ownership arbitration: the
|
|
62
|
+
* real AST outline via ast/engine. Lazy (the TS compiler API loads inside
|
|
63
|
+
* `parseFileSymbolsDiag` on first use) and fail-closed — an unsupported file
|
|
64
|
+
* or any parse failure is null, which the decision reports as a conflict so
|
|
65
|
+
* the pair defers to the sequential path instead of trusting a claim it
|
|
66
|
+
* could not verify.
|
|
67
|
+
*/
|
|
68
|
+
async function defaultSymbolExtractor(file) {
|
|
69
|
+
if (!isAstSupported(file))
|
|
70
|
+
return null;
|
|
71
|
+
const r = await parseFileSymbolsDiag(file);
|
|
72
|
+
return r.status === 'ok' ? r.symbols.map((s) => s.name) : null;
|
|
73
|
+
}
|
|
47
74
|
/** Default cap on concurrently-running tentacles across the whole graph. */
|
|
48
75
|
export const DEFAULT_MAX_PARALLEL = 12;
|
|
49
76
|
/** Default number of `fix` nodes the executor may spawn across one graph run. */
|
|
@@ -55,6 +82,17 @@ export function resolveMaxParallel(env = process.env) {
|
|
|
55
82
|
const n = Number.parseInt(raw, 10);
|
|
56
83
|
return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAX_PARALLEL;
|
|
57
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Transactional writer execution (P2.D), default OFF: a rollback discards
|
|
87
|
+
* partial work the existing fix/rework flow might have reused, and the
|
|
88
|
+
* whole-tree restore can revert a concurrent in-place sibling's writes.
|
|
89
|
+
* Opt in per run (`transactional: true`) or via env.
|
|
90
|
+
*/
|
|
91
|
+
export const DEFAULT_TRANSACTIONAL = false;
|
|
92
|
+
/** `ZELARI_KRAKEN_TRANSACTIONAL === '1'` turns transactional writers on. */
|
|
93
|
+
export function resolveTransactional(env = process.env) {
|
|
94
|
+
return env.ZELARI_KRAKEN_TRANSACTIONAL === '1';
|
|
95
|
+
}
|
|
58
96
|
/**
|
|
59
97
|
* How many `fix` nodes this run may spawn.
|
|
60
98
|
*
|
|
@@ -273,12 +311,26 @@ function firstLine(text, maxChars = 160) {
|
|
|
273
311
|
function agentForNode(node) {
|
|
274
312
|
return node.kind === 'explore' || node.kind === 'verify' ? node.kind : 'general';
|
|
275
313
|
}
|
|
314
|
+
/** P2.F: fold a graph node kind onto the ROI task-kind taxonomy. */
|
|
315
|
+
function roiTaskKindOf(kind) {
|
|
316
|
+
if (kind === 'explore')
|
|
317
|
+
return 'explore';
|
|
318
|
+
if (kind === 'verify')
|
|
319
|
+
return 'verify';
|
|
320
|
+
if (kind === 'spec' || kind === 'conformance')
|
|
321
|
+
return 'review';
|
|
322
|
+
return 'implement'; // general/fix/merge and unknown kinds all mutate the tree
|
|
323
|
+
}
|
|
276
324
|
export class KrakenGraphExecutor {
|
|
277
325
|
deps;
|
|
278
326
|
parentCwd;
|
|
279
327
|
sessionId;
|
|
280
328
|
goal;
|
|
281
329
|
maxParallel;
|
|
330
|
+
/** Fold case in ownership scope comparisons (win32/darwin FSes). */
|
|
331
|
+
ownershipCaseFolding;
|
|
332
|
+
/** P2.B: symbol extractor for semantic ownership (default: ast/engine). */
|
|
333
|
+
symbolExtractor;
|
|
282
334
|
/** Explicit all-kinds override; when undefined the budget is per-kind. */
|
|
283
335
|
nodeTimeoutMs;
|
|
284
336
|
cancelGraceMs;
|
|
@@ -288,9 +340,15 @@ export class KrakenGraphExecutor {
|
|
|
288
340
|
maxReviewRounds;
|
|
289
341
|
graphTimeoutMs;
|
|
290
342
|
worldModelGateOverride;
|
|
343
|
+
/** P2.D: wrap general (writer) tentacles in checkpoint→run→rollback. */
|
|
344
|
+
transactional;
|
|
291
345
|
runTentacleFn;
|
|
292
346
|
mergeFn;
|
|
293
347
|
backtestFn;
|
|
348
|
+
/** P2.F: pure score seam for the ROI gate (default: computeSpawnScore). */
|
|
349
|
+
roiScoreFn;
|
|
350
|
+
/** P2.F: ROI reputation source; null until resolved once per run. */
|
|
351
|
+
roiReputation;
|
|
294
352
|
/** Live workbench writer for this run (created once the graph starts). */
|
|
295
353
|
wb = null;
|
|
296
354
|
nodeRunState = new Map();
|
|
@@ -331,6 +389,9 @@ export class KrakenGraphExecutor {
|
|
|
331
389
|
this.sessionId = opts.sessionId;
|
|
332
390
|
this.goal = opts.goal;
|
|
333
391
|
this.maxParallel = opts.maxParallel ?? resolveMaxParallel();
|
|
392
|
+
this.ownershipCaseFolding =
|
|
393
|
+
opts.ownershipCaseFolding ?? caseInsensitiveFs(process.platform);
|
|
394
|
+
this.symbolExtractor = opts.symbolExtractor ?? defaultSymbolExtractor;
|
|
334
395
|
this.nodeTimeoutMs = opts.nodeTimeoutMs;
|
|
335
396
|
this.cancelGraceMs = opts.cancelGraceMs;
|
|
336
397
|
this.fixBudgetOption = opts.fixBudget;
|
|
@@ -339,10 +400,13 @@ export class KrakenGraphExecutor {
|
|
|
339
400
|
this.maxReviewRounds = opts.maxReviewRounds ?? resolveMaxReviewRounds();
|
|
340
401
|
this.graphTimeoutMs = opts.graphTimeoutMs ?? resolveGraphTimeoutMs();
|
|
341
402
|
this.worldModelGateOverride = opts.worldModelGate;
|
|
403
|
+
this.transactional = opts.transactional ?? resolveTransactional();
|
|
342
404
|
this.signal = opts.signal;
|
|
343
405
|
this.runTentacleFn = opts.runTentacleFn ?? runTentacle;
|
|
344
406
|
this.mergeFn = opts.mergeFn ?? mergeKrakenWorktree;
|
|
345
407
|
this.backtestFn = opts.backtestFn ?? runBacktest;
|
|
408
|
+
this.roiScoreFn = opts.roiScoreFn ?? computeSpawnScore;
|
|
409
|
+
this.roiReputation = opts.reputationRecords ?? null;
|
|
346
410
|
}
|
|
347
411
|
/** Map a graph node onto the workbench's node shape. */
|
|
348
412
|
toWorkbenchNode(n) {
|
|
@@ -386,6 +450,9 @@ export class KrakenGraphExecutor {
|
|
|
386
450
|
}
|
|
387
451
|
/** Execute the graph in place (mutates node statuses) until it settles. */
|
|
388
452
|
async execute(graph) {
|
|
453
|
+
// t29: refresh the reputation source (fail-open, fire-and-forget) so
|
|
454
|
+
// verifier routing consulted later in this process sees real history.
|
|
455
|
+
void this.refreshReputationSource();
|
|
389
456
|
// Now that the graph is known, size the repair budget to it (unless the
|
|
390
457
|
// caller pinned one). Done here rather than in the constructor because the
|
|
391
458
|
// node count is the whole input to the decision.
|
|
@@ -452,7 +519,7 @@ export class KrakenGraphExecutor {
|
|
|
452
519
|
break;
|
|
453
520
|
// Admit everything that can start alongside what is already running,
|
|
454
521
|
// up to the concurrency cap. A cancelled run admits nothing.
|
|
455
|
-
const admitted = this.aborted ? [] : this.admit(graph, inFlight);
|
|
522
|
+
const admitted = this.aborted ? [] : await this.admit(graph, inFlight);
|
|
456
523
|
if (admitted.length > 0) {
|
|
457
524
|
// Mark and publish the whole admission BEFORE starting any of it: an
|
|
458
525
|
// async function body runs synchronously up to its first await, so
|
|
@@ -595,8 +662,19 @@ export class KrakenGraphExecutor {
|
|
|
595
662
|
* Unlike a wave-at-a-time scheduler this is called on every completion, so a
|
|
596
663
|
* node becomes eligible the moment its blocker settles instead of waiting
|
|
597
664
|
* for the slowest member of some earlier batch.
|
|
665
|
+
*
|
|
666
|
+
* The surviving list then goes through file-ownership arbitration (P2.A):
|
|
667
|
+
* a writer whose scope overlaps a writer already admitted this round or
|
|
668
|
+
* already in flight is DEFERRED, never failed — it stays READY and is
|
|
669
|
+
* re-offered on the next completion, so overlapping writers serialize via
|
|
670
|
+
* the ordinary settle-one-at-a-time loop.
|
|
671
|
+
*
|
|
672
|
+
* P2.C exception: with ZELARI_KRAKEN_WORKTREE=auto a deferred writer whose
|
|
673
|
+
* overlap is merely partial (score < 0.75, `classifyOverlap`) is re-admitted
|
|
674
|
+
* immediately under git worktree isolation instead of idling behind its
|
|
675
|
+
* racing writer.
|
|
598
676
|
*/
|
|
599
|
-
admit(graph, inFlight) {
|
|
677
|
+
async admit(graph, inFlight) {
|
|
600
678
|
const capacity = this.maxParallel - inFlight.size;
|
|
601
679
|
if (capacity <= 0)
|
|
602
680
|
return [];
|
|
@@ -606,16 +684,275 @@ export class KrakenGraphExecutor {
|
|
|
606
684
|
if (n)
|
|
607
685
|
running.push(n);
|
|
608
686
|
}
|
|
609
|
-
const
|
|
687
|
+
const candidates = [];
|
|
610
688
|
for (const node of getReadyNodes(graph)) {
|
|
611
|
-
if (
|
|
689
|
+
if (candidates.length >= capacity)
|
|
612
690
|
break;
|
|
613
691
|
const safe = running.every((r) => canRunParallel(r, node)) &&
|
|
614
|
-
|
|
692
|
+
candidates.every((a) => canRunParallel(a, node));
|
|
615
693
|
if (safe)
|
|
616
|
-
|
|
694
|
+
candidates.push(node);
|
|
695
|
+
}
|
|
696
|
+
// P2.A: two writers with overlapping scopes must never share the tree.
|
|
697
|
+
// Arbitration can only shrink the candidate list, so this is pure
|
|
698
|
+
// defense-in-depth on top of canRunParallel — plus case folding on
|
|
699
|
+
// win32/darwin and a workbench/radio trail for every deferral.
|
|
700
|
+
const { admitted, deferred } = arbitrateAdmission(candidates, running, {
|
|
701
|
+
caseInsensitive: this.ownershipCaseFolding,
|
|
702
|
+
});
|
|
703
|
+
// P2.C: with ZELARI_KRAKEN_WORKTREE=auto, a writer arbitration would defer
|
|
704
|
+
// against another writer can still start NOW when the overlap is merely
|
|
705
|
+
// partial/containment — it runs in its own git worktree and tentacle
|
|
706
|
+
// merges stay sequential (Correction 4), so the race is merge-safe by
|
|
707
|
+
// construction and a cheap merge beats idling. Identical-grain overlap
|
|
708
|
+
// (same scope, wildcard claims folded to a match) still defers: that
|
|
709
|
+
// merge would cost more than the serialization. Any other mode keeps the
|
|
710
|
+
// deferral exactly as P2.A left it.
|
|
711
|
+
const worktreeMode = resolveWorktreeMode(process.env.ZELARI_KRAKEN_WORKTREE);
|
|
712
|
+
const rescued = [];
|
|
713
|
+
const held = [];
|
|
714
|
+
if (worktreeMode === 'auto' && deferred.length > 0) {
|
|
715
|
+
// The race set to score against: everything in flight PLUS everything
|
|
716
|
+
// admitted this round — those are running the moment admit() returns,
|
|
717
|
+
// and a first-round clash would otherwise never be rescuable (running
|
|
718
|
+
// is still empty then).
|
|
719
|
+
const racing = [...running, ...admitted];
|
|
720
|
+
for (const node of deferred) {
|
|
721
|
+
const decision = isWorktreeCapableKind(node.kind) &&
|
|
722
|
+
!this.reworks.has(node.id) && // a rework edits an EXISTING worktree
|
|
723
|
+
this.deps.allowWorktree !== false
|
|
724
|
+
? worktreeSchedulingDecision(node, racing, process.env, {
|
|
725
|
+
caseInsensitive: this.ownershipCaseFolding,
|
|
726
|
+
})
|
|
727
|
+
: undefined;
|
|
728
|
+
if (decision && decision.mode === 'parallel-worktree') {
|
|
729
|
+
rescued.push(node);
|
|
730
|
+
this.radio('node_worktree_scheduled', {
|
|
731
|
+
description: node.label,
|
|
732
|
+
agent: node.kind,
|
|
733
|
+
detail: `worktree-isolated parallel admission: overlap=${decision.overlapScore.toFixed(2)} (${decision.rationaleCode})`,
|
|
734
|
+
ok: true,
|
|
735
|
+
nodeId: node.id,
|
|
736
|
+
overlapScore: decision.overlapScore,
|
|
737
|
+
rationaleCode: decision.rationaleCode,
|
|
738
|
+
...(decision.bestMatchId !== undefined
|
|
739
|
+
? { runningNode: decision.bestMatchId }
|
|
740
|
+
: {}),
|
|
741
|
+
});
|
|
742
|
+
this.wb?.logEvent(`worktree-scheduled ${node.id} "${node.label}" — overlap ${decision.overlapScore.toFixed(2)} (${decision.rationaleCode}) vs ${decision.bestMatchId ?? 'writer'}; runs isolated, merge stays sequential`);
|
|
743
|
+
}
|
|
744
|
+
else {
|
|
745
|
+
held.push(node);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
else {
|
|
750
|
+
held.push(...deferred);
|
|
751
|
+
}
|
|
752
|
+
// P2.B: semantic rescue — a writer the arbitration still holds whose
|
|
753
|
+
// deferral is pure same-file grain can run alongside its racer when BOTH
|
|
754
|
+
// sides declare `ownedSymbols` and every contested pair verifies as
|
|
755
|
+
// symbol-disjoint. One undecided or conflicting pair keeps the t25
|
|
756
|
+
// deferral intact (fail-closed: undeclared claims, malformed specs and
|
|
757
|
+
// AST extraction failures all defer). When worktree scheduling is `auto`
|
|
758
|
+
// and the node is isolatable it reuses the t27 worktree path (same-file
|
|
759
|
+
// different-symbol is still a git merge risk); otherwise it is admitted
|
|
760
|
+
// plainly, with telemetry saying exactly that.
|
|
761
|
+
const semAdmitted = [];
|
|
762
|
+
if (held.length > 0) {
|
|
763
|
+
// Everything the node would race: in flight, admitted this round, or
|
|
764
|
+
// already rescued above (worktree-isolated but still concurrent).
|
|
765
|
+
const racing = [...running, ...admitted, ...rescued];
|
|
766
|
+
const ctx = {
|
|
767
|
+
extractSymbols: this.symbolExtractor,
|
|
768
|
+
astSupported: isAstSupported,
|
|
769
|
+
};
|
|
770
|
+
for (const node of held) {
|
|
771
|
+
const verdict = await this.semanticRescueDecision(node, racing, ctx);
|
|
772
|
+
if (!verdict)
|
|
773
|
+
continue;
|
|
774
|
+
const isolatable = worktreeMode === 'auto' &&
|
|
775
|
+
isWorktreeCapableKind(node.kind) &&
|
|
776
|
+
!this.reworks.has(node.id) && // a rework edits an EXISTING worktree
|
|
777
|
+
this.deps.allowWorktree !== false;
|
|
778
|
+
this.radio('node_semantic_admitted', {
|
|
779
|
+
description: node.label,
|
|
780
|
+
agent: node.kind,
|
|
781
|
+
detail: `semantic admission: disjoint symbol claims on ${verdict.contestedFile} vs ${verdict.racerId} (${verdict.reasonCode}) — ${isolatable ? 'worktree-isolated' : 'plain parallel; same-file merge risk'}`,
|
|
782
|
+
ok: true,
|
|
783
|
+
nodeId: node.id,
|
|
784
|
+
rationaleCode: isolatable ? 'semantic-disjoint-worktree' : 'semantic-disjoint-plain',
|
|
785
|
+
runningNode: verdict.racerId,
|
|
786
|
+
contestedFile: verdict.contestedFile,
|
|
787
|
+
symbolsA: verdict.symbolsA,
|
|
788
|
+
symbolsB: verdict.symbolsB,
|
|
789
|
+
});
|
|
790
|
+
this.wb?.logEvent(`semantic-admitted ${node.id} "${node.label}" — disjoint claims on ${verdict.contestedFile} vs ${verdict.racerId} (${verdict.reasonCode}); ${isolatable ? 'runs worktree-isolated' : 'runs plainly alongside'}`);
|
|
791
|
+
if (isolatable)
|
|
792
|
+
rescued.push(node);
|
|
793
|
+
else {
|
|
794
|
+
semAdmitted.push(node);
|
|
795
|
+
racing.push(node); // later held nodes must be arbitrated against it too
|
|
796
|
+
}
|
|
797
|
+
}
|
|
617
798
|
}
|
|
618
|
-
|
|
799
|
+
for (const node of held) {
|
|
800
|
+
if (rescued.includes(node) || semAdmitted.includes(node))
|
|
801
|
+
continue;
|
|
802
|
+
const scopes = node.scope && node.scope.length > 0 ? node.scope.join(', ') : '**';
|
|
803
|
+
this.radio('node_deferred', {
|
|
804
|
+
description: node.label,
|
|
805
|
+
agent: node.kind,
|
|
806
|
+
detail: `deferred: write scope (${scopes}) overlaps a running writer — stays READY`,
|
|
807
|
+
ok: true,
|
|
808
|
+
});
|
|
809
|
+
this.wb?.logEvent(`deferred ${node.id} "${node.label}" — write scope (${scopes}) overlaps a running writer; stays ready for the next round`);
|
|
810
|
+
}
|
|
811
|
+
// P2.F: spawn-ROI gate — every tentacle spawn must have positive expected
|
|
812
|
+
// value. Runs AFTER ownership arbitration and both rescues, so it can only
|
|
813
|
+
// shrink the final spawn list; a node scoring below the threshold goes
|
|
814
|
+
// back the deferred path (stays READY, re-offered next round — never
|
|
815
|
+
// failed) with a `node_roi_vetoed` radio trail. Fail-open: any error
|
|
816
|
+
// inside the gate spawns the batch (see roiGate).
|
|
817
|
+
return (await this.roiGate([...admitted, ...rescued, ...semAdmitted], running)).spawn;
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* P2.F: resolve the ROI gate's reputation source once per run — the
|
|
821
|
+
* injected fixture when given, else the repo's t29 store. Fail-open: a
|
|
822
|
+
* missing/corrupt store already degrades to [] inside loadRecords, and an
|
|
823
|
+
* unexpected error is swallowed the same way ("no history").
|
|
824
|
+
*/
|
|
825
|
+
async roiReputationRecords() {
|
|
826
|
+
if (this.roiReputation !== null)
|
|
827
|
+
return this.roiReputation;
|
|
828
|
+
try {
|
|
829
|
+
this.roiReputation = await loadRecords(resolveReputationStorePath(this.parentCwd));
|
|
830
|
+
}
|
|
831
|
+
catch {
|
|
832
|
+
this.roiReputation = [];
|
|
833
|
+
}
|
|
834
|
+
return this.roiReputation;
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* P2.F: build the ROI input for one node. Reputation comes from the t29
|
|
838
|
+
* (repo, host-agent-role) bucket and is trusted only at/above
|
|
839
|
+
* REPUTATION_MIN_SAMPLE — below that the rate/repair/latency fields stay
|
|
840
|
+
* null so the score falls back to its sane defaults (unknown ⇒ spawn).
|
|
841
|
+
* Token and per-token price estimates do not exist pre-run in v1
|
|
842
|
+
* (documented): both stay null. `now` is taken by the gate, never inside
|
|
843
|
+
* the pure module.
|
|
844
|
+
*/
|
|
845
|
+
roiInputFor(node, racing, records, now) {
|
|
846
|
+
let verifiedRate = null;
|
|
847
|
+
let avgRepairs = null;
|
|
848
|
+
let latencyMs = null;
|
|
849
|
+
let sample = 0;
|
|
850
|
+
try {
|
|
851
|
+
const summary = aggregate(records, { repo: path.basename(this.parentCwd), role: agentForNode(node) }, now);
|
|
852
|
+
sample = summary.sample;
|
|
853
|
+
if (summary.sample >= REPUTATION_MIN_SAMPLE) {
|
|
854
|
+
verifiedRate = summary.verifiedRate;
|
|
855
|
+
avgRepairs = summary.avgRepairs;
|
|
856
|
+
latencyMs = summary.avgLatencyMs;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
catch {
|
|
860
|
+
/* fail-open: treat as no history */
|
|
861
|
+
}
|
|
862
|
+
return {
|
|
863
|
+
reputationSample: sample,
|
|
864
|
+
verifiedRate,
|
|
865
|
+
historicalAvgRepairs: avgRepairs,
|
|
866
|
+
estimatedTokens: null,
|
|
867
|
+
costUsdPer1k: null,
|
|
868
|
+
latencyMsEstimate: latencyMs,
|
|
869
|
+
duplicationRisk: duplicationRiskFor(node, racing),
|
|
870
|
+
taskKind: roiTaskKindOf(node.kind),
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* P2.F: the veto itself. Scores every node about to spawn against
|
|
875
|
+
* ZELARI_KRAKEN_ROI_THRESHOLD (raw env string parsed per admit — invalid ⇒
|
|
876
|
+
* default) and returns the survivors. Vetoed nodes are NOT failed: they are
|
|
877
|
+
* simply not returned, so they stay READY and are re-offered next round,
|
|
878
|
+
* each with a `node_roi_vetoed` radio trail. Fail-open twice over: an error
|
|
879
|
+
* while scoring ONE node spawns that node, and an error that escapes the
|
|
880
|
+
* loop spawns the whole batch — the gate must never break a run.
|
|
881
|
+
*/
|
|
882
|
+
async roiGate(spawnList, running) {
|
|
883
|
+
const spawn = [];
|
|
884
|
+
const vetoed = [];
|
|
885
|
+
if (spawnList.length === 0)
|
|
886
|
+
return { spawn, vetoed };
|
|
887
|
+
try {
|
|
888
|
+
const threshold = parseRoiThreshold(process.env.ZELARI_KRAKEN_ROI_THRESHOLD);
|
|
889
|
+
const records = await this.roiReputationRecords();
|
|
890
|
+
const now = Date.now();
|
|
891
|
+
const racing = [...running, ...spawnList];
|
|
892
|
+
for (const node of spawnList) {
|
|
893
|
+
try {
|
|
894
|
+
const input = this.roiInputFor(node, racing.filter((r) => r.id !== node.id), records, now);
|
|
895
|
+
const score = this.roiScoreFn(input);
|
|
896
|
+
if (shouldSpawn(score, threshold)) {
|
|
897
|
+
spawn.push(node);
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
vetoed.push(node);
|
|
901
|
+
const pinned = Number(score.score.toFixed(4));
|
|
902
|
+
this.radio('node_roi_vetoed', {
|
|
903
|
+
description: node.label,
|
|
904
|
+
agent: node.kind,
|
|
905
|
+
detail: `roi gate: spawnScore ${pinned} < threshold ${threshold} (${score.rationaleCode}) — stays READY`,
|
|
906
|
+
ok: true,
|
|
907
|
+
nodeId: node.id,
|
|
908
|
+
spawnScore: pinned,
|
|
909
|
+
threshold,
|
|
910
|
+
rationaleCode: score.rationaleCode,
|
|
911
|
+
});
|
|
912
|
+
this.wb?.logEvent(`roi-vetoed ${node.id} "${node.label}" — spawnScore ${pinned} < ${threshold} (${score.rationaleCode}); deferred, not failed`);
|
|
913
|
+
}
|
|
914
|
+
catch {
|
|
915
|
+
spawn.push(node); // per-node fail-open
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
catch {
|
|
920
|
+
return { spawn: [...spawnList], vetoed: [] }; // whole-gate fail-open
|
|
921
|
+
}
|
|
922
|
+
return { spawn, vetoed };
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* P2.B: may `node` (held by P2.A/P2.C arbitration) run alongside the racing
|
|
926
|
+
* writers because BOTH sides declare disjoint symbol ownership? Only a pair
|
|
927
|
+
* that actually shares a contested file qualifies; EVERY overlapping racer
|
|
928
|
+
* must verify disjoint — one undecided or conflicting pair keeps the node
|
|
929
|
+
* held. Returns the telemetry anchor of the last verified pair, or null to
|
|
930
|
+
* defer exactly as today.
|
|
931
|
+
*/
|
|
932
|
+
async semanticRescueDecision(node, racing, ctx) {
|
|
933
|
+
if (!node.ownedSymbols || node.ownedSymbols.length === 0)
|
|
934
|
+
return null;
|
|
935
|
+
let verified = null;
|
|
936
|
+
for (const racer of racing) {
|
|
937
|
+
const overlaps = hasWriteOverlap(racer, node, {
|
|
938
|
+
caseInsensitive: this.ownershipCaseFolding,
|
|
939
|
+
});
|
|
940
|
+
if (!overlaps)
|
|
941
|
+
continue;
|
|
942
|
+
const verdict = await semanticConflictDecision(racer, node, ctx);
|
|
943
|
+
// No shared contested file ⇒ the claims cannot anchor a same-file
|
|
944
|
+
// rescue; treat as undecided (defer) rather than widening on spec faith.
|
|
945
|
+
if (verdict.conflict || verdict.contestedFile === undefined)
|
|
946
|
+
return null;
|
|
947
|
+
verified = {
|
|
948
|
+
racerId: racer.id,
|
|
949
|
+
contestedFile: verdict.contestedFile,
|
|
950
|
+
reasonCode: verdict.reasonCode,
|
|
951
|
+
symbolsA: [...(racer.ownedSymbols ?? [])],
|
|
952
|
+
symbolsB: [...node.ownedSymbols],
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
return verified;
|
|
619
956
|
}
|
|
620
957
|
/**
|
|
621
958
|
* Run one node, tagging the result with its id and converting an unexpected
|
|
@@ -679,7 +1016,7 @@ export class KrakenGraphExecutor {
|
|
|
679
1016
|
const inheritedCwd = node.kind === 'verify' || isRework
|
|
680
1017
|
? this.inheritedWorktreeCwdFor(node, graph)
|
|
681
1018
|
: undefined;
|
|
682
|
-
const
|
|
1019
|
+
const runOpts = {
|
|
683
1020
|
// `allowWorktree: false` is what actually stops a rework from opening
|
|
684
1021
|
// its own worktree: creation is driven by the agent kind ('general')
|
|
685
1022
|
// inside runTentacle, not by anything the executor passes per-call.
|
|
@@ -701,7 +1038,46 @@ export class KrakenGraphExecutor {
|
|
|
701
1038
|
graphId: graph.id,
|
|
702
1039
|
nodeId: node.id,
|
|
703
1040
|
signal: controller.signal,
|
|
704
|
-
}
|
|
1041
|
+
};
|
|
1042
|
+
const runOnce = () => this.withNodeTimeout(this.runTentacleFn(runOpts), agent, controller);
|
|
1043
|
+
// P2.D: transactional writers (opt-in) — checkpoint the parent tree
|
|
1044
|
+
// before the run, roll it back if the node fails, keep the checkpoint as
|
|
1045
|
+
// a recovery point (correlated to graph/node) on success. Explore,
|
|
1046
|
+
// verify, merge and rework paths are never wrapped.
|
|
1047
|
+
if (!this.transactional || node.kind !== 'general') {
|
|
1048
|
+
const res = await runOnce();
|
|
1049
|
+
if (res.ok && usesWorktree) {
|
|
1050
|
+
this.nodeRunState.set(node.id, { worktreeHandle: res.worktreeHandle });
|
|
1051
|
+
}
|
|
1052
|
+
return res;
|
|
1053
|
+
}
|
|
1054
|
+
const tx = await runTransactional({ cwd: this.parentCwd, taskId: graph.id, nodeId: node.id, label: node.label }, async () => {
|
|
1055
|
+
const res = await runOnce();
|
|
1056
|
+
// A failed tentacle RESOLVES with ok:false — map it to a throw so
|
|
1057
|
+
// runTransactional sees a transaction failure and rolls back.
|
|
1058
|
+
if (!res.ok)
|
|
1059
|
+
throw new Error(res.error);
|
|
1060
|
+
return res;
|
|
1061
|
+
});
|
|
1062
|
+
if (tx.outcome === 'rolledback') {
|
|
1063
|
+
this.radio('node_rolled_back', {
|
|
1064
|
+
description: node.label,
|
|
1065
|
+
agent: node.kind,
|
|
1066
|
+
detail: tx.error ?? 'node failed; workspace rolled back',
|
|
1067
|
+
ok: false,
|
|
1068
|
+
});
|
|
1069
|
+
this.wb?.logEvent(`rolled back ${node.id} "${node.label}" — ${tx.error ?? 'node failed'}`);
|
|
1070
|
+
// The ordinary failure path: retry (if budgeted) re-runs from the clean
|
|
1071
|
+
// checkpoint state. No `cancelled` field — the tentacle unwound, so a
|
|
1072
|
+
// re-run is safe.
|
|
1073
|
+
return { ok: false, agent, error: tx.error ?? `node "${node.label}" failed` };
|
|
1074
|
+
}
|
|
1075
|
+
const res = tx.value;
|
|
1076
|
+
if (!res) {
|
|
1077
|
+
// Contractually unreachable (success/passthrough carry the run's
|
|
1078
|
+
// result); an honest failure beats returning undefined.
|
|
1079
|
+
return { ok: false, agent, error: tx.note ?? 'transactional run produced no result' };
|
|
1080
|
+
}
|
|
705
1081
|
if (res.ok && usesWorktree) {
|
|
706
1082
|
this.nodeRunState.set(node.id, { worktreeHandle: res.worktreeHandle });
|
|
707
1083
|
}
|
|
@@ -885,6 +1261,10 @@ export class KrakenGraphExecutor {
|
|
|
885
1261
|
}
|
|
886
1262
|
/** Apply a tentacle result to its node: success, retry, fix-spawn, or terminal failure. */
|
|
887
1263
|
applyResult(graph, node, res) {
|
|
1264
|
+
this.applyResultInner(graph, node, res);
|
|
1265
|
+
this.recordNodeReputation(node, res);
|
|
1266
|
+
}
|
|
1267
|
+
applyResultInner(graph, node, res) {
|
|
888
1268
|
if (res.ok) {
|
|
889
1269
|
if (res.memoryId)
|
|
890
1270
|
this.memoryIds.set(node.id, res.memoryId);
|
|
@@ -994,6 +1374,70 @@ export class KrakenGraphExecutor {
|
|
|
994
1374
|
durationMs: this.durationsMs.get(node.id),
|
|
995
1375
|
});
|
|
996
1376
|
}
|
|
1377
|
+
/**
|
|
1378
|
+
* t29 (§15): append ONE reputation record for a settled node run — the
|
|
1379
|
+
* minimal recorder hook. Only terminal states are recorded (success, or a
|
|
1380
|
+
* failure that consumed the retry budget / got its fix node): retries leave
|
|
1381
|
+
* the node `pending` and cancelled/unconfirmed runs are skipped, because
|
|
1382
|
+
* cancellation and re-entry are not model signals. Repo = basename of the
|
|
1383
|
+
* parent cwd; role = host agent kind (agentForNode); model when the result
|
|
1384
|
+
* carries one ('n/a' merge pseudo-model ⇒ null); provider is NOT carried by
|
|
1385
|
+
* TentacleResult so it records as null (documented v1 limitation). FAILOPEN:
|
|
1386
|
+
* every failure path is swallowed — reputation must never break a run.
|
|
1387
|
+
*/
|
|
1388
|
+
recordNodeReputation(node, res) {
|
|
1389
|
+
try {
|
|
1390
|
+
if (!res.ok) {
|
|
1391
|
+
if (this.aborted || res.cancelled === true || res.cancelled === false)
|
|
1392
|
+
return;
|
|
1393
|
+
// Retry re-queues the node ('pending'): its final run records the
|
|
1394
|
+
// whole story via retryCount, so intermediate attempts are not rows.
|
|
1395
|
+
if (node.status === 'pending')
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
const model = res.ok && res.model && res.model !== 'n/a' ? res.model : null;
|
|
1399
|
+
const reviewerVerdict = res.ok && this.isReviewerKind(node.kind) && typeof node.result === 'string' && node.result.length > 0
|
|
1400
|
+
? parseVerifyVerdict(node.result).verdict
|
|
1401
|
+
: null;
|
|
1402
|
+
const record = reputationRecordFromNodeRun({
|
|
1403
|
+
repo: path.basename(this.parentCwd),
|
|
1404
|
+
role: agentForNode(node),
|
|
1405
|
+
kind: node.kind,
|
|
1406
|
+
ok: res.ok,
|
|
1407
|
+
reviewerVerdict,
|
|
1408
|
+
repairCount: node.retryCount,
|
|
1409
|
+
model,
|
|
1410
|
+
provider: null, // TentacleResult carries no provider identity (t29 v1).
|
|
1411
|
+
costUsd: res.ok && model && res.usage
|
|
1412
|
+
? calculateCost(model, res.usage.promptTokens, res.usage.completionTokens, res.usage.cachedPromptTokens ?? 0)
|
|
1413
|
+
: null,
|
|
1414
|
+
latencyMs: this.durationsMs.get(node.id) ?? null,
|
|
1415
|
+
});
|
|
1416
|
+
const storePath = resolveReputationStorePath(this.parentCwd);
|
|
1417
|
+
void appendRecord(storePath, record)
|
|
1418
|
+
.then(() => pruneStore(storePath, DEFAULT_MAX_RECORDS))
|
|
1419
|
+
.catch(() => {
|
|
1420
|
+
/* fail-open */
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
catch {
|
|
1424
|
+
/* fail-open: reputation must never break a run */
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
/**
|
|
1428
|
+
* t29 (§16): load the repo's reputation store once per run and publish it
|
|
1429
|
+
* as the verifier-routing source, so the NEXT advisory review inside this
|
|
1430
|
+
* process consults real history. Passive + fail-open: a missing/corrupt
|
|
1431
|
+
* store yields an empty list, which keeps t21 heuristics verbatim.
|
|
1432
|
+
*/
|
|
1433
|
+
async refreshReputationSource() {
|
|
1434
|
+
try {
|
|
1435
|
+
setReputationSource(await loadRecords(resolveReputationStorePath(this.parentCwd)));
|
|
1436
|
+
}
|
|
1437
|
+
catch {
|
|
1438
|
+
/* fail-open */
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
997
1441
|
async linkMemoryGraph(graph, converged) {
|
|
998
1442
|
const memory = this.deps.memoryService;
|
|
999
1443
|
if (!memory || this.deps.memoryAutoWrite === false)
|
|
@@ -1337,6 +1781,15 @@ export class KrakenGraphExecutor {
|
|
|
1337
1781
|
description: fields.description,
|
|
1338
1782
|
...(fields.detail !== undefined ? { detail: fields.detail } : {}),
|
|
1339
1783
|
...(fields.ok !== undefined ? { ok: fields.ok } : {}),
|
|
1784
|
+
...(fields.nodeId !== undefined ? { nodeId: fields.nodeId } : {}),
|
|
1785
|
+
...(fields.overlapScore !== undefined ? { overlapScore: fields.overlapScore } : {}),
|
|
1786
|
+
...(fields.rationaleCode !== undefined ? { rationaleCode: fields.rationaleCode } : {}),
|
|
1787
|
+
...(fields.runningNode !== undefined ? { runningNode: fields.runningNode } : {}),
|
|
1788
|
+
...(fields.contestedFile !== undefined ? { contestedFile: fields.contestedFile } : {}),
|
|
1789
|
+
...(fields.spawnScore !== undefined ? { spawnScore: fields.spawnScore } : {}),
|
|
1790
|
+
...(fields.threshold !== undefined ? { threshold: fields.threshold } : {}),
|
|
1791
|
+
...(fields.symbolsA !== undefined ? { symbolsA: fields.symbolsA } : {}),
|
|
1792
|
+
...(fields.symbolsB !== undefined ? { symbolsB: fields.symbolsB } : {}),
|
|
1340
1793
|
});
|
|
1341
1794
|
}
|
|
1342
1795
|
/**
|