diff --git a/.operator/data/tasks/T20260705-94A14772.md b/.operator/data/tasks/T20260705-94A14772.md index 1524fa0..53b0cb6 100644 --- a/.operator/data/tasks/T20260705-94A14772.md +++ b/.operator/data/tasks/T20260705-94A14772.md @@ -2,12 +2,12 @@ id: T20260705-94A14772 kind: task title: Split closed-pr-recovery.ts under the 200 code-line cap by extracting rejection-issue creation -status: pending +status: completed priority: 4 created_at: '2026-07-11T08:48:02Z' +completed_at: "2026-08-06T22:39:32Z" parent_id: F20260705-CC7FF1B9 --- - # Split closed-pr-recovery.ts under the pipeline line cap ## Problem diff --git a/engine/pipeline/composers/_shared/rejection-diagnoser.ts b/engine/pipeline/composers/_shared/rejection-diagnoser.ts new file mode 100644 index 0000000..50cc4a7 --- /dev/null +++ b/engine/pipeline/composers/_shared/rejection-diagnoser.ts @@ -0,0 +1,62 @@ +import type { OperationContext, PromptSource } from "@operator/core"; +import { errorMessage } from "@operator/core"; +import type { AgentRuntime, AgentRunInput } from "../../../agents/runtime.js"; +import type { AgentsFile } from "../../../config/schemas.js"; +import { resolveRole, instructionsPathToTopic } from "../../../agents/roles.js"; +import { stripPreamble, stripCodeFences } from "../../../agents/output-parser.js"; +import type { Logger } from "../../../logging/logger.js"; +import type { StateContextVars, WorkItemFileData } from "../../../work-items/work-items.js"; + +export interface RejectionDiagnoserDeps { + readonly agentRuntime: AgentRuntime; + readonly agentsConfig: AgentsFile; + readonly promptSource: PromptSource; + readonly automationDir: string; + readonly workspacePath: string; + readonly stateVars?: StateContextVars; + readonly log?: Logger; +} + +export async function runRejectionDiagnoser( + deps: RejectionDiagnoserDeps, + ctx: OperationContext, + item: WorkItemFileData, + feedback: string, +): Promise { + try { + const diagRole = resolveRole(deps.agentsConfig, "diagnoser"); + const runInput: AgentRunInput = { + agentName: "diagnoser", + providerId: diagRole.provider, + promptContext: { + promptSource: deps.promptSource, + automationDir: deps.automationDir, + contextFiles: diagRole.context, + instructionsTopic: instructionsPathToTopic(diagRole.instructions), + vars: { TASK_ID: item.id, FEEDBACK: feedback, ...deps.stateVars }, + }, + taskContent: `Analyze rejection for ${item.id}: ${item.title}\n\n${feedback}`, + model: diagRole.model, + timeoutMs: diagRole.timeout * 1000, + tools: diagRole.tools.length > 0 ? diagRole.tools : undefined, + maxBudgetUsd: diagRole.maxBudget, + maxRetries: 1, + reviewEnabled: false, + cwd: deps.workspacePath, + }; + const result = await deps.agentRuntime.run(runInput, ctx); + const cleaned = stripPreamble(stripCodeFences(result.output.trim())); + const match = cleaned.match(/^recommendation:\s*(.+)$/m); + const value = match ? match[1].trim() : "poor-implementation"; + deps.log?.info(`rejection: diagnoser for ${item.id} → ${value}`, { + scope: "rejection", itemId: item.id, recommendation: value, + }); + return value; + } catch (err) { + deps.log?.error(`rejection: diagnoser agent failed for ${item.id}`, { + scope: "rejection", itemId: item.id, error: errorMessage(err), + cause: err instanceof Error && err.cause ? String(err.cause) : undefined, + }); + return "poor-implementation"; + } +} diff --git a/engine/pipeline/composers/_shared/rejection-issue.test.ts b/engine/pipeline/composers/_shared/rejection-issue.test.ts new file mode 100644 index 0000000..86b267f --- /dev/null +++ b/engine/pipeline/composers/_shared/rejection-issue.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ConventionsConfig, TrackerPlatform } from "@operator/core"; +import type { TemplateSource } from "../../../agents/kv-template-source.js"; +import type { Logger } from "../../../logging/logger.js"; +import { + createRejectionIssue, + loadRejectedIssueTemplate, + type RejectionIssueDeps, +} from "./rejection-issue.js"; + +const CONVENTIONS: ConventionsConfig = { + labels: { + pending: "ai:pending", processing: "ai:processing", + inReview: "ai:in-review", readyToMerge: "ai:ready-to-merge", failed: "ai:failed", manual: "ai:manual", + }, + branches: { + aiPrefix: "ai", init: "ai/init", tasks: "ai/tasks", + findings: "ai/findings", research: "ai/research", improver: "ai/improver", + }, + prPrefixes: { + task: "[AI:Task]", finding: "[AI:Finding]", research: "[AI:Research]", + improver: "[AI:Improver]", init: "[AI:Init]", + }, + patterns: { taskId: "T{DATE}-{SEQ}", findingPrefix: "F" }, + commentMarker: "", +}; + +function makeTracker(): TrackerPlatform { + return { + id: "github", + capabilities: { codeReviews: false, labels: false, branches: false, comments: false, workItems: true, issueHierarchy: false }, + getWorkItems: vi.fn().mockResolvedValue([]), + getWorkItem: vi.fn().mockResolvedValue(null), + updateWorkItem: vi.fn().mockResolvedValue(undefined), + postWorkItemComment: vi.fn().mockResolvedValue({ id: "1", author: "bot", body: "", createdAt: "" }), + createWorkItem: vi.fn().mockResolvedValue({ id: "1", kind: "request", title: "", body: "", status: "pending", priority: 5, createdAt: "", updatedAt: "" }), + }; +} + +let tmp: string; +let templatesDir: string; + +beforeEach(async () => { + tmp = await mkdtemp(join(tmpdir(), "rejection-issue-")); + templatesDir = join(tmp, "templates"); + await mkdir(templatesDir, { recursive: true }); +}); + +afterEach(async () => { + await rm(tmp, { recursive: true, force: true }); +}); + +function makeDeps(overrides?: Partial): RejectionIssueDeps { + return { + tracker: makeTracker(), + conventions: CONVENTIONS, + templatesDir, + ...overrides, + }; +} + +describe("loadRejectedIssueTemplate", () => { + it("loads from TemplateSource when wired", async () => { + const templates = { + load: vi.fn().mockResolvedValue("kv-body {ITEM_ID}"), + } as unknown as TemplateSource; + const body = await loadRejectedIssueTemplate(makeDeps({ templates }), { ITEM_ID: "T1" }); + expect(body).toBe("kv-body {ITEM_ID}"); + expect(templates.load).toHaveBeenCalledWith("rejected-issue-body.md", { ITEM_ID: "T1" }); + }); + + it("falls back to filesystem read when TemplateSource is missing", async () => { + await writeFile(join(templatesDir, "rejected-issue-body.md"), "fs-body {ITEM_ID}"); + const warn = vi.fn(); + const log = { info: vi.fn(), debug: vi.fn(), warn, error: vi.fn() } as unknown as Logger; + const body = await loadRejectedIssueTemplate(makeDeps({ log }), { ITEM_ID: "T1" }); + expect(body).toBe("fs-body T1"); + expect(warn).toHaveBeenCalledWith( + "rejection: TemplateSource missing, falling back to filesystem read", + expect.objectContaining({ scope: "rejection", templatesDir }), + ); + }); +}); + +describe("createRejectionIssue", () => { + it("creates a manual tracker issue with substituted template", async () => { + await writeFile(join(templatesDir, "rejected-issue-body.md"), "{ITEM_ID} {RECOMMENDATION}"); + const tracker = makeTracker(); + await createRejectionIssue( + makeDeps({ tracker }), + { + id: "T20260322-000101", kind: "task", title: "Fix bug", body: "details", + status: "pending", priority: 5, createdAt: "", previousPrs: "10,20", + }, + "task", + 50, + "max-retries", + 2, + ); + expect(tracker.createWorkItem).toHaveBeenCalledOnce(); + expect(tracker.createWorkItem).toHaveBeenCalledWith({ + title: "[ai:manual] task T20260322-000101: Fix bug", + body: "T20260322-000101 max-retries", + labels: ["ai:manual"], + }); + }); + + it("returns without calling tracker when none is supplied", async () => { + const tracker = makeTracker(); + await createRejectionIssue( + makeDeps({ tracker: undefined }), + { + id: "T1", kind: "task", title: "T", body: "", + status: "pending", priority: 5, createdAt: "", + }, + "task", + 1, + "max-retries", + 0, + ); + expect(tracker.createWorkItem).not.toHaveBeenCalled(); + }); + + it("logs error and continues when tracker.createWorkItem fails", async () => { + await writeFile(join(templatesDir, "rejected-issue-body.md"), "{ITEM_ID}"); + const tracker = makeTracker(); + (tracker.createWorkItem as ReturnType).mockRejectedValue(new Error("boom")); + const error = vi.fn(); + const log = { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error } as unknown as Logger; + await createRejectionIssue( + makeDeps({ tracker, log }), + { + id: "T20260322-000101", kind: "task", title: "T1", body: "", + status: "pending", priority: 5, createdAt: "", previousPrs: "10,20", + }, + "task", + 50, + "max-retries", + 2, + ); + expect(error).toHaveBeenCalledWith( + "rejection: manual-issue creation failed for T20260322-000101", + expect.objectContaining({ scope: "rejection", itemId: "T20260322-000101" }), + ); + }); +}); diff --git a/engine/pipeline/composers/_shared/rejection-issue.ts b/engine/pipeline/composers/_shared/rejection-issue.ts new file mode 100644 index 0000000..d04d5cb --- /dev/null +++ b/engine/pipeline/composers/_shared/rejection-issue.ts @@ -0,0 +1,80 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { ConventionsConfig, TrackerPlatform, WorkItemKind } from "@operator/core"; +import { errorMessage } from "@operator/core"; +import type { TemplateSource } from "../../../agents/kv-template-source.js"; +import type { Logger } from "../../../logging/logger.js"; +import type { WorkItemFileData } from "../../../work-items/work-items.js"; + +export interface RejectionIssueDeps { + readonly tracker?: TrackerPlatform; + readonly conventions: ConventionsConfig; + readonly templates?: TemplateSource; + readonly templatesDir: string; + readonly log?: Logger; +} + +/** + * Load the `rejected-issue-body.md` template with placeholder substitution. + * Prefers the KV template source when wired (Step 15 runtime path); falls + * back to the filesystem read against `templatesDir` only for test harnesses + * that stub templates on disk without a KV instance. + */ +export async function loadRejectedIssueTemplate( + deps: RejectionIssueDeps, + vars: Record, +): Promise { + if (deps.templates) { + return deps.templates.load("rejected-issue-body.md", vars); + } + deps.log?.warn("rejection: TemplateSource missing, falling back to filesystem read", { + scope: "rejection", templatesDir: deps.templatesDir, + }); + const templatePath = join(deps.templatesDir, "rejected-issue-body.md"); + let body = await readFile(templatePath, "utf-8"); + for (const [key, value] of Object.entries(vars)) { + body = body.replaceAll(`{${key}}`, value); + } + return body; +} + +export async function createRejectionIssue( + deps: RejectionIssueDeps, + item: WorkItemFileData, + kind: WorkItemKind, + prId: number, + recommendation: string, + prevCount: number, +): Promise { + if (!deps.tracker) return; + try { + const prLinks = item.previousPrs + ? item.previousPrs.split(",").map((n) => `- #${n.trim()}`).join("\n") + `\n- #${prId}` + : `- #${prId}`; + + const template = await loadRejectedIssueTemplate(deps, { + ITEM_TYPE: kind, + ITEM_TITLE: item.title, + ITEM_ID: item.id, + PRIORITY: String(item.priority), + RECOMMENDATION: recommendation, + ATTEMPT_COUNT: String(prevCount + 1), + ITEM_BODY: item.body, + REJECTION_REPORT: `Rejected after ${prevCount + 1} attempt(s)`, + PR_LINKS: prLinks, + }); + + const manualLabel = deps.conventions.labels.manual || "ai:manual"; + if (deps.tracker.createWorkItem) { + await deps.tracker.createWorkItem({ + title: `[${manualLabel}] ${kind} ${item.id}: ${item.title}`, + body: template, + labels: [manualLabel], + }); + } + } catch (err) { + deps.log?.error(`rejection: manual-issue creation failed for ${item.id}`, { + scope: "rejection", itemId: item.id, error: errorMessage(err), + }); + } +} diff --git a/engine/pipeline/composers/_shared/reopen-work-item.ts b/engine/pipeline/composers/_shared/reopen-work-item.ts new file mode 100644 index 0000000..4ba47f3 --- /dev/null +++ b/engine/pipeline/composers/_shared/reopen-work-item.ts @@ -0,0 +1,33 @@ +import { readFile, writeFile } from "node:fs/promises"; +import type { OperationContext, StateManager } from "@operator/core"; +import { syncWorkItemToDb, type WorkItemFileData } from "../../../work-items/work-items.js"; + +export async function reopenWorkItem( + filePath: string, + item: WorkItemFileData, + prId: number, + state: StateManager, + ctx: OperationContext, +): Promise { + const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); + let content = await readFile(filePath, "utf-8"); + content = content.replace(/^status:\s*.+$/m, "status: reopened"); + + if (/^reopened_at:/m.test(content)) { + content = content.replace(/^reopened_at:\s*.+$/m, `reopened_at: "${timestamp}"`); + } else { + content = content.replace(/^(status:\s*.+)$/m, `$1\nreopened_at: "${timestamp}"`); + } + + const prevPrs = item.previousPrs ? `${item.previousPrs},${prId}` : String(prId); + if (/^previous_prs:/m.test(content)) { + content = content.replace(/^previous_prs:\s*.+$/m, `previous_prs: ${prevPrs}`); + } else { + content = content.replace(/^(status:\s*.+)$/m, `$1\nprevious_prs: ${prevPrs}`); + } + + await writeFile(filePath, content, "utf-8"); + await syncWorkItemToDb(state, ctx, { + ...item, status: "reopened", previousPrs: prevPrs, + }); +} diff --git a/engine/pipeline/composers/closed-pr-recovery.ts b/engine/pipeline/composers/closed-pr-recovery.ts index 985f1af..e6f49c0 100644 --- a/engine/pipeline/composers/closed-pr-recovery.ts +++ b/engine/pipeline/composers/closed-pr-recovery.ts @@ -1,4 +1,4 @@ -import { readFile, writeFile, readdir } from "node:fs/promises"; +import { readdir } from "node:fs/promises"; import { join, dirname } from "node:path"; import type { OperationContext, StateManager, VCSPlatform, TrackerPlatform, @@ -6,16 +6,17 @@ import type { } from "@operator/core"; import type { KindDefinition } from "@operator/core"; import { errorMessage } from "@operator/core"; -import type { AgentRuntime, AgentRunInput } from "../../agents/runtime.js"; +import type { AgentRuntime } from "../../agents/runtime.js"; import type { AgentsFile } from "../../config/schemas.js"; import type { Logger } from "../../logging/logger.js"; -import { resolveRole, instructionsPathToTopic } from "../../agents/roles.js"; -import { stripPreamble, stripCodeFences } from "../../agents/output-parser.js"; import type { TemplateSource } from "../../agents/kv-template-source.js"; import { readWorkItemFile, updateWorkItemFileStatus, syncWorkItemToDb, type StateContextVars, type WorkItemFileData, } from "../../work-items/work-items.js"; +import { createRejectionIssue } from "./_shared/rejection-issue.js"; +import { reopenWorkItem } from "./_shared/reopen-work-item.js"; +import { runRejectionDiagnoser } from "./_shared/rejection-diagnoser.js"; /** * Rejection-handler sub-flow (ports v4 `processRejections` from the deleted @@ -182,9 +183,9 @@ async function processItem( // User feedback → diagnoser pass. if (userComments.trim()) { - const recommendation = await runDiagnoser(deps, ctx, item, userComments); + const recommendation = await runRejectionDiagnoser(deps, ctx, item, userComments); if (shouldReopen(recommendation) && prevCount < MAX_REOPENS) { - await reopenItem(filePath, item, rejectedPR.id, deps.state, ctx); + await reopenWorkItem(filePath, item, rejectedPR.id, deps.state, ctx); return "reopened"; } await createRejectionIssue(deps, item, kind, rejectedPR.id, recommendation, prevCount); @@ -195,7 +196,7 @@ async function processItem( // No user comments: auto-retry until MAX_REOPENS. if (prevCount < MAX_REOPENS) { - await reopenItem(filePath, item, rejectedPR.id, deps.state, ctx); + await reopenWorkItem(filePath, item, rejectedPR.id, deps.state, ctx); return "reopened"; } @@ -220,145 +221,6 @@ function shouldReopen(recommendation: string): boolean { return recommendation === "poor-implementation" || recommendation === "approach-wrong"; } -async function runDiagnoser( - deps: RejectionHandlerDeps, - ctx: OperationContext, - item: WorkItemFileData, - feedback: string, -): Promise { - try { - const diagRole = resolveRole(deps.agentsConfig, "diagnoser"); - const runInput: AgentRunInput = { - agentName: "diagnoser", - providerId: diagRole.provider, - promptContext: { - promptSource: deps.promptSource, - automationDir: deps.automationDir, - contextFiles: diagRole.context, - instructionsTopic: instructionsPathToTopic(diagRole.instructions), - vars: { TASK_ID: item.id, FEEDBACK: feedback, ...deps.stateVars }, - }, - taskContent: `Analyze rejection for ${item.id}: ${item.title}\n\n${feedback}`, - model: diagRole.model, - timeoutMs: diagRole.timeout * 1000, - tools: diagRole.tools.length > 0 ? diagRole.tools : undefined, - maxBudgetUsd: diagRole.maxBudget, - maxRetries: 1, - reviewEnabled: false, - cwd: deps.workspacePath, - }; - const result = await deps.agentRuntime.run(runInput, ctx); - const cleaned = stripPreamble(stripCodeFences(result.output.trim())); - const match = cleaned.match(/^recommendation:\s*(.+)$/m); - const value = match ? match[1].trim() : "poor-implementation"; - deps.log?.info(`rejection: diagnoser for ${item.id} → ${value}`, { - scope: "rejection", itemId: item.id, recommendation: value, - }); - return value; - } catch (err) { - deps.log?.error(`rejection: diagnoser agent failed for ${item.id}`, { - scope: "rejection", itemId: item.id, error: errorMessage(err), - cause: err instanceof Error && err.cause ? String(err.cause) : undefined, - }); - return "poor-implementation"; - } -} - -async function reopenItem( - filePath: string, - item: WorkItemFileData, - prId: number, - state: StateManager, - ctx: OperationContext, -): Promise { - const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); - let content = await readFile(filePath, "utf-8"); - content = content.replace(/^status:\s*.+$/m, "status: reopened"); - - if (/^reopened_at:/m.test(content)) { - content = content.replace(/^reopened_at:\s*.+$/m, `reopened_at: "${timestamp}"`); - } else { - content = content.replace(/^(status:\s*.+)$/m, `$1\nreopened_at: "${timestamp}"`); - } - - const prevPrs = item.previousPrs ? `${item.previousPrs},${prId}` : String(prId); - if (/^previous_prs:/m.test(content)) { - content = content.replace(/^previous_prs:\s*.+$/m, `previous_prs: ${prevPrs}`); - } else { - content = content.replace(/^(status:\s*.+)$/m, `$1\nprevious_prs: ${prevPrs}`); - } - - await writeFile(filePath, content, "utf-8"); - await syncWorkItemToDb(state, ctx, { - ...item, status: "reopened", previousPrs: prevPrs, - }); -} - -/** - * Load the `rejected-issue-body.md` template with placeholder substitution. - * Prefers the KV template source when wired (Step 15 runtime path); falls - * back to the filesystem read against `templatesDir` only for test harnesses - * that stub templates on disk without a KV instance. - */ -async function loadRejectedIssueTemplate( - deps: RejectionHandlerDeps, - vars: Record, -): Promise { - if (deps.templates) { - return deps.templates.load("rejected-issue-body.md", vars); - } - deps.log?.warn("rejection: TemplateSource missing, falling back to filesystem read", { - scope: "rejection", templatesDir: deps.templatesDir, - }); - const templatePath = join(deps.templatesDir, "rejected-issue-body.md"); - let body = await readFile(templatePath, "utf-8"); - for (const [key, value] of Object.entries(vars)) { - body = body.replaceAll(`{${key}}`, value); - } - return body; -} - -async function createRejectionIssue( - deps: RejectionHandlerDeps, - item: WorkItemFileData, - kind: WorkItemKind, - prId: number, - recommendation: string, - prevCount: number, -): Promise { - if (!deps.tracker) return; - try { - const prLinks = item.previousPrs - ? item.previousPrs.split(",").map((n) => `- #${n.trim()}`).join("\n") + `\n- #${prId}` - : `- #${prId}`; - - const template = await loadRejectedIssueTemplate(deps, { - ITEM_TYPE: kind, - ITEM_TITLE: item.title, - ITEM_ID: item.id, - PRIORITY: String(item.priority), - RECOMMENDATION: recommendation, - ATTEMPT_COUNT: String(prevCount + 1), - ITEM_BODY: item.body, - REJECTION_REPORT: `Rejected after ${prevCount + 1} attempt(s)`, - PR_LINKS: prLinks, - }); - - const manualLabel = deps.conventions.labels.manual || "ai:manual"; - if (deps.tracker.createWorkItem) { - await deps.tracker.createWorkItem({ - title: `[${manualLabel}] ${kind} ${item.id}: ${item.title}`, - body: template, - labels: [manualLabel], - }); - } - } catch (err) { - deps.log?.error(`rejection: manual-issue creation failed for ${item.id}`, { - scope: "rejection", itemId: item.id, error: errorMessage(err), - }); - } -} - function countPreviousPrs(previousPrs?: string): number { if (!previousPrs) return 0; return previousPrs.split(",").filter((s) => /\d/.test(s)).length;