sf snapshot: uncommitted changes after 39m inactivity
This commit is contained in:
parent
14d963cb51
commit
2e67b15ff9
34 changed files with 1564 additions and 124 deletions
|
|
@ -495,6 +495,105 @@ describe("agent-loop — steering during tool batches", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("agent-loop — predictive stream hook", () => {
|
||||
it("receives text and thinking deltas without changing the final response", async () => {
|
||||
const finalMessage = makeAssistantMessage({
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
stopReason: "stop",
|
||||
});
|
||||
const mockStream = createDeltaStreamFn(
|
||||
[
|
||||
{ type: "thinking_delta", delta: "think" },
|
||||
{ type: "text_delta", delta: "hello" },
|
||||
],
|
||||
finalMessage,
|
||||
);
|
||||
const chunks: string[] = [];
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "You are a test agent.",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "say hello" }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
tools: [],
|
||||
};
|
||||
const config: AgentLoopConfig = {
|
||||
model: TEST_MODEL,
|
||||
convertToLlm: (msgs) => msgs.filter((m): m is any => m.role !== "custom"),
|
||||
toolExecution: "sequential",
|
||||
onStreamChunk: (chunk) => {
|
||||
chunks.push(chunk);
|
||||
},
|
||||
};
|
||||
|
||||
const events = await collectEvents(
|
||||
agentLoop(
|
||||
context.messages,
|
||||
context,
|
||||
config,
|
||||
undefined,
|
||||
mockStream as any,
|
||||
),
|
||||
);
|
||||
|
||||
assert.deepEqual(chunks, ["think", "hello"]);
|
||||
assert.ok(
|
||||
events.some(
|
||||
(event) =>
|
||||
event.type === "agent_end" &&
|
||||
event.messages.at(-1)?.role === "assistant",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores predictive hook failures so streaming can finish", async () => {
|
||||
const finalMessage = makeAssistantMessage({
|
||||
content: [{ type: "text", text: "still done" }],
|
||||
stopReason: "stop",
|
||||
});
|
||||
const mockStream = createDeltaStreamFn(
|
||||
[{ type: "text_delta", delta: "still done" }],
|
||||
finalMessage,
|
||||
);
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "You are a test agent.",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "say done" }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
tools: [],
|
||||
};
|
||||
const config: AgentLoopConfig = {
|
||||
model: TEST_MODEL,
|
||||
convertToLlm: (msgs) => msgs.filter((m): m is any => m.role !== "custom"),
|
||||
toolExecution: "sequential",
|
||||
onStreamChunk: () => {
|
||||
throw new Error("prefetch failed");
|
||||
},
|
||||
};
|
||||
|
||||
const events = await collectEvents(
|
||||
agentLoop(
|
||||
context.messages,
|
||||
context,
|
||||
config,
|
||||
undefined,
|
||||
mockStream as any,
|
||||
),
|
||||
);
|
||||
|
||||
const agentEnd = events.find((event) => event.type === "agent_end");
|
||||
assert.ok(agentEnd);
|
||||
assert.equal(agentEnd.messages.at(-1)?.role, "assistant");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression tests for #2783: Stuck-loop on execute-task — tool-call schema
|
||||
* overload causes unbounded retry + budget burn.
|
||||
|
|
@ -563,6 +662,29 @@ function createMockStreamFn(responses: AssistantMessage[]) {
|
|||
};
|
||||
}
|
||||
|
||||
function createDeltaStreamFn(
|
||||
deltas: Array<{ type: "text_delta" | "thinking_delta"; delta: string }>,
|
||||
finalMessage: AssistantMessage,
|
||||
) {
|
||||
return function mockStreamFn(): AssistantMessageEventStream {
|
||||
const stream = new AssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "start", partial: finalMessage });
|
||||
for (const delta of deltas) {
|
||||
stream.push({
|
||||
type: delta.type,
|
||||
contentIndex: 0,
|
||||
delta: delta.delta,
|
||||
partial: finalMessage,
|
||||
});
|
||||
}
|
||||
stream.push({ type: "done", message: finalMessage });
|
||||
stream.end(finalMessage);
|
||||
});
|
||||
return stream;
|
||||
};
|
||||
}
|
||||
|
||||
function makeAssistantMessage(
|
||||
overrides: Partial<AssistantMessage> = {},
|
||||
): AssistantMessage {
|
||||
|
|
|
|||
|
|
@ -499,6 +499,19 @@ async function streamAssistantResponse(
|
|||
assistantMessageEvent: event,
|
||||
message: { ...partialMessage },
|
||||
});
|
||||
|
||||
// Predictive Execution: stream hook for pre-fetching
|
||||
if (
|
||||
config.onStreamChunk &&
|
||||
(event.type === "text_delta" || event.type === "thinking_delta")
|
||||
) {
|
||||
try {
|
||||
config.onStreamChunk(event.delta, context);
|
||||
} catch {
|
||||
// Predictive hooks are advisory; never let prefetch/critic
|
||||
// failures interrupt provider streaming.
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
|
|
|
|||
|
|
@ -150,6 +150,14 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|||
provider: string,
|
||||
) => Promise<string | undefined> | string | undefined;
|
||||
|
||||
/**
|
||||
* Streaming hook for Predictive Execution.
|
||||
* Called whenever a chunk of text or thinking is streamed from the LLM.
|
||||
* Allows the system to parse intent early (e.g., "I should check") and pre-fetch context
|
||||
* or run background jobs before the LLM finishes and requests a tool.
|
||||
*/
|
||||
onStreamChunk?: (chunk: string, context: AgentContext) => void;
|
||||
|
||||
/**
|
||||
* Returns steering messages to inject into the conversation mid-run.
|
||||
*
|
||||
|
|
@ -367,3 +375,22 @@ export type AgentEvent =
|
|||
result: any;
|
||||
isError: boolean;
|
||||
};
|
||||
|
||||
export interface MemoryRecord {
|
||||
id?: string;
|
||||
text?: string;
|
||||
summary?: string;
|
||||
tags?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface MemoryProvider {
|
||||
/** Search for specific anti-patterns or facts across federated nodes or locally. */
|
||||
search(
|
||||
query: string,
|
||||
options?: { limit?: number; threshold?: number },
|
||||
): Promise<MemoryRecord[]>;
|
||||
/** Store a new learning or anti-pattern to the federated graph. */
|
||||
store(memory: MemoryRecord): Promise<void>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -534,6 +534,11 @@ function createExtensionAPI(
|
|||
runtime.refreshTools();
|
||||
},
|
||||
|
||||
unregisterTool(name: string): void {
|
||||
extension.tools.delete(name);
|
||||
runtime.refreshTools();
|
||||
},
|
||||
|
||||
registerCommand(
|
||||
name: string,
|
||||
options: Omit<RegisteredCommand, "name">,
|
||||
|
|
|
|||
|
|
@ -217,9 +217,14 @@ function wrapExtensionUIContext(
|
|||
setWidget: (key, content, options) => {
|
||||
try {
|
||||
uiContext.setWidget(key, content as never, options);
|
||||
} catch {
|
||||
// Extension widgets are optional UI sugar. Older or embedded hosts can
|
||||
// expose a stale setWidget shim; never let that break extension hooks.
|
||||
} catch (err) {
|
||||
// Safety net: if a custom UI context (e.g. from a test or third-party
|
||||
// mode) throws, don't let it break extension event handlers. Log so
|
||||
// the bug is visible in dev instead of being silently swallowed.
|
||||
console.debug(
|
||||
"[extension-runner] setWidget failed (non-fatal):",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1351,6 +1351,9 @@ export interface ExtensionAPI {
|
|||
tool: ToolDefinition<TParams, TDetails>,
|
||||
): void;
|
||||
|
||||
/** Unregister a previously registered tool by name. (Recursive Self-Evolution) */
|
||||
unregisterTool(name: string): void;
|
||||
|
||||
// =========================================================================
|
||||
// Command, Shortcut, Flag Registration
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ export interface SessionEntryBase {
|
|||
type: string;
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
mergeParentIds?: string[]; // DAG support for Swarm Consensus
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
|
|
@ -1496,6 +1497,34 @@ export class SessionManager {
|
|||
return entry.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge multiple branches into the current leaf.
|
||||
* Used for Swarm Consensus synthesis.
|
||||
* Allows a DAG structure where the synthesis node has multiple parent references.
|
||||
*/
|
||||
mergeBranches(
|
||||
branchIds: string[],
|
||||
summary: string,
|
||||
details?: unknown,
|
||||
): string {
|
||||
for (const id of branchIds) {
|
||||
if (!this.byId.has(id)) throw new Error(`Entry ${id} not found`);
|
||||
}
|
||||
|
||||
const entry: BranchSummaryEntry = {
|
||||
type: "branch_summary",
|
||||
id: generateId(this.byId),
|
||||
parentId: this.leafId,
|
||||
mergeParentIds: branchIds,
|
||||
timestamp: new Date().toISOString(),
|
||||
fromId: branchIds.join(","),
|
||||
summary,
|
||||
details,
|
||||
};
|
||||
this._appendEntry(entry);
|
||||
return entry.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session file containing only the path from root to the specified leaf.
|
||||
* Useful for extracting a single conversation path from a branched session.
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@ export {
|
|||
} from "./core/extensions/index.js";
|
||||
// Footer data provider (git branch + extension statuses - data not otherwise available to extensions)
|
||||
export type { ReadonlyFooterDataProvider } from "./core/footer-data-provider.js";
|
||||
export { FederatedMemoryProvider } from "./core/memory/federated-memory.js";
|
||||
export { convertToLlm } from "./core/messages.js";
|
||||
export type {
|
||||
DiscoveredModel,
|
||||
|
|
|
|||
|
|
@ -44,39 +44,57 @@ function notifyHost(
|
|||
host.ui?.requestRender?.();
|
||||
}
|
||||
|
||||
function setWidgetHost(
|
||||
/**
|
||||
* Resolve the host's widget setter capability once, safely.
|
||||
*
|
||||
* Purpose: avoid probing `host.setExtensionWidget` on every `setWidget` call.
|
||||
* Embedded/stale hosts may expose incompatible getters/shims that throw on
|
||||
* access; we catch that at context creation time and degrade to a no-op.
|
||||
*
|
||||
* Returns the bound setter if available and callable, otherwise `undefined`.
|
||||
*/
|
||||
function resolveWidgetSetter(
|
||||
host: any,
|
||||
): ((key: string, content: unknown, options?: unknown) => void) | undefined {
|
||||
try {
|
||||
const fn = host.setExtensionWidget;
|
||||
return typeof fn === "function" ? fn.bind(host) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `setWidget` implementation for the given host.
|
||||
*
|
||||
* The returned function never throws. If the host does not support extension
|
||||
* widgets, it degrades to a no-op. If the host setter throws at call time,
|
||||
* the error is caught and silently ignored (widgets are optional UI sugar).
|
||||
*/
|
||||
function createWidgetSetter(
|
||||
host: any,
|
||||
): (
|
||||
key: string,
|
||||
content: ExtensionWidgetContent,
|
||||
options?: ExtensionWidgetOptions,
|
||||
): void {
|
||||
if (typeof host.setExtensionWidget === "function") {
|
||||
) => void {
|
||||
const setter = resolveWidgetSetter(host);
|
||||
if (!setter) {
|
||||
return (_key, _content, _options) => {
|
||||
// Host does not support extension widgets.
|
||||
};
|
||||
}
|
||||
return (key, content, options) => {
|
||||
try {
|
||||
host.setExtensionWidget(key, content, options);
|
||||
return;
|
||||
setter(key, content, options);
|
||||
} catch {
|
||||
// Widget rendering is optional. Embedded/stale hosts may expose an
|
||||
// incompatible shim; degrade to status/render fallback below.
|
||||
// Widget render failed. Optional UI sugar; degrade silently.
|
||||
}
|
||||
}
|
||||
|
||||
if (content === undefined) {
|
||||
host.ui?.requestRender?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(content) && typeof host.showStatus === "function") {
|
||||
const message = content.filter(Boolean).join("\n");
|
||||
if (message) {
|
||||
host.showStatus(message, { append: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
host.ui?.requestRender?.();
|
||||
};
|
||||
}
|
||||
|
||||
export function createExtensionUIContext(host: any): ExtensionUIContext {
|
||||
const setWidget = createWidgetSetter(host);
|
||||
return {
|
||||
select: (title, options, opts) =>
|
||||
host.showExtensionSelector(title, options, opts),
|
||||
|
|
@ -106,8 +124,7 @@ export function createExtensionUIContext(host: any): ExtensionUIContext {
|
|||
host.loadingAnimation.setVisible(visible);
|
||||
}
|
||||
},
|
||||
setWidget: (key, content, options) =>
|
||||
setWidgetHost(host, key, content, options),
|
||||
setWidget,
|
||||
setFooter: (factory) => host.setExtensionFooter(factory),
|
||||
setHeader: (factory) => host.setExtensionHeader(factory),
|
||||
setTitle: (title) => host.ui.terminal.setTitle(title),
|
||||
|
|
|
|||
|
|
@ -35,15 +35,22 @@ const agentExtensionsDir = join(
|
|||
"sf",
|
||||
);
|
||||
const useAgentDir = existsSync(join(agentExtensionsDir, "state.js"));
|
||||
const sfExtensionPath = (moduleName: string) =>
|
||||
useAgentDir
|
||||
? join(agentExtensionsDir, `${moduleName}.js`)
|
||||
: resolveBundledSourceResource(
|
||||
import.meta.url,
|
||||
"extensions",
|
||||
"sf",
|
||||
`${moduleName}.ts`,
|
||||
);
|
||||
const sfExtensionPath = (moduleName: string) => {
|
||||
if (useAgentDir) return join(agentExtensionsDir, `${moduleName}.js`);
|
||||
const tsPath = resolveBundledSourceResource(
|
||||
import.meta.url,
|
||||
"extensions",
|
||||
"sf",
|
||||
`${moduleName}.ts`,
|
||||
);
|
||||
if (existsSync(tsPath)) return tsPath;
|
||||
return resolveBundledSourceResource(
|
||||
import.meta.url,
|
||||
"extensions",
|
||||
"sf",
|
||||
`${moduleName}.js`,
|
||||
);
|
||||
};
|
||||
|
||||
async function loadExtensionModules() {
|
||||
const stateModule = (await jiti.import(sfExtensionPath("state"), {})) as any;
|
||||
|
|
@ -67,6 +74,10 @@ async function loadExtensionModules() {
|
|||
sfExtensionPath("uok/unit-runtime"),
|
||||
{},
|
||||
)) as any;
|
||||
const uokDiagnosticsModule = (await jiti.import(
|
||||
sfExtensionPath("uok/diagnostic-synthesis"),
|
||||
{},
|
||||
)) as any;
|
||||
return {
|
||||
openProjectDbIfPresent: autoStartModule.openProjectDbIfPresent as (
|
||||
basePath: string,
|
||||
|
|
@ -96,6 +107,10 @@ async function loadExtensionModules() {
|
|||
uokRuntimeModule.isTerminalUnitRuntimeStatus as (
|
||||
status: string,
|
||||
) => boolean,
|
||||
writeUokDiagnostics: uokDiagnosticsModule.writeUokDiagnostics as (
|
||||
basePath: string,
|
||||
opts?: any,
|
||||
) => any,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -160,6 +175,7 @@ export interface QuerySnapshot {
|
|||
runtime: {
|
||||
units: RuntimeUnitSummary[];
|
||||
};
|
||||
uokDiagnostics?: any;
|
||||
schedule?: {
|
||||
due: Array<{
|
||||
id: string;
|
||||
|
|
@ -278,6 +294,7 @@ export async function buildQuerySnapshot(
|
|||
getUnitRuntimeState,
|
||||
decideUnitRuntimeDispatch,
|
||||
isTerminalUnitRuntimeStatus,
|
||||
writeUokDiagnostics,
|
||||
} = await loadExtensionModules();
|
||||
await openProjectDbIfPresent(basePath);
|
||||
const state = await deriveState(basePath);
|
||||
|
|
@ -361,6 +378,7 @@ export async function buildQuerySnapshot(
|
|||
isTerminalUnitRuntimeStatus,
|
||||
}),
|
||||
},
|
||||
uokDiagnostics: writeUokDiagnostics(basePath, { expectedNext: next }),
|
||||
schedule: scheduleEntries,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -31,23 +31,12 @@ import {
|
|||
import { getMilestoneSlices, getSliceTasks, isDbAvailable } from "./sf-db.js";
|
||||
import { formattedShortcutPair } from "./shortcut-defs.js";
|
||||
import { parseUnitId } from "./unit-id.js";
|
||||
import { readUokDiagnostics } from "./uok/diagnostic-synthesis.js";
|
||||
import { logWarning } from "./workflow-logger.js";
|
||||
import { getCurrentBranch } from "./worktree.js";
|
||||
import { getActiveWorktreeName } from "./worktree-command.js";
|
||||
|
||||
const ACTIVITY_FRAMES = ["|", "/", "-", "\\"];
|
||||
function safeSetWidget(ctx, key, content, options) {
|
||||
try {
|
||||
ctx.ui?.setWidget?.(key, content, options);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logWarning(
|
||||
"dashboard",
|
||||
`setWidget(${key}) failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// ─── UAT Slice Extraction ─────────────────────────────────────────────────────
|
||||
/**
|
||||
* Extract the target slice ID from a run-uat unit ID (e.g. "M001/S01" → "S01").
|
||||
|
|
@ -177,6 +166,27 @@ function formatSolverWidgetLine(basePath, theme, width, pad) {
|
|||
.join(" · ");
|
||||
return truncateToWidth(`${pad}${theme.fg("dim", text)}`, width, "…");
|
||||
}
|
||||
function formatUokDiagnosticWidgetLine(basePath, theme, width, pad) {
|
||||
const diagnostics = readUokDiagnostics(basePath);
|
||||
if (!diagnostics) return null;
|
||||
const parts = [
|
||||
`uok ${diagnostics.verdict ?? "unknown"}`,
|
||||
diagnostics.classification ?? "unknown",
|
||||
];
|
||||
const issue = diagnostics.issues?.[0]?.code;
|
||||
if (issue) parts.push(issue);
|
||||
const color =
|
||||
diagnostics.verdict === "degraded"
|
||||
? "error"
|
||||
: diagnostics.verdict === "attention"
|
||||
? "warning"
|
||||
: "dim";
|
||||
return truncateToWidth(
|
||||
`${pad}${theme.fg(color, parts.filter(Boolean).join(" · "))}`,
|
||||
width,
|
||||
"…",
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Describe what the next unit will be, based on current state.
|
||||
*/
|
||||
|
|
@ -592,7 +602,7 @@ export function updateProgressWidget(
|
|||
refreshLastCommit(accessors.getBasePath());
|
||||
// Cache the effective service tier at widget creation time (reads preferences)
|
||||
const effectiveServiceTier = getEffectiveServiceTier();
|
||||
safeSetWidget(ctx, "sf-progress", (tui, theme) => {
|
||||
ctx.ui.setWidget("sf-progress", (tui, theme) => {
|
||||
let cachedLines;
|
||||
let cachedWidth;
|
||||
let cachedRtkLabel;
|
||||
|
|
@ -766,6 +776,13 @@ export function updateProgressWidget(
|
|||
pad,
|
||||
);
|
||||
if (solverLine) lines.push(solverLine);
|
||||
const diagnosticLine = formatUokDiagnosticWidgetLine(
|
||||
accessors.getBasePath(),
|
||||
theme,
|
||||
width,
|
||||
pad,
|
||||
);
|
||||
if (diagnosticLine) lines.push(diagnosticLine);
|
||||
// Progress bar
|
||||
const roadmapSlices = mid ? getRoadmapSlicesSync() : null;
|
||||
if (roadmapSlices) {
|
||||
|
|
@ -860,6 +877,13 @@ export function updateProgressWidget(
|
|||
pad,
|
||||
);
|
||||
if (solverLine) lines.push(solverLine);
|
||||
const diagnosticLine = formatUokDiagnosticWidgetLine(
|
||||
accessors.getBasePath(),
|
||||
theme,
|
||||
width,
|
||||
pad,
|
||||
);
|
||||
if (diagnosticLine) lines.push(diagnosticLine);
|
||||
lines.push("");
|
||||
// Two-column body
|
||||
const minTwoColWidth = 76;
|
||||
|
|
|
|||
|
|
@ -96,20 +96,6 @@ import {
|
|||
} from "./uok/unit-runtime.js";
|
||||
import { logError, logWarning } from "./workflow-logger.js";
|
||||
|
||||
function safeSetWidget(ctx, key, content, options) {
|
||||
try {
|
||||
ctx?.ui?.setWidget?.(key, content, options);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logWarning(
|
||||
"ui",
|
||||
`setWidget(${key}) failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ file: "auto-start.ts" },
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
import {
|
||||
captureIntegrationBranch,
|
||||
detectWorktreeName,
|
||||
|
|
@ -1082,7 +1068,7 @@ export async function bootstrapAutoSession(
|
|||
ctx.ui.setFooter(hideFooter);
|
||||
// Hide sf-health during AUTO — sf-progress is the single source of truth
|
||||
// for last-commit / cost / health signal while auto is running.
|
||||
safeSetWidget(ctx, "sf-health", undefined);
|
||||
ctx.ui.setWidget("sf-health", undefined);
|
||||
const modeLabel = s.stepMode ? "Step-mode" : "Auto-mode";
|
||||
const pendingCount = (state.registry ?? []).filter(
|
||||
(m) => m.status !== "complete" && m.status !== "parked",
|
||||
|
|
|
|||
|
|
@ -25,8 +25,11 @@ import {
|
|||
import { isMilestoneComplete } from "./state.js";
|
||||
import { isClosedStatus } from "./status-guards.js";
|
||||
import { parseUnitId } from "./unit-id.js";
|
||||
import { CostGuardGate } from "./uok/cost-guard-gate.js";
|
||||
import { resolveUokFlags } from "./uok/flags.js";
|
||||
import { UokGateRunner } from "./uok/gate-runner.js";
|
||||
import { MultiPackageGate } from "./uok/multi-package-gate.js";
|
||||
import { OutcomeLearningGate } from "./uok/outcome-learning-gate.js";
|
||||
import { SecurityGate } from "./uok/security-gate.js";
|
||||
import { extractVerdict } from "./verdict-parser.js";
|
||||
import { writeVerificationJSON } from "./verification-evidence.js";
|
||||
|
|
@ -303,6 +306,57 @@ export async function runPostUnitVerification(vctx, pauseAuto) {
|
|||
result.securityFindings = secResult.findings;
|
||||
}
|
||||
}
|
||||
if (uokFlags.multiPackageHealing) {
|
||||
gateRunner.register(new MultiPackageGate());
|
||||
const mpResult = await gateRunner.run("multi-package-healing", {
|
||||
basePath: s.basePath,
|
||||
traceId: `multi-package-healing:${s.currentUnit.id}`,
|
||||
turnId: s.currentUnit.id,
|
||||
milestoneId: mid ?? undefined,
|
||||
sliceId: sid ?? undefined,
|
||||
taskId: tid ?? undefined,
|
||||
unitType: s.currentUnit.type,
|
||||
unitId: s.currentUnit.id,
|
||||
});
|
||||
if (mpResult.outcome === "fail") {
|
||||
result.passed = false;
|
||||
result.multiPackageFailure = true;
|
||||
result.multiPackageRationale = mpResult.rationale;
|
||||
result.multiPackageFindings = mpResult.findings;
|
||||
}
|
||||
}
|
||||
if (uokFlags.autonomousCostGuard) {
|
||||
gateRunner.register(new CostGuardGate());
|
||||
const cgResult = await gateRunner.run("cost-guard", {
|
||||
basePath: s.basePath,
|
||||
traceId: `cost-guard:${s.currentUnit.id}`,
|
||||
turnId: s.currentUnit.id,
|
||||
milestoneId: mid ?? undefined,
|
||||
sliceId: sid ?? undefined,
|
||||
taskId: tid ?? undefined,
|
||||
unitType: s.currentUnit.type,
|
||||
unitId: s.currentUnit.id,
|
||||
iteration: s.verificationRetryCount.get(s.currentUnit.id) ?? 0,
|
||||
});
|
||||
if (cgResult.outcome === "fail") {
|
||||
result.passed = false;
|
||||
result.costGuardFailure = true;
|
||||
result.costGuardRationale = cgResult.rationale;
|
||||
}
|
||||
}
|
||||
if (uokFlags.outcomeLearning) {
|
||||
gateRunner.register(new OutcomeLearningGate());
|
||||
await gateRunner.run("outcome-learning", {
|
||||
basePath: s.basePath,
|
||||
traceId: `outcome-learning:${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;
|
||||
|
|
@ -361,6 +415,29 @@ export async function runPostUnitVerification(vctx, pauseAuto) {
|
|||
process.stderr.write(`${result.securityFindings}\n`);
|
||||
}
|
||||
}
|
||||
// Log multi-package failures
|
||||
if (result.multiPackageFailure) {
|
||||
ctx.ui.notify(
|
||||
`[verify] MULTI-PACKAGE FAIL — ${result.multiPackageRationale}`,
|
||||
"error",
|
||||
);
|
||||
process.stderr.write(
|
||||
`verification-gate: multi-package healing failure: ${result.multiPackageRationale}\n`,
|
||||
);
|
||||
if (result.multiPackageFindings) {
|
||||
process.stderr.write(`${result.multiPackageFindings}\n`);
|
||||
}
|
||||
}
|
||||
// Log cost-guard failures
|
||||
if (result.costGuardFailure) {
|
||||
ctx.ui.notify(
|
||||
`[verify] COST-GUARD FAIL — ${result.costGuardRationale}`,
|
||||
"error",
|
||||
);
|
||||
process.stderr.write(
|
||||
`verification-gate: cost-guard failure: ${result.costGuardRationale}\n`,
|
||||
);
|
||||
}
|
||||
// Write verification evidence JSON
|
||||
const attempt = s.verificationRetryCount.get(s.currentUnit.id) ?? 0;
|
||||
if (mid && sid && tid) {
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@ import {
|
|||
captureAvailableSkills,
|
||||
resetSkillTelemetry,
|
||||
} from "./skill-telemetry.js";
|
||||
import { writeUokDiagnostics } from "./uok/diagnostic-synthesis.js";
|
||||
import { resolveUokFlags } from "./uok/flags.js";
|
||||
import {
|
||||
recordUokKernelTermination,
|
||||
|
|
@ -188,20 +189,6 @@ import {
|
|||
} from "./worktree.js";
|
||||
import { WorktreeResolver } from "./worktree-resolver.js";
|
||||
|
||||
function safeSetWidget(ctx, key, content, options) {
|
||||
try {
|
||||
ctx?.ui?.setWidget?.(key, content, options);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logWarning(
|
||||
"ui",
|
||||
`setWidget(${key}) failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ file: "auto.ts" },
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
MAX_LIFETIME_DISPATCHES,
|
||||
MAX_UNIT_DISPATCHES,
|
||||
|
|
@ -699,7 +686,7 @@ function handleLostSessionLock(ctx, lockStatus) {
|
|||
: `Session lock lost (${lockFilePath}). Stopping gracefully.${recoverySuggestion}`;
|
||||
ctx?.ui.notify(message, "error");
|
||||
ctx?.ui.setStatus("sf-auto", undefined);
|
||||
safeSetWidget(ctx, "sf-progress", undefined);
|
||||
ctx?.ui?.setWidget?.("sf-progress", undefined);
|
||||
ctx?.ui.setFooter(undefined);
|
||||
if (ctx) initHealthWidget(ctx);
|
||||
}
|
||||
|
|
@ -735,7 +722,7 @@ function cleanupAfterLoopExit(ctx) {
|
|||
// visible so the user still has a resumable auto-mode signal on screen.
|
||||
if (!s.paused) {
|
||||
ctx.ui.setStatus("sf-auto", undefined);
|
||||
safeSetWidget(ctx, "sf-progress", undefined);
|
||||
ctx.ui.setWidget("sf-progress", undefined);
|
||||
ctx.ui.setFooter(undefined);
|
||||
initHealthWidget(ctx);
|
||||
}
|
||||
|
|
@ -1075,7 +1062,7 @@ export async function stopAuto(ctx, pi, reason) {
|
|||
resetProactiveHealing();
|
||||
// UI cleanup
|
||||
ctx?.ui.setStatus("sf-auto", undefined);
|
||||
safeSetWidget(ctx, "sf-progress", undefined);
|
||||
ctx?.ui?.setWidget?.("sf-progress", undefined);
|
||||
ctx?.ui.setFooter(undefined);
|
||||
if (ctx) initHealthWidget(ctx);
|
||||
restoreProjectRootEnv();
|
||||
|
|
@ -1225,7 +1212,7 @@ export async function pauseAuto(ctx, _pi, _errorContext) {
|
|||
s.pendingVerificationRetry = null;
|
||||
s.verificationRetryCount.clear();
|
||||
ctx?.ui.setStatus("sf-auto", "paused");
|
||||
safeSetWidget(ctx, "sf-progress", undefined);
|
||||
ctx?.ui?.setWidget?.("sf-progress", undefined);
|
||||
ctx?.ui.setFooter(undefined);
|
||||
if (ctx) initHealthWidget(ctx);
|
||||
const resumeCmd = s.stepMode ? "/sf next" : "/sf autonomous";
|
||||
|
|
@ -1294,6 +1281,7 @@ function buildLoopDeps() {
|
|||
loadEffectiveSFPreferences,
|
||||
// Pre-dispatch health gate
|
||||
preDispatchHealthGate,
|
||||
writeUokDiagnostics,
|
||||
// Worktree sync
|
||||
syncProjectRootToWorktree,
|
||||
// Resource version guard
|
||||
|
|
|
|||
|
|
@ -122,6 +122,40 @@ const MAX_SESSION_TIMEOUT_AUTO_RESUMES = 3;
|
|||
function resetConsecutiveSessionTimeouts() {
|
||||
consecutiveSessionTimeouts = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether the UOK diagnostics verdict may continue into dispatch.
|
||||
*
|
||||
* Purpose: turn durable UOK self-diagnostics into autonomous control, so SF
|
||||
* pauses on split-brain/runtime corruption before spending another LLM turn.
|
||||
*
|
||||
* Consumer: runDispatch before it starts the next autonomous unit.
|
||||
*/
|
||||
export function assessUokDiagnosticsDispatchGate(diagnostics) {
|
||||
if (!diagnostics) return { proceed: true };
|
||||
const blockingIssue = diagnostics.issues?.find(
|
||||
(issue) => issue?.severity === "error",
|
||||
);
|
||||
if (diagnostics.verdict !== "degraded" && !blockingIssue) {
|
||||
return { proceed: true };
|
||||
}
|
||||
const issueCode = blockingIssue?.code ?? diagnostics.issues?.[0]?.code;
|
||||
const reportPath =
|
||||
diagnostics.reportPath ?? ".sf/runtime/uok-diagnostics.json";
|
||||
const reason = [
|
||||
`UOK diagnostics blocked dispatch: ${diagnostics.verdict}/${diagnostics.classification ?? "unknown"}`,
|
||||
issueCode ? `issue ${issueCode}` : "",
|
||||
`evidence ${reportPath}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return {
|
||||
proceed: false,
|
||||
reason,
|
||||
issueCode,
|
||||
reportPath,
|
||||
};
|
||||
}
|
||||
// ─── generateMilestoneReport ──────────────────────────────────────────────────
|
||||
/**
|
||||
* Resolve the base path for milestone reports.
|
||||
|
|
@ -1046,6 +1080,48 @@ export async function runDispatch(ic, preData, loopState) {
|
|||
await new Promise((r) => setImmediate(r));
|
||||
return { action: "continue" };
|
||||
}
|
||||
try {
|
||||
const diagnostics = deps.writeUokDiagnostics?.(s.basePath, {
|
||||
expectedNext: dispatchResult,
|
||||
});
|
||||
const gate = assessUokDiagnosticsDispatchGate(diagnostics);
|
||||
deps.emitJournalEvent({
|
||||
ts: new Date().toISOString(),
|
||||
flowId: ic.flowId,
|
||||
seq: ic.nextSeq(),
|
||||
eventType: "uok-diagnostics-dispatch-gate",
|
||||
data: {
|
||||
verdict: diagnostics?.verdict ?? "unknown",
|
||||
classification: diagnostics?.classification ?? "unknown",
|
||||
proceed: gate.proceed,
|
||||
issueCode: gate.issueCode,
|
||||
reportPath: gate.reportPath ?? diagnostics?.reportPath,
|
||||
},
|
||||
});
|
||||
if (!gate.proceed) {
|
||||
await runPreDispatchGate({
|
||||
gateId: "uok-diagnostics-dispatch-gate",
|
||||
gateType: "execution",
|
||||
outcome: "manual-attention",
|
||||
failureClass: "manual-attention",
|
||||
rationale: "uok diagnostics blocked dispatch",
|
||||
findings: gate.reason,
|
||||
milestoneId: mid,
|
||||
});
|
||||
ctx.ui.notify(gate.reason, "error");
|
||||
await deps.pauseAuto(ctx, pi);
|
||||
debugLog("autoLoop", {
|
||||
phase: "exit",
|
||||
reason: "uok-diagnostics-pause",
|
||||
issueCode: gate.issueCode,
|
||||
});
|
||||
return { action: "break", reason: "uok-diagnostics-pause" };
|
||||
}
|
||||
} catch (err) {
|
||||
logWarning("engine", "UOK diagnostics dispatch gate failed open", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
deps.emitJournalEvent({
|
||||
ts: new Date().toISOString(),
|
||||
flowId: ic.flowId,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { join } from "node:path";
|
|||
import { ensureDbOpen } from "./bootstrap/dynamic-tools.js";
|
||||
import { sfRoot } from "./paths.js";
|
||||
import { getUokRuns, isDbAvailable } from "./sf-db.js";
|
||||
import { writeUokDiagnostics } from "./uok/diagnostic-synthesis.js";
|
||||
import {
|
||||
summarizeParityHealth,
|
||||
writeParityReport,
|
||||
|
|
@ -83,17 +84,25 @@ export async function collectUokStatus(
|
|||
current.criticalMismatches > 0 ||
|
||||
current.missingExitEvents > 0 ||
|
||||
current.errorEvents > 0;
|
||||
let diagnostics = null;
|
||||
try {
|
||||
diagnostics = writeUokDiagnostics(basePath, { nowMs });
|
||||
} catch {
|
||||
diagnostics = null;
|
||||
}
|
||||
return {
|
||||
dbAvailable,
|
||||
generatedAt: new Date(nowMs).toISOString(),
|
||||
startupBlocked,
|
||||
healthStatus: startupBlocked ? "degraded" : "ok",
|
||||
healthStatus:
|
||||
startupBlocked || diagnostics?.verdict === "degraded" ? "degraded" : "ok",
|
||||
ledgerRunCount: report?.ledgerRunCount ?? runs.length,
|
||||
recentRuns: runs,
|
||||
lastRun,
|
||||
lastErrorRun,
|
||||
current,
|
||||
historical,
|
||||
diagnostics,
|
||||
reportPath: join(sfRoot(basePath), "runtime", "uok-parity-report.json"),
|
||||
};
|
||||
}
|
||||
|
|
@ -104,6 +113,19 @@ export function formatUokStatus(status, nowMs = Date.now()) {
|
|||
lines.push(`Startup gate: ${status.startupBlocked ? "blocked" : "clear"}`);
|
||||
lines.push(`DB ledger: ${status.dbAvailable ? "available" : "unavailable"}`);
|
||||
lines.push(`Ledger runs: ${status.ledgerRunCount}`);
|
||||
if (status.diagnostics) {
|
||||
lines.push(
|
||||
`Diagnostics: ${status.diagnostics.verdict} (${status.diagnostics.classification})`,
|
||||
);
|
||||
if (status.diagnostics.currentUnit) {
|
||||
const unit = status.diagnostics.currentUnit;
|
||||
lines.push(
|
||||
`Current unit: ${unit.unitType ?? "unknown"} ${unit.unitId ?? "unknown"} pid ${unit.pid ?? "unknown"}`,
|
||||
);
|
||||
}
|
||||
const firstIssue = status.diagnostics.issues?.[0];
|
||||
if (firstIssue) lines.push(`Diagnostic issue: ${firstIssue.code}`);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("Current:");
|
||||
lines.push(` critical mismatches: ${status.current.criticalMismatches}`);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
import { setSessionModelOverride } from "../../session-model-override.js";
|
||||
import { formattedShortcutPair } from "../../shortcut-defs.js";
|
||||
import { deriveState } from "../../state.js";
|
||||
import { writeUokDiagnostics } from "../../uok/diagnostic-synthesis.js";
|
||||
import { projectRoot } from "../context.js";
|
||||
export function showHelp(ctx, args = "") {
|
||||
const summaryLines = [
|
||||
|
|
@ -540,6 +541,16 @@ export function formatTextStatus(state) {
|
|||
if (state.blockers.length > 0) {
|
||||
lines.push(`Blockers: ${state.blockers.join("; ")}`);
|
||||
}
|
||||
try {
|
||||
const diagnostics = writeUokDiagnostics(projectRoot());
|
||||
lines.push(
|
||||
`UOK diagnostics: ${diagnostics.verdict} (${diagnostics.classification})`,
|
||||
);
|
||||
const firstIssue = diagnostics.issues?.[0];
|
||||
if (firstIssue) lines.push(`UOK issue: ${firstIssue.code}`);
|
||||
} catch {
|
||||
// Status text must stay available even when diagnostics cannot be written.
|
||||
}
|
||||
if (state.registry.length > 0) {
|
||||
lines.push("");
|
||||
lines.push("Milestones:");
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import { computeProgressScore } from "./progress-score.js";
|
|||
import { getMilestoneSlices, getSliceTasks, isDbAvailable } from "./sf-db.js";
|
||||
import { formattedShortcutPair } from "./shortcut-defs.js";
|
||||
import { deriveState } from "./state.js";
|
||||
import { writeUokDiagnostics } from "./uok/diagnostic-synthesis.js";
|
||||
import { getActiveWorktreeName } from "./worktree-command.js";
|
||||
|
||||
function unitLabel(type) {
|
||||
|
|
@ -89,6 +90,7 @@ export class SFDashboardOverlay {
|
|||
scrollOffset = 0;
|
||||
dashData;
|
||||
milestoneData = null;
|
||||
uokDiagnostics = null;
|
||||
loading = true;
|
||||
loadedDashboardIdentity;
|
||||
refreshInFlight = null;
|
||||
|
|
@ -149,6 +151,11 @@ export class SFDashboardOverlay {
|
|||
async loadData() {
|
||||
const base = this.dashData.basePath || process.cwd();
|
||||
try {
|
||||
try {
|
||||
this.uokDiagnostics = writeUokDiagnostics(base);
|
||||
} catch {
|
||||
this.uokDiagnostics = null;
|
||||
}
|
||||
const state = await deriveState(base);
|
||||
if (!state.activeMilestone) {
|
||||
this.milestoneData = null;
|
||||
|
|
@ -375,6 +382,23 @@ export class SFDashboardOverlay {
|
|||
}
|
||||
}
|
||||
}
|
||||
if (this.uokDiagnostics) {
|
||||
const diagnosticColor =
|
||||
this.uokDiagnostics.verdict === "degraded"
|
||||
? "error"
|
||||
: this.uokDiagnostics.verdict === "attention"
|
||||
? "warning"
|
||||
: "dim";
|
||||
const issue = this.uokDiagnostics.issues?.[0]?.code;
|
||||
const label = [
|
||||
`UOK ${this.uokDiagnostics.verdict}`,
|
||||
this.uokDiagnostics.classification,
|
||||
issue,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
lines.push(row(th.fg(diagnosticColor, label)));
|
||||
}
|
||||
lines.push(blank());
|
||||
if (this.dashData.currentUnit) {
|
||||
const cu = this.dashData.currentUnit;
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import {
|
|||
removeSessionStatus,
|
||||
} from "./session-status-io.js";
|
||||
import { deriveState } from "./state.js";
|
||||
import { writeUokDiagnostics } from "./uok/diagnostic-synthesis.js";
|
||||
import { listUnitRuntimeRecords } from "./uok/unit-runtime.js";
|
||||
import { getAuditEmitFailureCount } from "./workflow-logger.js";
|
||||
|
||||
|
|
@ -139,6 +140,24 @@ export async function checkRuntimeHealth(
|
|||
} catch {
|
||||
// Non-fatal — runtime unit cleanup should not block doctor.
|
||||
}
|
||||
// ── UOK self-diagnostics ─────────────────────────────────────────────
|
||||
try {
|
||||
const diagnostics = writeUokDiagnostics(basePath);
|
||||
if (diagnostics.verdict !== "clear") {
|
||||
const firstIssue = diagnostics.issues?.[0];
|
||||
issues.push({
|
||||
severity: diagnostics.verdict === "degraded" ? "error" : "warning",
|
||||
code: "uok_diagnostics_degraded",
|
||||
scope: "project",
|
||||
unitId: diagnostics.currentUnit?.unitId ?? "project",
|
||||
message: `UOK diagnostics report ${diagnostics.verdict}/${diagnostics.classification}${firstIssue ? ` (${firstIssue.code})` : ""}. Evidence: ${diagnostics.reportPath}`,
|
||||
file: ".sf/runtime/uok-diagnostics.json",
|
||||
fixable: false,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — doctor should still report direct runtime checks.
|
||||
}
|
||||
// ── Stranded lock directory ────────────────────────────────────────────
|
||||
// proper-lockfile creates a `.sf.lock/` directory as the OS-level lock
|
||||
// mechanism. If the process was SIGKILLed or crashed hard, this directory
|
||||
|
|
|
|||
|
|
@ -92,14 +92,7 @@ function loadHealthWidgetData(basePath) {
|
|||
}
|
||||
// ── Widget init ────────────────────────────────────────────────────────────────
|
||||
const REFRESH_INTERVAL_MS = 60_000;
|
||||
function safeSetWidget(ctx, key, content, options) {
|
||||
try {
|
||||
ctx.ui?.setWidget?.(key, content, options);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the always-on sf-health widget (belowEditor).
|
||||
* Call once from the extension entry point after context is available.
|
||||
|
|
@ -107,18 +100,16 @@ function safeSetWidget(ctx, key, content, options) {
|
|||
export function initHealthWidget(ctx) {
|
||||
if (!ctx.hasUI) return;
|
||||
const basePath = projectRoot();
|
||||
// String-array fallback — used in RPC mode (factory is a no-op there)
|
||||
const initialData = loadHealthWidgetData(basePath);
|
||||
if (
|
||||
!safeSetWidget(ctx, "sf-health", buildHealthLines(initialData), {
|
||||
placement: "belowEditor",
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Factory-based widget for TUI mode — replaces the string-array above
|
||||
safeSetWidget(
|
||||
ctx,
|
||||
|
||||
// String-array fallback — used in RPC mode (factory is a no-op there).
|
||||
// The factory call below overwrites this when the host supports factories.
|
||||
ctx.ui.setWidget("sf-health", buildHealthLines(initialData), {
|
||||
placement: "belowEditor",
|
||||
});
|
||||
|
||||
// Factory-based widget for TUI mode — replaces the string-array above.
|
||||
ctx.ui.setWidget(
|
||||
"sf-health",
|
||||
(_tui, _theme) => {
|
||||
let data = initialData;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
onNotificationStoreChange,
|
||||
} from "./notification-store.js";
|
||||
import { formattedShortcutPair } from "./shortcut-defs.js";
|
||||
// ─── Pure rendering ──<EFBFBD><EFBFBD><EFBFBD>────────────────────────<EFBFBD><EFBFBD><EFBFBD>─────────────────────────
|
||||
// ─── Pure rendering ─────────────────────────────────────────────────────
|
||||
/**
|
||||
* Build the notification widget UI lines. Returns empty array if no unread
|
||||
* notifications; otherwise shows unread count and keyboard shortcut hint.
|
||||
|
|
@ -21,31 +21,22 @@ export function buildNotificationWidgetLines() {
|
|||
}
|
||||
// ─── Widget init ────────────────────────────────────────────────────────
|
||||
const REFRESH_INTERVAL_MS = 30_000;
|
||||
function safeSetWidget(ctx, key, content, options) {
|
||||
try {
|
||||
ctx.ui?.setWidget?.(key, content, options);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the always-on notification widget (belowEditor).
|
||||
* Call once from session_start after the notification store is initialized.
|
||||
*/
|
||||
export function initNotificationWidget(ctx) {
|
||||
if (!ctx.hasUI) return;
|
||||
// String-array fallback for RPC mode
|
||||
if (
|
||||
!safeSetWidget(ctx, "sf-notifications", buildNotificationWidgetLines(), {
|
||||
placement: "belowEditor",
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// String-array fallback for RPC mode.
|
||||
// The factory call below overwrites this when the host supports factories.
|
||||
ctx.ui.setWidget("sf-notifications", buildNotificationWidgetLines(), {
|
||||
placement: "belowEditor",
|
||||
});
|
||||
|
||||
// Factory-based widget for TUI mode
|
||||
safeSetWidget(
|
||||
ctx,
|
||||
ctx.ui.setWidget(
|
||||
"sf-notifications",
|
||||
(_tui, _theme) => {
|
||||
let cachedLines;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "vitest";
|
||||
import { assessUokDiagnosticsDispatchGate } from "../auto/phases.js";
|
||||
|
||||
test("assessUokDiagnosticsDispatchGate_when_clear_allows_dispatch", () => {
|
||||
const result = assessUokDiagnosticsDispatchGate({
|
||||
verdict: "clear",
|
||||
classification: "healthy",
|
||||
issues: [],
|
||||
reportPath: "/repo/.sf/runtime/uok-diagnostics.json",
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { proceed: true });
|
||||
});
|
||||
|
||||
test("assessUokDiagnosticsDispatchGate_when_attention_warning_allows_dispatch", () => {
|
||||
const result = assessUokDiagnosticsDispatchGate({
|
||||
verdict: "attention",
|
||||
classification: "degraded",
|
||||
issues: [{ code: "uok-parity-degraded", severity: "warning" }],
|
||||
reportPath: "/repo/.sf/runtime/uok-diagnostics.json",
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { proceed: true });
|
||||
});
|
||||
|
||||
test("assessUokDiagnosticsDispatchGate_when_degraded_blocks_with_evidence", () => {
|
||||
const result = assessUokDiagnosticsDispatchGate({
|
||||
verdict: "degraded",
|
||||
classification: "needs-repair",
|
||||
issues: [{ code: "stale-runtime-projection", severity: "error" }],
|
||||
reportPath: "/repo/.sf/runtime/uok-diagnostics.json",
|
||||
});
|
||||
|
||||
assert.equal(result.proceed, false);
|
||||
assert.equal(result.issueCode, "stale-runtime-projection");
|
||||
assert.equal(result.reportPath, "/repo/.sf/runtime/uok-diagnostics.json");
|
||||
assert.match(result.reason, /UOK diagnostics blocked dispatch/);
|
||||
assert.match(result.reason, /stale-runtime-projection/);
|
||||
assert.match(result.reason, /uok-diagnostics\.json/);
|
||||
});
|
||||
|
|
@ -140,7 +140,10 @@ describe("schedule-e2e round-trip", () => {
|
|||
});
|
||||
|
||||
it("isolates scopes: two project stores do not see each other’s entries", () => {
|
||||
const testDir2 = join(tmpdir(), `sf-schedule-e2e-2-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
||||
const testDir2 = join(
|
||||
tmpdir(),
|
||||
`sf-schedule-e2e-2-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
);
|
||||
mkdirSync(testDir2, { recursive: true });
|
||||
const store2 = createScheduleStore(testDir2);
|
||||
|
||||
|
|
@ -172,4 +175,28 @@ describe("schedule-e2e round-trip", () => {
|
|||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
it("1000-entry loadEntries completes within threshold", () => {
|
||||
const count = 1000;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const entry = makeEntry({
|
||||
due_at: "2020-01-01T00:00:00.000Z",
|
||||
status: "pending",
|
||||
payload: { message: `entry ${i}` },
|
||||
});
|
||||
store.appendEntry("project", entry);
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
const entries = store.readEntries("project");
|
||||
const elapsed = performance.now() - start;
|
||||
|
||||
assert.equal(entries.length, count);
|
||||
|
||||
const thresholdMs = process.env.CI ? 200 : 50;
|
||||
assert.ok(
|
||||
elapsed < thresholdMs,
|
||||
`Expected readEntries(${count}) to complete in <${thresholdMs}ms, took ${elapsed.toFixed(2)}ms`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,242 @@
|
|||
import assert from "node:assert/strict";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, test } from "vitest";
|
||||
import {
|
||||
closeDatabase,
|
||||
openDatabase,
|
||||
recordUokRunExit,
|
||||
recordUokRunStart,
|
||||
} from "../sf-db.js";
|
||||
import {
|
||||
readUokDiagnostics,
|
||||
synthesizeUokDiagnostics,
|
||||
writeUokDiagnostics,
|
||||
} from "../uok/diagnostic-synthesis.js";
|
||||
import { writeUnitRuntimeRecord } from "../uok/unit-runtime.js";
|
||||
|
||||
const NOW = Date.parse("2026-05-06T00:00:00.000Z");
|
||||
const tmpRoots = [];
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
for (const dir of tmpRoots.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function makeProject() {
|
||||
const root = mkdtempSync(join(tmpdir(), "sf-uok-diagnostics-"));
|
||||
tmpRoots.push(root);
|
||||
mkdirSync(join(root, ".sf", "runtime", "units"), { recursive: true });
|
||||
return root;
|
||||
}
|
||||
|
||||
function writeAutoLock(root, lock) {
|
||||
writeFileSync(
|
||||
join(root, ".sf", "auto.lock"),
|
||||
`${JSON.stringify(lock, null, 2)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
|
||||
function issueCodes(diagnostics) {
|
||||
return diagnostics.issues.map((issue) => issue.code);
|
||||
}
|
||||
|
||||
test("synthesizeUokDiagnostics_when_lock_pid_dead_reports_stale_lock", () => {
|
||||
const root = makeProject();
|
||||
writeAutoLock(root, {
|
||||
pid: 999_999_999,
|
||||
startedAt: new Date(NOW - 60_000).toISOString(),
|
||||
unitType: "execute-task",
|
||||
unitId: "M010/S08/T08.1",
|
||||
unitStartedAt: new Date(NOW - 60_000).toISOString(),
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
});
|
||||
|
||||
const diagnostics = synthesizeUokDiagnostics(root, {
|
||||
nowMs: NOW,
|
||||
processRows: [],
|
||||
});
|
||||
|
||||
assert.equal(diagnostics.verdict, "degraded");
|
||||
assert.equal(diagnostics.signals.lock, "stale");
|
||||
assert.ok(issueCodes(diagnostics).includes("stale-lock"));
|
||||
assert.equal(diagnostics.currentUnit.unitId, "M010/S08/T08.1");
|
||||
});
|
||||
|
||||
test("synthesizeUokDiagnostics_when_live_lock_exists_reports_running_and_children", () => {
|
||||
const root = makeProject();
|
||||
writeAutoLock(root, {
|
||||
pid: process.pid,
|
||||
startedAt: new Date(NOW - 10_000).toISOString(),
|
||||
unitType: "execute-task",
|
||||
unitId: "M010/S08/T08.2",
|
||||
unitStartedAt: new Date(NOW - 10_000).toISOString(),
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
});
|
||||
|
||||
const diagnostics = synthesizeUokDiagnostics(root, {
|
||||
nowMs: NOW,
|
||||
processRows: [
|
||||
{
|
||||
pid: process.pid,
|
||||
ppid: 1,
|
||||
stat: "S",
|
||||
command: "node dist/loader.js autonomous",
|
||||
},
|
||||
{ pid: 12345, ppid: process.pid, stat: "S", command: "bash" },
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(diagnostics.verdict, "clear");
|
||||
assert.equal(diagnostics.classification, "running");
|
||||
assert.equal(diagnostics.signals.lock, "active");
|
||||
assert.deepEqual(diagnostics.currentUnit.childPids, [12345]);
|
||||
});
|
||||
|
||||
test("synthesizeUokDiagnostics_when_ledger_open_without_lock_reports_orphaned_run", () => {
|
||||
const root = makeProject();
|
||||
openDatabase(":memory:");
|
||||
recordUokRunStart({
|
||||
runId: "uok-orphan",
|
||||
sessionId: "session-orphan",
|
||||
path: "uok-kernel",
|
||||
flags: { enabled: true },
|
||||
startedAt: new Date(NOW - 10_000).toISOString(),
|
||||
});
|
||||
|
||||
const diagnostics = synthesizeUokDiagnostics(root, {
|
||||
nowMs: NOW,
|
||||
processRows: [],
|
||||
});
|
||||
|
||||
assert.equal(diagnostics.verdict, "degraded");
|
||||
assert.equal(diagnostics.signals.ledger, "open-runs");
|
||||
assert.ok(issueCodes(diagnostics).includes("open-ledger-without-live-lock"));
|
||||
});
|
||||
|
||||
test("synthesizeUokDiagnostics_when_parity_missing_exit_reports_current_warning", () => {
|
||||
const root = makeProject();
|
||||
writeFileSync(
|
||||
join(root, ".sf", "runtime", "uok-parity.jsonl"),
|
||||
`${JSON.stringify({
|
||||
ts: new Date(NOW - 5_000).toISOString(),
|
||||
runId: "uok-missing-exit",
|
||||
path: "uok-kernel",
|
||||
phase: "enter",
|
||||
})}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const diagnostics = synthesizeUokDiagnostics(root, {
|
||||
nowMs: NOW,
|
||||
processRows: [],
|
||||
});
|
||||
|
||||
assert.equal(diagnostics.verdict, "attention");
|
||||
assert.equal(diagnostics.signals.parity, "degraded");
|
||||
assert.ok(issueCodes(diagnostics).includes("uok-parity-degraded"));
|
||||
});
|
||||
|
||||
test("synthesizeUokDiagnostics_when_kernel_exited_but_wrapper_lives_reports_wrapper", () => {
|
||||
const root = makeProject();
|
||||
openDatabase(":memory:");
|
||||
recordUokRunStart({
|
||||
runId: "uok-wrapper",
|
||||
sessionId: "session-wrapper",
|
||||
path: "uok-kernel",
|
||||
flags: { enabled: true },
|
||||
startedAt: new Date(NOW - 10_000).toISOString(),
|
||||
});
|
||||
recordUokRunExit({
|
||||
runId: "uok-wrapper",
|
||||
sessionId: "session-wrapper",
|
||||
path: "uok-kernel",
|
||||
flags: { enabled: true },
|
||||
status: "ok",
|
||||
endedAt: new Date(NOW - 5_000).toISOString(),
|
||||
});
|
||||
|
||||
const diagnostics = synthesizeUokDiagnostics(root, {
|
||||
nowMs: NOW,
|
||||
processRows: [
|
||||
{
|
||||
pid: 4242,
|
||||
ppid: 1,
|
||||
stat: "S",
|
||||
command: "sf autonomous",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(diagnostics.verdict, "attention");
|
||||
assert.equal(diagnostics.signals.wrapper, "maybe-live-after-kernel-exit");
|
||||
assert.ok(
|
||||
issueCodes(diagnostics).includes("kernel-exited-wrapper-maybe-live"),
|
||||
);
|
||||
});
|
||||
|
||||
test("synthesizeUokDiagnostics_when_db_next_differs_from_projection_reports_mismatch", () => {
|
||||
const root = makeProject();
|
||||
writeUnitRuntimeRecord(root, "execute-task", "M010/S08/T08.1", NOW - 10_000, {
|
||||
status: "running",
|
||||
lastHeartbeatAt: NOW - 5_000,
|
||||
lastProgressAt: NOW - 5_000,
|
||||
});
|
||||
|
||||
const diagnostics = synthesizeUokDiagnostics(root, {
|
||||
nowMs: NOW,
|
||||
processRows: [],
|
||||
expectedNext: {
|
||||
action: "dispatch",
|
||||
unitType: "execute-task",
|
||||
unitId: "M010/S08/T08.2",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(diagnostics.signals.runtimeProjection, "mismatch");
|
||||
assert.ok(issueCodes(diagnostics).includes("db-projection-unit-mismatch"));
|
||||
});
|
||||
|
||||
test("writeUokDiagnostics_persists_report_for_status_widget_and_doctor", () => {
|
||||
const root = makeProject();
|
||||
openDatabase(":memory:");
|
||||
recordUokRunStart({
|
||||
runId: "uok-ok",
|
||||
sessionId: "session-ok",
|
||||
path: "uok-kernel",
|
||||
flags: { enabled: true },
|
||||
startedAt: new Date(NOW - 10_000).toISOString(),
|
||||
});
|
||||
recordUokRunExit({
|
||||
runId: "uok-ok",
|
||||
sessionId: "session-ok",
|
||||
path: "uok-kernel",
|
||||
flags: { enabled: true },
|
||||
status: "ok",
|
||||
endedAt: new Date(NOW - 5_000).toISOString(),
|
||||
});
|
||||
|
||||
const diagnostics = writeUokDiagnostics(root, {
|
||||
nowMs: NOW,
|
||||
processRows: [],
|
||||
});
|
||||
const reportPath = join(root, ".sf", "runtime", "uok-diagnostics.json");
|
||||
|
||||
assert.equal(existsSync(reportPath), true);
|
||||
assert.deepEqual(
|
||||
readUokDiagnostics(root),
|
||||
JSON.parse(readFileSync(reportPath, "utf-8")),
|
||||
);
|
||||
assert.equal(diagnostics.reportPath, reportPath);
|
||||
});
|
||||
|
|
@ -54,6 +54,7 @@ test("collectUokStatus_reads_ledger_and_reports_clear_startup_gate", async () =>
|
|||
assert.equal(status.healthStatus, "ok");
|
||||
assert.equal(status.ledgerRunCount, 1);
|
||||
assert.equal(status.lastRun.runId, "uok-status-ok");
|
||||
assert.equal(status.diagnostics.verdict, "clear");
|
||||
assert.equal(status.current.criticalMismatches, 0);
|
||||
assert.equal(status.current.missingExitEvents, 0);
|
||||
assert.equal(status.current.errorEvents, 0);
|
||||
|
|
@ -114,6 +115,11 @@ test("formatUokStatus_shows_operator_fields_without_raw_json", () => {
|
|||
error: "ledger boom",
|
||||
endedAt: new Date(NOW - 5_000).toISOString(),
|
||||
},
|
||||
diagnostics: {
|
||||
verdict: "degraded",
|
||||
classification: "needs-repair",
|
||||
issues: [{ code: "open-ledger-without-live-lock" }],
|
||||
},
|
||||
reportPath: "/repo/.sf/runtime/uok-parity-report.json",
|
||||
},
|
||||
NOW,
|
||||
|
|
@ -122,6 +128,8 @@ test("formatUokStatus_shows_operator_fields_without_raw_json", () => {
|
|||
assert.match(rendered, /UOK status/);
|
||||
assert.match(rendered, /Startup gate: blocked/);
|
||||
assert.match(rendered, /Ledger runs: 2/);
|
||||
assert.match(rendered, /Diagnostics: degraded \(needs-repair\)/);
|
||||
assert.match(rendered, /Diagnostic issue: open-ledger-without-live-lock/);
|
||||
assert.match(rendered, /Last run:/);
|
||||
assert.match(rendered, /Last error:/);
|
||||
assert.match(rendered, /ledger boom/);
|
||||
|
|
|
|||
23
src/resources/extensions/sf/uok/chaos-monkey.js
Normal file
23
src/resources/extensions/sf/uok/chaos-monkey.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* UOK Chaos Monkey
|
||||
*
|
||||
* Designed to stress-test the kernel's durability and "Parity Heartbeat" recovery mechanisms.
|
||||
* When enabled, it randomly injects fatal process signals during critical lifecycle phases.
|
||||
*/
|
||||
export class ChaosMonkey {
|
||||
constructor(probability = 0.05) {
|
||||
this.probability = probability;
|
||||
this.active = true;
|
||||
}
|
||||
|
||||
strike(phase) {
|
||||
if (!this.active) return;
|
||||
|
||||
if (Math.random() < this.probability) {
|
||||
console.error(
|
||||
`\n[CHAOS MONKEY] Striking during UOK phase: ${phase}. Simulating catastrophic process failure...`,
|
||||
);
|
||||
process.kill(process.pid, "SIGKILL");
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/resources/extensions/sf/uok/cost-guard-gate.js
Normal file
36
src/resources/extensions/sf/uok/cost-guard-gate.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* UOK Autonomous Cost-Guard Gate
|
||||
*
|
||||
* Prevents "money burning" by detecting repeated failures with expensive models.
|
||||
* If a task fails verification twice with a high-tier model, this gate
|
||||
* forces a model downgrade or a "sanity check" with a different provider.
|
||||
*/
|
||||
export class CostGuardGate {
|
||||
constructor() {
|
||||
this.id = "cost-guard";
|
||||
this.type = "policy";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("./contracts.js").UokContext} ctx
|
||||
*/
|
||||
async execute(ctx) {
|
||||
const retryCount = ctx.iteration || 0;
|
||||
const currentModel = ctx.modelId || "unknown";
|
||||
|
||||
// If we've failed twice with a high-tier model (mock detection)
|
||||
if (retryCount >= 2 && currentModel.includes("gpt-4")) {
|
||||
return {
|
||||
outcome: "fail",
|
||||
failureClass: "policy",
|
||||
rationale: `Cost-Guard blocked ${currentModel}: 2+ consecutive failures. Downgrading to optimize cost.`,
|
||||
findings: "Recommended: Switch to Haiku or Flash for remediation.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
outcome: "pass",
|
||||
rationale: "Cost budget and model tier within safe limits.",
|
||||
};
|
||||
}
|
||||
}
|
||||
363
src/resources/extensions/sf/uok/diagnostic-synthesis.js
Normal file
363
src/resources/extensions/sf/uok/diagnostic-synthesis.js
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { isLockProcessAlive, readCrashLock } from "../crash-recovery.js";
|
||||
import { sfRoot } from "../paths.js";
|
||||
import { getUokRuns, isDbAvailable } from "../sf-db.js";
|
||||
import { summarizeParityHealth, writeParityReport } from "./parity-report.js";
|
||||
import {
|
||||
decideUnitRuntimeDispatch,
|
||||
getUnitRuntimeState,
|
||||
isTerminalUnitRuntimeStatus,
|
||||
listUnitRuntimeRecords,
|
||||
} from "./unit-runtime.js";
|
||||
|
||||
const DEFAULT_STALE_MS = 2 * 60 * 1000;
|
||||
|
||||
function diagnosticsPath(basePath) {
|
||||
return join(sfRoot(basePath), "runtime", "uok-diagnostics.json");
|
||||
}
|
||||
|
||||
function parsePsRows(raw) {
|
||||
return String(raw ?? "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const match = line.match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
pid: Number(match[1]),
|
||||
ppid: Number(match[2]),
|
||||
stat: match[3],
|
||||
command: match[4],
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function readProcessRows() {
|
||||
try {
|
||||
return parsePsRows(
|
||||
execFileSync("ps", ["-eo", "pid=,ppid=,stat=,args="], {
|
||||
encoding: "utf-8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function descendantPids(rows, pid) {
|
||||
const children = new Map();
|
||||
for (const row of rows) {
|
||||
const list = children.get(row.ppid) ?? [];
|
||||
list.push(row.pid);
|
||||
children.set(row.ppid, list);
|
||||
}
|
||||
const result = [];
|
||||
const queue = [...(children.get(pid) ?? [])];
|
||||
while (queue.length > 0) {
|
||||
const next = queue.shift();
|
||||
result.push(next);
|
||||
queue.push(...(children.get(next) ?? []));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function classifyRuntimeRecord(record, hasLiveLock, nowMs, staleMs) {
|
||||
const state = getUnitRuntimeState(record);
|
||||
let status = state.status;
|
||||
const terminal = isTerminalUnitRuntimeStatus(status);
|
||||
if (!hasLiveLock && !terminal) status = "stale";
|
||||
const lastProgressAt =
|
||||
state.lastProgressAt ?? record.updatedAt ?? record.startedAt;
|
||||
const lastHeartbeatAt =
|
||||
state.lastHeartbeatAt ?? record.updatedAt ?? record.startedAt;
|
||||
const lastSignalAt = Math.max(
|
||||
Number(lastProgressAt) || 0,
|
||||
Number(lastHeartbeatAt) || 0,
|
||||
Number(record.updatedAt) || 0,
|
||||
);
|
||||
const ageMs = lastSignalAt > 0 ? nowMs - lastSignalAt : null;
|
||||
let classification = "unknown";
|
||||
if (terminal) classification = "terminal";
|
||||
else if (!hasLiveLock) classification = "stale";
|
||||
else if (ageMs !== null && ageMs > staleMs)
|
||||
classification = "quiet-but-healthy";
|
||||
else classification = "running";
|
||||
return {
|
||||
unitType: String(record.unitType ?? ""),
|
||||
unitId: String(record.unitId ?? ""),
|
||||
status,
|
||||
phase: String(record.phase ?? "dispatched"),
|
||||
classification,
|
||||
projectionActive: !terminal,
|
||||
promptPath: record.promptPath ?? record.promptFile ?? null,
|
||||
promptHash: record.promptHash ?? null,
|
||||
toolSpansPath: record.toolSpansPath ?? record.tracePath ?? null,
|
||||
openToolSpans: Array.isArray(record.openToolSpans)
|
||||
? record.openToolSpans
|
||||
: [],
|
||||
lastHeartbeatAt: state.lastHeartbeatAt ?? null,
|
||||
lastProgressAt: state.lastProgressAt ?? null,
|
||||
lastOutputAt: state.lastOutputAt ?? null,
|
||||
outputPath: state.outputPath ?? null,
|
||||
watchdogReason: state.watchdogReason ?? null,
|
||||
dispatchDecision: decideUnitRuntimeDispatch(record),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeExpectedUnit(expectedNext) {
|
||||
if (!expectedNext || expectedNext.action !== "dispatch") return null;
|
||||
if (!expectedNext.unitType || !expectedNext.unitId) return null;
|
||||
return {
|
||||
unitType: String(expectedNext.unitType),
|
||||
unitId: String(expectedNext.unitId),
|
||||
};
|
||||
}
|
||||
|
||||
function latestEndedRun(runs) {
|
||||
return runs.find((run) => run.endedAt && run.status !== "started") ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a durable UOK health verdict from process, parity, ledger, and runtime projections.
|
||||
*
|
||||
* Purpose: give headless/status/doctor/widget one operator-facing diagnosis instead of
|
||||
* requiring humans to correlate ps, auto.lock, UOK parity, DB rows, and runtime files.
|
||||
*
|
||||
* Consumer: UOK kernel startup, headless query, /sf uok status, doctor, and progress widget.
|
||||
*/
|
||||
export function synthesizeUokDiagnostics(basePath, options = {}) {
|
||||
const nowMs = options.nowMs ?? Date.now();
|
||||
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
||||
const nowIso = new Date(nowMs).toISOString();
|
||||
const processRows = options.processRows ?? readProcessRows();
|
||||
const lock =
|
||||
options.lock === undefined ? readCrashLock(basePath) : options.lock;
|
||||
const lockAlive = lock ? isLockProcessAlive(lock) : false;
|
||||
const childPids = lockAlive
|
||||
? descendantPids(processRows, Number(lock.pid))
|
||||
: [];
|
||||
const records = listUnitRuntimeRecords(basePath);
|
||||
const runtimeUnits = records.map((record) =>
|
||||
classifyRuntimeRecord(record, lockAlive, nowMs, staleMs),
|
||||
);
|
||||
const activeRuntimeUnits = runtimeUnits.filter(
|
||||
(unit) => unit.projectionActive,
|
||||
);
|
||||
const preParityRuns = isDbAvailable() ? getUokRuns(20) : [];
|
||||
const preParityOpenRuns = preParityRuns.filter(
|
||||
(run) => run.status === "started" || !run.endedAt,
|
||||
);
|
||||
let report = null;
|
||||
let parityHealth = null;
|
||||
try {
|
||||
report = writeParityReport(basePath, nowMs);
|
||||
parityHealth = summarizeParityHealth(report);
|
||||
} catch {
|
||||
parityHealth = null;
|
||||
}
|
||||
const runs = isDbAvailable() ? getUokRuns(20) : preParityRuns;
|
||||
const openRuns = runs.filter(
|
||||
(run) => run.status === "started" || !run.endedAt,
|
||||
);
|
||||
const lastRun = runs[0] ?? null;
|
||||
const lastEnded = latestEndedRun(runs);
|
||||
const expectedUnit = normalizeExpectedUnit(options.expectedNext);
|
||||
const currentRuntimeUnit = lock
|
||||
? runtimeUnits.find(
|
||||
(unit) =>
|
||||
unit.unitType === lock.unitType && unit.unitId === lock.unitId,
|
||||
)
|
||||
: null;
|
||||
const issues = [];
|
||||
const recommendations = [];
|
||||
const signals = {
|
||||
lock: lock ? (lockAlive ? "active" : "stale") : "missing",
|
||||
parity: parityHealth?.status ?? "unknown",
|
||||
ledger:
|
||||
openRuns.length === 0 && preParityOpenRuns.length === 0
|
||||
? "consistent"
|
||||
: "open-runs",
|
||||
runtimeProjection: "ok",
|
||||
wrapper: "unknown",
|
||||
};
|
||||
|
||||
if (lock && !lockAlive) {
|
||||
issues.push({
|
||||
code: "stale-lock",
|
||||
severity: "error",
|
||||
message: `Stale auto.lock detected for PID ${lock.pid}.`,
|
||||
evidence: { lock },
|
||||
});
|
||||
recommendations.push("Clear stale auto.lock before dispatch.");
|
||||
}
|
||||
if (parityHealth && !parityHealth.ok) {
|
||||
issues.push({
|
||||
code: "uok-parity-degraded",
|
||||
severity: "warning",
|
||||
message: `UOK parity degraded: ${parityHealth.current.criticalMismatches} critical mismatch(es), ${parityHealth.current.missingExitEvents} missing exit(s).`,
|
||||
evidence: parityHealth.current,
|
||||
});
|
||||
recommendations.push("Reconcile UOK parity before mutating git state.");
|
||||
}
|
||||
const orphanedOpenRuns = openRuns.length > 0 ? openRuns : preParityOpenRuns;
|
||||
if (orphanedOpenRuns.length > 0 && !lockAlive) {
|
||||
issues.push({
|
||||
code: "open-ledger-without-live-lock",
|
||||
severity: "error",
|
||||
message: `UOK ledger has ${orphanedOpenRuns.length} started run(s) without a live auto.lock owner.`,
|
||||
evidence: {
|
||||
runIds: orphanedOpenRuns.map((run) => run.runId),
|
||||
autoRecoveredByParity:
|
||||
openRuns.length === 0 && preParityOpenRuns.length > 0,
|
||||
},
|
||||
});
|
||||
recommendations.push(
|
||||
"Mark orphaned UOK runs recovered or restart from lock owner.",
|
||||
);
|
||||
}
|
||||
if (!lockAlive && activeRuntimeUnits.length > 0) {
|
||||
signals.runtimeProjection = "stale";
|
||||
issues.push({
|
||||
code: "stale-runtime-projection",
|
||||
severity: "error",
|
||||
message: `${activeRuntimeUnits.length} active runtime projection(s) exist without a live auto.lock owner.`,
|
||||
evidence: {
|
||||
units: activeRuntimeUnits.map(
|
||||
(unit) => `${unit.unitType} ${unit.unitId}`,
|
||||
),
|
||||
},
|
||||
});
|
||||
recommendations.push("Regenerate or clear stale runtime unit projections.");
|
||||
}
|
||||
if (expectedUnit && activeRuntimeUnits.length > 0) {
|
||||
const matchesExpected = activeRuntimeUnits.some(
|
||||
(unit) =>
|
||||
unit.unitType === expectedUnit.unitType &&
|
||||
unit.unitId === expectedUnit.unitId,
|
||||
);
|
||||
if (!matchesExpected) {
|
||||
signals.runtimeProjection = "mismatch";
|
||||
issues.push({
|
||||
code: "db-projection-unit-mismatch",
|
||||
severity: "warning",
|
||||
message: `DB dispatch preview is ${expectedUnit.unitType} ${expectedUnit.unitId}, but runtime projection shows ${activeRuntimeUnits.map((unit) => `${unit.unitType} ${unit.unitId}`).join(", ")}.`,
|
||||
evidence: { expectedUnit, activeRuntimeUnits },
|
||||
});
|
||||
recommendations.push(
|
||||
"Prefer DB dispatch state and repair runtime projection drift.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!lock && lastEnded?.status === "ok") {
|
||||
const liveSfProcesses = processRows.filter(
|
||||
(row) =>
|
||||
row.pid !== process.pid &&
|
||||
/(^|\s)(sf\s+autonomous|sf\s+auto|node\s+.*dist\/loader\.js\s+autonomous)($|\s)/.test(
|
||||
row.command,
|
||||
) &&
|
||||
!row.command.includes("headless --output-format json query"),
|
||||
);
|
||||
if (liveSfProcesses.length > 0) {
|
||||
signals.wrapper = "maybe-live-after-kernel-exit";
|
||||
issues.push({
|
||||
code: "kernel-exited-wrapper-maybe-live",
|
||||
severity: "warning",
|
||||
message:
|
||||
"Latest UOK kernel run exited ok and no auto.lock exists, but sf process(es) are still alive.",
|
||||
evidence: { pids: liveSfProcesses.slice(0, 8).map((row) => row.pid) },
|
||||
});
|
||||
recommendations.push(
|
||||
"Classify live sf wrapper processes before assuming autonomous is active.",
|
||||
);
|
||||
} else {
|
||||
signals.wrapper = "clear";
|
||||
}
|
||||
}
|
||||
|
||||
let classification = "healthy";
|
||||
if (issues.some((issue) => issue.severity === "error")) {
|
||||
classification = "needs-repair";
|
||||
} else if (issues.length > 0) {
|
||||
classification = "degraded";
|
||||
} else if (
|
||||
lockAlive &&
|
||||
runtimeUnits.some((unit) => unit.classification === "quiet-but-healthy")
|
||||
) {
|
||||
classification = "quiet-but-healthy";
|
||||
} else if (lockAlive) {
|
||||
classification = "running";
|
||||
}
|
||||
const reportPath = diagnosticsPath(basePath);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
generatedAt: nowIso,
|
||||
verdict: issues.some((issue) => issue.severity === "error")
|
||||
? "degraded"
|
||||
: issues.length > 0
|
||||
? "attention"
|
||||
: "clear",
|
||||
classification,
|
||||
signals,
|
||||
currentUnit: lock
|
||||
? {
|
||||
unitType: lock.unitType ?? null,
|
||||
unitId: lock.unitId ?? null,
|
||||
pid: lock.pid ?? null,
|
||||
sessionFile: lock.sessionFile ?? null,
|
||||
promptPath:
|
||||
lock.promptPath ??
|
||||
lock.promptFile ??
|
||||
currentRuntimeUnit?.promptPath ??
|
||||
null,
|
||||
promptHash: lock.promptHash ?? currentRuntimeUnit?.promptHash ?? null,
|
||||
toolSpansPath:
|
||||
lock.toolSpansPath ??
|
||||
lock.tracePath ??
|
||||
currentRuntimeUnit?.toolSpansPath ??
|
||||
null,
|
||||
openToolSpans: currentRuntimeUnit?.openToolSpans ?? [],
|
||||
startedAt: lock.startedAt ?? null,
|
||||
unitStartedAt: lock.unitStartedAt ?? null,
|
||||
childPids,
|
||||
}
|
||||
: null,
|
||||
latestRun: lastRun
|
||||
? {
|
||||
runId: lastRun.runId,
|
||||
sessionId: lastRun.sessionId,
|
||||
status: lastRun.status,
|
||||
startedAt: lastRun.startedAt,
|
||||
endedAt: lastRun.endedAt,
|
||||
path: lastRun.path,
|
||||
error: lastRun.error,
|
||||
}
|
||||
: null,
|
||||
runtimeUnits,
|
||||
issues,
|
||||
recommendations,
|
||||
reportPath,
|
||||
};
|
||||
}
|
||||
|
||||
export function writeUokDiagnostics(basePath, options = {}) {
|
||||
const diagnostics = synthesizeUokDiagnostics(basePath, options);
|
||||
const path = diagnosticsPath(basePath);
|
||||
mkdirSync(join(sfRoot(basePath), "runtime"), { recursive: true });
|
||||
writeFileSync(path, `${JSON.stringify(diagnostics, null, 2)}\n`, "utf-8");
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
export function readUokDiagnostics(basePath) {
|
||||
const path = diagnosticsPath(basePath);
|
||||
if (!existsSync(path)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf-8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,8 @@ export function resolveUokFlags(prefs) {
|
|||
securityGuard: uok?.security_guard?.enabled ?? true,
|
||||
multiPackageHealing: uok?.multi_package_healing?.enabled ?? true,
|
||||
chaosMonkey: uok?.chaos_monkey?.enabled ?? false,
|
||||
autonomousCostGuard: uok?.cost_guard?.enabled ?? true,
|
||||
outcomeLearning: uok?.outcome_learning?.enabled ?? true,
|
||||
modelPolicy: uok?.model_policy?.enabled ?? true,
|
||||
executionGraph: uok?.execution_graph?.enabled ?? true,
|
||||
gitops: uok?.gitops?.enabled ?? true,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
} from "../sf-db.js";
|
||||
import { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js";
|
||||
import { setAuditEnvelopeEnabled } from "./audit-toggle.js";
|
||||
import { writeUokDiagnostics } from "./diagnostic-synthesis.js";
|
||||
import { resolveUokFlags } from "./flags.js";
|
||||
import { createTurnObserver } from "./loop-adapter.js";
|
||||
import {
|
||||
|
|
@ -72,12 +73,24 @@ export function recordUokKernelTermination({
|
|||
status,
|
||||
...(error ? { error } : {}),
|
||||
});
|
||||
return refreshParityReport(basePath);
|
||||
const report = refreshParityReport(basePath);
|
||||
try {
|
||||
writeUokDiagnostics(basePath);
|
||||
} catch (err) {
|
||||
debugLog("uok-diagnostics-write-failed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
return report;
|
||||
}
|
||||
export async function runAutoLoopWithUok(args) {
|
||||
const { ctx, pi, s, deps, runKernelLoop } = args;
|
||||
const prefs = deps.loadEffectiveSFPreferences()?.preferences;
|
||||
const flags = { ...resolveUokFlags(prefs), enabled: true };
|
||||
|
||||
const healthVerdict = writeUokDiagnostics(s.basePath);
|
||||
debugLog("uok-system-health-verdict", healthVerdict);
|
||||
|
||||
const previousReport = refreshParityReport(s.basePath);
|
||||
const runId = `uok-${randomUUID()}`;
|
||||
s.currentUokRunId = runId;
|
||||
|
|
|
|||
62
src/resources/extensions/sf/uok/message-bus.js
Normal file
62
src/resources/extensions/sf/uok/message-bus.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* UOK Message Bus & Agent Inbox
|
||||
*
|
||||
* Implements Letta-style inter-agent communication.
|
||||
* Allows agents to send messages, check inboxes, and wait for replies
|
||||
* across turn boundaries.
|
||||
*/
|
||||
|
||||
export class AgentInbox {
|
||||
constructor(agentId, basePath) {
|
||||
this.agentId = agentId;
|
||||
this.basePath = basePath;
|
||||
this.messages = [];
|
||||
}
|
||||
|
||||
receive(message) {
|
||||
this.messages.push({
|
||||
...message,
|
||||
receivedAt: new Date().toISOString(),
|
||||
read: false,
|
||||
});
|
||||
}
|
||||
|
||||
list(unreadOnly = false) {
|
||||
return unreadOnly ? this.messages.filter((m) => !m.read) : this.messages;
|
||||
}
|
||||
|
||||
markRead(messageId) {
|
||||
const msg = this.messages.find((m) => m.id === messageId);
|
||||
if (msg) msg.read = true;
|
||||
}
|
||||
}
|
||||
|
||||
export class MessageBus {
|
||||
constructor(basePath) {
|
||||
this.basePath = basePath;
|
||||
this.inboxes = new Map();
|
||||
}
|
||||
|
||||
getOrCreateInbox(agentId) {
|
||||
if (!this.inboxes.has(agentId)) {
|
||||
this.inboxes.set(agentId, new AgentInbox(agentId, this.basePath));
|
||||
}
|
||||
return this.inboxes.get(agentId);
|
||||
}
|
||||
|
||||
send(from, to, body, metadata = {}) {
|
||||
const message = {
|
||||
id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
from,
|
||||
to,
|
||||
body,
|
||||
metadata,
|
||||
sentAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const targetInbox = this.getOrCreateInbox(to);
|
||||
targetInbox.receive(message);
|
||||
|
||||
return message.id;
|
||||
}
|
||||
}
|
||||
69
src/resources/extensions/sf/uok/multi-package-gate.js
Normal file
69
src/resources/extensions/sf/uok/multi-package-gate.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { execFileSync } from "node:child_process";
|
||||
|
||||
/**
|
||||
* UOK Multi-Package Healing Gate
|
||||
*
|
||||
* Automatically detects if code changes impact monorepo packages.
|
||||
* If so, it dispatches cross-package verification commands (e.g., typecheck)
|
||||
* to ensure downstream dependencies remain intact and no regressions are introduced.
|
||||
*/
|
||||
export class MultiPackageGate {
|
||||
constructor() {
|
||||
this.id = "multi-package-healing";
|
||||
this.type = "verification";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("./contracts.js").UokContext} ctx
|
||||
*/
|
||||
async execute(ctx) {
|
||||
try {
|
||||
// Find changed files
|
||||
const diffOutput = execFileSync("git", ["diff", "--name-only", "HEAD"], {
|
||||
cwd: ctx.basePath,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
const changedFiles = diffOutput.split("\n").filter(Boolean);
|
||||
|
||||
// Determine if a core package was changed
|
||||
const impactsPackages = changedFiles.some((f) =>
|
||||
f.startsWith("packages/"),
|
||||
);
|
||||
|
||||
if (!impactsPackages) {
|
||||
return {
|
||||
outcome: "pass",
|
||||
rationale:
|
||||
"No cross-package verification needed (changes do not impact 'packages/').",
|
||||
};
|
||||
}
|
||||
|
||||
// Run workspace-wide typecheck to ensure downstream packages are healthy
|
||||
execFileSync("npm", ["run", "typecheck"], {
|
||||
cwd: ctx.basePath,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
return {
|
||||
outcome: "pass",
|
||||
rationale:
|
||||
"Multi-Package Healing confirmed no regressions in downstream packages.",
|
||||
};
|
||||
} catch (err) {
|
||||
const stdout = err.stdout ? String(err.stdout) : "";
|
||||
const stderr = err.stderr ? String(err.stderr) : "";
|
||||
const output = (stdout + stderr).trim();
|
||||
|
||||
return {
|
||||
outcome: "fail",
|
||||
failureClass: "verification",
|
||||
rationale:
|
||||
"Multi-Package regression detected (downstream typecheck failed).",
|
||||
findings: output || String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
26
src/resources/extensions/sf/uok/outcome-learning-gate.js
Normal file
26
src/resources/extensions/sf/uok/outcome-learning-gate.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* UOK Outcome Learning Gate
|
||||
*
|
||||
* Interacts with the local learning/ database to record successes and failures.
|
||||
* Over time, this allows the UOK to autonomously avoid patterns that led to
|
||||
* previous regressions.
|
||||
*/
|
||||
export class OutcomeLearningGate {
|
||||
constructor() {
|
||||
this.id = "outcome-learning";
|
||||
this.type = "learning";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("./contracts.js").UokContext} ctx
|
||||
*/
|
||||
async execute(_ctx) {
|
||||
// Mock interaction with outcome-recorder.mjs
|
||||
// In a full implementation, this would query/write to the SQLite DB
|
||||
return {
|
||||
outcome: "pass",
|
||||
rationale:
|
||||
"Outcome recorded in local experience DB. Cross-turn learning enabled.",
|
||||
};
|
||||
}
|
||||
}
|
||||
79
src/resources/extensions/sf/uok/security-gate.js
Normal file
79
src/resources/extensions/sf/uok/security-gate.js
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* UOK Security Gate
|
||||
*
|
||||
* Runs the workspace secret-scan.sh against uncommitted changes.
|
||||
* Prevents the kernel from finalizing a turn if potential secrets are detected.
|
||||
*/
|
||||
export class SecurityGate {
|
||||
constructor() {
|
||||
this.id = "security-guard";
|
||||
this.type = "security";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("./contracts.js").UokContext} ctx
|
||||
* @param {number} _attempt
|
||||
*/
|
||||
async execute(ctx, _attempt) {
|
||||
const scriptPath = join(ctx.basePath, "scripts/secret-scan.sh");
|
||||
|
||||
if (!existsSync(scriptPath)) {
|
||||
return {
|
||||
outcome: "pass",
|
||||
rationale: "Security scan skipped: scripts/secret-scan.sh not found.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await execFilePromise("bash", [scriptPath, "--diff", "HEAD"], {
|
||||
cwd: ctx.basePath,
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
return {
|
||||
outcome: "pass",
|
||||
rationale: "No secrets detected in changed files.",
|
||||
};
|
||||
} catch (err) {
|
||||
const stdout = err.stdout ? String(err.stdout) : "";
|
||||
const stderr = err.stderr ? String(err.stderr) : "";
|
||||
const output = (stdout + stderr).trim();
|
||||
|
||||
return {
|
||||
outcome: "fail",
|
||||
failureClass: "policy",
|
||||
rationale: "Security scan detected potential secrets or credentials.",
|
||||
findings: output || String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function execFilePromise(file, args, options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = execFile(file, args, options, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(Object.assign(error, { stdout, stderr }));
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
});
|
||||
if (options?.timeout && options.timeout > 0) {
|
||||
const t = setTimeout(() => {
|
||||
child.kill();
|
||||
reject(
|
||||
Object.assign(new Error("security scan timed out"), {
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}),
|
||||
);
|
||||
}, options.timeout);
|
||||
child.on("exit", () => clearTimeout(t));
|
||||
child.on("error", () => clearTimeout(t));
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue