feat(headless): sf headless triage --run — dispatch via @singularity-forge/ai
Adds runTriage to self-feedback-drain.js, mirroring runReflection in reflection.js: provider-agnostic dispatch via @singularity-forge/ai's completeSimple, dependency-injectable for tests, 8-minute timeout race, clean-finish detection on the canonical "Self-feedback triage complete" terminator. `sf headless triage --run [--model provider/modelId]` now dispatches the canonical triage prompt and writes the model's decision text to .sf/triage/decisions/<ts>.md. Operators apply the decisions (resolve_issue calls, code edits) — a tool-enabled variant that lets the model close entries directly is follow-up work. Default model: google-gemini-cli/gemini-3-pro-preview (matches DEFAULT_REFLECTION_MODEL). Continues the bounded chip away at sf-mp4rxkwb-l4baga: triage now has both an operator-pipe path (default) and a one-shot dispatch path (--run). The full unit-type registration that wires this into the autonomous dispatcher's idle path is the remaining slice of that entry. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8fde12301f
commit
34521814cc
4 changed files with 291 additions and 2 deletions
|
|
@ -59,6 +59,8 @@ export interface HandleTriageOptions {
|
|||
json?: boolean;
|
||||
list?: boolean;
|
||||
max?: number;
|
||||
run?: boolean;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface HandleTriageResult {
|
||||
|
|
@ -102,6 +104,21 @@ export async function handleTriage(
|
|||
let drainModule: {
|
||||
selectInlineFixCandidates: (basePath: string) => TriageCandidate[];
|
||||
buildInlineFixPrompt: (entries: TriageCandidate[]) => string;
|
||||
runTriage: (
|
||||
prompt: string,
|
||||
options?: { model?: string; timeoutMs?: number },
|
||||
) => Promise<{
|
||||
ok: boolean;
|
||||
content?: string;
|
||||
error?: string;
|
||||
cleanFinish?: boolean;
|
||||
provider?: string;
|
||||
modelId?: string;
|
||||
}>;
|
||||
writeTriageDecisionReport: (
|
||||
basePath: string,
|
||||
content: string,
|
||||
) => string | null;
|
||||
};
|
||||
try {
|
||||
drainModule = (await jiti.import(
|
||||
|
|
@ -183,6 +200,54 @@ export async function handleTriage(
|
|||
return { exitCode: 1 };
|
||||
}
|
||||
|
||||
process.stdout.write(`${prompt}\n`);
|
||||
if (!options.run) {
|
||||
process.stdout.write(`${prompt}\n`);
|
||||
return { exitCode: 0 };
|
||||
}
|
||||
|
||||
// --run: dispatch the prompt via @singularity-forge/ai completeSimple,
|
||||
// capture the decision text, persist to .sf/triage/decisions/<ts>.md.
|
||||
// Same shape as `sf headless reflect --run`. The model's output is a
|
||||
// decision matrix — applying the decisions (resolve_issue calls, code
|
||||
// edits) is operator-driven; a tool-enabled variant is follow-up work.
|
||||
process.stderr.write(
|
||||
"[triage] dispatching to model (this can take a few minutes)…\n",
|
||||
);
|
||||
const result = await drainModule.runTriage(prompt, { model: options.model });
|
||||
if (!result.ok) {
|
||||
const payload = {
|
||||
ok: false,
|
||||
error: result.error ?? "unknown triage error",
|
||||
provider: result.provider,
|
||||
modelId: result.modelId,
|
||||
};
|
||||
process.stdout.write(
|
||||
options.json
|
||||
? `${JSON.stringify(payload)}\n`
|
||||
: `[triage] failed: ${payload.error}\n`,
|
||||
);
|
||||
return { exitCode: 1 };
|
||||
}
|
||||
const reportPath = drainModule.writeTriageDecisionReport(
|
||||
cwd,
|
||||
result.content ?? "",
|
||||
);
|
||||
const payload = {
|
||||
ok: true,
|
||||
reportPath,
|
||||
cleanFinish: result.cleanFinish === true,
|
||||
provider: result.provider,
|
||||
modelId: result.modelId,
|
||||
};
|
||||
if (options.json) {
|
||||
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
||||
} else {
|
||||
process.stdout.write(`Triage decisions written to: ${reportPath}\n`);
|
||||
if (!result.cleanFinish) {
|
||||
process.stderr.write(
|
||||
'[triage] WARNING: report did not include "Self-feedback triage complete" terminator — output may be truncated\n',
|
||||
);
|
||||
}
|
||||
}
|
||||
return { exitCode: 0 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -837,17 +837,25 @@ async function runHeadlessOnce(
|
|||
if (options.command === "triage") {
|
||||
const wantsJson = options.json || options.commandArgs.includes("--json");
|
||||
const wantsList = options.commandArgs.includes("--list");
|
||||
const wantsRun = options.commandArgs.includes("--run");
|
||||
const maxIdx = options.commandArgs.indexOf("--max");
|
||||
let max: number | undefined;
|
||||
if (maxIdx >= 0 && maxIdx + 1 < options.commandArgs.length) {
|
||||
const n = Number.parseInt(options.commandArgs[maxIdx + 1] ?? "", 10);
|
||||
if (Number.isFinite(n) && n > 0) max = n;
|
||||
}
|
||||
const modelIdx = options.commandArgs.indexOf("--model");
|
||||
const model =
|
||||
modelIdx >= 0 && modelIdx + 1 < options.commandArgs.length
|
||||
? options.commandArgs[modelIdx + 1]
|
||||
: undefined;
|
||||
const { handleTriage } = await import("./headless-triage.js");
|
||||
const result = await handleTriage(process.cwd(), {
|
||||
json: wantsJson,
|
||||
list: wantsList,
|
||||
max,
|
||||
run: wantsRun,
|
||||
model,
|
||||
});
|
||||
return { exitCode: result.exitCode, interrupted: false, timedOut: false };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
} from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { getErrorMessage } from "./error-utils.js";
|
||||
import { sfRuntimeRoot } from "./paths.js";
|
||||
import { sfRoot, sfRuntimeRoot } from "./paths.js";
|
||||
import { readAllSelfFeedback } from "./self-feedback.js";
|
||||
|
||||
/**
|
||||
|
|
@ -338,6 +338,163 @@ export function dispatchSelfFeedbackInlineFixIfNeeded(basePath, ctx, pi) {
|
|||
*
|
||||
* Consumer: register-hooks.ts turn_end handler.
|
||||
*/
|
||||
/**
|
||||
* Default provider/model used by runTriage when --model is not supplied.
|
||||
* Matches DEFAULT_REFLECTION_MODEL — both reasoning passes benefit from the
|
||||
* same pro tier and route through the operator's persistent gemini-cli
|
||||
* session by default.
|
||||
*/
|
||||
const DEFAULT_TRIAGE_MODEL = "google-gemini-cli/gemini-3-pro-preview";
|
||||
|
||||
const TRIAGE_TERMINATOR = "Self-feedback triage complete";
|
||||
|
||||
function parseTriageModelString(input) {
|
||||
if (typeof input !== "string") return null;
|
||||
const slash = input.indexOf("/");
|
||||
if (slash <= 0 || slash === input.length - 1) return null;
|
||||
return [input.slice(0, slash), input.slice(slash + 1)];
|
||||
}
|
||||
|
||||
async function resolveTriageModel(providerModelString) {
|
||||
const parsed = parseTriageModelString(providerModelString);
|
||||
if (!parsed) return null;
|
||||
const [provider, modelId] = parsed;
|
||||
const ai = await import("@singularity-forge/ai");
|
||||
if (typeof ai.getModel !== "function") return null;
|
||||
try {
|
||||
return ai.getModel(provider, modelId) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractAssistantText(message) {
|
||||
const content = message?.content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
const out = [];
|
||||
for (const part of content) {
|
||||
if (part && typeof part === "object" && part.type === "text") {
|
||||
if (typeof part.text === "string") out.push(part.text);
|
||||
}
|
||||
}
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a triage decision report to .sf/triage/decisions/<timestamp>.md.
|
||||
* Returns the absolute path on success, null on failure (best-effort).
|
||||
*
|
||||
* Why a separate subdirectory under .sf/triage/: the captures-triage system
|
||||
* (commands-todo.js) already owns .sf/triage/{backlog,...}; using
|
||||
* .sf/triage/decisions/ keeps both subsystems clearly namespaced without
|
||||
* either subsystem accidentally consuming the other's files.
|
||||
*
|
||||
* Consumer: headless-triage operator surface (--run flag).
|
||||
*/
|
||||
export function writeTriageDecisionReport(basePath, content) {
|
||||
try {
|
||||
const dir = join(sfRoot(basePath), "triage", "decisions");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const path = join(dir, `${ts}.md`);
|
||||
writeFileSync(path, content, "utf-8");
|
||||
return path;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a triage pass against the canonical inline-fix prompt.
|
||||
*
|
||||
* Provider-agnostic: routes through @singularity-forge/ai's completeSimple,
|
||||
* mirroring runReflection. Captures the model's decision text — the actual
|
||||
* fix/promote/close actions are operator-applied (or, in a follow-up, a
|
||||
* tool-enabled variant will let the model call resolve_issue directly).
|
||||
*
|
||||
* options.model — "provider/modelId" string. Defaults to DEFAULT_TRIAGE_MODEL.
|
||||
* options.complete — dependency injection for tests; same shape as the
|
||||
* reflection runner.
|
||||
* options.timeoutMs — defaults to 8 minutes.
|
||||
*
|
||||
* Returns { ok, content?, cleanFinish?, error?, provider?, modelId? }.
|
||||
* Best-effort; never throws.
|
||||
*
|
||||
* Consumer: headless-triage operator surface (--run flag).
|
||||
*/
|
||||
export async function runTriage(prompt, options = {}) {
|
||||
const modelString = options.model ?? DEFAULT_TRIAGE_MODEL;
|
||||
const timeoutMs = options.timeoutMs ?? 8 * 60 * 1000;
|
||||
|
||||
let model;
|
||||
try {
|
||||
model = await resolveTriageModel(modelString);
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `failed to load model catalog: ${getErrorMessage(err)}`,
|
||||
};
|
||||
}
|
||||
if (!model) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `unknown model "${modelString}" — expected "provider/modelId" with a model registered in @singularity-forge/ai MODELS`,
|
||||
};
|
||||
}
|
||||
|
||||
const context = {
|
||||
systemPrompt: undefined,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: prompt }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const completeFn =
|
||||
options.complete ??
|
||||
(await (async () => {
|
||||
const ai = await import("@singularity-forge/ai");
|
||||
return ai.completeSimple;
|
||||
})());
|
||||
|
||||
const callPromise = (async () => {
|
||||
try {
|
||||
const message = await completeFn(model, context, {});
|
||||
const content = extractAssistantText(message);
|
||||
return {
|
||||
ok: true,
|
||||
content,
|
||||
cleanFinish: content.includes(TRIAGE_TERMINATOR),
|
||||
provider: model.provider,
|
||||
modelId: model.id,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `provider call failed: ${getErrorMessage(err)}`,
|
||||
provider: model.provider,
|
||||
modelId: model.id,
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
const timeoutPromise = new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
ok: false,
|
||||
error: `triage call timed out after ${timeoutMs}ms`,
|
||||
provider: model.provider,
|
||||
modelId: model.id,
|
||||
});
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
return Promise.race([callPromise, timeoutPromise]);
|
||||
}
|
||||
|
||||
export function consumeCompletedInlineFixClaim(basePath) {
|
||||
const claim = readClaim(basePath);
|
||||
if (!claim || claim.ids.length === 0) return [];
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ import { join } from "node:path";
|
|||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import {
|
||||
buildInlineFixPrompt,
|
||||
runTriage,
|
||||
selectInlineFixCandidates,
|
||||
writeTriageDecisionReport,
|
||||
} from "../self-feedback-drain.js";
|
||||
|
||||
let tempDirs = [];
|
||||
|
|
@ -277,3 +279,60 @@ describe("buildInlineFixPrompt", () => {
|
|||
expect(prompt).toContain("Self-feedback triage complete");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runTriage (dependency-injected)", () => {
|
||||
test("returns ok+content on success and flags clean-finish from terminator", async () => {
|
||||
const fakeMessage = {
|
||||
content: [
|
||||
{ type: "text", text: "decision: close.\nSelf-feedback triage complete" },
|
||||
],
|
||||
};
|
||||
const result = await runTriage("triage prompt", {
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
complete: async () => fakeMessage,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.content).toContain("decision: close");
|
||||
expect(result.cleanFinish).toBe(true);
|
||||
});
|
||||
|
||||
test("flags cleanFinish=false when terminator missing", async () => {
|
||||
const result = await runTriage("triage prompt", {
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
complete: async () => ({
|
||||
content: [{ type: "text", text: "partial output, no terminator" }],
|
||||
}),
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.cleanFinish).toBe(false);
|
||||
});
|
||||
|
||||
test("returns ok=false on unknown model string", async () => {
|
||||
const result = await runTriage("triage prompt", {
|
||||
model: "no-such-provider/no-such-model",
|
||||
complete: async () => ({ content: [] }),
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("unknown model");
|
||||
});
|
||||
|
||||
test("returns ok=false when complete throws", async () => {
|
||||
const result = await runTriage("triage prompt", {
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
complete: async () => {
|
||||
throw new Error("provider boom");
|
||||
},
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("provider boom");
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeTriageDecisionReport", () => {
|
||||
test("writes to .sf/triage/decisions/<ts>.md and returns the path", () => {
|
||||
const dir = makeForgeProject();
|
||||
const path = writeTriageDecisionReport(dir, "## decisions\nfoo");
|
||||
expect(path).toBeTruthy();
|
||||
expect(path).toMatch(/\.sf\/triage\/decisions\/.*\.md$/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue