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
126 changes: 104 additions & 22 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 3 additions & 6 deletions packages/naive-agent/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,9 @@ export function createApp(): Hono {
const id = await createReview(body.prUrl, { source: 'naive-agent', workflow: 'code-review' })
const ctx = { tracer: storeTracer(), runId: id }

// ┌─────────────────────────────────────────────────────────────────────┐
// │ BLOCKING: every `await` below holds the HTTP connection open. │
// │ The client cannot get a response until the *entire* pipeline │
// │ finishes — including multiple LLM round-trips. This is the core │
// │ trade-off of Pattern 1: zero infrastructure, zero resilience. │
// └─────────────────────────────────────────────────────────────────────┘
// Blocking: every `await` below holds the HTTP connection open, so the
// client waits out the whole pipeline including several LLM round-trips.
// That is the Pattern 1 trade-off — no infrastructure, no resilience.
try {
// Step 1 — Fetch the PR diff from GitHub. This `await` blocks the request.
const allPatches = await prepareDiff({ url: body.prUrl, labels: [] })
Expand Down
11 changes: 4 additions & 7 deletions packages/queue-agents/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,10 @@ await consumeReviews(
const emit = (event: ReviewEvent) => publishProgress(job.reviewId, event)
const ctx = { tracer: storeTracer(), runId: job.reviewId }

// ┌─────────────────────────────────────────────────────────────────────┐
// │ BACKGROUND: these awaits run in a queue consumer, not an HTTP │
// │ handler. The web tier already returned 202 to the client. A slow │
// │ PR doesn't block any request — it just takes longer in this │
// │ worker. Compare with naive-agent (blocking) and workflow-agents │
// │ (each step in its own isolated Render task). │
// └─────────────────────────────────────────────────────────────────────┘
// Background: these awaits run in a queue consumer, not an HTTP handler,
// and the web tier already returned 202. A slow PR blocks no request, it
// just takes longer here. Compare with naive-agent, which blocks, and
// workflow-agents, which runs each step in its own Render task.
try {
// Step 1 — Fetch the PR diff from GitHub.
await emit({ type: 'phase', phase: 'prepare' })
Expand Down
4 changes: 2 additions & 2 deletions packages/workflow-agents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@
"scripts": {
"typecheck": "tsc --noEmit",
"dev": "RENDER_USE_LOCAL_DEV=true node --env-file-if-exists=../../.env --env-file-if-exists=.env --import tsx src/server.ts",
"dev:workflows": "RENDER_USE_LOCAL_DEV=true RENDER_LOCAL_DEV_URL=http://127.0.0.1:8120 RENDER_API_KEY=local-dev render workflows dev -- npm run dev",
"dev:workflows": "RENDER_USE_LOCAL_DEV=true RENDER_LOCAL_DEV_URL=http://127.0.0.1:8120 RENDER_API_KEY=local-dev ./scripts/dev-workflows.sh",
"start": "node --import tsx src/server.ts",
"start:workflow": "node --import tsx src/workflow.ts"
},
"dependencies": {
"@hono/node-server": "^1.13.7",
"@renderinc/sdk": "^0.5.0",
"@renderinc/sdk": "^1.0.0",
"@workshop/agent": "*",
"@workshop/db": "*",
"@workshop/ui": "*",
Expand Down
21 changes: 21 additions & 0 deletions packages/workflow-agents/scripts/dev-workflows.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/bin/sh
#
# Full-fidelity local dev: the Render task server plus the gateway.
#
# `render workflows dev` re-runs its start command for every task run, so that
# each task gets a fresh process the way it gets a fresh instance in production.
# The start command therefore has to be the task-registration entry point
# (src/workflow.ts) and nothing else — pointing it at the gateway would try to
# bind port 3000 once per task run and fail with "start command exited before
# registering tasks".
#
# So the gateway runs alongside the CLI rather than under it. It reaches the
# task server over RENDER_LOCAL_DEV_URL.
set -e

# Kill the whole process group on exit so Ctrl-C takes the gateway down too.
trap 'kill 0' EXIT INT TERM

npm run dev &

exec render workflows dev -- npm run start:workflow
53 changes: 32 additions & 21 deletions packages/workflow-agents/src/workflows/code-review/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
* The agents themselves come from @workshop/agent — identical to the ones the
* naive and queue patterns run. Only the substrate differs.
*/
import { task } from "@renderinc/sdk/workflows";
import { task, type TaskContext } from "@renderinc/sdk/workflows";
import {
prepareDiff,
filterDiff,
Expand All @@ -30,15 +30,18 @@ import {
} from "@workshop/agent";
import { storeTracer } from "@workshop/db";

// ┌─────────────────────────────────────────────────────────────────────────┐
// │ TASK REGISTRATION: each shared agent becomes its own Render task. │
// `agent.run()` is the same call naive-agent and queue-agents make; │
// │ wrapping it in `task()` buys isolation, retries, timeouts, and │
// │ per-task traces in the Render Dashboard — for free. │
// └─────────────────────────────────────────────────────────────────────────┘
// Task registration: each shared agent becomes its own Render task, which adds
// isolation, retries, timeouts, and per-task traces around the same
// `agent.run()` call the other patterns make.
//
// Every handler takes a TaskContext first. `task()` returns a definition, not a
// callable — run it with `ctx.run(definition, ...args)`.
type Patches = Array<{ file: string; diff: string }>;
type Findings = Array<{ agent: string; note: string }>;
const ctx = (runId?: string) => ({ tracer: storeTracer(), ...(runId ? { runId } : {}) });
const agentContext = (runId?: string) => ({
tracer: storeTracer(),
...(runId ? { runId } : {}),
});

const agentTaskOptions = {
timeoutSeconds: 120,
Expand All @@ -47,22 +50,26 @@ const agentTaskOptions = {

const securityTask = task(
{ name: "security", ...agentTaskOptions },
async (input: { patches: Patches }, runId?: string) => securityReviewer.run(input, ctx(runId)),
async (_ctx: TaskContext, input: { patches: Patches }, runId?: string) =>
securityReviewer.run(input, agentContext(runId)),
);

const performanceTask = task(
{ name: "performance", ...agentTaskOptions },
async (input: { patches: Patches }, runId?: string) => performanceReviewer.run(input, ctx(runId)),
async (_ctx: TaskContext, input: { patches: Patches }, runId?: string) =>
performanceReviewer.run(input, agentContext(runId)),
);

const uxTask = task(
{ name: "ux", ...agentTaskOptions },
async (input: { patches: Patches }, runId?: string) => uxReviewer.run(input, ctx(runId)),
async (_ctx: TaskContext, input: { patches: Patches }, runId?: string) =>
uxReviewer.run(input, agentContext(runId)),
);

const judgeTask = task(
{ name: "judge", ...agentTaskOptions },
async (input: { findings: Findings }, runId?: string) => judge.run(input, ctx(runId)),
async (_ctx: TaskContext, input: { findings: Findings }, runId?: string) =>
judge.run(input, agentContext(runId)),
);

interface CodeReviewInput {
Expand All @@ -77,7 +84,7 @@ export default task(
timeoutSeconds: 600,
retry: { maxRetries: 2, waitDurationMs: 2000, backoffScaling: 2 },
},
async function codeReview(input: CodeReviewInput) {
async function codeReview(ctx: TaskContext, input: CodeReviewInput) {
const runId = input._runId;

// Step 1 — Fetch the PR diff from GitHub. Runs in-process inside the root
Expand All @@ -93,25 +100,29 @@ export default task(
// times out, the others are unaffected (compare with naive-agent where a
// single failure kills the entire HTTP response).
const reviewerTasks = [
{ name: securityReviewer.name, run: securityTask },
{ name: performanceReviewer.name, run: performanceTask },
{ name: securityReviewer.name, definition: securityTask },
{ name: performanceReviewer.name, definition: performanceTask },
];
if (hasFrontendFiles(patches)) {
reviewerTasks.push({ name: uxReviewer.name, run: uxTask });
reviewerTasks.push({ name: uxReviewer.name, definition: uxTask });
}

// Step 4 — Fan out in parallel. Same `Promise.all` as the other patterns,
// but each `run()` dispatches to its own Render task instance with its own
// retry budget and timeout.
// but each `ctx.run()` dispatches to its own Render task instance with its
// own retry budget and timeout.
const reviewerResults = await Promise.all(
reviewerTasks.map(async ({ name, run }) => {
const result = await run({ patches }, runId);
reviewerTasks.map(async ({ name, definition }) => {
const result = await ctx.run(definition, { patches }, runId);
return { agent: name, note: result.text, usage: result.usage };
}),
);

// Step 5 — Judge: weigh findings and produce a verdict. Also its own task.
const decision = await judgeTask({ findings: reviewerResults.map(({ agent, note }) => ({ agent, note })) }, runId);
const decision = await ctx.run(
judgeTask,
{ findings: reviewerResults.map(({ agent, note }) => ({ agent, note })) },
runId,
);

// Step 6 — Summarize (shared helper across all three patterns).
return toReviewSummary(reviewerResults, decision);
Expand Down
Loading