Files
gstack/lib/outside-voice-runner.ts
Garry Tanandcapy-ai[bot] 140003be95 v1.91.72.0 feat(autoplan): unattended runner gstack-autoplan next|submit, pluggable outside voice, review checklists, spec-loop cap (multi-agent wave B-runner) (#3117)
* v1.91.70.0 feat(unattended): artifact contract, unattended session kind, findings-derived review log, gate list

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.72.0 feat(autoplan): unattended runner next|submit, pluggable outside voice, review checklists, spec-loop cap

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(b-runner): atomic lock creation in spend-ledger, judge bundle skips runner-only sections, fixture env spelling

- lib/spend-ledger.ts: create the lock by linking a complete temp file so a
  contender never reads an empty lock and breaks a live owner (CI admitted 20
  of a cap of 8 under contention); unreadable locks break only by mtime age.
- test/helpers/workflow-judge-input.ts: exclude manifest runner_only sections
  (the B4 checklists) from the judge bundle; the judge rates the interactive
  workflow. test/workflow-judge-input.test.ts expects the same set.
- test/helpers/autoplan-run-fixture.ts: spell the runner's session kind
  without the literal the free-suite environment scan rejects.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(spend-ledger): accept Windows' status-only report of the killed child

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-11 00:32:15 +00:00

278 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* outside-voice-runner — the pluggable outside voice (plan B3). The rendered
* skills still shell to Codex for the interactive path; this module is the
* runtime choice behind `bin/gstack-outside-voice run --runner <r>` so an
* unattended run (bin/gstack-autoplan) picks a runner per run, not per render.
* Every runner implements one adapter: declared capabilities (repository
* access or supplied input only), the input digest, the actual model metadata,
* cancellation, a terminal result and usage. Runners: `codex-cli` (today's
* path through gstack-codex-probe and `codex exec`), `api` (a direct call via
* lib/outside-voice-api.ts, labeled `supplied-input review`), and
* `host-subagent` (the host ran a cross-family subagent from the prompt file
* and hands back its output file; spend is `unknown`). Verdicts still go
* through lib/outside-review-result.ts. The same-family rule is enforced by
* `familyConflict`: an outside model in the native reviewer's family is not an
* outside voice. A missing Codex CLI is "unavailable; using <runner>" only when
* another runner is configured; with none it is missing coverage.
*/
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { classifyOutsideReview, type OutsideGate, type OutsideReviewClassification } from './outside-review-result';
export type RunnerId = 'codex-cli' | 'api' | 'host-subagent';
export const RUNNER_IDS: readonly RunnerId[] = ['codex-cli', 'api', 'host-subagent'];
export type ModelFamily = 'anthropic' | 'openai' | 'google' | 'xai' | 'deepseek' | 'qwen' | 'zai' | 'moonshot' | 'meta' | 'mistral' | 'minimax' | 'unknown';
export interface RunnerCapabilities {
/** `read-only`: the reviewer can read the repository; `none`: it sees the prompt bytes only; `host`: whatever the host gave its subagent. */
repository_access: 'read-only' | 'none' | 'host';
/** Whether this process starts and can cancel the reviewer. */
executes_here: boolean;
/** The label the record carries; `supplied-input review` is never silently equal to a repo-reading review. */
label: 'repository review' | 'supplied-input review' | 'host-dispatched review';
}
export interface RunnerRequest {
promptFile: string; outFile: string; model?: string; timeoutMs: number; cwd: string; gate: OutsideGate;
/** host-subagent: the file the host's subagent wrote. */
resultFile?: string;
env?: Record<string, string | undefined>;
/** Directory of the gstack bins (gstack-codex-probe); defaults to this module's sibling bin/. */
binDir?: string;
}
export type RunnerStatus = 'completed' | 'unverified' | 'unavailable' | 'failed' | 'timeout' | 'cancelled';
export interface RunnerUsage { input_tokens?: number; output_tokens?: number; usd: number | 'unknown' }
export interface RunnerResult {
schema_version: 1; runner: RunnerId; status: RunnerStatus; model: string | null; family: ModelFamily;
capabilities: RunnerCapabilities; input_sha256: string; output_sha256: string | null; output: string | null;
usage: RunnerUsage; started_at: string; ended_at: string; wall_s: number; exit: number;
verdict?: OutsideReviewClassification; reason?: string; detail?: string;
}
export interface OutsideRunner {
id: RunnerId;
capabilities: RunnerCapabilities;
available(req: Pick<RunnerRequest, 'env' | 'resultFile' | 'model' | 'binDir'>): { ok: true } | { ok: false; reason: string };
run(req: RunnerRequest): Promise<RunnerResult>;
cancel(): void;
}
const FAMILY_RULES: Array<[ModelFamily, RegExp]> = [
['anthropic', /^(anthropic\/|claude)/],
['openai', /^(openai\/|gpt-|o[0-9](?:-|$)|codex|chatgpt|text-davinci)/],
['google', /^(google\/|gemini|gemma)/],
['xai', /^(xai\/|grok)/],
['deepseek', /^(deepseek\/|deepseek)/],
['qwen', /^(qwen\/|qwen|alibaba\/)/],
['zai', /^(zai\/|glm)/],
['moonshot', /^(moonshotai\/|moonshot\/|kimi)/],
['meta', /^(meta\/|meta-llama\/|llama|muse-)/],
['mistral', /^(mistral(ai)?\/|mistral|mixtral|codestral)/],
['minimax', /^(minimax\/|minimax)/],
];
/** Family from a model id (`openai/gpt-6-astra`, `claude-opus-4-7`, `gpt-5.4`); `unknown` when no rule matches. */
export function modelFamily(model: string | undefined | null): ModelFamily {
const id = (model ?? '').trim().toLowerCase();
if (!id) return 'unknown';
for (const [family, re] of FAMILY_RULES) if (re.test(id)) return family;
return 'unknown';
}
/** The same-family rule: two voices from one family are one voice. Returns the refusal detail or undefined. */
export function familyConflict(outsideModel: string | undefined, nativeModel: string | undefined): string | undefined {
const outside = modelFamily(outsideModel);
if (outside === 'unknown') return `outside model ${JSON.stringify(outsideModel ?? '')} has no recognized family`;
if (!nativeModel) return undefined;
const native = modelFamily(nativeModel);
if (native === 'unknown') return `native model ${JSON.stringify(nativeModel)} has no recognized family`;
return native === outside ? `outside ${outsideModel} and native ${nativeModel} are both ${native}` : undefined;
}
export const sha256 = (data: string | Buffer) => createHash('sha256').update(data).digest('hex');
const DEFAULT_BIN = path.resolve(import.meta.dir, '..', 'bin');
function finish(base: Omit<RunnerResult, 'ended_at' | 'wall_s' | 'output_sha256' | 'schema_version'>, startedMs: number, outFile?: string): RunnerResult {
const ended = Date.now();
const outputExists = outFile !== undefined && fs.existsSync(outFile) && fs.statSync(outFile).size > 0;
return { schema_version: 1, ...base, output: outputExists ? outFile : null, output_sha256: outputExists ? sha256(fs.readFileSync(outFile)) : null, ended_at: new Date(ended).toISOString(), wall_s: Math.round((ended - startedMs) / 1000) };
}
function classify(text: string, gate: OutsideGate, extra: { stderr?: string; exit?: number; events?: string }): { status: RunnerStatus; verdict: OutsideReviewClassification } {
const verdict = classifyOutsideReview({ text, gate, ...extra });
const status: RunnerStatus = verdict.verdict === 'unavailable' ? 'unavailable' : verdict.verdict === 'unverified' ? 'unverified' : 'completed';
return { status, verdict };
}
/** Token usage from `codex exec --json` events, read loosely: any object carrying usage counters. */
export function usageFromEvents(events: string): RunnerUsage {
let input: number | undefined; let output: number | undefined;
for (const line of events.split('\n')) {
if (!line.trim()) continue;
let obj: any;
try { obj = JSON.parse(line); } catch { continue; }
const usage = obj?.usage ?? obj?.info?.total_token_usage ?? obj?.payload?.info?.total_token_usage ?? obj?.msg?.info?.total_token_usage;
if (!usage || typeof usage !== 'object') continue;
if (typeof usage.input_tokens === 'number') input = usage.input_tokens;
if (typeof usage.output_tokens === 'number') output = usage.output_tokens;
}
return { ...(input !== undefined ? { input_tokens: input } : {}), ...(output !== undefined ? { output_tokens: output } : {}), usd: 'unknown' };
}
// ---------------------------------------------------------------------------
// codex-cli: today's path, as one process this module can cancel
// ---------------------------------------------------------------------------
class CodexCliRunner implements OutsideRunner {
id: RunnerId = 'codex-cli';
capabilities: RunnerCapabilities = { repository_access: 'read-only', executes_here: true, label: 'repository review' };
private child: ReturnType<typeof Bun.spawn> | undefined;
private cancelled = false;
available(req: Pick<RunnerRequest, 'env'>): { ok: true } | { ok: false; reason: string } {
const env = req.env ?? process.env;
const found = Bun.which('codex', { PATH: env.PATH ?? '' });
return found ? { ok: true } : { ok: false, reason: 'codex CLI not on PATH' };
}
cancel(): void { this.cancelled = true; this.child?.kill('SIGTERM'); }
async run(req: RunnerRequest): Promise<RunnerResult> {
const env = { ...(req.env ?? process.env) } as Record<string, string>;
const started = Date.now();
const prompt = fs.readFileSync(req.promptFile);
const base = { runner: this.id, capabilities: this.capabilities, input_sha256: sha256(prompt), started_at: new Date(started).toISOString(), usage: { usd: 'unknown' } as RunnerUsage, model: req.model ?? null, family: modelFamily(req.model), output: null } as const;
const avail = this.available(req);
if (!avail.ok) return finish({ ...base, status: 'unavailable', exit: 1, reason: 'cli_missing', detail: avail.reason }, started);
const probe = path.join(req.binDir ?? DEFAULT_BIN, 'gstack-codex-probe');
if (req.model) env.GSTACK_CODEX_MODEL = req.model;
const select = Bun.spawnSync([probe, 'select-model', 'exec', ...(req.model ? ['--model', req.model] : [])], { cwd: req.cwd, env, stdout: 'pipe', stderr: 'pipe', timeout: 60_000 });
const selOut = new TextDecoder().decode(select.stdout);
const selected = /^CODEX_SEL: (.+)$/m.exec(selOut)?.[1]?.trim();
const sandbox = /^CODEX_SANDBOX: (.+)$/m.exec(selOut)?.[1]?.trim();
if (select.exitCode !== 0 || !selected || !sandbox) {
return finish({ ...base, status: 'unavailable', exit: select.exitCode ?? 1, reason: 'model_unselected', detail: new TextDecoder().decode(select.stderr).trim() || 'select-model printed no CODEX_SEL/CODEX_SANDBOX' }, started);
}
const eventsFile = `${req.outFile}.events.jsonl`;
const stderrFile = `${req.outFile}.stderr`;
fs.mkdirSync(path.dirname(req.outFile), { recursive: true });
fs.rmSync(req.outFile, { force: true });
const argv = ['codex', 'exec', '-', '-C', req.cwd, '-s', sandbox, '-c', `model="${selected}"`, '-c', 'skills.include_instructions=false',
'-c', 'model_reasoning_effort="high"', '-c', 'web_search="cached"', '--json', '-o', req.outFile];
this.child = Bun.spawn(argv, { cwd: req.cwd, env, stdin: new Blob([prompt]), stdout: Bun.file(eventsFile), stderr: Bun.file(stderrFile) });
const timer = setTimeout(() => { this.child?.kill('SIGTERM'); setTimeout(() => this.child?.kill('SIGKILL'), 10_000).unref(); }, req.timeoutMs);
const exit = await this.child.exited;
clearTimeout(timer);
const timedOut = Date.now() - started >= req.timeoutMs && exit !== 0;
const text = fs.existsSync(req.outFile) ? fs.readFileSync(req.outFile, 'utf8') : '';
const events = fs.existsSync(eventsFile) ? fs.readFileSync(eventsFile, 'utf8') : '';
const stderr = fs.existsSync(stderrFile) ? fs.readFileSync(stderrFile, 'utf8') : '';
const usage = usageFromEvents(events);
if (this.cancelled) return finish({ ...base, model: selected, family: modelFamily(selected), status: 'cancelled', exit: exit || 1, usage, reason: 'cancelled' }, started, req.outFile);
if (timedOut) return finish({ ...base, model: selected, family: modelFamily(selected), status: 'timeout', exit: 124, usage, reason: 'timeout', detail: `after ${req.timeoutMs} ms` }, started, req.outFile);
const { status, verdict } = classify(text, req.gate, { stderr, exit, events });
return finish({ ...base, model: selected, family: modelFamily(selected), status, exit, usage, verdict, ...(verdict.reason ? { reason: verdict.reason } : {}), ...(verdict.detail ? { detail: verdict.detail } : {}) }, started, req.outFile);
}
}
// ---------------------------------------------------------------------------
// api: a direct call, labeled supplied-input review
// ---------------------------------------------------------------------------
class ApiRunner implements OutsideRunner {
id: RunnerId = 'api';
capabilities: RunnerCapabilities = { repository_access: 'none', executes_here: true, label: 'supplied-input review' };
private controller = new AbortController();
available(req: Pick<RunnerRequest, 'env' | 'model'>): { ok: true } | { ok: false; reason: string } {
const env = req.env ?? process.env;
if (!req.model) return { ok: false, reason: '--model <id> is required for the api runner' };
const family = modelFamily(req.model);
if (family === 'anthropic' && env.ANTHROPIC_API_KEY) return { ok: true };
if (family === 'openai' && env.OPENAI_API_KEY) return { ok: true };
return { ok: false, reason: family === 'anthropic' || family === 'openai' ? `${family.toUpperCase()}_API_KEY is not set` : `no API adapter for model family ${family}` };
}
cancel(): void { this.controller.abort(new Error('cancelled')); }
async run(req: RunnerRequest): Promise<RunnerResult> {
const started = Date.now();
const prompt = fs.readFileSync(req.promptFile, 'utf8');
const base = { runner: this.id, capabilities: this.capabilities, input_sha256: sha256(prompt), started_at: new Date(started).toISOString(), usage: { usd: 'unknown' } as RunnerUsage, model: req.model ?? null, family: modelFamily(req.model), output: null } as const;
const avail = this.available(req);
if (!avail.ok) return finish({ ...base, status: 'unavailable', exit: 1, reason: 'runner_unavailable', detail: avail.reason }, started);
const { callModel } = await import('./outside-voice-api');
try {
const res = await callModel({ model: req.model!, prompt, timeoutMs: req.timeoutMs, env: req.env, signal: this.controller.signal });
fs.mkdirSync(path.dirname(req.outFile), { recursive: true });
fs.writeFileSync(req.outFile, res.text);
const usage: RunnerUsage = { ...res.usage, usd: 'unknown' };
const { status, verdict } = classify(res.text, req.gate, { exit: 0 });
return finish({ ...base, model: res.model, family: modelFamily(res.model), status, exit: 0, usage, verdict, ...(verdict.reason ? { reason: verdict.reason } : {}), ...(verdict.detail ? { detail: verdict.detail } : {}) }, started, req.outFile);
} catch (e: any) {
const cancelled = this.controller.signal.aborted;
const timeout = /timeout/i.test(String(e?.message ?? e)) || e?.name === 'TimeoutError';
return finish({ ...base, status: cancelled ? 'cancelled' : timeout ? 'timeout' : 'failed', exit: timeout ? 124 : 1, reason: cancelled ? 'cancelled' : timeout ? 'timeout' : 'provider_error', detail: String(e?.message ?? e) }, started);
}
}
}
// ---------------------------------------------------------------------------
// host-subagent: the host ran a cross-family subagent; this binds its file
// ---------------------------------------------------------------------------
class HostSubagentRunner implements OutsideRunner {
id: RunnerId = 'host-subagent';
capabilities: RunnerCapabilities = { repository_access: 'host', executes_here: false, label: 'host-dispatched review' };
available(req: Pick<RunnerRequest, 'resultFile' | 'model'>): { ok: true } | { ok: false; reason: string } {
if (!req.resultFile) return { ok: false, reason: '--result <file> is required for host-subagent (the file the subagent wrote)' };
if (!fs.existsSync(req.resultFile)) return { ok: false, reason: `result file not found: ${req.resultFile}` };
if (!req.model) return { ok: false, reason: '--model <id> is required for host-subagent (the subagent’s model)' };
return { ok: true };
}
cancel(): void { /* nothing runs here */ }
async run(req: RunnerRequest): Promise<RunnerResult> {
const started = Date.now();
const prompt = fs.readFileSync(req.promptFile);
const base = { runner: this.id, capabilities: this.capabilities, input_sha256: sha256(prompt), started_at: new Date(started).toISOString(), usage: { usd: 'unknown' } as RunnerUsage, model: req.model ?? null, family: modelFamily(req.model), output: null } as const;
const avail = this.available(req);
if (!avail.ok) return finish({ ...base, status: 'unavailable', exit: 1, reason: 'runner_unavailable', detail: avail.reason }, started);
fs.mkdirSync(path.dirname(req.outFile), { recursive: true });
if (path.resolve(req.resultFile!) !== path.resolve(req.outFile)) fs.copyFileSync(req.resultFile!, req.outFile);
const text = fs.readFileSync(req.outFile, 'utf8');
const { status, verdict } = classify(text, req.gate, { exit: 0 });
return finish({ ...base, status, exit: 0, verdict, ...(verdict.reason ? { reason: verdict.reason } : {}), ...(verdict.detail ? { detail: verdict.detail } : {}) }, started, req.outFile);
}
}
export function createRunner(id: RunnerId): OutsideRunner {
if (id === 'codex-cli') return new CodexCliRunner();
if (id === 'api') return new ApiRunner();
if (id === 'host-subagent') return new HostSubagentRunner();
throw new Error(`unknown runner ${id}`);
}
/** The runner table, once: id, capabilities and whether it can run here. */
export function runnerTable(req: Pick<RunnerRequest, 'env' | 'resultFile' | 'model' | 'binDir'> = {}): Array<{ id: RunnerId; capabilities: RunnerCapabilities; available: boolean; reason?: string }> {
return RUNNER_IDS.map(id => {
const r = createRunner(id);
const a = r.available(req);
return { id, capabilities: r.capabilities, available: a.ok, ...(a.ok ? {} : { reason: a.reason }) };
});
}
/**
* Pick the runner to use: the requested one when available; otherwise the
* configured fallback (`--fallback` or GSTACK_OUTSIDE_RUNNER) with the line
* `<requested> unavailable; using <fallback>`; with no fallback, unavailable.
*/
export function selectRunner(requested: RunnerId, req: Pick<RunnerRequest, 'env' | 'resultFile' | 'model' | 'binDir'>, fallback?: RunnerId): { runner: RunnerId; note?: string } | { runner: null; reason: string } {
const first = createRunner(requested).available(req);
if (first.ok) return { runner: requested };
if (fallback && fallback !== requested) {
const second = createRunner(fallback).available(req);
if (second.ok) return { runner: fallback, note: `${requested} unavailable; using ${fallback}` };
return { runner: null, reason: `${requested} unavailable (${first.reason}); ${fallback} unavailable (${second.reason})` };
}
return { runner: null, reason: `${requested} unavailable (${first.reason})` };
}
export function outsideStatusLine(result: RunnerResult): string {
const label = result.capabilities.label === 'supplied-input review' ? ' label=supplied-input-review' : '';
return `OUTSIDE_STATUS: ${result.status} provider=${result.runner} model=${result.model ?? 'unknown'} family=${result.family}${label}`;
}