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-7E556EBC.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
id: T20260705-7E556EBC
kind: task
title: Split pr-feedback-supervisor-stage.ts under the 200 code-line cap by extracting the supervisor prompt builder
status: pending
status: completed
priority: 4
created_at: '2026-07-11T08:48:02Z'
completed_at: "2026-07-11T10:44:36Z"
parent_id: F20260705-CC7FF1B9
---

# Split pr-feedback-supervisor-stage.ts under the pipeline line cap

## Problem
Expand Down
15 changes: 15 additions & 0 deletions engine/pipeline/composers/_shared/supervisor-after-agent-deps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { KindRegistry, WorkItemSource, AgentEventStream } from "@operator/core";
import type { PRManager } from "../../../delivery/pr-manager.js";
import type { WorkspaceGit } from "../../../infra/git.js";
import type { Logger } from "../../../logging/logger.js";

export interface SupervisorAfterAgentDeps {
readonly prManager: PRManager;
readonly git: WorkspaceGit;
readonly kindRegistry: KindRegistry;
readonly workItemSource: WorkItemSource;
readonly agentEventStream: AgentEventStream;
readonly log?: Logger;
readonly debug?: boolean;
readonly debugRunUrl?: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, it, expect, vi } from "vitest";
import type { OperationContext } from "@operator/core";
import type { AgentResult, StageDef, StageInput } from "../../types.js";
import type { WorkspaceHandle } from "../../primitives/workspace-scope.js";
import type { PrFeedbackSupervisorHookDeps } from "./supervisor-stage-deps.js";
import { StageLogicError } from "../errors.js";
import { buildPrFeedbackSupervisorAfterAgent } from "./supervisor-after-agent-hook.js";

function makeCtx(): OperationContext {
return {
traceId: `trace-${Math.random()}`,
repoId: "sample",
action: "test",
budget: { limitUsd: undefined, spentUsd: 0, add: () => {}, isExceeded: () => false },
signal: AbortSignal.timeout(10_000),
};
}

function makeInput(): StageInput {
return {
scopeKey: "8",
data: {
prId: 8, branch: "ai/tasks/T-8", baseBranch: "develop", prType: "task",
newFeedback: "", fullThread: "", botAttempts: 0, oldestFreshAt: "",
checks: { value: "passing" as const, observedAt: "", checks: [] },
respondedIds: [], ciAttempts: 0, maxCiRetryAttempts: 3,
reviewThreads: [], freshReviewCommentIds: [],
},
};
}

function makeDeps(): PrFeedbackSupervisorHookDeps {
return {
prManager: { postBotComment: vi.fn().mockResolvedValue(undefined) } as never,
git: {
isClean: vi.fn().mockResolvedValue(true),
headSha: vi.fn().mockResolvedValue("sha"),
} as never,
kindRegistry: { all: [] } as never,
workItemSource: {} as never,
agentEventStream: {
parse: vi.fn().mockReturnValue({
events: [{ type: "verdict", value: "approved", summary: "ok" }],
diagnostics: [],
}),
} as never,
log: { info: vi.fn(), error: vi.fn() } as never,
agentsConfig: {} as never,
promptSource: {} as never,
defaults: {} as never,
automationDir: "/tmp",
workspacePath: "/tmp/ws",
agentRole: "supervisor",
verifierTopic: "pr-feedback",
};
}

const stage = { name: "pr-review" } as StageDef;
const workspace = {} as WorkspaceHandle;
const agentResult = { verdict: "approved", summary: "ok", output: "", attempts: 1 } as AgentResult;

describe("buildPrFeedbackSupervisorAfterAgent", () => {
it("throws STAGE_SCRATCH_MISSING when scratch is absent", async () => {
const hook = buildPrFeedbackSupervisorAfterAgent(makeDeps());
await expect(
hook(stage, makeInput(), agentResult, workspace, makeCtx()),
).rejects.toBeInstanceOf(StageLogicError);
await expect(
hook(stage, makeInput(), agentResult, workspace, makeCtx()),
).rejects.toMatchObject({ code: "STAGE_SCRATCH_MISSING" });
});
});
44 changes: 44 additions & 0 deletions engine/pipeline/composers/_shared/supervisor-after-agent-hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { unlink } from "node:fs/promises";
import type { OperationContext } from "@operator/core";
import { errorMessage } from "@operator/core";
import type { StageDef, StageInput, AgentResult, Verdict } from "../../types.js";
import type { WorkspaceHandle } from "../../primitives/workspace-scope.js";
import {
prFeedbackSupervisorScratch,
prFeedbackSupervisorScratchKey,
} from "./supervisor-scratch.js";
import { processSupervisorAfterAgent } from "./supervisor-after-agent.js";
import type { PrFeedbackSupervisorHookDeps } from "./supervisor-stage-deps.js";
import { payloadOf } from "./supervisor-payload.js";
import { StageLogicError } from "../errors.js";

export function buildPrFeedbackSupervisorAfterAgent(deps: PrFeedbackSupervisorHookDeps) {
return async (
stage: StageDef,
input: StageInput,
agentResult: AgentResult,
_workspace: WorkspaceHandle,
ctx: OperationContext,
): Promise<{ verdictOverride?: Verdict; summaryOverride?: string } | void> => {
const payload = payloadOf(stage.name, input);
const scratch = prFeedbackSupervisorScratch.get(ctx, prFeedbackSupervisorScratchKey(payload.prId));
if (!scratch) {
throw new StageLogicError(
"STAGE_SCRATCH_MISSING",
`${stage.name} afterAgent: missing scratch for PR #${payload.prId} — beforeAgent not run`,
);
}
try {
return await processSupervisorAfterAgent(deps, stage, payload, scratch, agentResult, ctx);
} catch (err) {
deps.log?.error(`${stage.name}: afterAgent failed for PR #${payload.prId}`, {
stage: stage.name, prNumber: payload.prId, error: errorMessage(err),
});
throw err;
} finally {
if (scratch.threadFile) {
await unlink(scratch.threadFile).catch(() => { /* best-effort cleanup */ });
}
}
};
}
91 changes: 91 additions & 0 deletions engine/pipeline/composers/_shared/supervisor-after-agent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, it, expect, vi } from "vitest";
import type { OperationContext } from "@operator/core";
import type { AgentResult, StageDef } from "../../types.js";
import type { PrFeedbackPayload } from "../../primitives/pr-feedback-selector.js";
import type { SupervisorAfterAgentDeps } from "./supervisor-after-agent-deps.js";
import type { PrFeedbackSupervisorScratch } from "./supervisor-scratch.js";
import { processSupervisorAfterAgent } from "./supervisor-after-agent.js";

function makeCtx(): OperationContext {
return {
traceId: `trace-${Math.random()}`,
repoId: "sample",
action: "test",
budget: { limitUsd: undefined, spentUsd: 0, add: () => {}, isExceeded: () => false },
signal: AbortSignal.timeout(10_000),
};
}

function makePayload(): PrFeedbackPayload {
return {
prId: 7, branch: "ai/tasks/T-7", baseBranch: "develop", prType: "task",
newFeedback: "", fullThread: "", botAttempts: 0, oldestFreshAt: "",
checks: { value: "passing", observedAt: "", checks: [] },
respondedIds: [], ciAttempts: 0, maxCiRetryAttempts: 3,
reviewThreads: [], freshReviewCommentIds: [],
};
}

function makeScratch(overrides: Partial<PrFeedbackSupervisorScratch> = {}): PrFeedbackSupervisorScratch {
return {
prId: 7, branch: "ai/tasks/T-7", prType: "task",
reviewAttempts: 0, maxAttempts: 20, limitReached: false,
threadFile: "", newFeedback: "", checksContextFile: "", preAgentHeadSha: "sha-pre",
...overrides,
};
}

function makeAgentResult(verdict: AgentResult["verdict"]): AgentResult {
return { verdict, summary: "ok", output: "", attempts: 1 } as AgentResult;
}

function makeDeps(): SupervisorAfterAgentDeps {
return {
prManager: { postBotComment: vi.fn().mockResolvedValue(undefined) } as never,
git: {
isClean: vi.fn().mockResolvedValue(true),
headSha: vi.fn().mockResolvedValue("sha-pre"),
} as never,
kindRegistry: { all: [] } as never,
workItemSource: {} as never,
agentEventStream: {
parse: vi.fn().mockReturnValue({
events: [{ type: "verdict", value: "approved", summary: "ok" }],
diagnostics: [],
}),
} as never,
log: { info: vi.fn(), warn: vi.fn() } as never,
};
}

const stage = { name: "pr-review" } as StageDef;

describe("processSupervisorAfterAgent", () => {
it("overrides verdict to failed when review limit was reached", async () => {
const deps = makeDeps();
const result = await processSupervisorAfterAgent(
deps, stage, makePayload(),
makeScratch({ limitReached: true, reviewAttempts: 20, maxAttempts: 20 }),
makeAgentResult("approved"), makeCtx(),
);
expect(result).toMatchObject({
verdictOverride: "failed",
summaryOverride: "review cycle limit reached (20/20)",
});
expect(deps.prManager.postBotComment).toHaveBeenCalledWith(
7, expect.stringContaining("Review cycle limit reached"), expect.any(Object),
);
});

it("posts applied-feedback comment when workspace has changes", async () => {
const deps = makeDeps();
deps.git.isClean = vi.fn().mockResolvedValue(false);
await processSupervisorAfterAgent(
deps, stage, makePayload(), makeScratch(),
makeAgentResult("approved"), makeCtx(),
);
expect(deps.prManager.postBotComment).toHaveBeenCalledWith(
7, expect.stringContaining("Applied review feedback"), expect.any(Object),
);
});
});
51 changes: 51 additions & 0 deletions engine/pipeline/composers/_shared/supervisor-after-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { OperationContext } from "@operator/core";
import { formatDebugRunLinkSuffix } from "../../../delivery/vcs-helpers.js";
import type { BotAttribution } from "../../../delivery/bot-footer.js";
import type { StageDef, AgentResult, Verdict } from "../../types.js";
import type { PrFeedbackPayload } from "../../primitives/pr-feedback-selector.js";
import { formatReviewLimitReachedMessage } from "./supervisor-bot-messages.js";
import type { PrFeedbackSupervisorScratch } from "./supervisor-scratch.js";
import type { SupervisorAfterAgentDeps } from "./supervisor-after-agent-deps.js";
import { applySupervisorAgentEvents } from "./supervisor-aop-apply.js";
import { detectSupervisorChanges } from "./supervisor-change-detection.js";
import { routeSupervisorVerdict } from "./supervisor-verdict-routing.js";

export async function processSupervisorAfterAgent(
deps: SupervisorAfterAgentDeps,
stage: StageDef,
payload: PrFeedbackPayload,
scratch: PrFeedbackSupervisorScratch,
agentResult: AgentResult,
ctx: OperationContext,
): Promise<{ verdictOverride?: Verdict; summaryOverride?: string } | void> {
const suffix = formatDebugRunLinkSuffix(deps.debug, deps.debugRunUrl);
Comment thread
dzykovic marked this conversation as resolved.
const ciFailing = payload.checks.value === "failing";
const nextAttribution: BotAttribution = {
responded: new Set(payload.respondedIds),
ciHead: payload.checks.headSha,
ciAttempt: ciFailing
? { current: payload.ciAttempts + 1, max: payload.maxCiRetryAttempts }
: undefined,
};

if (scratch.limitReached) {
const msg = formatReviewLimitReachedMessage(scratch.reviewAttempts, scratch.maxAttempts, suffix);
await deps.prManager.postBotComment(payload.prId, msg, nextAttribution);
deps.log?.info(`${stage.name}: PR #${payload.prId} limit reached — verdict override failed`, {
stage: stage.name, prNumber: payload.prId,
reviewAttempts: scratch.reviewAttempts, maxAttempts: scratch.maxAttempts,
});
return {
verdictOverride: "failed",
summaryOverride: `review cycle limit reached (${scratch.reviewAttempts}/${scratch.maxAttempts})`,
};
}

const applied = await applySupervisorAgentEvents(
deps, stage, payload, agentResult.output, ctx, deps.log,
);
const changes = await detectSupervisorChanges(deps.git, scratch, stage, payload, deps.log);
return routeSupervisorVerdict(
deps, stage, payload, scratch, agentResult, applied, changes, nextAttribution, ciFailing,
);
}
74 changes: 74 additions & 0 deletions engine/pipeline/composers/_shared/supervisor-aop-apply.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, it, expect, vi } from "vitest";
import type { OperationContext } from "@operator/core";
import type { StageDef } from "../../types.js";
import type { PrFeedbackPayload } from "../../primitives/pr-feedback-selector.js";
import type { SupervisorAfterAgentDeps } from "./supervisor-after-agent-deps.js";
import { applySupervisorAgentEvents } from "./supervisor-aop-apply.js";

function makeCtx(): OperationContext {
return {
traceId: `trace-${Math.random()}`,
repoId: "sample",
action: "test",
budget: { limitUsd: undefined, spentUsd: 0, add: () => {}, isExceeded: () => false },
signal: AbortSignal.timeout(10_000),
};
}

function makePayload(): PrFeedbackPayload {
return {
prId: 10, branch: "ai/tasks/T-10", baseBranch: "develop", prType: "task",
newFeedback: "", fullThread: "", botAttempts: 0, oldestFreshAt: "",
checks: { value: "passing", observedAt: "", checks: [] },
respondedIds: [], ciAttempts: 0, maxCiRetryAttempts: 3,
reviewThreads: [{
threadId: "THREAD_1", isResolved: false, authorType: "Bot", commentIds: ["c1"],
}],
freshReviewCommentIds: ["c1"],
};
}

function makeDeps(events: unknown[]): SupervisorAfterAgentDeps {
return {
prManager: {
postThreadReply: vi.fn().mockResolvedValue(undefined),
resolveThread: vi.fn().mockResolvedValue(undefined),
} as never,
git: {} as never,
kindRegistry: {
all: [{
name: "task", idPrefix: "T", dataDir: ".operator/data/tasks",
branchPrefix: "ai/tasks", terminalStatuses: [], parentKinds: [],
}],
} as never,
workItemSource: {} as never,
agentEventStream: {
parse: vi.fn().mockReturnValue({ events, diagnostics: [] }),
} as never,
log: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() } as never,
};
}

const stage = { name: "pr-review" } as StageDef;

describe("applySupervisorAgentEvents", () => {
it("applies agent output and disposes review threads when replies are present", async () => {
const deps = makeDeps([
{ type: "comment-reply", thread: "c1", disposition: "fixed", note: "done" },
{ type: "verdict", value: "approved", summary: "fixed" },
]);
const result = await applySupervisorAgentEvents(
deps, stage, makePayload(), "agent stdout", makeCtx(),
);
expect(result.verdict).toBe("approved");
expect(deps.prManager.postThreadReply).toHaveBeenCalled();
expect(deps.prManager.resolveThread).toHaveBeenCalledWith("THREAD_1");
});

it("skips thread disposition when there are no threads and no replies", async () => {
const deps = makeDeps([{ type: "verdict", value: "approved", summary: "ok" }]);
const payload = { ...makePayload(), reviewThreads: [], freshReviewCommentIds: [] };
await applySupervisorAgentEvents(deps, stage, payload, "out", makeCtx());
expect(deps.prManager.postThreadReply).not.toHaveBeenCalled();
});
});
Loading
Loading