Skip to content
Merged
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
11 changes: 11 additions & 0 deletions src/schemas/updatePRSchema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { z } from "zod";

export const updatePRPayloadSchema = z.object({
feedback: z.string().min(1, "feedback is required"),
snapshotId: z.string().min(1, "snapshotId is required"),
branch: z.string().min(1, "branch is required"),
repo: z.string().min(1, "repo is required"),
callbackThreadId: z.string().min(1, "callbackThreadId is required"),
});

export type UpdatePRPayload = z.infer<typeof updatePRPayloadSchema>;
168 changes: 168 additions & 0 deletions src/tasks/__tests__/updatePRTask.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

const mockRun = vi.fn();
vi.mock("@trigger.dev/sdk/v3", () => ({
logger: { log: vi.fn(), error: vi.fn() },
metadata: { set: vi.fn(), append: vi.fn() },
tags: { add: vi.fn() },
schemaTask: (config: { run: unknown }) => {
mockRun.mockImplementation(config.run as (...args: unknown[]) => unknown);
return config;
},
}));

const mockSandboxCreate = vi.fn();
const mockSandboxStop = vi.fn();
const mockSandboxSnapshot = vi.fn().mockResolvedValue({ snapshotId: "snap_new" });

vi.mock("@vercel/sandbox", () => ({
Sandbox: {
create: (...args: unknown[]) => mockSandboxCreate(...args),
},
}));

vi.mock("../../sandboxes/getVercelSandboxCredentials", () => ({
getVercelSandboxCredentials: vi.fn().mockReturnValue({
token: "tok", teamId: "team", projectId: "proj",
}),
}));

vi.mock("../../sandboxes/installOpenClaw", () => ({
installOpenClaw: vi.fn(),
}));

vi.mock("../../sandboxes/setupOpenClaw", () => ({
setupOpenClaw: vi.fn(),
}));

vi.mock("../../sandboxes/runOpenClawAgent", () => ({
runOpenClawAgent: vi.fn().mockResolvedValue({ exitCode: 0, stdout: "done", stderr: "" }),
}));

vi.mock("../../sandboxes/notifyCodingAgentCallback", () => ({
notifyCodingAgentCallback: vi.fn(),
}));

vi.mock("../../sandboxes/logStep", () => ({
logStep: vi.fn(),
}));

vi.mock("../../sandboxes/configureGitAuth", () => ({
configureGitAuth: vi.fn(),
}));

vi.mock("../../sandboxes/getSandboxEnv", () => ({
getSandboxEnv: vi.fn().mockReturnValue({
RECOUP_API_KEY: "test-key",
RECOUP_ACCOUNT_ID: "coding-agent",
GITHUB_TOKEN: "test-gh-token",
}),
}));

// Import after mocks
await import("../updatePRTask");

beforeEach(() => {
vi.clearAllMocks();
mockSandboxCreate.mockResolvedValue({
sandboxId: "sbx-456",
stop: mockSandboxStop,
snapshot: mockSandboxSnapshot,
});
});

describe("updatePRTask", () => {
const basePayload = {
feedback: "Make the button blue instead",
snapshotId: "snap_old",
branch: "agent/fix-bug-123",
repo: "recoupable/api",
callbackThreadId: "slack:C123:123.456",
};

it("resumes sandbox from snapshot", async () => {
await mockRun(basePayload);

expect(mockSandboxCreate).toHaveBeenCalledWith(
expect.objectContaining({
source: { type: "snapshot", snapshotId: "snap_old" },
}),
);
});

it("runs OpenClaw agent with feedback prompt", async () => {
const { runOpenClawAgent } = await import("../../sandboxes/runOpenClawAgent");

await mockRun(basePayload);

expect(runOpenClawAgent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
message: expect.stringContaining("Make the button blue instead"),
}),
);
});

it("delegates push to agent instead of using runGitCommand", async () => {
const { runOpenClawAgent } = await import("../../sandboxes/runOpenClawAgent");

await mockRun(basePayload);

// Should be called twice: once for feedback, once for push
expect(runOpenClawAgent).toHaveBeenCalledTimes(2);
expect(runOpenClawAgent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
label: "Push feedback changes",
message: expect.stringContaining("git push"),
}),
);
});

it("notifies callback with updated status and new snapshot", async () => {
const { notifyCodingAgentCallback } = await import("../../sandboxes/notifyCodingAgentCallback");

await mockRun(basePayload);

expect(notifyCodingAgentCallback).toHaveBeenCalledWith(
expect.objectContaining({
threadId: "slack:C123:123.456",
status: "updated",
snapshotId: "snap_new",
}),
);
});

it("stops sandbox in finally block", async () => {
await mockRun(basePayload);
expect(mockSandboxStop).toHaveBeenCalledOnce();
});

it("configures git auth before running agent", async () => {
const { configureGitAuth } = await import("../../sandboxes/configureGitAuth");

await mockRun(basePayload);

expect(configureGitAuth).toHaveBeenCalledOnce();
});

it("passes sandbox env to both agent calls", async () => {
const { runOpenClawAgent } = await import("../../sandboxes/runOpenClawAgent");
const { getSandboxEnv } = await import("../../sandboxes/getSandboxEnv");

await mockRun(basePayload);

expect(getSandboxEnv).toHaveBeenCalledWith("coding-agent");

const expectedEnv = {
RECOUP_API_KEY: "test-key",
RECOUP_ACCOUNT_ID: "coding-agent",
GITHUB_TOKEN: "test-gh-token",
};

// Both agent calls should receive env
const calls = vi.mocked(runOpenClawAgent).mock.calls;
expect(calls[0][1]).toEqual(expect.objectContaining({ env: expectedEnv }));
expect(calls[1][1]).toEqual(expect.objectContaining({ env: expectedEnv }));
});
});
92 changes: 92 additions & 0 deletions src/tasks/updatePRTask.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { metadata, schemaTask } from "@trigger.dev/sdk/v3";
import { Sandbox } from "@vercel/sandbox";
import { getVercelSandboxCredentials } from "../sandboxes/getVercelSandboxCredentials";
import { installOpenClaw } from "../sandboxes/installOpenClaw";
import { setupOpenClaw } from "../sandboxes/setupOpenClaw";
import { runOpenClawAgent } from "../sandboxes/runOpenClawAgent";
import { notifyCodingAgentCallback } from "../sandboxes/notifyCodingAgentCallback";
import { logStep } from "../sandboxes/logStep";
import { configureGitAuth } from "../sandboxes/configureGitAuth";
import { getSandboxEnv } from "../sandboxes/getSandboxEnv";
import { updatePRPayloadSchema } from "../schemas/updatePRSchema";

const CODING_AGENT_ACCOUNT_ID = "coding-agent";

/**
* Background task that resumes a sandbox from a snapshot, applies feedback
* via the AI agent, and delegates push of updates to the agent.
*/
export const updatePRTask = schemaTask({
id: "update-pr",
schema: updatePRPayloadSchema,
maxDuration: 60 * 15,
retry: {
maxAttempts: 0,
},
run: async (payload) => {
const { feedback, snapshotId, branch, repo, callbackThreadId } = payload;
const { token, teamId, projectId } = getVercelSandboxCredentials();

logStep("Resuming sandbox from snapshot");

const sandbox = await Sandbox.create({
token,
teamId,
projectId,
source: { type: "snapshot", snapshotId },
timeoutMs: 30 * 60 * 1000,
});

logStep("Sandbox resumed", false, { sandboxId: sandbox.sandboxId, snapshotId });

try {
logStep("Ensuring OpenClaw is running");
await installOpenClaw(sandbox);
await setupOpenClaw(sandbox, CODING_AGENT_ACCOUNT_ID);
await configureGitAuth(sandbox);

const env = getSandboxEnv(CODING_AGENT_ACCOUNT_ID);

logStep("Running AI agent with feedback");
await runOpenClawAgent(sandbox, {
label: "Apply feedback",
message: `The following feedback was given on the existing changes on branch "${branch}":\n\n${feedback}\n\nPlease make the requested changes.`,
env,
});

logStep("Pushing updates via agent");
await runOpenClawAgent(sandbox, {
label: "Push feedback changes",
env,
message: [
`Stage, commit, and push the feedback changes to the existing PR branch.`,
``,
`Repo: ${repo}, branch: ${branch}`,
``,
`Steps:`,
`1. cd into the submodule directory for ${repo}`,
`2. git add -A`,
`3. git commit -m "agent: address feedback"`,
`4. git push origin ${branch}`,
].join("\n"),
});
Comment on lines +51 to +72

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Agent call failures are not explicitly handled.

If runOpenClawAgent throws or returns a non-zero exit code, the task will fail without notifying the callback about the error. Since retry.maxAttempts: 0 is set, users may be left without feedback on what happened.

Consider checking the exit code and notifying on failure:

🛡️ Suggested: Handle agent failure
       logStep("Running AI agent with feedback");
-      await runOpenClawAgent(sandbox, {
+      const feedbackResult = await runOpenClawAgent(sandbox, {
         label: "Apply feedback",
         message: `The following feedback was given on the existing changes on branch "${branch}":\n\n${feedback}\n\nPlease make the requested changes.`,
         env,
       });
+      if (feedbackResult.exitCode !== 0) {
+        await notifyCodingAgentCallback({
+          threadId: callbackThreadId,
+          status: "failed",
+          error: feedbackResult.stderr || "Agent failed to apply feedback",
+        });
+        throw new Error("Agent failed to apply feedback");
+      }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await runOpenClawAgent(sandbox, {
label: "Apply feedback",
message: `The following feedback was given on the existing changes on branch "${branch}":\n\n${feedback}\n\nPlease make the requested changes.`,
env,
});
logStep("Pushing updates via agent");
const prList = prs
.map((pr) => ` - repo=${pr.repo}, branch=${branch}`)
.join("\n");
await runOpenClawAgent(sandbox, {
label: "Push feedback changes",
env,
message: [
`Stage, commit, and push the feedback changes to the existing PR branches.`,
``,
`For each of these PRs, find the submodule directory, stage all changes, commit, and push:`,
prList,
``,
`Commit message: agent: address feedback`,
``,
`Steps for each:`,
`1. cd into the submodule directory`,
`2. git add -A`,
`3. git commit -m "agent: address feedback"`,
`4. git push origin ${branch}`,
].join("\n"),
});
logStep("Running AI agent with feedback");
const feedbackResult = await runOpenClawAgent(sandbox, {
label: "Apply feedback",
message: `The following feedback was given on the existing changes on branch "${branch}":\n\n${feedback}\n\nPlease make the requested changes.`,
env,
});
if (feedbackResult.exitCode !== 0) {
await notifyCodingAgentCallback({
threadId: callbackThreadId,
status: "failed",
error: feedbackResult.stderr || "Agent failed to apply feedback",
});
throw new Error("Agent failed to apply feedback");
}
logStep("Pushing updates via agent");
const prList = prs
.map((pr) => ` - repo=${pr.repo}, branch=${branch}`)
.join("\n");
await runOpenClawAgent(sandbox, {
label: "Push feedback changes",
env,
message: [
`Stage, commit, and push the feedback changes to the existing PR branches.`,
``,
`For each of these PRs, find the submodule directory, stage all changes, commit, and push:`,
prList,
``,
`Commit message: agent: address feedback`,
``,
`Steps for each:`,
`1. cd into the submodule directory`,
`2. git add -A`,
`3. git commit -m "agent: address feedback"`,
`4. git push origin ${branch}`,
].join("\n"),
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tasks/updatePRTask.ts` around lines 51 - 79, The calls to
runOpenClawAgent (the "Apply feedback" and "Push feedback changes" invocations)
are not handling agent failures; wrap each call in a try/catch (or check the
agent result/exit code) and on error log via logStep/processLogger with the
error and the pr list (prs and branch), and propagate the failure to the task
caller (reject/throw or call the task callback with the error) so the task
doesn’t silently succeed when runOpenClawAgent fails; ensure the error handling
references the same labels ("Apply feedback" and "Push feedback changes") so
failures are clear in logs.


logStep("Taking new snapshot");
const newSnapshot = await sandbox.snapshot();

logStep("Notifying bot");
await notifyCodingAgentCallback({
threadId: callbackThreadId,
status: "updated",
snapshotId: newSnapshot.snapshotId,
});

metadata.set("currentStep", "Complete");

return { snapshotId: newSnapshot.snapshotId };
} finally {
logStep("Stopping sandbox", false, { sandboxId: sandbox.sandboxId });
await sandbox.stop();
}
},
});