Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .operator/data/tasks/T20260705-94A14772.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions engine/pipeline/composers/_shared/rejection-diagnoser.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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";
}
}
149 changes: 149 additions & 0 deletions engine/pipeline/composers/_shared/rejection-issue.test.ts
Original file line number Diff line number Diff line change
@@ -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: "<!-- bot:operator -->",
};

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>): 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<typeof vi.fn>).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" }),
);
});
});
80 changes: 80 additions & 0 deletions engine/pipeline/composers/_shared/rejection-issue.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>,
): Promise<string> {
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<void> {
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),
});
}
}
33 changes: 33 additions & 0 deletions engine/pipeline/composers/_shared/reopen-work-item.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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,
});
}
Loading
Loading