Integrate UOK model policy gates and kernel loop adapter
This commit is contained in:
parent
856c3f5cf5
commit
bb1b9dce07
36 changed files with 2026 additions and 43 deletions
|
|
@ -16,6 +16,8 @@ import { getLedger, getProjectTotals } from "./metrics.js";
|
|||
import { unitPhaseLabel } from "./auto-dashboard.js";
|
||||
import { getSessionModelOverride } from "./session-model-override.js";
|
||||
import { logWarning } from "./workflow-logger.js";
|
||||
import { resolveUokFlags } from "./uok/flags.js";
|
||||
import { applyModelPolicyFilter } from "./uok/model-policy.js";
|
||||
|
||||
export interface ModelSelectionResult {
|
||||
/** Routing metadata for metrics recording */
|
||||
|
|
@ -75,6 +77,7 @@ export async function selectAndApplyModel(
|
|||
/** Explicit /gsd model pin captured at bootstrap for long-running auto loops. */
|
||||
sessionModelOverride?: { provider: string; id: string } | null,
|
||||
): Promise<ModelSelectionResult> {
|
||||
const uokFlags = resolveUokFlags(prefs);
|
||||
const effectiveSessionModelOverride = sessionModelOverride === undefined
|
||||
? getSessionModelOverride(ctx.sessionManager.getSessionId())
|
||||
: (sessionModelOverride ?? undefined);
|
||||
|
|
@ -97,6 +100,9 @@ export async function selectAndApplyModel(
|
|||
|
||||
if (modelConfig) {
|
||||
const availableModels = ctx.modelRegistry.getAvailable();
|
||||
const modelPolicyTraceId = `model:${ctx.sessionManager.getSessionId()}:${Date.now()}`;
|
||||
const modelPolicyTurnId = `${unitType}:${unitId}`;
|
||||
let policyAllowedModelKeys: Set<string> | null = null;
|
||||
|
||||
// ─── Dynamic Model Routing ─────────────────────────────────────────
|
||||
// Dynamic routing (complexity-based downgrading) only applies in auto-mode.
|
||||
|
|
@ -106,8 +112,35 @@ export async function selectAndApplyModel(
|
|||
if (!isAutoMode) {
|
||||
routingConfig.enabled = false;
|
||||
}
|
||||
// burn-max defaults to quality-first dispatch (no downgrade routing).
|
||||
if (prefs?.token_profile === "burn-max") {
|
||||
routingConfig.enabled = false;
|
||||
}
|
||||
let effectiveModelConfig = modelConfig;
|
||||
let routingTierLabel = "";
|
||||
let routingEligibleModels = availableModels;
|
||||
|
||||
if (uokFlags.modelPolicy) {
|
||||
const policy = applyModelPolicyFilter(
|
||||
availableModels,
|
||||
{
|
||||
basePath,
|
||||
traceId: modelPolicyTraceId,
|
||||
turnId: modelPolicyTurnId,
|
||||
unitType,
|
||||
currentProvider: ctx.model?.provider,
|
||||
allowCrossProvider: routingConfig.cross_provider !== false,
|
||||
requiredTools: pi.getActiveTools(),
|
||||
},
|
||||
);
|
||||
routingEligibleModels = policy.eligible;
|
||||
policyAllowedModelKeys = new Set(
|
||||
policy.eligible.map((m) => `${m.provider.toLowerCase()}/${m.id.toLowerCase()}`),
|
||||
);
|
||||
if (routingEligibleModels.length === 0) {
|
||||
throw new Error(`Model policy denied all candidate models for ${unitType}/${unitId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Disable routing for flat-rate providers like GitHub Copilot (#3453).
|
||||
// All models cost the same per request, so downgrading to a cheaper
|
||||
|
|
@ -115,7 +148,7 @@ export async function selectAndApplyModel(
|
|||
// Fail-closed: if primary model can't be resolved, fall back to
|
||||
// provider-level signals rather than allowing unwanted downgrades.
|
||||
if (routingConfig.enabled) {
|
||||
const primaryModel = resolveModelId(modelConfig.primary, availableModels, ctx.model?.provider);
|
||||
const primaryModel = resolveModelId(modelConfig.primary, routingEligibleModels, ctx.model?.provider);
|
||||
if (primaryModel) {
|
||||
const primaryFlatRateCtx = buildFlatRateContext(primaryModel.provider, ctx, prefs);
|
||||
if (isFlatRateProvider(primaryModel.provider, primaryFlatRateCtx)) {
|
||||
|
|
@ -150,7 +183,7 @@ export async function selectAndApplyModel(
|
|||
|
||||
if (shouldClassify) {
|
||||
let classification = classifyUnitComplexity(unitType, unitId, basePath, budgetPct);
|
||||
const availableModelIds = availableModels.map(m => m.id);
|
||||
const availableModelIds = routingEligibleModels.map(m => m.id);
|
||||
|
||||
// Escalate tier on retry when escalate_on_failure is enabled (default: true)
|
||||
if (
|
||||
|
|
@ -257,6 +290,7 @@ export async function selectAndApplyModel(
|
|||
}
|
||||
|
||||
const modelsToTry = [effectiveModelConfig.primary, ...effectiveModelConfig.fallbacks];
|
||||
let attemptedPolicyEligible = false;
|
||||
|
||||
for (const modelId of modelsToTry) {
|
||||
const model = resolveModelId(modelId, availableModels, ctx.model?.provider);
|
||||
|
|
@ -266,6 +300,17 @@ export async function selectAndApplyModel(
|
|||
continue;
|
||||
}
|
||||
|
||||
if (policyAllowedModelKeys) {
|
||||
const key = `${model.provider.toLowerCase()}/${model.id.toLowerCase()}`;
|
||||
if (!policyAllowedModelKeys.has(key)) {
|
||||
if (verbose) {
|
||||
ctx.ui.notify(`Model policy denied ${model.provider}/${model.id}; trying fallback.`, "warning");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
attemptedPolicyEligible = true;
|
||||
}
|
||||
|
||||
// Warn if the ID is ambiguous across providers
|
||||
if (!modelId.includes("/")) {
|
||||
const providers = availableModels.filter(m => m.id === modelId).map(m => m.provider);
|
||||
|
|
@ -331,6 +376,10 @@ export async function selectAndApplyModel(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uokFlags.modelPolicy && policyAllowedModelKeys && !attemptedPolicyEligible) {
|
||||
throw new Error(`Model policy denied dispatch for ${unitType}/${unitId} before prompt send`);
|
||||
}
|
||||
} else if (autoModeStartModel) {
|
||||
// No model preference for this unit type — re-apply the model captured
|
||||
// at auto-mode start to prevent bleed from shared global settings.json (#650).
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ import { runPostExecutionChecks, type PostExecutionResult } from "./post-executi
|
|||
import type { AutoSession } from "./auto/session.js";
|
||||
import type { VerificationResult as VerificationGateResult } from "./types.js";
|
||||
import { join } from "node:path";
|
||||
import { resolveUokFlags } from "./uok/flags.js";
|
||||
import { UokGateRunner } from "./uok/gate-runner.js";
|
||||
|
||||
export interface VerificationContext {
|
||||
s: AutoSession;
|
||||
|
|
@ -158,6 +160,7 @@ export async function runPostUnitVerification(
|
|||
try {
|
||||
const effectivePrefs = loadEffectiveGSDPreferences();
|
||||
const prefs = effectivePrefs?.preferences;
|
||||
const uokFlags = resolveUokFlags(prefs);
|
||||
|
||||
// Read task plan verify field
|
||||
const { milestone: mid, slice: sid, task: tid } = parseUnitId(s.currentUnit.id);
|
||||
|
|
@ -196,6 +199,37 @@ export async function runPostUnitVerification(
|
|||
}
|
||||
}
|
||||
|
||||
if (uokFlags.gates) {
|
||||
const gateRunner = new UokGateRunner();
|
||||
gateRunner.register({
|
||||
id: "verification-gate",
|
||||
type: "verification",
|
||||
execute: async () => ({
|
||||
outcome: result.passed ? "pass" : "fail",
|
||||
failureClass: result.runtimeErrors?.some((e) => e.blocking)
|
||||
? "execution"
|
||||
: "verification",
|
||||
rationale: result.passed
|
||||
? "verification checks passed"
|
||||
: "verification checks failed",
|
||||
findings: result.passed
|
||||
? ""
|
||||
: formatFailureContext(result),
|
||||
}),
|
||||
});
|
||||
|
||||
await gateRunner.run("verification-gate", {
|
||||
basePath: s.basePath,
|
||||
traceId: `verification:${s.currentUnit.id}`,
|
||||
turnId: s.currentUnit.id,
|
||||
milestoneId: mid ?? undefined,
|
||||
sliceId: sid ?? undefined,
|
||||
taskId: tid ?? undefined,
|
||||
unitType: s.currentUnit.type,
|
||||
unitId: s.currentUnit.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-fix retry preferences
|
||||
const autoFixEnabled = prefs?.verification_auto_fix !== false;
|
||||
const maxRetries =
|
||||
|
|
|
|||
|
|
@ -202,6 +202,7 @@ import {
|
|||
import { bootstrapAutoSession, openProjectDbIfPresent, type BootstrapDeps } from "./auto-start.js";
|
||||
import { initHealthWidget } from "./health-widget.js";
|
||||
import { autoLoop, resolveAgentEnd, resolveAgentEndCancelled, _resetPendingResolve, isSessionSwitchInFlight, type LoopDeps, type ErrorContext } from "./auto-loop.js";
|
||||
import { runAutoLoopWithUok } from "./uok/kernel.js";
|
||||
// Slice-level parallelism (#2340)
|
||||
import { getEligibleSlices } from "./slice-parallel-eligibility.js";
|
||||
import { startSliceParallel } from "./slice-parallel-orchestrator.js";
|
||||
|
|
@ -1513,7 +1514,13 @@ export async function startAuto(
|
|||
logCmuxEvent(loadEffectiveGSDPreferences()?.preferences, s.stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "progress");
|
||||
|
||||
captureProjectRootEnv(s.originalBasePath || s.basePath);
|
||||
await autoLoop(ctx, pi, s, buildLoopDeps());
|
||||
await runAutoLoopWithUok({
|
||||
ctx,
|
||||
pi,
|
||||
s,
|
||||
deps: buildLoopDeps(),
|
||||
runLegacyLoop: autoLoop,
|
||||
});
|
||||
cleanupAfterLoopExit(ctx);
|
||||
return;
|
||||
}
|
||||
|
|
@ -1548,7 +1555,13 @@ export async function startAuto(
|
|||
logCmuxEvent(loadEffectiveGSDPreferences()?.preferences, requestedStepMode ? "Step-mode started." : "Auto-mode started.", "progress");
|
||||
|
||||
// Dispatch the first unit
|
||||
await autoLoop(ctx, pi, s, buildLoopDeps());
|
||||
await runAutoLoopWithUok({
|
||||
ctx,
|
||||
pi,
|
||||
s,
|
||||
deps: buildLoopDeps(),
|
||||
runLegacyLoop: autoLoop,
|
||||
});
|
||||
cleanupAfterLoopExit(ctx);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import type { WorktreeResolver } from "../worktree-resolver.js";
|
|||
import type { CmuxLogLevel } from "../../cmux/index.js";
|
||||
import type { JournalEntry } from "../journal.js";
|
||||
import type { MergeReconcileResult } from "../auto-recovery.js";
|
||||
import type { UokTurnObserver } from "../uok/contracts.js";
|
||||
|
||||
/**
|
||||
* Dependencies injected by the caller (auto.ts startAuto) so autoLoop
|
||||
|
|
@ -274,4 +275,7 @@ export interface LoopDeps {
|
|||
|
||||
// Journal
|
||||
emitJournalEvent: (entry: JournalEntry) => void;
|
||||
|
||||
// UOK (optional, flag-gated)
|
||||
uokObserver?: UokTurnObserver;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,6 +128,39 @@ export async function autoLoop(
|
|||
const flowId = randomUUID();
|
||||
let seqCounter = 0;
|
||||
const nextSeq = () => ++seqCounter;
|
||||
const turnId = randomUUID();
|
||||
const turnStartedAt = new Date().toISOString();
|
||||
let observedUnitType: string | undefined;
|
||||
let observedUnitId: string | undefined;
|
||||
let turnFinished = false;
|
||||
const finishTurn = (
|
||||
status: "completed" | "failed" | "paused" | "stopped" | "skipped" | "retry",
|
||||
failureClass: "none" | "unknown" | "manual-attention" | "timeout" | "execution" = "none",
|
||||
error?: string,
|
||||
): void => {
|
||||
if (turnFinished) return;
|
||||
turnFinished = true;
|
||||
deps.uokObserver?.onTurnResult({
|
||||
traceId: flowId,
|
||||
turnId,
|
||||
iteration,
|
||||
unitType: observedUnitType,
|
||||
unitId: observedUnitId,
|
||||
status,
|
||||
failureClass,
|
||||
phaseResults: [],
|
||||
error,
|
||||
startedAt: turnStartedAt,
|
||||
finishedAt: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
deps.uokObserver?.onTurnStart({
|
||||
traceId: flowId,
|
||||
turnId,
|
||||
iteration,
|
||||
basePath: s.basePath,
|
||||
startedAt: turnStartedAt,
|
||||
});
|
||||
|
||||
if (iteration > MAX_LOOP_ITERATIONS) {
|
||||
debugLog("autoLoop", {
|
||||
|
|
@ -140,6 +173,7 @@ export async function autoLoop(
|
|||
pi,
|
||||
`Safety: loop exceeded ${MAX_LOOP_ITERATIONS} iterations — possible runaway`,
|
||||
);
|
||||
finishTurn("stopped", "manual-attention", "max-iterations");
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -157,12 +191,14 @@ export async function autoLoop(
|
|||
`Stopping gracefully to prevent OOM kill after ${iteration} iterations. ` +
|
||||
`Resume with /gsd auto to continue from where you left off.`,
|
||||
);
|
||||
finishTurn("stopped", "timeout", "memory-pressure");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!s.cmdCtx) {
|
||||
debugLog("autoLoop", { phase: "exit", reason: "no-cmdCtx" });
|
||||
finishTurn("stopped", "manual-attention", "missing-command-context");
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -256,27 +292,53 @@ export async function autoLoop(
|
|||
isRetry: false,
|
||||
previousTier: undefined,
|
||||
};
|
||||
observedUnitType = iterData.unitType;
|
||||
observedUnitId = iterData.unitId;
|
||||
|
||||
// ── Progress widget (mirrors dev path in runDispatch) ──
|
||||
deps.updateProgressWidget(ctx, iterData.unitType, iterData.unitId, iterData.state);
|
||||
|
||||
// ── Guards (shared with dev path) ──
|
||||
const guardsResult = await runGuards(ic, s.currentMilestoneId ?? "workflow");
|
||||
if (guardsResult.action === "break") break;
|
||||
deps.uokObserver?.onPhaseResult("guard", guardsResult.action, {
|
||||
unitType: iterData.unitType,
|
||||
unitId: iterData.unitId,
|
||||
});
|
||||
if (guardsResult.action === "break") {
|
||||
finishTurn("stopped", "manual-attention", "guard-break");
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Unit execution (shared with dev path) ──
|
||||
const unitPhaseResult = await runUnitPhase(ic, iterData, loopState);
|
||||
if (unitPhaseResult.action === "break") break;
|
||||
deps.uokObserver?.onPhaseResult("unit", unitPhaseResult.action, {
|
||||
unitType: iterData.unitType,
|
||||
unitId: iterData.unitId,
|
||||
});
|
||||
if (unitPhaseResult.action === "break") {
|
||||
finishTurn("stopped", "execution", "unit-break");
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Verify first, then reconcile (only mark complete on pass) ──
|
||||
debugLog("autoLoop", { phase: "custom-engine-verify", iteration, unitId: iterData.unitId });
|
||||
const verifyResult = await policy.verify(iterData.unitType, iterData.unitId, { basePath: s.basePath });
|
||||
if (verifyResult === "pause") {
|
||||
await deps.pauseAuto(ctx, pi);
|
||||
deps.uokObserver?.onPhaseResult("custom-engine", "pause", {
|
||||
unitType: iterData.unitType,
|
||||
unitId: iterData.unitId,
|
||||
});
|
||||
finishTurn("paused", "manual-attention", "custom-engine-verify-pause");
|
||||
break;
|
||||
}
|
||||
if (verifyResult === "retry") {
|
||||
debugLog("autoLoop", { phase: "custom-engine-verify-retry", iteration, unitId: iterData.unitId });
|
||||
deps.uokObserver?.onPhaseResult("custom-engine", "retry", {
|
||||
unitType: iterData.unitType,
|
||||
unitId: iterData.unitId,
|
||||
});
|
||||
finishTurn("retry");
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -299,36 +361,77 @@ export async function autoLoop(
|
|||
|
||||
if (reconcileResult.outcome === "milestone-complete") {
|
||||
await deps.stopAuto(ctx, pi, "Workflow complete");
|
||||
deps.uokObserver?.onPhaseResult("custom-engine", "milestone-complete", {
|
||||
unitType: iterData.unitType,
|
||||
unitId: iterData.unitId,
|
||||
});
|
||||
finishTurn("completed");
|
||||
break;
|
||||
}
|
||||
if (reconcileResult.outcome === "pause") {
|
||||
await deps.pauseAuto(ctx, pi);
|
||||
deps.uokObserver?.onPhaseResult("custom-engine", "pause", {
|
||||
unitType: iterData.unitType,
|
||||
unitId: iterData.unitId,
|
||||
});
|
||||
finishTurn("paused", "manual-attention");
|
||||
break;
|
||||
}
|
||||
if (reconcileResult.outcome === "stop") {
|
||||
await deps.stopAuto(ctx, pi, reconcileResult.reason ?? "Engine stopped");
|
||||
deps.uokObserver?.onPhaseResult("custom-engine", "stop", {
|
||||
unitType: iterData.unitType,
|
||||
unitId: iterData.unitId,
|
||||
reason: reconcileResult.reason,
|
||||
});
|
||||
finishTurn("stopped", "manual-attention", reconcileResult.reason);
|
||||
break;
|
||||
}
|
||||
deps.uokObserver?.onPhaseResult("custom-engine", "continue", {
|
||||
unitType: iterData.unitType,
|
||||
unitId: iterData.unitId,
|
||||
});
|
||||
finishTurn("completed");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!sidecarItem) {
|
||||
// ── Phase 1: Pre-dispatch ─────────────────────────────────────────
|
||||
const preDispatchResult = await runPreDispatch(ic, loopState);
|
||||
if (preDispatchResult.action === "break") break;
|
||||
if (preDispatchResult.action === "continue") continue;
|
||||
deps.uokObserver?.onPhaseResult("pre-dispatch", preDispatchResult.action);
|
||||
if (preDispatchResult.action === "break") {
|
||||
finishTurn("stopped", "manual-attention", "pre-dispatch-break");
|
||||
break;
|
||||
}
|
||||
if (preDispatchResult.action === "continue") {
|
||||
finishTurn("skipped");
|
||||
continue;
|
||||
}
|
||||
|
||||
const preData = preDispatchResult.data;
|
||||
|
||||
// ── Phase 2: Guards ───────────────────────────────────────────────
|
||||
const guardsResult = await runGuards(ic, preData.mid);
|
||||
if (guardsResult.action === "break") break;
|
||||
deps.uokObserver?.onPhaseResult("guard", guardsResult.action);
|
||||
if (guardsResult.action === "break") {
|
||||
finishTurn("stopped", "manual-attention", "guard-break");
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Phase 3: Dispatch ─────────────────────────────────────────────
|
||||
const dispatchResult = await runDispatch(ic, preData, loopState);
|
||||
if (dispatchResult.action === "break") break;
|
||||
if (dispatchResult.action === "continue") continue;
|
||||
deps.uokObserver?.onPhaseResult("dispatch", dispatchResult.action);
|
||||
if (dispatchResult.action === "break") {
|
||||
finishTurn("stopped", "manual-attention", "dispatch-break");
|
||||
break;
|
||||
}
|
||||
if (dispatchResult.action === "continue") {
|
||||
finishTurn("skipped");
|
||||
continue;
|
||||
}
|
||||
iterData = dispatchResult.data;
|
||||
observedUnitType = iterData.unitType;
|
||||
observedUnitId = iterData.unitId;
|
||||
} else {
|
||||
// ── Sidecar path: use values from the sidecar item directly ──
|
||||
const sidecarState = await deps.deriveState(s.basePath);
|
||||
|
|
@ -343,22 +446,47 @@ export async function autoLoop(
|
|||
midTitle: sidecarState.activeMilestone?.title,
|
||||
isRetry: false, previousTier: undefined,
|
||||
};
|
||||
observedUnitType = iterData.unitType;
|
||||
observedUnitId = iterData.unitId;
|
||||
deps.uokObserver?.onPhaseResult("dispatch", "sidecar", {
|
||||
unitType: iterData.unitType,
|
||||
unitId: iterData.unitId,
|
||||
sidecarKind: sidecarItem.kind,
|
||||
});
|
||||
}
|
||||
|
||||
const unitPhaseResult = await runUnitPhase(ic, iterData, loopState, sidecarItem);
|
||||
if (unitPhaseResult.action === "break") break;
|
||||
deps.uokObserver?.onPhaseResult("unit", unitPhaseResult.action, {
|
||||
unitType: iterData.unitType,
|
||||
unitId: iterData.unitId,
|
||||
});
|
||||
if (unitPhaseResult.action === "break") {
|
||||
finishTurn("stopped", "execution", "unit-break");
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Phase 5: Finalize ───────────────────────────────────────────────
|
||||
|
||||
const finalizeResult = await runFinalize(ic, iterData, loopState, sidecarItem);
|
||||
if (finalizeResult.action === "break") break;
|
||||
if (finalizeResult.action === "continue") continue;
|
||||
deps.uokObserver?.onPhaseResult("finalize", finalizeResult.action, {
|
||||
unitType: iterData.unitType,
|
||||
unitId: iterData.unitId,
|
||||
});
|
||||
if (finalizeResult.action === "break") {
|
||||
finishTurn("stopped", "closeout", "finalize-break");
|
||||
break;
|
||||
}
|
||||
if (finalizeResult.action === "continue") {
|
||||
finishTurn("retry");
|
||||
continue;
|
||||
}
|
||||
|
||||
consecutiveErrors = 0; // Iteration completed successfully
|
||||
consecutiveCooldowns = 0;
|
||||
recentErrorMessages.length = 0;
|
||||
deps.emitJournalEvent({ ts: new Date().toISOString(), flowId, seq: nextSeq(), eventType: "iteration-end", data: { iteration } });
|
||||
debugLog("autoLoop", { phase: "iteration-complete", iteration });
|
||||
finishTurn("completed");
|
||||
} catch (loopErr) {
|
||||
// ── Blanket catch: absorb unexpected exceptions, apply graduated recovery ──
|
||||
const msg = loopErr instanceof Error ? loopErr.message : String(loopErr);
|
||||
|
|
@ -388,6 +516,7 @@ export async function autoLoop(
|
|||
pi,
|
||||
`Infrastructure error (${infraCode}): not recoverable by retry`,
|
||||
);
|
||||
finishTurn("failed", "execution", msg);
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -429,6 +558,7 @@ export async function autoLoop(
|
|||
"warning",
|
||||
);
|
||||
await new Promise(resolve => setTimeout(resolve, waitMs));
|
||||
finishTurn("retry", "timeout", msg);
|
||||
continue; // Retry iteration without incrementing consecutiveErrors
|
||||
}
|
||||
|
||||
|
|
@ -455,6 +585,7 @@ export async function autoLoop(
|
|||
pi,
|
||||
`${consecutiveErrors} consecutive iteration failures`,
|
||||
);
|
||||
finishTurn("failed", "execution", msg);
|
||||
break;
|
||||
} else if (consecutiveErrors === 2) {
|
||||
// 2nd consecutive: try invalidating caches + re-deriving state
|
||||
|
|
@ -467,6 +598,7 @@ export async function autoLoop(
|
|||
// 1st error: log and retry — transient failures happen
|
||||
ctx.ui.notify(`Iteration error: ${msg}. Retrying.`, "warning");
|
||||
}
|
||||
finishTurn("retry", "execution", msg);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import { withTimeout, FINALIZE_PRE_TIMEOUT_MS, FINALIZE_POST_TIMEOUT_MS } from "
|
|||
import { getEligibleSlices } from "../slice-parallel-eligibility.js";
|
||||
import { startSliceParallel } from "../slice-parallel-orchestrator.js";
|
||||
import { isDbAvailable, getMilestoneSlices } from "../gsd-db.js";
|
||||
import { ensurePlanV2Graph } from "../uok/plan-v2.js";
|
||||
import { resetEvidence } from "../safety/evidence-collector.js";
|
||||
import { createCheckpoint, cleanupCheckpoint, rollbackToCheckpoint } from "../safety/git-checkpoint.js";
|
||||
import { resolveSafetyHarnessConfig } from "../safety/safety-harness.js";
|
||||
|
|
@ -252,6 +253,15 @@ export async function runPreDispatch(
|
|||
|
||||
// Derive state
|
||||
let state = await deps.deriveState(s.basePath);
|
||||
if (prefs?.uok?.plan_v2?.enabled) {
|
||||
const compiled = ensurePlanV2Graph(s.basePath, state);
|
||||
if (!compiled.ok) {
|
||||
const reason = compiled.reason ?? "Plan v2 compilation failed";
|
||||
ctx.ui.notify(`Plan gate failed-closed: ${reason}`, "error");
|
||||
await deps.pauseAuto(ctx, pi);
|
||||
return { action: "break", reason: "plan-v2-gate-failed" };
|
||||
}
|
||||
}
|
||||
deps.syncCmuxSidebar(prefs, state);
|
||||
let mid = state.activeMilestone?.id;
|
||||
let midTitle = state.activeMilestone?.title;
|
||||
|
|
|
|||
|
|
@ -822,7 +822,7 @@ export function serializePreferencesToFrontmatter(prefs: Record<string, unknown>
|
|||
"budget_ceiling", "budget_enforcement", "context_pause_threshold",
|
||||
"notifications", "cmux", "remote_questions", "git",
|
||||
"post_unit_hooks", "pre_dispatch_hooks",
|
||||
"dynamic_routing", "token_profile", "phases", "parallel",
|
||||
"dynamic_routing", "uok", "token_profile", "phases", "parallel",
|
||||
"auto_visualize", "auto_report",
|
||||
"verification_commands", "verification_auto_fix", "verification_max_retries",
|
||||
"search_provider", "context_selection",
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea
|
|||
|
||||
- `context_pause_threshold`: number (0-100) — context window usage percentage at which auto-mode should pause to suggest checkpointing. Set to `0` to disable. Default: `0` (disabled).
|
||||
|
||||
- `token_profile`: `"budget"`, `"balanced"`, or `"quality"` — coordinates model selection, phase skipping, and context compression. `budget` skips research/reassessment and uses cheaper models; `balanced` (default) skips research/reassessment to reduce token burn; `quality` prefers higher-quality models. See token-optimization docs.
|
||||
- `token_profile`: `"budget"`, `"balanced"`, `"quality"`, or `"burn-max"` — coordinates model selection, phase skipping, and context compression. `budget` skips research/reassessment and uses cheaper models; `balanced` (default) skips research/reassessment to reduce token burn; `quality` prefers higher-quality models; `burn-max` keeps full-context defaults, disables downgrade routing, and keeps phase skips off.
|
||||
|
||||
- `phases`: fine-grained control over which phases run. Usually set by `token_profile`, but can be overridden. Keys:
|
||||
- `skip_research`: boolean — skip milestone-level research. Default: `false`.
|
||||
|
|
@ -191,6 +191,17 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea
|
|||
- `hooks`: boolean — enable routing hooks. Default: `true`.
|
||||
- `capability_routing`: boolean — enable capability-profile scoring for model selection within a tier. Requires `enabled: true`. Default: `false`.
|
||||
|
||||
- `uok`: Unified Orchestration Kernel controls (all flags default to `false` during migration). Keys:
|
||||
- `enabled`: boolean — enable kernel wrappers and contract observers.
|
||||
- `gates.enabled`: boolean — route checks through the unified gate runner and persist `gate_runs`.
|
||||
- `model_policy.enabled`: boolean — enforce policy filtering before model capability scoring.
|
||||
- `execution_graph.enabled`: boolean — enable DAG scheduler facade/adapters for execution.
|
||||
- `gitops.enabled`: boolean — persist turn-level git transaction records.
|
||||
- `gitops.turn_action`: `"commit"` | `"snapshot"` | `"status-only"` — turn transaction mode.
|
||||
- `gitops.turn_push`: boolean — whether turn transactions should include push intent metadata.
|
||||
- `audit_unified.enabled`: boolean — dual-write unified audit envelope events.
|
||||
- `plan_v2.enabled`: boolean — enable bounded clarify/research/draft/compile planning flow.
|
||||
|
||||
- `context_management`: configures context hygiene for auto-mode sessions. Keys:
|
||||
- `observation_masking`: boolean — mask old tool results to reduce context bloat. Default: `true`.
|
||||
- `observation_mask_turns`: number — keep this many recent turns verbatim (1-50). Default: `8`.
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ function openRawDb(path: string): unknown {
|
|||
return new Database(path);
|
||||
}
|
||||
|
||||
const SCHEMA_VERSION = 14;
|
||||
const SCHEMA_VERSION = 15;
|
||||
|
||||
function indexExists(db: DbAdapter, name: string): boolean {
|
||||
return !!db.prepare(
|
||||
|
|
@ -443,6 +443,70 @@ function initSchema(db: DbAdapter, fileBacked: boolean): void {
|
|||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS gate_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trace_id TEXT NOT NULL,
|
||||
turn_id TEXT NOT NULL,
|
||||
gate_id TEXT NOT NULL,
|
||||
gate_type TEXT NOT NULL DEFAULT '',
|
||||
unit_type TEXT DEFAULT NULL,
|
||||
unit_id TEXT DEFAULT NULL,
|
||||
milestone_id TEXT DEFAULT NULL,
|
||||
slice_id TEXT DEFAULT NULL,
|
||||
task_id TEXT DEFAULT NULL,
|
||||
outcome TEXT NOT NULL DEFAULT 'pass',
|
||||
failure_class TEXT NOT NULL DEFAULT 'none',
|
||||
rationale TEXT NOT NULL DEFAULT '',
|
||||
findings TEXT NOT NULL DEFAULT '',
|
||||
attempt INTEGER NOT NULL DEFAULT 1,
|
||||
max_attempts INTEGER NOT NULL DEFAULT 1,
|
||||
retryable INTEGER NOT NULL DEFAULT 0,
|
||||
evaluated_at TEXT NOT NULL DEFAULT ''
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS turn_git_transactions (
|
||||
trace_id TEXT NOT NULL,
|
||||
turn_id TEXT NOT NULL,
|
||||
unit_type TEXT DEFAULT NULL,
|
||||
unit_id TEXT DEFAULT NULL,
|
||||
stage TEXT NOT NULL DEFAULT 'turn-start',
|
||||
action TEXT NOT NULL DEFAULT 'status-only',
|
||||
push INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'ok',
|
||||
error TEXT DEFAULT NULL,
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (trace_id, turn_id, stage)
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS audit_events (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
trace_id TEXT NOT NULL,
|
||||
turn_id TEXT DEFAULT NULL,
|
||||
caused_by TEXT DEFAULT NULL,
|
||||
category TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
ts TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL DEFAULT '{}'
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS audit_turn_index (
|
||||
trace_id TEXT NOT NULL,
|
||||
turn_id TEXT NOT NULL,
|
||||
first_ts TEXT NOT NULL,
|
||||
last_ts TEXT NOT NULL,
|
||||
event_count INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (trace_id, turn_id)
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_memories_active ON memories(superseded_by)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_replan_history_milestone ON replan_history(milestone_id, created_at)");
|
||||
|
||||
|
|
@ -456,6 +520,11 @@ function initSchema(db: DbAdapter, fileBacked: boolean): void {
|
|||
|
||||
// v14 index — slice dependency lookups
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_slice_deps_target ON slice_dependencies(milestone_id, depends_on_slice_id)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_gate_runs_turn ON gate_runs(trace_id, turn_id)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_gate_runs_lookup ON gate_runs(milestone_id, slice_id, task_id, gate_id)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_turn_git_tx_turn ON turn_git_transactions(trace_id, turn_id)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_audit_events_trace ON audit_events(trace_id, ts)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_audit_events_turn ON audit_events(trace_id, turn_id, ts)");
|
||||
|
||||
db.exec(`CREATE VIEW IF NOT EXISTS active_decisions AS SELECT * FROM decisions WHERE superseded_by IS NULL`);
|
||||
db.exec(`CREATE VIEW IF NOT EXISTS active_requirements AS SELECT * FROM requirements WHERE superseded_by IS NULL`);
|
||||
|
|
@ -810,6 +879,78 @@ function migrateSchema(db: DbAdapter): void {
|
|||
});
|
||||
}
|
||||
|
||||
if (currentVersion < 15) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS gate_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trace_id TEXT NOT NULL,
|
||||
turn_id TEXT NOT NULL,
|
||||
gate_id TEXT NOT NULL,
|
||||
gate_type TEXT NOT NULL DEFAULT '',
|
||||
unit_type TEXT DEFAULT NULL,
|
||||
unit_id TEXT DEFAULT NULL,
|
||||
milestone_id TEXT DEFAULT NULL,
|
||||
slice_id TEXT DEFAULT NULL,
|
||||
task_id TEXT DEFAULT NULL,
|
||||
outcome TEXT NOT NULL DEFAULT 'pass',
|
||||
failure_class TEXT NOT NULL DEFAULT 'none',
|
||||
rationale TEXT NOT NULL DEFAULT '',
|
||||
findings TEXT NOT NULL DEFAULT '',
|
||||
attempt INTEGER NOT NULL DEFAULT 1,
|
||||
max_attempts INTEGER NOT NULL DEFAULT 1,
|
||||
retryable INTEGER NOT NULL DEFAULT 0,
|
||||
evaluated_at TEXT NOT NULL DEFAULT ''
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS turn_git_transactions (
|
||||
trace_id TEXT NOT NULL,
|
||||
turn_id TEXT NOT NULL,
|
||||
unit_type TEXT DEFAULT NULL,
|
||||
unit_id TEXT DEFAULT NULL,
|
||||
stage TEXT NOT NULL DEFAULT 'turn-start',
|
||||
action TEXT NOT NULL DEFAULT 'status-only',
|
||||
push INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'ok',
|
||||
error TEXT DEFAULT NULL,
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (trace_id, turn_id, stage)
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS audit_events (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
trace_id TEXT NOT NULL,
|
||||
turn_id TEXT DEFAULT NULL,
|
||||
caused_by TEXT DEFAULT NULL,
|
||||
category TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
ts TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL DEFAULT '{}'
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS audit_turn_index (
|
||||
trace_id TEXT NOT NULL,
|
||||
turn_id TEXT NOT NULL,
|
||||
first_ts TEXT NOT NULL,
|
||||
last_ts TEXT NOT NULL,
|
||||
event_count INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (trace_id, turn_id)
|
||||
)
|
||||
`);
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_gate_runs_turn ON gate_runs(trace_id, turn_id)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_gate_runs_lookup ON gate_runs(milestone_id, slice_id, task_id, gate_id)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_turn_git_tx_turn ON turn_git_transactions(trace_id, turn_id)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_audit_events_trace ON audit_events(trace_id, ts)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_audit_events_turn ON audit_events(trace_id, turn_id, ts)");
|
||||
db.prepare("INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)").run({
|
||||
":version": 15,
|
||||
":applied_at": new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
db.exec("COMMIT");
|
||||
} catch (err) {
|
||||
db.exec("ROLLBACK");
|
||||
|
|
@ -2287,6 +2428,9 @@ export function deleteMilestone(milestoneId: string): void {
|
|||
currentDb!.prepare(
|
||||
`DELETE FROM quality_gates WHERE milestone_id = :mid`,
|
||||
).run({ ":mid": milestoneId });
|
||||
currentDb!.prepare(
|
||||
`DELETE FROM gate_runs WHERE milestone_id = :mid`,
|
||||
).run({ ":mid": milestoneId });
|
||||
currentDb!.prepare(
|
||||
`DELETE FROM tasks WHERE milestone_id = :mid`,
|
||||
).run({ ":mid": milestoneId });
|
||||
|
|
@ -2420,6 +2564,30 @@ export function saveGateResult(g: {
|
|||
":findings": g.findings,
|
||||
":evaluated_at": new Date().toISOString(),
|
||||
});
|
||||
|
||||
const outcome =
|
||||
g.verdict === "pass"
|
||||
? "pass"
|
||||
: g.verdict === "omitted"
|
||||
? "manual-attention"
|
||||
: "fail";
|
||||
insertGateRun({
|
||||
traceId: `quality-gate:${g.milestoneId}:${g.sliceId}`,
|
||||
turnId: `gate:${g.gateId}:${g.taskId ?? "slice"}`,
|
||||
gateId: g.gateId,
|
||||
gateType: "quality-gate",
|
||||
milestoneId: g.milestoneId,
|
||||
sliceId: g.sliceId,
|
||||
taskId: g.taskId ?? undefined,
|
||||
outcome,
|
||||
failureClass: outcome === "fail" ? "verification" : outcome === "manual-attention" ? "manual-attention" : "none",
|
||||
rationale: g.rationale,
|
||||
findings: g.findings,
|
||||
attempt: 1,
|
||||
maxAttempts: 1,
|
||||
retryable: false,
|
||||
evaluatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
export function getPendingGates(milestoneId: string, sliceId: string, scope?: GateScope): GateRow[] {
|
||||
|
|
@ -2513,6 +2681,156 @@ export function getPendingGateCountForTurn(
|
|||
return getPendingGatesForTurn(milestoneId, sliceId, turn).length;
|
||||
}
|
||||
|
||||
export function insertGateRun(entry: {
|
||||
traceId: string;
|
||||
turnId: string;
|
||||
gateId: string;
|
||||
gateType: string;
|
||||
unitType?: string;
|
||||
unitId?: string;
|
||||
milestoneId?: string;
|
||||
sliceId?: string;
|
||||
taskId?: string;
|
||||
outcome: "pass" | "fail" | "retry" | "manual-attention";
|
||||
failureClass: "none" | "policy" | "input" | "execution" | "artifact" | "verification" | "closeout" | "git" | "timeout" | "manual-attention" | "unknown";
|
||||
rationale?: string;
|
||||
findings?: string;
|
||||
attempt: number;
|
||||
maxAttempts: number;
|
||||
retryable: boolean;
|
||||
evaluatedAt: string;
|
||||
}): void {
|
||||
if (!currentDb) return;
|
||||
currentDb.prepare(
|
||||
`INSERT INTO gate_runs (
|
||||
trace_id, turn_id, gate_id, gate_type, unit_type, unit_id, milestone_id, slice_id, task_id,
|
||||
outcome, failure_class, rationale, findings, attempt, max_attempts, retryable, evaluated_at
|
||||
) VALUES (
|
||||
:trace_id, :turn_id, :gate_id, :gate_type, :unit_type, :unit_id, :milestone_id, :slice_id, :task_id,
|
||||
:outcome, :failure_class, :rationale, :findings, :attempt, :max_attempts, :retryable, :evaluated_at
|
||||
)`,
|
||||
).run({
|
||||
":trace_id": entry.traceId,
|
||||
":turn_id": entry.turnId,
|
||||
":gate_id": entry.gateId,
|
||||
":gate_type": entry.gateType,
|
||||
":unit_type": entry.unitType ?? null,
|
||||
":unit_id": entry.unitId ?? null,
|
||||
":milestone_id": entry.milestoneId ?? null,
|
||||
":slice_id": entry.sliceId ?? null,
|
||||
":task_id": entry.taskId ?? null,
|
||||
":outcome": entry.outcome,
|
||||
":failure_class": entry.failureClass,
|
||||
":rationale": entry.rationale ?? "",
|
||||
":findings": entry.findings ?? "",
|
||||
":attempt": entry.attempt,
|
||||
":max_attempts": entry.maxAttempts,
|
||||
":retryable": entry.retryable ? 1 : 0,
|
||||
":evaluated_at": entry.evaluatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
export function upsertTurnGitTransaction(entry: {
|
||||
traceId: string;
|
||||
turnId: string;
|
||||
unitType?: string;
|
||||
unitId?: string;
|
||||
stage: string;
|
||||
action: "commit" | "snapshot" | "status-only";
|
||||
push: boolean;
|
||||
status: "ok" | "failed";
|
||||
error?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
updatedAt: string;
|
||||
}): void {
|
||||
if (!currentDb) return;
|
||||
currentDb.prepare(
|
||||
`INSERT OR REPLACE INTO turn_git_transactions (
|
||||
trace_id, turn_id, unit_type, unit_id, stage, action, push, status, error, metadata_json, updated_at
|
||||
) VALUES (
|
||||
:trace_id, :turn_id, :unit_type, :unit_id, :stage, :action, :push, :status, :error, :metadata_json, :updated_at
|
||||
)`,
|
||||
).run({
|
||||
":trace_id": entry.traceId,
|
||||
":turn_id": entry.turnId,
|
||||
":unit_type": entry.unitType ?? null,
|
||||
":unit_id": entry.unitId ?? null,
|
||||
":stage": entry.stage,
|
||||
":action": entry.action,
|
||||
":push": entry.push ? 1 : 0,
|
||||
":status": entry.status,
|
||||
":error": entry.error ?? null,
|
||||
":metadata_json": JSON.stringify(entry.metadata ?? {}),
|
||||
":updated_at": entry.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
export function insertAuditEvent(entry: {
|
||||
eventId: string;
|
||||
traceId: string;
|
||||
turnId?: string;
|
||||
causedBy?: string;
|
||||
category: string;
|
||||
type: string;
|
||||
ts: string;
|
||||
payload: Record<string, unknown>;
|
||||
}): void {
|
||||
if (!currentDb) return;
|
||||
transaction(() => {
|
||||
currentDb!.prepare(
|
||||
`INSERT OR IGNORE INTO audit_events (
|
||||
event_id, trace_id, turn_id, caused_by, category, type, ts, payload_json
|
||||
) VALUES (
|
||||
:event_id, :trace_id, :turn_id, :caused_by, :category, :type, :ts, :payload_json
|
||||
)`,
|
||||
).run({
|
||||
":event_id": entry.eventId,
|
||||
":trace_id": entry.traceId,
|
||||
":turn_id": entry.turnId ?? null,
|
||||
":caused_by": entry.causedBy ?? null,
|
||||
":category": entry.category,
|
||||
":type": entry.type,
|
||||
":ts": entry.ts,
|
||||
":payload_json": JSON.stringify(entry.payload ?? {}),
|
||||
});
|
||||
|
||||
if (entry.turnId) {
|
||||
const row = currentDb!.prepare(
|
||||
`SELECT event_count, first_ts, last_ts
|
||||
FROM audit_turn_index
|
||||
WHERE trace_id = :trace_id AND turn_id = :turn_id`,
|
||||
).get({
|
||||
":trace_id": entry.traceId,
|
||||
":turn_id": entry.turnId,
|
||||
});
|
||||
if (row) {
|
||||
currentDb!.prepare(
|
||||
`UPDATE audit_turn_index
|
||||
SET first_ts = CASE WHEN :ts < first_ts THEN :ts ELSE first_ts END,
|
||||
last_ts = CASE WHEN :ts > last_ts THEN :ts ELSE last_ts END,
|
||||
event_count = event_count + 1
|
||||
WHERE trace_id = :trace_id AND turn_id = :turn_id`,
|
||||
).run({
|
||||
":trace_id": entry.traceId,
|
||||
":turn_id": entry.turnId,
|
||||
":ts": entry.ts,
|
||||
});
|
||||
} else {
|
||||
currentDb!.prepare(
|
||||
`INSERT INTO audit_turn_index (trace_id, turn_id, first_ts, last_ts, event_count)
|
||||
VALUES (:trace_id, :turn_id, :first_ts, :last_ts, :event_count)`,
|
||||
).run({
|
||||
":trace_id": entry.traceId,
|
||||
":turn_id": entry.turnId,
|
||||
":first_ts": entry.ts,
|
||||
":last_ts": entry.ts,
|
||||
":event_count": 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Single-writer bypass wrappers ───────────────────────────────────────
|
||||
// These wrappers exist so modules outside this file never need to call
|
||||
// `_getAdapter()` for writes. Each one is a byte-equivalent replacement for
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ interface ProjectPreferences {
|
|||
mainBranch: string;
|
||||
verificationCommands: string[];
|
||||
customInstructions: string[];
|
||||
tokenProfile: "budget" | "balanced" | "quality";
|
||||
tokenProfile: "budget" | "balanced" | "quality" | "burn-max";
|
||||
skipResearch: boolean;
|
||||
autoPush: boolean;
|
||||
}
|
||||
|
|
@ -413,10 +413,11 @@ async function customizeAdvancedPrefs(
|
|||
{ id: "balanced", label: "Balanced", description: "Good trade-off (default)", recommended: true },
|
||||
{ id: "budget", label: "Budget", description: "Minimize token usage" },
|
||||
{ id: "quality", label: "Quality", description: "Maximize thoroughness" },
|
||||
{ id: "burn-max", label: "Burn Max", description: "Maximum depth, no phase skips" },
|
||||
],
|
||||
});
|
||||
if (profileChoice !== "not_yet") {
|
||||
prefs.tokenProfile = profileChoice as "budget" | "balanced" | "quality";
|
||||
prefs.tokenProfile = profileChoice as "budget" | "balanced" | "quality" | "burn-max";
|
||||
}
|
||||
|
||||
// Skip research
|
||||
|
|
|
|||
|
|
@ -355,7 +355,7 @@ export function resolveAutoSupervisorConfig(): AutoSupervisorConfig {
|
|||
|
||||
// ─── Token Profile Resolution ─────────────────────────────────────────────
|
||||
|
||||
const VALID_TOKEN_PROFILES = new Set<TokenProfile>(["budget", "balanced", "quality"]);
|
||||
const VALID_TOKEN_PROFILES = new Set<TokenProfile>(["budget", "balanced", "quality", "burn-max"]);
|
||||
|
||||
/**
|
||||
* Resolve profile defaults for a given token profile tier.
|
||||
|
|
@ -400,6 +400,22 @@ export function resolveProfileDefaults(profile: TokenProfile): Partial<GSDPrefer
|
|||
skip_reassess: true,
|
||||
},
|
||||
};
|
||||
case "burn-max":
|
||||
return {
|
||||
// Quality-first profile: keep user-selected models, disable downgrade routing.
|
||||
// Policy constraints still apply at dispatch time.
|
||||
dynamic_routing: {
|
||||
enabled: false,
|
||||
},
|
||||
context_selection: "full",
|
||||
phases: {
|
||||
skip_research: false,
|
||||
skip_slice_research: false,
|
||||
skip_reassess: false,
|
||||
skip_milestone_validation: false,
|
||||
reassess_after_slice: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -416,7 +432,7 @@ export function resolveEffectiveProfile(): TokenProfile {
|
|||
|
||||
/**
|
||||
* Resolve the inline level from the active token profile.
|
||||
* budget -> minimal, balanced -> standard, quality -> full.
|
||||
* budget -> minimal, balanced -> standard, quality/burn-max -> full.
|
||||
*/
|
||||
export function resolveInlineLevel(): InlineLevel {
|
||||
const profile = resolveEffectiveProfile();
|
||||
|
|
@ -424,12 +440,13 @@ export function resolveInlineLevel(): InlineLevel {
|
|||
case "budget": return "minimal";
|
||||
case "balanced": return "standard";
|
||||
case "quality": return "full";
|
||||
case "burn-max": return "full";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the context selection mode from the active token profile.
|
||||
* budget -> "smart", balanced/quality -> "full".
|
||||
* budget -> "smart", balanced/quality/burn-max -> "full".
|
||||
* Explicit preference always wins.
|
||||
*/
|
||||
export function resolveContextSelection(): import("./types.js").ContextSelectionMode {
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set<string>([
|
|||
"post_unit_hooks",
|
||||
"pre_dispatch_hooks",
|
||||
"dynamic_routing",
|
||||
"uok",
|
||||
"token_profile",
|
||||
"phases",
|
||||
"auto_visualize",
|
||||
|
|
@ -208,6 +209,32 @@ export interface CmuxPreferences {
|
|||
browser?: boolean;
|
||||
}
|
||||
|
||||
export type UokTurnActionMode = "commit" | "snapshot" | "status-only";
|
||||
|
||||
export interface UokPreferences {
|
||||
enabled?: boolean;
|
||||
gates?: {
|
||||
enabled?: boolean;
|
||||
};
|
||||
model_policy?: {
|
||||
enabled?: boolean;
|
||||
};
|
||||
execution_graph?: {
|
||||
enabled?: boolean;
|
||||
};
|
||||
gitops?: {
|
||||
enabled?: boolean;
|
||||
turn_action?: UokTurnActionMode;
|
||||
turn_push?: boolean;
|
||||
};
|
||||
audit_unified?: {
|
||||
enabled?: boolean;
|
||||
};
|
||||
plan_v2?: {
|
||||
enabled?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Opt-in experimental features. All features in this block are disabled by
|
||||
* default and must be explicitly enabled. They may change or be removed without
|
||||
|
|
@ -256,6 +283,8 @@ export interface GSDPreferences {
|
|||
post_unit_hooks?: PostUnitHookConfig[];
|
||||
pre_dispatch_hooks?: PreDispatchHookConfig[];
|
||||
dynamic_routing?: DynamicRoutingConfig;
|
||||
/** Unified Orchestration Kernel controls (all flags default off). */
|
||||
uok?: UokPreferences;
|
||||
/** Per-model capability overrides. Deep-merged with built-in profiles for capability-aware routing (ADR-004). */
|
||||
modelOverrides?: Record<string, { capabilities?: Partial<ModelCapabilities> }>;
|
||||
context_management?: ContextManagementConfig;
|
||||
|
|
|
|||
|
|
@ -22,7 +22,12 @@ import {
|
|||
type GSDSkillRule,
|
||||
} from "./preferences-types.js";
|
||||
|
||||
const VALID_TOKEN_PROFILES = new Set<TokenProfile>(["budget", "balanced", "quality"]);
|
||||
const VALID_TOKEN_PROFILES = new Set<TokenProfile>(["budget", "balanced", "quality", "burn-max"]);
|
||||
const VALID_UOK_TURN_ACTIONS = new Set<"commit" | "snapshot" | "status-only">([
|
||||
"commit",
|
||||
"snapshot",
|
||||
"status-only",
|
||||
]);
|
||||
|
||||
export function validatePreferences(preferences: GSDPreferences): {
|
||||
preferences: GSDPreferences;
|
||||
|
|
@ -161,12 +166,110 @@ export function validatePreferences(preferences: GSDPreferences): {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── UOK Flags ──────────────────────────────────────────────────────
|
||||
if (preferences.uok !== undefined) {
|
||||
if (typeof preferences.uok === "object" && preferences.uok !== null) {
|
||||
const raw = preferences.uok as Record<string, unknown>;
|
||||
const valid: NonNullable<GSDPreferences["uok"]> = {};
|
||||
|
||||
if (raw.enabled !== undefined) {
|
||||
if (typeof raw.enabled === "boolean") valid.enabled = raw.enabled;
|
||||
else errors.push("uok.enabled must be a boolean");
|
||||
}
|
||||
|
||||
const parseEnabledBlock = (
|
||||
key: "gates" | "model_policy" | "execution_graph" | "audit_unified" | "plan_v2",
|
||||
): void => {
|
||||
const value = raw[key];
|
||||
if (value === undefined) return;
|
||||
if (typeof value !== "object" || value === null) {
|
||||
errors.push(`uok.${key} must be an object`);
|
||||
return;
|
||||
}
|
||||
const block = value as Record<string, unknown>;
|
||||
const parsed: { enabled?: boolean } = {};
|
||||
if (block.enabled !== undefined) {
|
||||
if (typeof block.enabled === "boolean") parsed.enabled = block.enabled;
|
||||
else errors.push(`uok.${key}.enabled must be a boolean`);
|
||||
}
|
||||
const unknown = Object.keys(block).filter((k) => k !== "enabled");
|
||||
for (const unk of unknown) {
|
||||
warnings.push(`unknown uok.${key} key "${unk}" — ignored`);
|
||||
}
|
||||
if (Object.keys(parsed).length > 0) {
|
||||
valid[key] = parsed;
|
||||
}
|
||||
};
|
||||
|
||||
parseEnabledBlock("gates");
|
||||
parseEnabledBlock("model_policy");
|
||||
parseEnabledBlock("execution_graph");
|
||||
parseEnabledBlock("audit_unified");
|
||||
parseEnabledBlock("plan_v2");
|
||||
|
||||
if (raw.gitops !== undefined) {
|
||||
if (typeof raw.gitops !== "object" || raw.gitops === null) {
|
||||
errors.push("uok.gitops must be an object");
|
||||
} else {
|
||||
const gitops = raw.gitops as Record<string, unknown>;
|
||||
const parsed: NonNullable<NonNullable<GSDPreferences["uok"]>["gitops"]> = {};
|
||||
if (gitops.enabled !== undefined) {
|
||||
if (typeof gitops.enabled === "boolean") parsed.enabled = gitops.enabled;
|
||||
else errors.push("uok.gitops.enabled must be a boolean");
|
||||
}
|
||||
if (gitops.turn_action !== undefined) {
|
||||
if (
|
||||
typeof gitops.turn_action === "string" &&
|
||||
VALID_UOK_TURN_ACTIONS.has(gitops.turn_action as "commit" | "snapshot" | "status-only")
|
||||
) {
|
||||
parsed.turn_action = gitops.turn_action as "commit" | "snapshot" | "status-only";
|
||||
} else {
|
||||
errors.push("uok.gitops.turn_action must be one of: commit, snapshot, status-only");
|
||||
}
|
||||
}
|
||||
if (gitops.turn_push !== undefined) {
|
||||
if (typeof gitops.turn_push === "boolean") parsed.turn_push = gitops.turn_push;
|
||||
else errors.push("uok.gitops.turn_push must be a boolean");
|
||||
}
|
||||
const unknown = Object.keys(gitops).filter((k) => !["enabled", "turn_action", "turn_push"].includes(k));
|
||||
for (const unk of unknown) {
|
||||
warnings.push(`unknown uok.gitops key "${unk}" — ignored`);
|
||||
}
|
||||
if (Object.keys(parsed).length > 0) {
|
||||
valid.gitops = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const knownUokKeys = new Set([
|
||||
"enabled",
|
||||
"gates",
|
||||
"model_policy",
|
||||
"execution_graph",
|
||||
"gitops",
|
||||
"audit_unified",
|
||||
"plan_v2",
|
||||
]);
|
||||
for (const key of Object.keys(raw)) {
|
||||
if (!knownUokKeys.has(key)) {
|
||||
warnings.push(`unknown uok key "${key}" — ignored`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(valid).length > 0) {
|
||||
validated.uok = valid;
|
||||
}
|
||||
} else {
|
||||
errors.push("uok must be an object");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Token Profile ─────────────────────────────────────────────────
|
||||
if (preferences.token_profile !== undefined) {
|
||||
if (typeof preferences.token_profile === "string" && VALID_TOKEN_PROFILES.has(preferences.token_profile as TokenProfile)) {
|
||||
validated.token_profile = preferences.token_profile as TokenProfile;
|
||||
} else {
|
||||
errors.push(`token_profile must be one of: budget, balanced, quality`);
|
||||
errors.push(`token_profile must be one of: budget, balanced, quality, burn-max`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ export type {
|
|||
AutoSupervisorConfig,
|
||||
RemoteQuestionsConfig,
|
||||
CmuxPreferences,
|
||||
UokTurnActionMode,
|
||||
UokPreferences,
|
||||
CodebaseMapPreferences,
|
||||
GSDPreferences,
|
||||
LoadedGSDPreferences,
|
||||
|
|
@ -378,6 +380,29 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr
|
|||
dynamic_routing: (base.dynamic_routing || override.dynamic_routing)
|
||||
? { ...(base.dynamic_routing ?? {}), ...(override.dynamic_routing ?? {}) } as DynamicRoutingConfig
|
||||
: undefined,
|
||||
uok: (base.uok || override.uok)
|
||||
? {
|
||||
enabled: override.uok?.enabled ?? base.uok?.enabled,
|
||||
gates: (base.uok?.gates || override.uok?.gates)
|
||||
? { ...(base.uok?.gates ?? {}), ...(override.uok?.gates ?? {}) }
|
||||
: undefined,
|
||||
model_policy: (base.uok?.model_policy || override.uok?.model_policy)
|
||||
? { ...(base.uok?.model_policy ?? {}), ...(override.uok?.model_policy ?? {}) }
|
||||
: undefined,
|
||||
execution_graph: (base.uok?.execution_graph || override.uok?.execution_graph)
|
||||
? { ...(base.uok?.execution_graph ?? {}), ...(override.uok?.execution_graph ?? {}) }
|
||||
: undefined,
|
||||
gitops: (base.uok?.gitops || override.uok?.gitops)
|
||||
? { ...(base.uok?.gitops ?? {}), ...(override.uok?.gitops ?? {}) }
|
||||
: undefined,
|
||||
audit_unified: (base.uok?.audit_unified || override.uok?.audit_unified)
|
||||
? { ...(base.uok?.audit_unified ?? {}), ...(override.uok?.audit_unified ?? {}) }
|
||||
: undefined,
|
||||
plan_v2: (base.uok?.plan_v2 || override.uok?.plan_v2)
|
||||
? { ...(base.uok?.plan_v2 ?? {}), ...(override.uok?.plan_v2 ?? {}) }
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
token_profile: override.token_profile ?? base.token_profile,
|
||||
phases: (base.phases || override.phases)
|
||||
? { ...(base.phases ?? {}), ...(override.phases ?? {}) }
|
||||
|
|
|
|||
|
|
@ -39,6 +39,22 @@ dynamic_routing:
|
|||
budget_pressure:
|
||||
cross_provider:
|
||||
hooks:
|
||||
uok:
|
||||
enabled: false
|
||||
gates:
|
||||
enabled: false
|
||||
model_policy:
|
||||
enabled: false
|
||||
execution_graph:
|
||||
enabled: false
|
||||
gitops:
|
||||
enabled: false
|
||||
turn_action: status-only
|
||||
turn_push: false
|
||||
audit_unified:
|
||||
enabled: false
|
||||
plan_v2:
|
||||
enabled: false
|
||||
auto_visualize:
|
||||
auto_report:
|
||||
parallel:
|
||||
|
|
|
|||
|
|
@ -1267,7 +1267,7 @@ test("auto-loop.ts barrel re-exports autoLoop, runUnit, and resolveAgentEnd", ()
|
|||
);
|
||||
});
|
||||
|
||||
test("auto.ts startAuto calls autoLoop (not dispatchNextUnit as first dispatch)", () => {
|
||||
test("auto.ts startAuto dispatches through the UOK kernel wrapper (legacy loop adapter)", () => {
|
||||
const src = readFileSync(
|
||||
resolve(import.meta.dirname, "..", "auto.ts"),
|
||||
"utf-8",
|
||||
|
|
@ -1279,8 +1279,12 @@ test("auto.ts startAuto calls autoLoop (not dispatchNextUnit as first dispatch)"
|
|||
const fnBlock =
|
||||
fnEnd > -1 ? src.slice(fnIdx, fnEnd) : src.slice(fnIdx, fnIdx + 5000);
|
||||
assert.ok(
|
||||
fnBlock.includes("autoLoop("),
|
||||
"startAuto must call autoLoop() instead of dispatchNextUnit()",
|
||||
fnBlock.includes("runAutoLoopWithUok("),
|
||||
"startAuto must dispatch through runAutoLoopWithUok()",
|
||||
);
|
||||
assert.ok(
|
||||
fnBlock.includes("runLegacyLoop: autoLoop"),
|
||||
"startAuto must preserve the legacy autoLoop adapter in kernel dispatch",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -125,9 +125,9 @@ console.log('\n=== complete-slice: schema v6 migration ===');
|
|||
|
||||
const adapter = _getAdapter()!;
|
||||
|
||||
// Verify schema version is current (v14 after indexes + slice_dependencies)
|
||||
// Verify schema version is current (v15 with UOK projection tables)
|
||||
const versionRow = adapter.prepare('SELECT MAX(version) as v FROM schema_version').get();
|
||||
assertEq(versionRow?.['v'], 14, 'schema version should be 14');
|
||||
assertEq(versionRow?.['v'], 15, 'schema version should be 15');
|
||||
|
||||
// Verify slices table has full_summary_md and full_uat_md columns
|
||||
const cols = adapter.prepare("PRAGMA table_info(slices)").all();
|
||||
|
|
|
|||
|
|
@ -109,9 +109,9 @@ console.log('\n=== complete-task: schema v5 migration ===');
|
|||
|
||||
const adapter = _getAdapter()!;
|
||||
|
||||
// Verify schema version is current (v14 after indexes + slice_dependencies)
|
||||
// Verify schema version is current (v15 with UOK projection tables)
|
||||
const versionRow = adapter.prepare('SELECT MAX(version) as v FROM schema_version').get();
|
||||
assertEq(versionRow?.['v'], 14, 'schema version should be 14');
|
||||
assertEq(versionRow?.['v'], 15, 'schema version should be 15');
|
||||
|
||||
// Verify all 4 new tables exist
|
||||
const tables = adapter.prepare(
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ describe('gsd-db', () => {
|
|||
// Check schema_version table
|
||||
const adapter = _getAdapter()!;
|
||||
const version = adapter.prepare('SELECT MAX(version) as version FROM schema_version').get();
|
||||
assert.deepStrictEqual(version?.['version'], 14, 'schema version should be 14');
|
||||
assert.deepStrictEqual(version?.['version'], 15, 'schema version should be 15');
|
||||
|
||||
// Check tables exist by querying them
|
||||
const dRows = adapter.prepare('SELECT count(*) as cnt FROM decisions').get();
|
||||
|
|
|
|||
|
|
@ -363,7 +363,7 @@ test('md-importer: schema v1→v2 migration', () => {
|
|||
openDatabase(':memory:');
|
||||
const adapter = _getAdapter();
|
||||
const version = adapter?.prepare('SELECT MAX(version) as v FROM schema_version').get();
|
||||
assert.deepStrictEqual(version?.v, 14, 'new DB should be at schema version 14');
|
||||
assert.deepStrictEqual(version?.v, 15, 'new DB should be at schema version 15');
|
||||
|
||||
// Artifacts table should exist
|
||||
const tableCheck = adapter?.prepare("SELECT count(*) as c FROM sqlite_master WHERE type='table' AND name='artifacts'").get();
|
||||
|
|
@ -413,4 +413,3 @@ test('md-importer: round-trip fidelity', () => {
|
|||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
|
|
|||
|
|
@ -323,10 +323,9 @@ test('memory-store: schema includes memories table', () => {
|
|||
const viewCount = adapter.prepare('SELECT count(*) as cnt FROM active_memories').get();
|
||||
assert.deepStrictEqual(viewCount?.['cnt'], 0, 'active_memories view should exist');
|
||||
|
||||
// Verify schema version is 14 (after indexes + slice_dependencies)
|
||||
// Verify schema version is 15 (UOK gate/git/audit projection tables included)
|
||||
const version = adapter.prepare('SELECT MAX(version) as v FROM schema_version').get();
|
||||
assert.deepStrictEqual(version?.['v'], 14, 'schema version should be 14');
|
||||
assert.deepStrictEqual(version?.['v'], 15, 'schema version should be 15');
|
||||
|
||||
closeDatabase();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -34,11 +34,12 @@ const typesSrc = readFileSync(join(__dirname, "..", "types.ts"), "utf-8");
|
|||
// Type Definitions
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test("types: TokenProfile type exported with budget/balanced/quality", () => {
|
||||
test("types: TokenProfile type exported with budget/balanced/quality/burn-max", () => {
|
||||
assert.ok(typesSrc.includes("export type TokenProfile"), "TokenProfile should be exported");
|
||||
assert.match(typesSrc, /["']budget["']/, "should include budget");
|
||||
assert.match(typesSrc, /["']balanced["']/, "should include balanced");
|
||||
assert.match(typesSrc, /["']quality["']/, "should include quality");
|
||||
assert.match(typesSrc, /["']burn-max["']/, "should include burn-max");
|
||||
});
|
||||
|
||||
test("types: InlineLevel type exported with full/standard/minimal", () => {
|
||||
|
|
@ -91,7 +92,7 @@ test("preferences: KNOWN_PREFERENCE_KEYS includes token_profile and phases", ()
|
|||
// Profile Resolution
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test("profile: resolveProfileDefaults exists and handles all 3 tiers", () => {
|
||||
test("profile: resolveProfileDefaults exists and handles all 4 tiers", () => {
|
||||
assert.ok(
|
||||
preferencesSrc.includes("export function resolveProfileDefaults"),
|
||||
"resolveProfileDefaults should be exported",
|
||||
|
|
@ -99,8 +100,9 @@ test("profile: resolveProfileDefaults exists and handles all 3 tiers", () => {
|
|||
assert.ok(
|
||||
preferencesSrc.includes('case "budget"') &&
|
||||
preferencesSrc.includes('case "balanced"') &&
|
||||
preferencesSrc.includes('case "quality"'),
|
||||
"resolveProfileDefaults should handle all 3 tiers",
|
||||
preferencesSrc.includes('case "quality"') &&
|
||||
preferencesSrc.includes('case "burn-max"'),
|
||||
"resolveProfileDefaults should handle all 4 tiers",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -158,6 +160,7 @@ test("profile: resolveInlineLevel maps profile to inline level", () => {
|
|||
assert.ok(preferencesSrc.includes('case "budget": return "minimal"'), "budget → minimal");
|
||||
assert.ok(preferencesSrc.includes('case "balanced": return "standard"'), "balanced → standard");
|
||||
assert.ok(preferencesSrc.includes('case "quality": return "full"'), "quality → full");
|
||||
assert.ok(preferencesSrc.includes('case "burn-max": return "full"'), "burn-max → full");
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -167,7 +170,7 @@ test("profile: resolveInlineLevel maps profile to inline level", () => {
|
|||
test("validate: validatePreferences handles token_profile", () => {
|
||||
assert.ok(
|
||||
preferencesSrc.includes("preferences.token_profile") &&
|
||||
preferencesSrc.includes("budget, balanced, quality"),
|
||||
preferencesSrc.includes("budget, balanced, quality, burn-max"),
|
||||
"validatePreferences should validate token_profile enum values",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
85
src/resources/extensions/gsd/tests/uok-contracts.test.ts
Normal file
85
src/resources/extensions/gsd/tests/uok-contracts.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type {
|
||||
AuditEventEnvelope,
|
||||
GateResult,
|
||||
TurnContract,
|
||||
TurnResult,
|
||||
UokNodeKind,
|
||||
} from "../uok/contracts.ts";
|
||||
import { buildAuditEnvelope } from "../uok/audit.ts";
|
||||
|
||||
test("uok contracts serialize/deserialize turn envelopes", () => {
|
||||
const contract: TurnContract = {
|
||||
traceId: "trace-1",
|
||||
turnId: "turn-1",
|
||||
iteration: 1,
|
||||
basePath: "/tmp/project",
|
||||
unitType: "execute-task",
|
||||
unitId: "M001.S01.T01",
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const gate: GateResult = {
|
||||
gateId: "Q3",
|
||||
gateType: "policy",
|
||||
outcome: "pass",
|
||||
failureClass: "none",
|
||||
attempt: 1,
|
||||
maxAttempts: 1,
|
||||
retryable: false,
|
||||
evaluatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const result: TurnResult = {
|
||||
traceId: contract.traceId,
|
||||
turnId: contract.turnId,
|
||||
iteration: contract.iteration,
|
||||
unitType: contract.unitType,
|
||||
unitId: contract.unitId,
|
||||
status: "completed",
|
||||
failureClass: "none",
|
||||
phaseResults: [
|
||||
{ phase: "dispatch", action: "next", ts: new Date().toISOString() },
|
||||
{ phase: "unit", action: "continue", ts: new Date().toISOString() },
|
||||
{ phase: "finalize", action: "next", ts: new Date().toISOString() },
|
||||
],
|
||||
gateResults: [gate],
|
||||
startedAt: contract.startedAt,
|
||||
finishedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const roundTrip = JSON.parse(JSON.stringify(result)) as TurnResult;
|
||||
assert.equal(roundTrip.turnId, "turn-1");
|
||||
assert.equal(roundTrip.gateResults?.[0]?.gateId, "Q3");
|
||||
assert.equal(roundTrip.phaseResults.length, 3);
|
||||
});
|
||||
|
||||
test("uok contracts include required DAG node kinds", () => {
|
||||
const required: UokNodeKind[] = [
|
||||
"unit",
|
||||
"hook",
|
||||
"subagent",
|
||||
"team-worker",
|
||||
"verification",
|
||||
"reprocess",
|
||||
];
|
||||
assert.deepEqual(required.length, 6);
|
||||
});
|
||||
|
||||
test("uok audit envelope includes trace/turn/causality fields", () => {
|
||||
const event: AuditEventEnvelope = buildAuditEnvelope({
|
||||
traceId: "trace-xyz",
|
||||
turnId: "turn-xyz",
|
||||
causedBy: "turn-start",
|
||||
category: "orchestration",
|
||||
type: "turn-result",
|
||||
payload: { status: "completed" },
|
||||
});
|
||||
|
||||
assert.equal(event.traceId, "trace-xyz");
|
||||
assert.equal(event.turnId, "turn-xyz");
|
||||
assert.equal(event.causedBy, "turn-start");
|
||||
assert.equal(event.payload.status, "completed");
|
||||
});
|
||||
70
src/resources/extensions/gsd/tests/uok-gate-runner.test.ts
Normal file
70
src/resources/extensions/gsd/tests/uok-gate-runner.test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { closeDatabase, openDatabase, _getAdapter } from "../gsd-db.ts";
|
||||
import { UokGateRunner } from "../uok/gate-runner.ts";
|
||||
|
||||
test.beforeEach(() => {
|
||||
closeDatabase();
|
||||
const ok = openDatabase(":memory:");
|
||||
assert.equal(ok, true);
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
closeDatabase();
|
||||
});
|
||||
|
||||
test("uok gate runner retries timeout failures using deterministic matrix", async () => {
|
||||
const runner = new UokGateRunner();
|
||||
|
||||
let calls = 0;
|
||||
runner.register({
|
||||
id: "timeout-gate",
|
||||
type: "verification",
|
||||
execute: async (_ctx, attempt) => {
|
||||
calls += 1;
|
||||
if (attempt < 2) {
|
||||
return {
|
||||
outcome: "fail",
|
||||
failureClass: "timeout",
|
||||
rationale: "first attempt timed out",
|
||||
};
|
||||
}
|
||||
return {
|
||||
outcome: "pass",
|
||||
failureClass: "none",
|
||||
rationale: "second attempt passed",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runner.run("timeout-gate", {
|
||||
basePath: process.cwd(),
|
||||
traceId: "trace-a",
|
||||
turnId: "turn-a",
|
||||
milestoneId: "M001",
|
||||
sliceId: "S01",
|
||||
taskId: "T01",
|
||||
});
|
||||
|
||||
assert.equal(result.outcome, "pass");
|
||||
assert.equal(calls, 2);
|
||||
|
||||
const adapter = _getAdapter();
|
||||
const rows = adapter?.prepare("SELECT gate_id, outcome, attempt FROM gate_runs ORDER BY id").all() ?? [];
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(rows[0]?.["outcome"], "retry");
|
||||
assert.equal(rows[1]?.["outcome"], "pass");
|
||||
});
|
||||
|
||||
test("uok gate runner returns manual-attention for unknown gate id", async () => {
|
||||
const runner = new UokGateRunner();
|
||||
const result = await runner.run("missing-gate", {
|
||||
basePath: process.cwd(),
|
||||
traceId: "trace-b",
|
||||
turnId: "turn-b",
|
||||
});
|
||||
|
||||
assert.equal(result.outcome, "manual-attention");
|
||||
assert.equal(result.failureClass, "unknown");
|
||||
});
|
||||
40
src/resources/extensions/gsd/tests/uok-preferences.test.ts
Normal file
40
src/resources/extensions/gsd/tests/uok-preferences.test.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { validatePreferences } from "../preferences-validation.ts";
|
||||
|
||||
test("uok preferences validate nested flags and turn_action", () => {
|
||||
const input = {
|
||||
uok: {
|
||||
enabled: true,
|
||||
gates: { enabled: true },
|
||||
model_policy: { enabled: true },
|
||||
execution_graph: { enabled: false },
|
||||
gitops: {
|
||||
enabled: true,
|
||||
turn_action: "status-only",
|
||||
turn_push: false,
|
||||
},
|
||||
audit_unified: { enabled: true },
|
||||
plan_v2: { enabled: true },
|
||||
},
|
||||
};
|
||||
|
||||
const result = validatePreferences(input as never);
|
||||
assert.equal(result.errors.length, 0);
|
||||
assert.equal(result.preferences.uok?.enabled, true);
|
||||
assert.equal(result.preferences.uok?.gitops?.turn_action, "status-only");
|
||||
assert.equal(result.preferences.uok?.plan_v2?.enabled, true);
|
||||
});
|
||||
|
||||
test("uok preferences reject invalid turn_action", () => {
|
||||
const result = validatePreferences({
|
||||
uok: {
|
||||
gitops: {
|
||||
turn_action: "push-everything",
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
assert.ok(result.errors.some((e) => e.includes("uok.gitops.turn_action")));
|
||||
});
|
||||
|
|
@ -306,7 +306,7 @@ export interface HookDispatchResult {
|
|||
|
||||
export type BudgetEnforcementMode = "warn" | "pause" | "halt";
|
||||
|
||||
export type TokenProfile = "budget" | "balanced" | "quality";
|
||||
export type TokenProfile = "budget" | "balanced" | "quality" | "burn-max";
|
||||
|
||||
export type InlineLevel = "full" | "standard" | "minimal";
|
||||
|
||||
|
|
|
|||
51
src/resources/extensions/gsd/uok/audit.ts
Normal file
51
src/resources/extensions/gsd/uok/audit.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { appendFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { gsdRoot } from "../paths.js";
|
||||
import { isDbAvailable, insertAuditEvent } from "../gsd-db.js";
|
||||
import type { AuditEventEnvelope } from "./contracts.js";
|
||||
|
||||
function auditLogPath(basePath: string): string {
|
||||
return join(gsdRoot(basePath), "audit", "events.jsonl");
|
||||
}
|
||||
|
||||
function ensureAuditDir(basePath: string): void {
|
||||
mkdirSync(join(gsdRoot(basePath), "audit"), { recursive: true });
|
||||
}
|
||||
|
||||
export function buildAuditEnvelope(args: {
|
||||
traceId: string;
|
||||
turnId?: string;
|
||||
causedBy?: string;
|
||||
category: AuditEventEnvelope["category"];
|
||||
type: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}): AuditEventEnvelope {
|
||||
return {
|
||||
eventId: randomUUID(),
|
||||
traceId: args.traceId,
|
||||
turnId: args.turnId,
|
||||
causedBy: args.causedBy,
|
||||
category: args.category,
|
||||
type: args.type,
|
||||
ts: new Date().toISOString(),
|
||||
payload: args.payload ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
export function emitUokAuditEvent(basePath: string, event: AuditEventEnvelope): void {
|
||||
try {
|
||||
ensureAuditDir(basePath);
|
||||
appendFileSync(auditLogPath(basePath), `${JSON.stringify(event)}\n`, "utf-8");
|
||||
} catch {
|
||||
// Best-effort: audit writes must never break orchestration.
|
||||
}
|
||||
|
||||
if (!isDbAvailable()) return;
|
||||
try {
|
||||
insertAuditEvent(event);
|
||||
} catch {
|
||||
// Projection failures are non-fatal while legacy readers are still active.
|
||||
}
|
||||
}
|
||||
135
src/resources/extensions/gsd/uok/contracts.ts
Normal file
135
src/resources/extensions/gsd/uok/contracts.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
export type FailureClass =
|
||||
| "none"
|
||||
| "policy"
|
||||
| "input"
|
||||
| "execution"
|
||||
| "artifact"
|
||||
| "verification"
|
||||
| "closeout"
|
||||
| "git"
|
||||
| "timeout"
|
||||
| "manual-attention"
|
||||
| "unknown";
|
||||
|
||||
export type GateOutcome = "pass" | "fail" | "retry" | "manual-attention";
|
||||
|
||||
export interface GateResult {
|
||||
gateId: string;
|
||||
gateType: string;
|
||||
outcome: GateOutcome;
|
||||
failureClass: FailureClass;
|
||||
rationale?: string;
|
||||
findings?: string;
|
||||
attempt: number;
|
||||
maxAttempts: number;
|
||||
retryable: boolean;
|
||||
evaluatedAt: string;
|
||||
}
|
||||
|
||||
export type TurnPhase =
|
||||
| "pre-dispatch"
|
||||
| "dispatch"
|
||||
| "unit"
|
||||
| "finalize"
|
||||
| "guard"
|
||||
| "custom-engine";
|
||||
|
||||
export type TurnStatus =
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "paused"
|
||||
| "stopped"
|
||||
| "skipped"
|
||||
| "retry";
|
||||
|
||||
export interface TurnContract {
|
||||
traceId: string;
|
||||
turnId: string;
|
||||
iteration: number;
|
||||
basePath: string;
|
||||
unitType?: string;
|
||||
unitId?: string;
|
||||
sidecarKind?: string;
|
||||
startedAt: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TurnCloseoutRecord {
|
||||
traceId: string;
|
||||
turnId: string;
|
||||
unitType?: string;
|
||||
unitId?: string;
|
||||
status: TurnStatus;
|
||||
failureClass: FailureClass;
|
||||
gitAction: "commit" | "snapshot" | "status-only";
|
||||
gitPushed: boolean;
|
||||
activityFile?: string;
|
||||
finishedAt: string;
|
||||
}
|
||||
|
||||
export interface TurnResult {
|
||||
traceId: string;
|
||||
turnId: string;
|
||||
iteration: number;
|
||||
unitType?: string;
|
||||
unitId?: string;
|
||||
status: TurnStatus;
|
||||
failureClass: FailureClass;
|
||||
phaseResults: Array<{
|
||||
phase: TurnPhase;
|
||||
action: string;
|
||||
ts: string;
|
||||
data?: Record<string, unknown>;
|
||||
}>;
|
||||
gateResults?: GateResult[];
|
||||
closeout?: TurnCloseoutRecord;
|
||||
error?: string;
|
||||
startedAt: string;
|
||||
finishedAt: string;
|
||||
}
|
||||
|
||||
export interface AuditEventEnvelope {
|
||||
eventId: string;
|
||||
traceId: string;
|
||||
turnId?: string;
|
||||
causedBy?: string;
|
||||
category:
|
||||
| "orchestration"
|
||||
| "gate"
|
||||
| "model-policy"
|
||||
| "gitops"
|
||||
| "verification"
|
||||
| "metrics"
|
||||
| "plan"
|
||||
| "execution";
|
||||
type: string;
|
||||
ts: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type UokNodeKind =
|
||||
| "unit"
|
||||
| "hook"
|
||||
| "subagent"
|
||||
| "team-worker"
|
||||
| "verification"
|
||||
| "reprocess";
|
||||
|
||||
export interface UokGraphNode {
|
||||
id: string;
|
||||
kind: UokNodeKind;
|
||||
dependsOn: string[];
|
||||
writes?: string[];
|
||||
reads?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UokTurnObserver {
|
||||
onTurnStart(contract: TurnContract): void;
|
||||
onPhaseResult(
|
||||
phase: TurnPhase,
|
||||
action: string,
|
||||
data?: Record<string, unknown>,
|
||||
): void;
|
||||
onTurnResult(result: TurnResult): void;
|
||||
}
|
||||
121
src/resources/extensions/gsd/uok/execution-graph.ts
Normal file
121
src/resources/extensions/gsd/uok/execution-graph.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import type { UokGraphNode } from "./contracts.js";
|
||||
|
||||
export interface ExecutionGraphRunOptions {
|
||||
parallel?: boolean;
|
||||
maxWorkers?: number;
|
||||
}
|
||||
|
||||
export interface ExecutionGraphResult {
|
||||
order: string[];
|
||||
conflicts: Array<{ nodeA: string; nodeB: string; file: string }>;
|
||||
}
|
||||
|
||||
export type ExecutionNodeHandler = (node: UokGraphNode) => Promise<void>;
|
||||
|
||||
export class ExecutionGraphScheduler {
|
||||
private readonly handlers = new Map<string, ExecutionNodeHandler>();
|
||||
|
||||
registerHandler(kind: UokGraphNode["kind"], handler: ExecutionNodeHandler): void {
|
||||
this.handlers.set(kind, handler);
|
||||
}
|
||||
|
||||
async run(nodes: UokGraphNode[], options?: ExecutionGraphRunOptions): Promise<ExecutionGraphResult> {
|
||||
const sorted = topologicalSort(nodes);
|
||||
const conflicts = detectFileConflicts(nodes);
|
||||
|
||||
// Default deterministic serial execution remains the reference path.
|
||||
if (!options?.parallel) {
|
||||
for (const node of sorted) {
|
||||
const handler = this.handlers.get(node.kind);
|
||||
if (handler) await handler(node);
|
||||
}
|
||||
return { order: sorted.map((n) => n.id), conflicts };
|
||||
}
|
||||
|
||||
// Parallel mode only for nodes whose dependencies are already satisfied.
|
||||
const maxWorkers = Math.max(1, Math.min(8, options.maxWorkers ?? 2));
|
||||
const remaining = new Map(nodes.map((n) => [n.id, n]));
|
||||
const done = new Set<string>();
|
||||
const order: string[] = [];
|
||||
|
||||
while (remaining.size > 0) {
|
||||
const ready = Array.from(remaining.values()).filter((node) =>
|
||||
node.dependsOn.every((dep) => done.has(dep)),
|
||||
);
|
||||
if (ready.length === 0) {
|
||||
throw new Error("Execution graph deadlock detected: no ready nodes and graph not complete");
|
||||
}
|
||||
|
||||
const batch = ready.slice(0, maxWorkers);
|
||||
await Promise.all(
|
||||
batch.map(async (node) => {
|
||||
const handler = this.handlers.get(node.kind);
|
||||
if (handler) await handler(node);
|
||||
done.add(node.id);
|
||||
order.push(node.id);
|
||||
remaining.delete(node.id);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return { order, conflicts };
|
||||
}
|
||||
}
|
||||
|
||||
function topologicalSort(nodes: UokGraphNode[]): UokGraphNode[] {
|
||||
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
|
||||
const inDegree = new Map(nodes.map((n) => [n.id, 0]));
|
||||
|
||||
for (const node of nodes) {
|
||||
for (const dep of node.dependsOn) {
|
||||
if (nodeMap.has(dep)) {
|
||||
inDegree.set(node.id, (inDegree.get(node.id) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const queue = nodes
|
||||
.filter((n) => (inDegree.get(n.id) ?? 0) === 0)
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
const ordered: UokGraphNode[] = [];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
ordered.push(current);
|
||||
|
||||
for (const next of nodes) {
|
||||
if (!next.dependsOn.includes(current.id)) continue;
|
||||
const deg = (inDegree.get(next.id) ?? 0) - 1;
|
||||
inDegree.set(next.id, deg);
|
||||
if (deg === 0) {
|
||||
queue.push(next);
|
||||
queue.sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ordered.length !== nodes.length) {
|
||||
throw new Error("Execution graph has cyclic dependencies");
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function detectFileConflicts(nodes: UokGraphNode[]): Array<{ nodeA: string; nodeB: string; file: string }> {
|
||||
const conflicts: Array<{ nodeA: string; nodeB: string; file: string }> = [];
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const a = nodes[i];
|
||||
const writesA = new Set(a.writes ?? []);
|
||||
if (writesA.size === 0) continue;
|
||||
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const b = nodes[j];
|
||||
for (const file of b.writes ?? []) {
|
||||
if (writesA.has(file)) {
|
||||
conflicts.push({ nodeA: a.id, nodeB: b.id, file });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return conflicts;
|
||||
}
|
||||
34
src/resources/extensions/gsd/uok/flags.ts
Normal file
34
src/resources/extensions/gsd/uok/flags.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import type { GSDPreferences } from "../preferences.js";
|
||||
import { loadEffectiveGSDPreferences } from "../preferences.js";
|
||||
|
||||
export interface UokFlags {
|
||||
enabled: boolean;
|
||||
gates: boolean;
|
||||
modelPolicy: boolean;
|
||||
executionGraph: boolean;
|
||||
gitops: boolean;
|
||||
gitopsTurnAction: "commit" | "snapshot" | "status-only";
|
||||
gitopsTurnPush: boolean;
|
||||
auditUnified: boolean;
|
||||
planV2: boolean;
|
||||
}
|
||||
|
||||
export function resolveUokFlags(prefs: GSDPreferences | undefined): UokFlags {
|
||||
const uok = prefs?.uok;
|
||||
return {
|
||||
enabled: uok?.enabled === true,
|
||||
gates: uok?.gates?.enabled === true,
|
||||
modelPolicy: uok?.model_policy?.enabled === true,
|
||||
executionGraph: uok?.execution_graph?.enabled === true,
|
||||
gitops: uok?.gitops?.enabled === true,
|
||||
gitopsTurnAction: uok?.gitops?.turn_action ?? "status-only",
|
||||
gitopsTurnPush: uok?.gitops?.turn_push === true,
|
||||
auditUnified: uok?.audit_unified?.enabled === true,
|
||||
planV2: uok?.plan_v2?.enabled === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadUokFlags(): UokFlags {
|
||||
const prefs = loadEffectiveGSDPreferences()?.preferences;
|
||||
return resolveUokFlags(prefs);
|
||||
}
|
||||
146
src/resources/extensions/gsd/uok/gate-runner.ts
Normal file
146
src/resources/extensions/gsd/uok/gate-runner.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import type { FailureClass, GateResult } from "./contracts.js";
|
||||
import { insertGateRun } from "../gsd-db.js";
|
||||
import { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js";
|
||||
|
||||
export interface GateRunnerContext {
|
||||
basePath: string;
|
||||
traceId: string;
|
||||
turnId: string;
|
||||
milestoneId?: string;
|
||||
sliceId?: string;
|
||||
taskId?: string;
|
||||
unitType?: string;
|
||||
unitId?: string;
|
||||
}
|
||||
|
||||
export interface GateExecutionInput {
|
||||
id: string;
|
||||
type: string;
|
||||
execute: (ctx: GateRunnerContext, attempt: number) => Promise<{
|
||||
outcome: "pass" | "fail" | "retry" | "manual-attention";
|
||||
rationale?: string;
|
||||
findings?: string;
|
||||
failureClass?: FailureClass;
|
||||
}>;
|
||||
}
|
||||
|
||||
const RETRY_MATRIX: Record<FailureClass, number> = {
|
||||
none: 0,
|
||||
policy: 0,
|
||||
input: 0,
|
||||
execution: 1,
|
||||
artifact: 1,
|
||||
verification: 1,
|
||||
closeout: 1,
|
||||
git: 1,
|
||||
timeout: 2,
|
||||
"manual-attention": 0,
|
||||
unknown: 0,
|
||||
};
|
||||
|
||||
export class UokGateRunner {
|
||||
private readonly registry = new Map<string, GateExecutionInput>();
|
||||
|
||||
register(gate: GateExecutionInput): void {
|
||||
this.registry.set(gate.id, gate);
|
||||
}
|
||||
|
||||
list(): GateExecutionInput[] {
|
||||
return Array.from(this.registry.values());
|
||||
}
|
||||
|
||||
async run(id: string, ctx: GateRunnerContext): Promise<GateResult> {
|
||||
const gate = this.registry.get(id);
|
||||
if (!gate) {
|
||||
return {
|
||||
gateId: id,
|
||||
gateType: "unknown",
|
||||
outcome: "manual-attention",
|
||||
failureClass: "unknown",
|
||||
rationale: `Gate ${id} not registered`,
|
||||
attempt: 1,
|
||||
maxAttempts: 1,
|
||||
retryable: false,
|
||||
evaluatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
let attempt = 0;
|
||||
let final: GateResult | null = null;
|
||||
const maxAttemptsByFailureClass = RETRY_MATRIX;
|
||||
|
||||
while (attempt < 3) {
|
||||
attempt += 1;
|
||||
const now = new Date().toISOString();
|
||||
const result = await gate.execute(ctx, attempt);
|
||||
const failureClass = result.failureClass ?? (result.outcome === "pass" ? "none" : "unknown");
|
||||
const retryBudget = maxAttemptsByFailureClass[failureClass] ?? 0;
|
||||
const retryable = result.outcome !== "pass" && attempt <= retryBudget;
|
||||
|
||||
final = {
|
||||
gateId: gate.id,
|
||||
gateType: gate.type,
|
||||
outcome: retryable ? "retry" : result.outcome,
|
||||
failureClass,
|
||||
rationale: result.rationale,
|
||||
findings: result.findings,
|
||||
attempt,
|
||||
maxAttempts: Math.max(1, retryBudget),
|
||||
retryable,
|
||||
evaluatedAt: now,
|
||||
};
|
||||
|
||||
insertGateRun({
|
||||
traceId: ctx.traceId,
|
||||
turnId: ctx.turnId,
|
||||
gateId: final.gateId,
|
||||
gateType: final.gateType,
|
||||
unitType: ctx.unitType,
|
||||
unitId: ctx.unitId,
|
||||
milestoneId: ctx.milestoneId,
|
||||
sliceId: ctx.sliceId,
|
||||
taskId: ctx.taskId,
|
||||
outcome: final.outcome,
|
||||
failureClass: final.failureClass,
|
||||
rationale: final.rationale,
|
||||
findings: final.findings,
|
||||
attempt: final.attempt,
|
||||
maxAttempts: final.maxAttempts,
|
||||
retryable: final.retryable,
|
||||
evaluatedAt: final.evaluatedAt,
|
||||
});
|
||||
|
||||
emitUokAuditEvent(
|
||||
ctx.basePath,
|
||||
buildAuditEnvelope({
|
||||
traceId: ctx.traceId,
|
||||
turnId: ctx.turnId,
|
||||
category: "gate",
|
||||
type: "gate-run",
|
||||
payload: {
|
||||
gateId: final.gateId,
|
||||
gateType: final.gateType,
|
||||
outcome: final.outcome,
|
||||
failureClass: final.failureClass,
|
||||
attempt: final.attempt,
|
||||
maxAttempts: final.maxAttempts,
|
||||
retryable: final.retryable,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (!retryable) break;
|
||||
}
|
||||
|
||||
return final ?? {
|
||||
gateId: gate.id,
|
||||
gateType: gate.type,
|
||||
outcome: "manual-attention",
|
||||
failureClass: "unknown",
|
||||
attempt: 1,
|
||||
maxAttempts: 1,
|
||||
retryable: false,
|
||||
evaluatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
75
src/resources/extensions/gsd/uok/gitops.ts
Normal file
75
src/resources/extensions/gsd/uok/gitops.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { isDbAvailable, upsertTurnGitTransaction } from "../gsd-db.js";
|
||||
import type { TurnCloseoutRecord } from "./contracts.js";
|
||||
import { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js";
|
||||
|
||||
export type TurnGitStage = "turn-start" | "stage" | "checkpoint" | "publish" | "record";
|
||||
|
||||
interface GitTxArgs {
|
||||
basePath: string;
|
||||
traceId: string;
|
||||
turnId: string;
|
||||
unitType?: string;
|
||||
unitId?: string;
|
||||
stage: TurnGitStage;
|
||||
action: "commit" | "snapshot" | "status-only";
|
||||
push: boolean;
|
||||
status: "ok" | "failed";
|
||||
error?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function writeTurnGitTransaction(args: GitTxArgs): void {
|
||||
if (!isDbAvailable()) return;
|
||||
upsertTurnGitTransaction({
|
||||
traceId: args.traceId,
|
||||
turnId: args.turnId,
|
||||
unitType: args.unitType,
|
||||
unitId: args.unitId,
|
||||
stage: args.stage,
|
||||
action: args.action,
|
||||
push: args.push,
|
||||
status: args.status,
|
||||
error: args.error,
|
||||
metadata: args.metadata,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
emitUokAuditEvent(
|
||||
args.basePath,
|
||||
buildAuditEnvelope({
|
||||
traceId: args.traceId,
|
||||
turnId: args.turnId,
|
||||
category: "gitops",
|
||||
type: `turn-git-${args.stage}`,
|
||||
payload: {
|
||||
unitType: args.unitType,
|
||||
unitId: args.unitId,
|
||||
action: args.action,
|
||||
push: args.push,
|
||||
status: args.status,
|
||||
error: args.error,
|
||||
...(args.metadata ?? {}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function writeTurnCloseoutGitRecord(basePath: string, record: TurnCloseoutRecord): void {
|
||||
writeTurnGitTransaction({
|
||||
basePath,
|
||||
traceId: record.traceId,
|
||||
turnId: record.turnId,
|
||||
unitType: record.unitType,
|
||||
unitId: record.unitId,
|
||||
stage: "record",
|
||||
action: record.gitAction,
|
||||
push: record.gitPushed,
|
||||
status: record.failureClass === "git" ? "failed" : "ok",
|
||||
error: record.failureClass === "git" ? "git closeout failure" : undefined,
|
||||
metadata: {
|
||||
turnStatus: record.status,
|
||||
finishedAt: record.finishedAt,
|
||||
activityFile: record.activityFile,
|
||||
},
|
||||
});
|
||||
}
|
||||
98
src/resources/extensions/gsd/uok/kernel.ts
Normal file
98
src/resources/extensions/gsd/uok/kernel.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent";
|
||||
import { appendFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { AutoSession } from "../auto/session.js";
|
||||
import type { LoopDeps } from "../auto/loop-deps.js";
|
||||
import { gsdRoot } from "../paths.js";
|
||||
import { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js";
|
||||
import { resolveUokFlags } from "./flags.js";
|
||||
import { createTurnObserver } from "./loop-adapter.js";
|
||||
|
||||
interface RunAutoLoopWithUokArgs {
|
||||
ctx: ExtensionContext;
|
||||
pi: ExtensionAPI;
|
||||
s: AutoSession;
|
||||
deps: LoopDeps;
|
||||
runLegacyLoop: (
|
||||
ctx: ExtensionContext,
|
||||
pi: ExtensionAPI,
|
||||
s: AutoSession,
|
||||
deps: LoopDeps,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
function parityLogPath(basePath: string): string {
|
||||
return join(gsdRoot(basePath), "runtime", "uok-parity.jsonl");
|
||||
}
|
||||
|
||||
function writeParityEvent(basePath: string, event: Record<string, unknown>): void {
|
||||
try {
|
||||
mkdirSync(join(gsdRoot(basePath), "runtime"), { recursive: true });
|
||||
appendFileSync(parityLogPath(basePath), `${JSON.stringify(event)}\n`, "utf-8");
|
||||
} catch {
|
||||
// parity telemetry must never block orchestration
|
||||
}
|
||||
}
|
||||
|
||||
export async function runAutoLoopWithUok(args: RunAutoLoopWithUokArgs): Promise<void> {
|
||||
const { ctx, pi, s, deps, runLegacyLoop } = args;
|
||||
const prefs = deps.loadEffectiveGSDPreferences()?.preferences;
|
||||
const flags = resolveUokFlags(prefs);
|
||||
|
||||
writeParityEvent(s.basePath, {
|
||||
ts: new Date().toISOString(),
|
||||
path: flags.enabled ? "uok-wrapper" : "legacy-wrapper",
|
||||
flags,
|
||||
phase: "enter",
|
||||
});
|
||||
|
||||
if (flags.auditUnified) {
|
||||
emitUokAuditEvent(
|
||||
s.basePath,
|
||||
buildAuditEnvelope({
|
||||
traceId: `session:${String(s.autoStartTime || Date.now())}`,
|
||||
category: "orchestration",
|
||||
type: "uok-kernel-enter",
|
||||
payload: {
|
||||
flags,
|
||||
sessionId: ctx.sessionManager?.getSessionId?.(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const decoratedDeps: LoopDeps = flags.enabled
|
||||
? {
|
||||
...deps,
|
||||
uokObserver: createTurnObserver({
|
||||
basePath: s.basePath,
|
||||
gitAction: flags.gitopsTurnAction,
|
||||
gitPush: flags.gitopsTurnPush,
|
||||
enableAudit: flags.auditUnified,
|
||||
enableGitops: flags.gitops,
|
||||
}),
|
||||
}
|
||||
: deps;
|
||||
|
||||
try {
|
||||
await runLegacyLoop(ctx, pi, s, decoratedDeps);
|
||||
writeParityEvent(s.basePath, {
|
||||
ts: new Date().toISOString(),
|
||||
path: flags.enabled ? "uok-wrapper" : "legacy-wrapper",
|
||||
flags,
|
||||
phase: "exit",
|
||||
status: "ok",
|
||||
});
|
||||
} catch (err) {
|
||||
writeParityEvent(s.basePath, {
|
||||
ts: new Date().toISOString(),
|
||||
path: flags.enabled ? "uok-wrapper" : "legacy-wrapper",
|
||||
flags,
|
||||
phase: "exit",
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
162
src/resources/extensions/gsd/uok/loop-adapter.ts
Normal file
162
src/resources/extensions/gsd/uok/loop-adapter.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import type {
|
||||
TurnCloseoutRecord,
|
||||
TurnContract,
|
||||
TurnResult,
|
||||
UokTurnObserver,
|
||||
} from "./contracts.js";
|
||||
import { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js";
|
||||
import { writeTurnCloseoutGitRecord, writeTurnGitTransaction } from "./gitops.js";
|
||||
|
||||
export interface CreateTurnObserverOptions {
|
||||
basePath: string;
|
||||
gitAction: "commit" | "snapshot" | "status-only";
|
||||
gitPush: boolean;
|
||||
enableAudit: boolean;
|
||||
enableGitops: boolean;
|
||||
}
|
||||
|
||||
export function createTurnObserver(options: CreateTurnObserverOptions): UokTurnObserver {
|
||||
let current: TurnContract | null = null;
|
||||
const phaseResults: TurnResult["phaseResults"] = [];
|
||||
|
||||
return {
|
||||
onTurnStart(contract): void {
|
||||
current = contract;
|
||||
phaseResults.length = 0;
|
||||
|
||||
if (options.enableGitops) {
|
||||
writeTurnGitTransaction({
|
||||
basePath: options.basePath,
|
||||
traceId: contract.traceId,
|
||||
turnId: contract.turnId,
|
||||
unitType: contract.unitType,
|
||||
unitId: contract.unitId,
|
||||
stage: "turn-start",
|
||||
action: options.gitAction,
|
||||
push: options.gitPush,
|
||||
status: "ok",
|
||||
metadata: {
|
||||
iteration: contract.iteration,
|
||||
sidecarKind: contract.sidecarKind,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (options.enableAudit) {
|
||||
emitUokAuditEvent(
|
||||
options.basePath,
|
||||
buildAuditEnvelope({
|
||||
traceId: contract.traceId,
|
||||
turnId: contract.turnId,
|
||||
category: "orchestration",
|
||||
type: "turn-start",
|
||||
payload: {
|
||||
iteration: contract.iteration,
|
||||
unitType: contract.unitType,
|
||||
unitId: contract.unitId,
|
||||
sidecarKind: contract.sidecarKind,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
onPhaseResult(phase, action, data): void {
|
||||
phaseResults.push({
|
||||
phase,
|
||||
action,
|
||||
ts: new Date().toISOString(),
|
||||
data,
|
||||
});
|
||||
|
||||
if (!current || !options.enableGitops) return;
|
||||
if (phase === "dispatch") {
|
||||
writeTurnGitTransaction({
|
||||
basePath: options.basePath,
|
||||
traceId: current.traceId,
|
||||
turnId: current.turnId,
|
||||
unitType: data?.unitType as string | undefined,
|
||||
unitId: data?.unitId as string | undefined,
|
||||
stage: "stage",
|
||||
action: options.gitAction,
|
||||
push: options.gitPush,
|
||||
status: "ok",
|
||||
metadata: { action },
|
||||
});
|
||||
}
|
||||
if (phase === "unit") {
|
||||
writeTurnGitTransaction({
|
||||
basePath: options.basePath,
|
||||
traceId: current.traceId,
|
||||
turnId: current.turnId,
|
||||
unitType: data?.unitType as string | undefined,
|
||||
unitId: data?.unitId as string | undefined,
|
||||
stage: "checkpoint",
|
||||
action: options.gitAction,
|
||||
push: options.gitPush,
|
||||
status: "ok",
|
||||
metadata: { action },
|
||||
});
|
||||
}
|
||||
if (phase === "finalize") {
|
||||
writeTurnGitTransaction({
|
||||
basePath: options.basePath,
|
||||
traceId: current.traceId,
|
||||
turnId: current.turnId,
|
||||
unitType: data?.unitType as string | undefined,
|
||||
unitId: data?.unitId as string | undefined,
|
||||
stage: "publish",
|
||||
action: options.gitAction,
|
||||
push: options.gitPush,
|
||||
status: "ok",
|
||||
metadata: { action },
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onTurnResult(result): void {
|
||||
const merged: TurnResult = {
|
||||
...result,
|
||||
phaseResults: result.phaseResults.length > 0 ? result.phaseResults : [...phaseResults],
|
||||
};
|
||||
|
||||
if (options.enableAudit) {
|
||||
emitUokAuditEvent(
|
||||
options.basePath,
|
||||
buildAuditEnvelope({
|
||||
traceId: merged.traceId,
|
||||
turnId: merged.turnId,
|
||||
category: "orchestration",
|
||||
type: "turn-result",
|
||||
payload: {
|
||||
unitType: merged.unitType,
|
||||
unitId: merged.unitId,
|
||||
status: merged.status,
|
||||
failureClass: merged.failureClass,
|
||||
error: merged.error,
|
||||
phaseCount: merged.phaseResults.length,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (options.enableGitops) {
|
||||
const closeout: TurnCloseoutRecord = merged.closeout ?? {
|
||||
traceId: merged.traceId,
|
||||
turnId: merged.turnId,
|
||||
unitType: merged.unitType,
|
||||
unitId: merged.unitId,
|
||||
status: merged.status,
|
||||
failureClass: merged.failureClass,
|
||||
gitAction: options.gitAction,
|
||||
gitPushed: options.gitPush,
|
||||
finishedAt: merged.finishedAt,
|
||||
};
|
||||
writeTurnCloseoutGitRecord(options.basePath, closeout);
|
||||
}
|
||||
|
||||
current = null;
|
||||
phaseResults.length = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
112
src/resources/extensions/gsd/uok/model-policy.ts
Normal file
112
src/resources/extensions/gsd/uok/model-policy.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import type { TaskMetadata } from "../model-router.js";
|
||||
import { computeTaskRequirements, filterToolsForProvider } from "../model-router.js";
|
||||
import { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js";
|
||||
|
||||
export interface ModelCandidate {
|
||||
id: string;
|
||||
provider: string;
|
||||
api: string;
|
||||
}
|
||||
|
||||
export interface ModelPolicyDecision {
|
||||
modelId: string;
|
||||
provider: string;
|
||||
allowed: boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ModelPolicyOptions {
|
||||
basePath: string;
|
||||
traceId: string;
|
||||
turnId?: string;
|
||||
unitType?: string;
|
||||
taskMetadata?: TaskMetadata;
|
||||
currentProvider?: string;
|
||||
allowCrossProvider?: boolean;
|
||||
requiredTools?: string[];
|
||||
deniedProviders?: string[];
|
||||
allowedApis?: string[];
|
||||
}
|
||||
|
||||
export function buildRequirementVector(unitType?: string, taskMetadata?: TaskMetadata): Partial<Record<string, number>> {
|
||||
if (!unitType) return {};
|
||||
return computeTaskRequirements(unitType, taskMetadata) as unknown as Partial<Record<string, number>>;
|
||||
}
|
||||
|
||||
export function applyModelPolicyFilter(
|
||||
candidates: ModelCandidate[],
|
||||
options: ModelPolicyOptions,
|
||||
): {
|
||||
eligible: ModelCandidate[];
|
||||
decisions: ModelPolicyDecision[];
|
||||
requirements: Partial<Record<string, number>>;
|
||||
} {
|
||||
const requiredTools = options.requiredTools ?? [];
|
||||
const deniedProviders = new Set((options.deniedProviders ?? []).map((p) => p.toLowerCase()));
|
||||
const allowedApis = options.allowedApis ? new Set(options.allowedApis) : null;
|
||||
const requirements = buildRequirementVector(options.unitType, options.taskMetadata);
|
||||
const decisions: ModelPolicyDecision[] = [];
|
||||
const eligible: ModelCandidate[] = [];
|
||||
|
||||
for (const model of candidates) {
|
||||
let allowed = true;
|
||||
let reason = "allowed";
|
||||
|
||||
if (options.allowCrossProvider === false && options.currentProvider && model.provider !== options.currentProvider) {
|
||||
allowed = false;
|
||||
reason = `cross-provider routing disabled (${model.provider} != ${options.currentProvider})`;
|
||||
}
|
||||
|
||||
if (allowed && deniedProviders.has(model.provider.toLowerCase())) {
|
||||
allowed = false;
|
||||
reason = `provider denied by policy: ${model.provider}`;
|
||||
}
|
||||
|
||||
if (allowed && allowedApis && !allowedApis.has(model.api)) {
|
||||
allowed = false;
|
||||
reason = `transport/api denied by policy: ${model.api}`;
|
||||
}
|
||||
|
||||
if (allowed && requiredTools.length > 0) {
|
||||
const compatibility = filterToolsForProvider(requiredTools, model.api);
|
||||
if (compatibility.filtered.length > 0) {
|
||||
allowed = false;
|
||||
reason = `tool policy denied (${compatibility.filtered.join(", ")}) for ${model.api}`;
|
||||
}
|
||||
}
|
||||
|
||||
const decision: ModelPolicyDecision = {
|
||||
modelId: model.id,
|
||||
provider: model.provider,
|
||||
allowed,
|
||||
reason,
|
||||
};
|
||||
decisions.push(decision);
|
||||
|
||||
emitUokAuditEvent(
|
||||
options.basePath,
|
||||
buildAuditEnvelope({
|
||||
traceId: options.traceId,
|
||||
turnId: options.turnId,
|
||||
category: "model-policy",
|
||||
type: allowed ? "model-policy-allow" : "model-policy-deny",
|
||||
payload: {
|
||||
modelId: model.id,
|
||||
provider: model.provider,
|
||||
api: model.api,
|
||||
reason,
|
||||
unitType: options.unitType,
|
||||
requirements,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (allowed) eligible.push(model);
|
||||
}
|
||||
|
||||
return {
|
||||
eligible,
|
||||
decisions,
|
||||
requirements,
|
||||
};
|
||||
}
|
||||
87
src/resources/extensions/gsd/uok/plan-v2.ts
Normal file
87
src/resources/extensions/gsd/uok/plan-v2.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { GSDState } from "../types.js";
|
||||
import { gsdRoot } from "../paths.js";
|
||||
import { isDbAvailable, getMilestoneSlices, getSliceTasks } from "../gsd-db.js";
|
||||
import type { UokGraphNode } from "./contracts.js";
|
||||
|
||||
export interface PlanV2CompileResult {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
graphPath?: string;
|
||||
nodeCount?: number;
|
||||
}
|
||||
|
||||
function graphOutputPath(basePath: string): string {
|
||||
return join(gsdRoot(basePath), "runtime", "uok-plan-v2-graph.json");
|
||||
}
|
||||
|
||||
export function compileUnitGraphFromState(basePath: string, state: GSDState): PlanV2CompileResult {
|
||||
const mid = state.activeMilestone?.id;
|
||||
if (!mid) return { ok: false, reason: "no active milestone" };
|
||||
if (!isDbAvailable()) return { ok: false, reason: "database not available" };
|
||||
|
||||
const slices = getMilestoneSlices(mid).sort((a, b) => Number(a.sequence ?? 0) - Number(b.sequence ?? 0));
|
||||
const nodes: UokGraphNode[] = [];
|
||||
|
||||
for (const slice of slices) {
|
||||
const sid = slice.id;
|
||||
const tasks = getSliceTasks(mid, sid)
|
||||
.sort((a, b) => Number(a.sequence ?? 0) - Number(b.sequence ?? 0));
|
||||
|
||||
let previousTaskNodeId: string | null = null;
|
||||
for (const task of tasks) {
|
||||
const nodeId = `execute-task:${mid}:${sid}:${task.id}`;
|
||||
const dependsOn = previousTaskNodeId ? [previousTaskNodeId] : [];
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
kind: "unit",
|
||||
dependsOn,
|
||||
writes: task.key_files,
|
||||
metadata: {
|
||||
unitType: "execute-task",
|
||||
unitId: `${mid}.${sid}.${task.id}`,
|
||||
title: task.title,
|
||||
status: task.status,
|
||||
},
|
||||
});
|
||||
previousTaskNodeId = nodeId;
|
||||
}
|
||||
|
||||
if (previousTaskNodeId) {
|
||||
nodes.push({
|
||||
id: `complete-slice:${mid}:${sid}`,
|
||||
kind: "verification",
|
||||
dependsOn: [previousTaskNodeId],
|
||||
metadata: {
|
||||
unitType: "complete-slice",
|
||||
unitId: `${mid}.${sid}`,
|
||||
title: slice.title,
|
||||
status: slice.status,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const output = {
|
||||
compiledAt: new Date().toISOString(),
|
||||
milestoneId: mid,
|
||||
nodes,
|
||||
};
|
||||
|
||||
const outPath = graphOutputPath(basePath);
|
||||
mkdirSync(join(gsdRoot(basePath), "runtime"), { recursive: true });
|
||||
writeFileSync(outPath, JSON.stringify(output, null, 2) + "\n", "utf-8");
|
||||
|
||||
return { ok: true, graphPath: outPath, nodeCount: nodes.length };
|
||||
}
|
||||
|
||||
export function ensurePlanV2Graph(basePath: string, state: GSDState): PlanV2CompileResult {
|
||||
const compiled = compileUnitGraphFromState(basePath, state);
|
||||
if (!compiled.ok) return compiled;
|
||||
if ((compiled.nodeCount ?? 0) <= 0) {
|
||||
return { ok: false, reason: "compiled graph is empty" };
|
||||
}
|
||||
return compiled;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue