小橘 e05c71d6b0 refactor(sense-generator): use createCursorRole factory, slim meta to routing-only
- planner/coder: replaced 80+ lines hand-written agent calls with createCursorRole()
- SenseMeta slimmed to routing signals only (senseName, filesCreated, passed/attempt)
- Roles read context from thread via nerve thread <id>, not from previous role's meta
- tester stays hand-written (pure CLI logic)
- Re-exported spawnSafe from workflow-utils for helper use

Refs uncaged/nerve#210

小橘 🍊(NEKO Team)
2026-04-28 02:22:38 +00:00

1070 lines
30 KiB
TypeScript

import type {
ModeratorContext,
RoleResult,
StartStep,
WorkflowDefinition,
WorkflowMessage,
} from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core";
import type { SpawnError } from "@uncaged/nerve-workflow-utils";
import { cursorAgent, llmExtract, nerveAgentContext, readNerveYaml, spawnSafe } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
const NERVE_ROOT = "/home/azureuser/.uncaged-nerve";
const ISSUE_READER_MAX_ATTEMPTS = 3;
const TESTER_MAX_ATTEMPTS = 3;
const PUBLISHER_MAX_ATTEMPTS = 3;
type IssueData = {
id: number;
number: number;
title: string;
body: string;
state: string;
labels: string[];
comments: Array<{
author: string;
body: string;
createdAt: string;
}>;
url: string;
};
type TestCommandResult = {
command: string;
ok: boolean;
stdoutPreview: string;
stderrPreview: string;
};
type WorkflowMeta = {
intake: {
issueUrl: string;
host: string | null;
owner: string | null;
repo: string | null;
issueNumber: number | null;
valid: boolean;
failureKind: "none" | "invalid_input";
failureReason: string | null;
};
"issue-reader": {
issue: IssueData | null;
fetchOk: boolean;
transientError: boolean;
attempt: number;
failureKind: "none" | "transient" | "permanent";
failureReason: string | null;
};
planner: {
plan: string | null;
targetFiles: string[] | null;
testCommands: string[] | null;
riskNotes: string[] | null;
planningOk: boolean;
failureKind: "none" | "planning_failed";
failureReason: string | null;
};
implementer: {
branchName: string | null;
changedFiles: string[] | null;
implementationOk: boolean;
attempt: number;
failureKind: "none" | "branch_failed" | "agent_failed" | "no_diff";
failureReason: string | null;
implementationLog: string | null;
};
tester: {
passed: boolean;
attempt: number;
failureReason: string | null;
testCommandResults: TestCommandResult[] | null;
};
"pr-publisher": {
prUrl: string | null;
prNumber: number | null;
linkedIssue: string | null;
published: boolean;
attempt: number;
failureKind: "none" | "auth_or_network" | "permanent";
failureReason: string | null;
};
};
type Provider = {
baseUrl: string;
apiKey: string;
model: string;
};
const issueSchema = z.object({
id: z.number().int().default(0),
number: z.number().int().default(0),
title: z.string().default(""),
body: z.string().default(""),
state: z.string().default("unknown"),
labels: z.array(z.string().default("")).default([]),
comments: z
.array(
z.object({
author: z.string().default("unknown"),
body: z.string().default(""),
createdAt: z.string().default(""),
}),
)
.default([]),
url: z.string().default(""),
});
const planSchema = z.object({
targetFiles: z.array(z.string().default("")).default([]),
testCommands: z.array(z.string().default("")).default([]),
riskNotes: z.array(z.string().default("")).default([]),
});
function lastMetaForRole<M>(messages: WorkflowMessage[], role: string): M | null {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === role) {
return messages[i].meta as M;
}
}
return null;
}
function formatSpawnFailure(error: SpawnError): string {
if (error.kind === "spawn_failed") {
return `spawn_failed: ${error.message}`;
}
if (error.kind === "timeout") {
return `timeout: stdout=${error.stdout.slice(0, 200)} stderr=${error.stderr.slice(0, 200)}`;
}
return `non_zero_exit(${error.exitCode}): stderr=${error.stderr.slice(0, 400)}`;
}
function errorText(error: SpawnError): string {
if (error.kind === "spawn_failed") {
return error.message.toLowerCase();
}
if (error.kind === "timeout") {
return `${error.stdout} ${error.stderr}`.toLowerCase();
}
return `${error.stdout} ${error.stderr}`.toLowerCase();
}
function classifyIssueReaderFailure(error: SpawnError): { transientError: boolean; failureKind: "transient" | "permanent" } {
if (error.kind === "timeout" || error.kind === "spawn_failed") {
return { transientError: true, failureKind: "transient" };
}
const text = errorText(error);
if (
text.includes("network") ||
text.includes("timed out") ||
text.includes("timeout") ||
text.includes("connection reset") ||
text.includes("connection refused") ||
text.includes("429") ||
text.includes("500") ||
text.includes("502") ||
text.includes("503") ||
text.includes("504")
) {
return { transientError: true, failureKind: "transient" };
}
return { transientError: false, failureKind: "permanent" };
}
function classifyPublisherFailure(error: SpawnError): "auth_or_network" | "permanent" {
if (error.kind === "timeout" || error.kind === "spawn_failed") {
return "auth_or_network";
}
const text = errorText(error);
if (
text.includes("401") ||
text.includes("403") ||
text.includes("auth") ||
text.includes("token") ||
text.includes("permission") ||
text.includes("network") ||
text.includes("connection") ||
text.includes("timeout")
) {
return "auth_or_network";
}
return "permanent";
}
function parseIssueUrl(raw: string): WorkflowMeta["intake"] {
const issueUrl = raw.trim();
const match = issueUrl.match(/^https?:\/\/([^/\s]+)\/([^/\s]+)\/([^/\s]+)\/issues\/(\d+)(?:[/?#].*)?$/i);
if (match === null) {
return {
issueUrl,
host: null,
owner: null,
repo: null,
issueNumber: null,
valid: false,
failureKind: "invalid_input",
failureReason: `invalid issue URL: ${issueUrl}`,
};
}
const issueNumber = Number(match[4]);
if (!Number.isInteger(issueNumber) || issueNumber <= 0) {
return {
issueUrl,
host: null,
owner: null,
repo: null,
issueNumber: null,
valid: false,
failureKind: "invalid_input",
failureReason: `invalid issue number: ${match[4]}`,
};
}
return {
issueUrl,
host: match[1],
owner: match[2],
repo: match[3],
issueNumber,
valid: true,
failureKind: "none",
failureReason: null,
};
}
function slugify(input: string): string {
const normalized = input
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return normalized.length > 0 ? normalized.slice(0, 40) : "update";
}
function readNerveConfigText(): string {
const result = readNerveYaml({ nerveRoot: NERVE_ROOT });
return result.ok ? result.value : "# nerve.yaml unavailable";
}
async function cfgGet(key: string): Promise<string | null> {
const result = await spawnSafe("cfg", ["get", key], { cwd: NERVE_ROOT, env: null, timeoutMs: 10_000 });
if (!result.ok) {
return null;
}
const value = result.value.stdout.trim();
return value.length > 0 ? value : null;
}
async function resolveDashScopeProvider(): Promise<Provider | null> {
const apiKey = process.env.DASHSCOPE_API_KEY ?? (await cfgGet("DASHSCOPE_API_KEY"));
const baseUrl = process.env.DASHSCOPE_BASE_URL ?? (await cfgGet("DASHSCOPE_BASE_URL"));
const model = process.env.DASHSCOPE_MODEL ?? (await cfgGet("DASHSCOPE_MODEL")) ?? "qwen-plus";
if (!apiKey || !baseUrl) {
return null;
}
return { apiKey, baseUrl, model };
}
function toIssueFromJson(raw: unknown): IssueData | null {
if (typeof raw !== "object" || raw === null) {
return null;
}
const obj = raw as Record<string, unknown>;
const labels = (Array.isArray(obj.labels) ? obj.labels : [])
.map((label) => {
if (typeof label === "string") {
return label;
}
if (typeof label === "object" && label !== null && typeof (label as Record<string, unknown>).name === "string") {
return String((label as Record<string, unknown>).name);
}
return "";
})
.filter((label) => label.length > 0);
const comments = (Array.isArray(obj.comments) ? obj.comments : []).map((comment) => {
const item = (comment ?? {}) as Record<string, unknown>;
const userObj = (item.user ?? {}) as Record<string, unknown>;
return {
author:
typeof item.author === "string"
? item.author
: typeof userObj.login === "string"
? userObj.login
: "unknown",
body: typeof item.body === "string" ? item.body : "",
createdAt:
typeof item.created_at === "string"
? item.created_at
: typeof item.createdAt === "string"
? item.createdAt
: "",
};
});
const parsed = issueSchema.parse({
id: Number(obj.id ?? 0),
number: Number(obj.number ?? 0),
title: typeof obj.title === "string" ? obj.title : "",
body: typeof obj.body === "string" ? obj.body : "",
state: typeof obj.state === "string" ? obj.state : "unknown",
labels,
comments,
url: typeof obj.url === "string" ? obj.url : "",
});
return parsed.number > 0 ? parsed : null;
}
async function parseIssueFromText(text: string, provider: Provider | null): Promise<IssueData | null> {
if (provider === null) {
return null;
}
const extracted = await llmExtract({ text, schema: issueSchema, provider });
if (!extracted.ok) {
return null;
}
return extracted.value.number > 0 ? extracted.value : null;
}
async function runWithRetries(
run: () => Promise<
| { ok: true; stdout: string; stderr: string }
| { ok: false; error: SpawnError; lastStdout: string; lastStderr: string }
>,
maxAttempts: number,
): Promise<
| { ok: true; stdout: string; stderr: string; attemptsUsed: number }
| { ok: false; error: SpawnError; attemptsUsed: number; lastStdout: string; lastStderr: string }
> {
let attemptsUsed = 0;
let lastError: SpawnError | null = null;
let lastStdout = "";
let lastStderr = "";
while (attemptsUsed < maxAttempts) {
attemptsUsed += 1;
const current = await run();
if (current.ok) {
return { ok: true, stdout: current.stdout, stderr: current.stderr, attemptsUsed };
}
lastError = current.error;
lastStdout = current.lastStdout;
lastStderr = current.lastStderr;
if (attemptsUsed < maxAttempts) {
const backoffMs = Math.min(1000 * 2 ** (attemptsUsed - 1), 4000);
await new Promise((resolve) => setTimeout(resolve, backoffMs));
}
}
if (lastError === null) {
return {
ok: false,
error: { kind: "spawn_failed", message: "unknown retry failure" },
attemptsUsed,
lastStdout,
lastStderr,
};
}
return { ok: false, error: lastError, attemptsUsed, lastStdout, lastStderr };
}
function extractPrInfo(text: string): { prUrl: string | null; prNumber: number | null } {
const url = text.match(/https?:\/\/\S+/)?.[0] ?? null;
const num = text.match(/(?:pulls|pull|pr)\/(\d+)/i)?.[1] ?? text.match(/#(\d+)/)?.[1] ?? null;
return { prUrl: url, prNumber: num === null ? null : Number(num) };
}
async function runTestCommand(command: string): Promise<{
ok: boolean;
stdoutPreview: string;
stderrPreview: string;
reason: string | null;
}> {
const result = await spawnSafe("bash", ["-lc", command], {
cwd: NERVE_ROOT,
env: null,
timeoutMs: 300_000,
});
if (result.ok) {
return {
ok: true,
stdoutPreview: result.value.stdout.slice(0, 600),
stderrPreview: result.value.stderr.slice(0, 400),
reason: null,
};
}
return {
ok: false,
stdoutPreview: (result.error.kind === "spawn_failed" ? "" : result.error.stdout).slice(0, 600),
stderrPreview: (result.error.kind === "spawn_failed" ? result.error.message : result.error.stderr).slice(0, 400),
reason: formatSpawnFailure(result.error),
};
}
async function intake(start: StartStep, _messages: WorkflowMessage[]): Promise<RoleResult<WorkflowMeta["intake"]>> {
const parsed = parseIssueUrl(start.content);
if (!parsed.valid) {
return { content: parsed.failureReason ?? "invalid issue URL", meta: parsed };
}
return {
content: `parsed issue URL: ${parsed.owner}/${parsed.repo}#${parsed.issueNumber} @ ${parsed.host}`,
meta: parsed,
};
}
async function issueReader(
_start: StartStep,
messages: WorkflowMessage[],
): Promise<RoleResult<WorkflowMeta["issue-reader"]>> {
const intakeMeta = lastMetaForRole<WorkflowMeta["intake"]>(messages, "intake");
const attempt = messages.filter((message) => message.role === "issue-reader").length + 1;
if (
intakeMeta === null ||
!intakeMeta.valid ||
intakeMeta.owner === null ||
intakeMeta.repo === null ||
intakeMeta.issueNumber === null
) {
return {
content: "issue-reader cannot continue: missing valid intake meta",
meta: {
issue: null,
fetchOk: false,
transientError: false,
attempt,
failureKind: "permanent",
failureReason: "missing valid intake meta",
},
};
}
const repoSpec = `${intakeMeta.owner}/${intakeMeta.repo}`;
const jsonRun = await runWithRetries(async () => {
const run = await spawnSafe(
"tea",
[
"issue",
"show",
String(intakeMeta.issueNumber),
"--repo",
repoSpec,
"--comments",
"--json",
"id,number,title,body,state,labels,comments,url",
],
{
cwd: NERVE_ROOT,
env: null,
timeoutMs: 60_000,
},
);
if (run.ok) {
return { ok: true, stdout: run.value.stdout, stderr: run.value.stderr };
}
return {
ok: false,
error: run.error,
lastStdout: run.error.kind === "spawn_failed" ? "" : run.error.stdout,
lastStderr: run.error.kind === "spawn_failed" ? run.error.message : run.error.stderr,
};
}, ISSUE_READER_MAX_ATTEMPTS);
if (jsonRun.ok) {
try {
const issue = toIssueFromJson(JSON.parse(jsonRun.stdout) as unknown);
if (issue !== null) {
return {
content: `issue fetched: #${issue.number} ${issue.title}`,
meta: {
issue,
fetchOk: true,
transientError: false,
attempt,
failureKind: "none",
failureReason: null,
},
};
}
} catch {
// fallback to text parsing
}
}
const textRun = await runWithRetries(async () => {
const run = await spawnSafe(
"tea",
["issue", "show", String(intakeMeta.issueNumber), "--repo", repoSpec, "--comments"],
{
cwd: NERVE_ROOT,
env: null,
timeoutMs: 60_000,
},
);
if (run.ok) {
return { ok: true, stdout: run.value.stdout, stderr: run.value.stderr };
}
return {
ok: false,
error: run.error,
lastStdout: run.error.kind === "spawn_failed" ? "" : run.error.stdout,
lastStderr: run.error.kind === "spawn_failed" ? run.error.message : run.error.stderr,
};
}, ISSUE_READER_MAX_ATTEMPTS);
if (textRun.ok) {
const provider = await resolveDashScopeProvider();
const issue = await parseIssueFromText(textRun.stdout, provider);
if (issue !== null) {
return {
content: `issue fetched (text+extract): #${issue.number} ${issue.title}`,
meta: {
issue,
fetchOk: true,
transientError: false,
attempt,
failureKind: "none",
failureReason: null,
},
};
}
return {
content: "tea issue output received, but could not parse structured fields.",
meta: {
issue: null,
fetchOk: false,
transientError: false,
attempt,
failureKind: "permanent",
failureReason: "unable to parse tea issue output",
},
};
}
const classified = classifyIssueReaderFailure(textRun.error);
return {
content: `issue read failed after retries: ${formatSpawnFailure(textRun.error)}`,
meta: {
issue: null,
fetchOk: false,
transientError: classified.transientError,
attempt,
failureKind: classified.failureKind,
failureReason: formatSpawnFailure(textRun.error),
},
};
}
async function planner(_start: StartStep, messages: WorkflowMessage[]): Promise<RoleResult<WorkflowMeta["planner"]>> {
const issueMeta = lastMetaForRole<WorkflowMeta["issue-reader"]>(messages, "issue-reader");
if (issueMeta === null || !issueMeta.fetchOk || issueMeta.issue === null) {
return {
content: "planner cannot continue: issue-reader has no issue data",
meta: {
plan: null,
targetFiles: null,
testCommands: null,
riskNotes: null,
planningOk: false,
failureKind: "planning_failed",
failureReason: "missing issue data",
},
};
}
const prompt = `You are planning a fix for a Gitea issue in this repository.
${nerveAgentContext}
Issue URL: ${issueMeta.issue.url}
Issue title: ${issueMeta.issue.title}
Issue body:
${issueMeta.issue.body}
Issue comments:
${issueMeta.issue.comments.map((c) => `- ${c.author} (${c.createdAt}): ${c.body}`).join("\n") || "(none)"}
Current nerve.yaml:
\`\`\`yaml
${readNerveConfigText()}
\`\`\`
Output implementation-ready markdown with sections:
1) Problem understanding
2) Change strategy
3) Test strategy (commands)
4) Risks`;
const result = await cursorAgent({
prompt,
mode: "ask",
cwd: NERVE_ROOT,
env: null,
timeoutMs: null,
});
if (!result.ok) {
return {
content: `planner failed: ${formatSpawnFailure(result.error)}`,
meta: {
plan: null,
targetFiles: null,
testCommands: null,
riskNotes: null,
planningOk: false,
failureKind: "planning_failed",
failureReason: formatSpawnFailure(result.error),
},
};
}
const plan = result.value;
const provider = await resolveDashScopeProvider();
if (provider === null) {
return {
content: plan,
meta: {
plan,
targetFiles: null,
testCommands: null,
riskNotes: null,
planningOk: true,
failureKind: "none",
failureReason: null,
},
};
}
const structured = await llmExtract({ text: plan, schema: planSchema, provider });
if (!structured.ok) {
return {
content: `${plan}\n\n[llmExtract error] ${JSON.stringify(structured.error)}`,
meta: {
plan,
targetFiles: null,
testCommands: null,
riskNotes: null,
planningOk: true,
failureKind: "none",
failureReason: null,
},
};
}
return {
content: plan,
meta: {
plan,
targetFiles: structured.value.targetFiles,
testCommands: structured.value.testCommands,
riskNotes: structured.value.riskNotes,
planningOk: true,
failureKind: "none",
failureReason: null,
},
};
}
async function implementer(
_start: StartStep,
messages: WorkflowMessage[],
): Promise<RoleResult<WorkflowMeta["implementer"]>> {
const plannerMeta = lastMetaForRole<WorkflowMeta["planner"]>(messages, "planner");
const intakeMeta = lastMetaForRole<WorkflowMeta["intake"]>(messages, "intake");
const issueMeta = lastMetaForRole<WorkflowMeta["issue-reader"]>(messages, "issue-reader");
const attempt = messages.filter((message) => message.role === "implementer").length + 1;
if (
plannerMeta === null ||
!plannerMeta.planningOk ||
plannerMeta.plan === null ||
intakeMeta === null ||
intakeMeta.issueNumber === null
) {
return {
content: "implementer cannot continue: missing planner or intake context",
meta: {
branchName: null,
changedFiles: null,
implementationOk: false,
attempt,
failureKind: "agent_failed",
failureReason: "missing planner/intake context",
implementationLog: null,
},
};
}
const slugSource = issueMeta?.issue?.title ?? plannerMeta.plan.split("\n")[0] ?? "fix";
const branchName = `fix/issue-${intakeMeta.issueNumber}-${slugify(slugSource)}`;
const existsResult = await spawnSafe("git", ["rev-parse", "--verify", `refs/heads/${branchName}`], {
cwd: NERVE_ROOT,
env: null,
timeoutMs: 10_000,
});
const checkoutResult = existsResult.ok
? await spawnSafe("git", ["checkout", branchName], { cwd: NERVE_ROOT, env: null, timeoutMs: 20_000 })
: await spawnSafe("git", ["checkout", "-b", branchName], { cwd: NERVE_ROOT, env: null, timeoutMs: 20_000 });
if (!checkoutResult.ok) {
return {
content: `branch setup failed: ${formatSpawnFailure(checkoutResult.error)}`,
meta: {
branchName,
changedFiles: null,
implementationOk: false,
attempt,
failureKind: "branch_failed",
failureReason: formatSpawnFailure(checkoutResult.error),
implementationLog: null,
},
};
}
const prompt = `Implement the planned fix in this repository.
Issue #${intakeMeta.issueNumber}
Plan:
${plannerMeta.plan}
Target files:
${plannerMeta.targetFiles?.join("\n") ?? "(not specified)"}
Test commands:
${plannerMeta.testCommands?.join("\n") ?? "(not specified)"}
Apply the code changes now with minimal, focused edits.`;
const agentResult = await cursorAgent({
prompt,
mode: "default",
cwd: NERVE_ROOT,
env: null,
timeoutMs: null,
});
if (!agentResult.ok) {
return {
content: `implementer agent failed: ${formatSpawnFailure(agentResult.error)}`,
meta: {
branchName,
changedFiles: null,
implementationOk: false,
attempt,
failureKind: "agent_failed",
failureReason: formatSpawnFailure(agentResult.error),
implementationLog: null,
},
};
}
const diffResult = await spawnSafe("git", ["diff", "--name-only"], {
cwd: NERVE_ROOT,
env: null,
timeoutMs: 15_000,
});
if (!diffResult.ok) {
return {
content: `implementation finished but diff check failed: ${formatSpawnFailure(diffResult.error)}`,
meta: {
branchName,
changedFiles: null,
implementationOk: false,
attempt,
failureKind: "no_diff",
failureReason: formatSpawnFailure(diffResult.error),
implementationLog: agentResult.value,
},
};
}
const changedFiles = diffResult.value.stdout
.split("\n")
.map((file) => file.trim())
.filter((file) => file.length > 0);
if (changedFiles.length === 0) {
return {
content: "implementer made no local diff",
meta: {
branchName,
changedFiles: [],
implementationOk: false,
attempt,
failureKind: "no_diff",
failureReason: "git diff --name-only is empty",
implementationLog: agentResult.value,
},
};
}
return {
content: `branch ready: ${branchName}\nchanged files:\n${changedFiles.join("\n")}`,
meta: {
branchName,
changedFiles,
implementationOk: true,
attempt,
failureKind: "none",
failureReason: null,
implementationLog: agentResult.value,
},
};
}
async function tester(_start: StartStep, messages: WorkflowMessage[]): Promise<RoleResult<WorkflowMeta["tester"]>> {
const plannerMeta = lastMetaForRole<WorkflowMeta["planner"]>(messages, "planner");
const implementerMeta = lastMetaForRole<WorkflowMeta["implementer"]>(messages, "implementer");
const attempt = messages.filter((message) => message.role === "tester").length + 1;
if (implementerMeta === null || !implementerMeta.implementationOk || implementerMeta.branchName === null) {
return {
content: "tester cannot continue: no successful implementer output",
meta: {
passed: false,
attempt,
failureReason: "no successful implementer output",
testCommandResults: null,
},
};
}
const commands =
plannerMeta?.testCommands !== null && plannerMeta?.testCommands !== undefined && plannerMeta.testCommands.length > 0
? plannerMeta.testCommands
: ["pnpm test"];
const testCommandResults: TestCommandResult[] = [];
for (const command of commands) {
const run = await runTestCommand(command);
testCommandResults.push({
command,
ok: run.ok,
stdoutPreview: run.stdoutPreview,
stderrPreview: run.stderrPreview,
});
if (!run.ok) {
return {
content: `test failed: ${command}\n${run.reason ?? "unknown error"}`,
meta: {
passed: false,
attempt,
failureReason: `command failed: ${command} (${run.reason ?? "unknown error"})`,
testCommandResults,
},
};
}
}
return {
content: `all tests passed on branch ${implementerMeta.branchName}`,
meta: {
passed: true,
attempt,
failureReason: null,
testCommandResults,
},
};
}
async function prPublisher(
_start: StartStep,
messages: WorkflowMessage[],
): Promise<RoleResult<WorkflowMeta["pr-publisher"]>> {
const attempt = messages.filter((message) => message.role === "pr-publisher").length + 1;
const intakeMeta = lastMetaForRole<WorkflowMeta["intake"]>(messages, "intake");
const issueMeta = lastMetaForRole<WorkflowMeta["issue-reader"]>(messages, "issue-reader");
const plannerMeta = lastMetaForRole<WorkflowMeta["planner"]>(messages, "planner");
const implementerMeta = lastMetaForRole<WorkflowMeta["implementer"]>(messages, "implementer");
const testerMeta = lastMetaForRole<WorkflowMeta["tester"]>(messages, "tester");
if (testerMeta === null || !testerMeta.passed) {
return {
content: "skip PR publishing: tester.passed is not true",
meta: {
prUrl: null,
prNumber: null,
linkedIssue: intakeMeta?.issueUrl ?? null,
published: false,
attempt,
failureKind: "permanent",
failureReason: "tester did not pass",
},
};
}
if (
intakeMeta === null ||
intakeMeta.owner === null ||
intakeMeta.repo === null ||
implementerMeta === null ||
implementerMeta.branchName === null
) {
return {
content: "pr-publisher cannot continue: missing required context",
meta: {
prUrl: null,
prNumber: null,
linkedIssue: intakeMeta?.issueUrl ?? null,
published: false,
attempt,
failureKind: "permanent",
failureReason: "missing context",
},
};
}
const pushRun = await runWithRetries(async () => {
const run = await spawnSafe("git", ["push", "-u", "origin", implementerMeta.branchName as string], {
cwd: NERVE_ROOT,
env: null,
timeoutMs: 180_000,
});
if (run.ok) {
return { ok: true, stdout: run.value.stdout, stderr: run.value.stderr };
}
return {
ok: false,
error: run.error,
lastStdout: run.error.kind === "spawn_failed" ? "" : run.error.stdout,
lastStderr: run.error.kind === "spawn_failed" ? run.error.message : run.error.stderr,
};
}, PUBLISHER_MAX_ATTEMPTS);
if (!pushRun.ok) {
const failureKind = classifyPublisherFailure(pushRun.error);
return {
content: `failed to push branch before PR: ${formatSpawnFailure(pushRun.error)}`,
meta: {
prUrl: null,
prNumber: null,
linkedIssue: intakeMeta.issueUrl,
published: false,
attempt,
failureKind,
failureReason: formatSpawnFailure(pushRun.error),
},
};
}
const shortTitle = (issueMeta?.issue?.title ?? "issue fix").slice(0, 72);
const title = `fix: ${shortTitle}`;
const body = [
`Issue: ${intakeMeta.issueUrl}`,
"",
"## Summary",
...(implementerMeta.changedFiles ?? []).map((file) => `- updated \`${file}\``),
"",
"## Plan",
plannerMeta?.plan ?? "(no planner output)",
"",
"## Test Results",
testerMeta.testCommandResults?.map((r) => `- ${r.ok ? "PASS" : "FAIL"} \`${r.command}\``).join("\n") ??
"- no test logs",
].join("\n");
const repoSpec = `${intakeMeta.owner}/${intakeMeta.repo}`;
const prRun = await runWithRetries(async () => {
const run = await spawnSafe(
"tea",
["pr", "create", "--repo", repoSpec, "--title", title, "--body", body, "--head", implementerMeta.branchName as string],
{
cwd: NERVE_ROOT,
env: null,
timeoutMs: 120_000,
},
);
if (run.ok) {
return { ok: true, stdout: run.value.stdout, stderr: run.value.stderr };
}
return {
ok: false,
error: run.error,
lastStdout: run.error.kind === "spawn_failed" ? "" : run.error.stdout,
lastStderr: run.error.kind === "spawn_failed" ? run.error.message : run.error.stderr,
};
}, PUBLISHER_MAX_ATTEMPTS);
if (!prRun.ok) {
const failureKind = classifyPublisherFailure(prRun.error);
return {
content: `PR creation failed: ${formatSpawnFailure(prRun.error)}`,
meta: {
prUrl: null,
prNumber: null,
linkedIssue: intakeMeta.issueUrl,
published: false,
attempt,
failureKind,
failureReason: formatSpawnFailure(prRun.error),
},
};
}
const info = extractPrInfo(`${prRun.stdout}\n${prRun.stderr}`);
return {
content: `PR created: ${title}\n${prRun.stdout}`,
meta: {
prUrl: info.prUrl,
prNumber: info.prNumber,
linkedIssue: intakeMeta.issueUrl,
published: true,
attempt,
failureKind: "none",
failureReason: null,
},
};
}
const workflow: WorkflowDefinition<WorkflowMeta> = {
name: "gitea-issue-solver",
roles: {
intake,
"issue-reader": issueReader,
planner,
implementer,
tester,
"pr-publisher": prPublisher,
},
moderator(context: ModeratorContext<WorkflowMeta>) {
if (context.steps.length === 0) {
return "intake";
}
const last = context.steps[context.steps.length - 1];
if (last.role === "intake") {
const meta = last.meta as WorkflowMeta["intake"];
return meta.valid ? "issue-reader" : END;
}
if (last.role === "issue-reader") {
const meta = last.meta as WorkflowMeta["issue-reader"];
if (meta.fetchOk) {
return "planner";
}
return END;
}
if (last.role === "planner") {
const meta = last.meta as WorkflowMeta["planner"];
return meta.planningOk ? "implementer" : END;
}
if (last.role === "implementer") {
return "tester";
}
if (last.role === "tester") {
const meta = last.meta as WorkflowMeta["tester"];
if (meta.passed) {
return "pr-publisher";
}
return meta.attempt < TESTER_MAX_ATTEMPTS ? "implementer" : END;
}
if (last.role === "pr-publisher") {
return END;
}
return END;
},
};
export default workflow;