From b24247c1dd8c4359150fe30e8eb399bfa50f7e23 Mon Sep 17 00:00:00 2001 From: Ho1yShif Date: Fri, 21 Aug 2026 14:30:25 -0700 Subject: [PATCH 1/2] Migrate workflow-agents to Render SDK 1.0.0 task() now takes a TaskContext as its first parameter and returns a TaskDefinition instead of a callable, so subtasks run through ctx.run(). The loader matched exported functions and had nothing to call for in-process dispatch, so it now matches the {name, func} shape and supplies a local context that runs the graph inline. Also fixes dev:workflows. The CLI re-runs its start command for every task run, so pointing it at the gateway made each run try to bind port 3000 and fail. The start command is now the task-only entry, with the gateway running alongside it. Co-Authored-By: Claude Opus 5 --- package-lock.json | 126 +++++++++++++++--- packages/workflow-agents/package.json | 4 +- .../workflow-agents/scripts/dev-workflows.sh | 21 +++ .../src/workflows/code-review/index.ts | 46 ++++--- .../workflow-agents/src/workflows/loader.ts | 47 +++++-- .../src/workflows/your-review/index.ts | 19 +-- tests/integration/agentTask.test.ts | 40 +++--- 7 files changed, 227 insertions(+), 76 deletions(-) create mode 100755 packages/workflow-agents/scripts/dev-workflows.sh diff --git a/package-lock.json b/package-lock.json index 1c07cd84..c249707a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,8 +15,7 @@ "packages/workflow-agents" ], "dependencies": { - "tsx": "^4.20.5", - "yaml": "^2.9.0" + "tsx": "^4.20.5" }, "devDependencies": { "@types/node": "^22.10.0", @@ -501,14 +500,96 @@ } } }, + "node_modules/@redis/bloom": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.12.1.tgz", + "integrity": "sha512-PUUfv+ms7jgPSBVoo/DN4AkPHj4D5TZSd6SbJX7egzBplkYUcKmHRE8RKia7UtZ8bSQbLguLvxVO+asKtQfZWA==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/client": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz", + "integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0", + "@opentelemetry/api": ">=1 <2" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@redis/client/node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@redis/json": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.12.1.tgz", + "integrity": "sha512-eOze75esLve4vfqDel7aMX08CNaiLLQS2fV8mpRN9NxPe1rVR4vQyYiW/OgtGUysF6QOr9ANhfxABKNOJfXdKg==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/search": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.12.1.tgz", + "integrity": "sha512-ItlxbxC9cKI6IU1TLWoczwJCRb6TdmkEpWv05UrPawqaAnWGRu3rcIqsc5vN483T2fSociuyV1UkWIL5I4//2w==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/time-series": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.12.1.tgz", + "integrity": "sha512-c6JL6E3EcZJuNqKFz+KM+l9l5mpcQiKvTwgA3blt5glWJ8hjDk0yeHN3beE/MpqYIQ8UEX44ItQzgkE/gCBELQ==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, "node_modules/@renderinc/sdk": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/@renderinc/sdk/-/sdk-0.5.1.tgz", - "integrity": "sha512-SLWFvZaBk6DkewJo4rl8wN41QE65YXNbceB8oBvfxS76Ofssto1KKZlNPLhFfcBsvnGSAhFv6ewNgsRNDvLFgQ==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@renderinc/sdk/-/sdk-1.0.0.tgz", + "integrity": "sha512-upLn8LBi8j6C43FyIl33cSAIOtsAmvU2CZXNY0Y7rE0Pt8N7g8tX8MmNB10z9BZMh8WLIrwNCtwCd1dPskl45g==", "license": "MIT", "dependencies": { "eventsource": "^4.0.0", - "openapi-fetch": "^0.15.0" + "openapi-fetch": "^0.15.0", + "redis": "^5.11.0" }, "engines": { "node": ">=18.0.0" @@ -1681,6 +1762,22 @@ "node": ">= 0.10" } }, + "node_modules/redis": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz", + "integrity": "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==", + "license": "MIT", + "dependencies": { + "@redis/bloom": "5.12.1", + "@redis/client": "5.12.1", + "@redis/json": "5.12.1", + "@redis/search": "5.12.1", + "@redis/time-series": "5.12.1" + }, + "engines": { + "node": ">= 18.19.0" + } + }, "node_modules/redis-errors": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", @@ -2048,21 +2145,6 @@ "node": ">=0.4" } }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", @@ -2111,7 +2193,7 @@ "version": "1.0.0", "dependencies": { "@hono/node-server": "^1.13.7", - "@renderinc/sdk": "^0.5.0", + "@renderinc/sdk": "^1.0.0", "@workshop/agent": "*", "@workshop/db": "*", "@workshop/ui": "*", diff --git a/packages/workflow-agents/package.json b/packages/workflow-agents/package.json index 68236c9a..c917b3a6 100644 --- a/packages/workflow-agents/package.json +++ b/packages/workflow-agents/package.json @@ -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": "*", diff --git a/packages/workflow-agents/scripts/dev-workflows.sh b/packages/workflow-agents/scripts/dev-workflows.sh new file mode 100755 index 00000000..80049d95 --- /dev/null +++ b/packages/workflow-agents/scripts/dev-workflows.sh @@ -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 diff --git a/packages/workflow-agents/src/workflows/code-review/index.ts b/packages/workflow-agents/src/workflows/code-review/index.ts index 3d15ec58..91de1f7e 100644 --- a/packages/workflow-agents/src/workflows/code-review/index.ts +++ b/packages/workflow-agents/src/workflows/code-review/index.ts @@ -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, @@ -35,10 +35,18 @@ import { storeTracer } from "@workshop/db"; // │ `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. │ +// │ │ +// │ Every task handler takes a TaskContext as its first parameter. Use │ +// │ `ctx.run(otherTask, ...args)` to run another task on its own compute. │ +// │ `task()` returns a task definition, not a callable function — pass it │ +// │ to `ctx.run` rather than invoking it directly. │ // └─────────────────────────────────────────────────────────────────────────┘ 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, @@ -47,22 +55,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 { @@ -77,7 +89,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 @@ -93,25 +105,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); diff --git a/packages/workflow-agents/src/workflows/loader.ts b/packages/workflow-agents/src/workflows/loader.ts index e3e53395..4b750970 100644 --- a/packages/workflow-agents/src/workflows/loader.ts +++ b/packages/workflow-agents/src/workflows/loader.ts @@ -1,9 +1,9 @@ /** * Auto-discover workflows from the `workflows/` directory. * - * Convention: each `workflows/{name}/index.ts` must export at least one - * function (the Render task). The folder name becomes the route name and - * the Render slug is derived as `{serviceName}/{folderName}`. + * Convention: each `workflows/{name}/index.ts` must export at least one task + * definition (the value `task()` returns). The folder name becomes the route + * name and the Render slug is derived as `{serviceName}/{folderName}`. * * Returns both the WorkflowMapping (for remote dispatch) and localTasks * (for in-process dispatch), eliminating the need for a hardcoded registry. @@ -12,18 +12,31 @@ import { readdir } from "node:fs/promises"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; +import type { TaskContext, TaskDefinition } from "@renderinc/sdk/workflows"; export interface DiscoveredWorkflows { /** Maps route name → Render task slug (for production dispatch). */ mapping: Record; /** Maps route name → callable task function (for local dev dispatch). */ - localTasks: Record unknown | Promise>; + localTasks: Record Promise>; } +/** + * A TaskContext for running tasks in this process, without a Render workflow + * environment. `ctx.run` calls the target task's function directly instead of + * dispatching it to its own instance, so the whole workflow runs inline. + * Used by the in-process dev mode and by tests. + */ +export const localTaskContext: TaskContext = { + run(taskDefinition, ...args) { + return Promise.resolve(taskDefinition.func(localTaskContext, ...args)); + }, +}; + export async function loadWorkflows(dir: string): Promise { const entries = await readdir(dir, { withFileTypes: true }); const mapping: Record = {}; - const localTasks: Record unknown | Promise> = {}; + const localTasks: Record Promise> = {}; const workflowSlug = process.env.RENDER_WORKFLOW_SLUG?.trim(); for (const entry of entries) { @@ -33,27 +46,33 @@ export async function loadWorkflows(dir: string): Promise { const mod = await import(pathToFileURL(indexPath).href); - const taskFn = findTaskExport(mod); - if (!taskFn) continue; + const definition = findTaskExport(mod); + if (!definition) continue; mapping[name] = workflowSlug ? `${workflowSlug}/${name}` : name; - localTasks[name] = taskFn; + localTasks[name] = (input: unknown) => localTaskContext.run(definition, input); } return { mapping, localTasks }; } /** - * Find the first exported function that looks like a Render task. - * Skips type-only exports and non-function values. + * Find the first exported Render task definition. `task()` returns a frozen + * `{ name, func }` object, so skip type-only exports and plain values. */ function findTaskExport( mod: Record, -): ((input: unknown) => unknown) | undefined { - for (const key of Object.keys(mod)) { - if (typeof mod[key] === "function") { - return mod[key] as (input: unknown) => unknown; +): TaskDefinition<[unknown], unknown> | undefined { + for (const value of Object.values(mod)) { + if (isTaskDefinition(value)) { + return value as TaskDefinition<[unknown], unknown>; } } return undefined; } + +function isTaskDefinition(value: unknown): value is TaskDefinition { + if (typeof value !== "object" || value === null) return false; + const candidate = value as Partial; + return typeof candidate.name === "string" && typeof candidate.func === "function"; +} diff --git a/packages/workflow-agents/src/workflows/your-review/index.ts b/packages/workflow-agents/src/workflows/your-review/index.ts index b79c4bee..cddb203b 100644 --- a/packages/workflow-agents/src/workflows/your-review/index.ts +++ b/packages/workflow-agents/src/workflows/your-review/index.ts @@ -9,7 +9,7 @@ * a `task()` for isolation and retries, and called from the root workflow. This * is the minimum viable agent-in-a-workflow — modify it freely. */ -import { task } from "@renderinc/sdk/workflows"; +import { task, type TaskContext } from "@renderinc/sdk/workflows"; import { defineAgent, prepareDiff, @@ -63,6 +63,9 @@ If you find nothing, say so explicitly.`, // │ • Retries — transient LLM failures retry automatically │ // │ • Traces — appears in the Render Dashboard with duration + logs │ // │ │ +// │ Every handler takes a TaskContext first, then its own inputs. task() │ +// │ returns a definition — run it with ctx.run(definition, ...args). │ +// │ │ // │ Try: add retry config, change the timeout, or force a failure: │ // │ if (Math.random() < 0.5) throw new Error("flaky!"); │ // └─────────────────────────────────────────────────────────────────────────┘ @@ -74,7 +77,7 @@ const myReviewerTask = task( timeoutSeconds: 120, retry: { maxRetries: 2, waitDurationMs: 1000, backoffScaling: 2 }, }, - async (input: { patches: Patches }, runId?: string) => { + async (_ctx: TaskContext, input: { patches: Patches }, runId?: string) => { return myReviewer.run(input, { tracer: storeTracer(), ...(runId ? { runId } : {}), @@ -93,7 +96,7 @@ export default task( timeoutSeconds: 300, retry: { maxRetries: 2, waitDurationMs: 2000, backoffScaling: 2 }, }, - async function yourReview(input: YourReviewInput) { + async function yourReview(ctx: TaskContext, input: YourReviewInput) { const runId = input._runId; // Step 1 — Fetch the PR diff from GitHub. @@ -103,7 +106,7 @@ export default task( const { patches } = filterDiff(allPatches); // Step 3 — Run your custom agent as its own Render task. - const result = await myReviewerTask({ patches }, runId); + const result = await ctx.run(myReviewerTask, { patches }, runId); // Return a result the gateway can persist. Including `verdict` and `reviews` // tells the server to use the standard persistReview path — same as @@ -127,8 +130,8 @@ export default task( // task(), and fan out both with Promise.all: // // const [clarity, errors] = await Promise.all([ -// myReviewerTask({ patches }, runId), -// errorHandlingTask({ patches }, runId), +// ctx.run(myReviewerTask, { patches }, runId), +// ctx.run(errorHandlingTask, { patches }, runId), // ]); // // 3. ADD A JUDGE — import `judge` from @workshop/agent and wire it after the @@ -137,9 +140,9 @@ export default task( // import { judge } from "@workshop/agent"; // const judgeTask = task( // { name: "judge", timeoutSeconds: 120 }, -// async (input, runId?) => judge.run(input, { tracer: storeTracer(), runId }), +// async (_ctx, input, runId?) => judge.run(input, { tracer: storeTracer(), runId }), // ); -// const decision = await judgeTask({ findings }, runId); +// const decision = await ctx.run(judgeTask, { findings }, runId); // // 4. FORCE A FAILURE — uncomment the line below inside the task to watch // Render retry in a fresh instance (then remove it): diff --git a/tests/integration/agentTask.test.ts b/tests/integration/agentTask.test.ts index 8378d224..0e1ac2a3 100644 --- a/tests/integration/agentTask.test.ts +++ b/tests/integration/agentTask.test.ts @@ -1,31 +1,41 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { task } from '@renderinc/sdk/workflows' +import type { TaskContext } from '@renderinc/sdk/workflows' import { securityReviewer } from '@workshop/agent' import { storeTracer } from '@workshop/db' +import { localTaskContext } from '../../packages/workflow-agents/src/workflows/loader.js' -test('an agent wrapped in task() runs in-process outside a workflow context', async () => { - const securityTask = task( - { name: 'security' }, - async (input: { patches: Array<{ file: string; diff: string }> }, runId?: string) => { - return securityReviewer.run(input, { tracer: storeTracer(), runId }) - }, - ) - assert.equal(typeof securityTask, 'function') +const securityTask = task( + { name: 'security' }, + async ( + _ctx: TaskContext, + input: { patches: Array<{ file: string; diff: string }> }, + runId?: string, + ) => { + return securityReviewer.run(input, { tracer: storeTracer(), runId }) + }, +) + +test('task() returns a definition naming the task and holding its function', () => { + assert.equal(securityTask.name, 'security') + assert.equal(typeof securityTask.func, 'function') +}) - const result = await securityTask({ patches: [{ file: 'a.ts', diff: '+x' }] }) +test('an agent wrapped in task() runs in-process against a supplied context', async () => { + const result = await localTaskContext.run(securityTask, { + patches: [{ file: 'a.ts', diff: '+x' }], + }) assert.equal(typeof result.text, 'string') assert.ok(result.text.length > 0) assert.equal(typeof result.usage.inputTokens, 'number') }) test('an agent task accepts an optional runId for span correlation', async () => { - const securityTask = task( - { name: 'security' }, - async (input: { patches: Array<{ file: string; diff: string }> }, runId?: string) => { - return securityReviewer.run(input, { tracer: storeTracer(), runId }) - }, + const result = await localTaskContext.run( + securityTask, + { patches: [{ file: 'a.ts', diff: '+x' }] }, + 'test-run-id', ) - const result = await securityTask({ patches: [{ file: 'a.ts', diff: '+x' }] }, 'test-run-id') assert.equal(typeof result.text, 'string') }) From f19443df932056cbcdbe10c18411f94c499df031 Mon Sep 17 00:00:00 2001 From: Ho1yShif Date: Fri, 21 Aug 2026 14:50:05 -0700 Subject: [PATCH 2/2] Condense the ASCII-box comment blocks Same content as plain comments. The workshop's teaching notes stay; only the borders and their padding go. Co-Authored-By: Claude Opus 5 --- packages/naive-agent/src/server.ts | 9 ++---- packages/queue-agents/src/worker.ts | 11 +++---- .../src/workflows/code-review/index.ts | 17 ++++------ .../src/workflows/your-review/index.ts | 32 ++++++------------- 4 files changed, 22 insertions(+), 47 deletions(-) diff --git a/packages/naive-agent/src/server.ts b/packages/naive-agent/src/server.ts index 70184874..69db410e 100644 --- a/packages/naive-agent/src/server.ts +++ b/packages/naive-agent/src/server.ts @@ -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: [] }) diff --git a/packages/queue-agents/src/worker.ts b/packages/queue-agents/src/worker.ts index d3e6dbbb..d9f69025 100644 --- a/packages/queue-agents/src/worker.ts +++ b/packages/queue-agents/src/worker.ts @@ -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' }) diff --git a/packages/workflow-agents/src/workflows/code-review/index.ts b/packages/workflow-agents/src/workflows/code-review/index.ts index 91de1f7e..74f7602b 100644 --- a/packages/workflow-agents/src/workflows/code-review/index.ts +++ b/packages/workflow-agents/src/workflows/code-review/index.ts @@ -30,17 +30,12 @@ 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. │ -// │ │ -// │ Every task handler takes a TaskContext as its first parameter. Use │ -// │ `ctx.run(otherTask, ...args)` to run another task on its own compute. │ -// │ `task()` returns a task definition, not a callable function — pass it │ -// │ to `ctx.run` rather than invoking it directly. │ -// └─────────────────────────────────────────────────────────────────────────┘ +// 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 agentContext = (runId?: string) => ({ diff --git a/packages/workflow-agents/src/workflows/your-review/index.ts b/packages/workflow-agents/src/workflows/your-review/index.ts index cddb203b..06472cc6 100644 --- a/packages/workflow-agents/src/workflows/your-review/index.ts +++ b/packages/workflow-agents/src/workflows/your-review/index.ts @@ -18,15 +18,9 @@ import { } from "@workshop/agent"; import { storeTracer } from "@workshop/db"; -// ┌─────────────────────────────────────────────────────────────────────────┐ -// │ YOUR AGENT — defined inline with defineAgent(). │ -// │ │ -// │ Try changing: │ -// │ • The systemPrompt — focus on docs, naming, error handling, etc. │ -// │ • The model tier — 'small' is fast/cheap, 'large' is thorough │ -// │ • The tools — try 'scan_for_secrets', 'contrast_ratio', or add │ -// │ your own in shared/agent/src/tools/ │ -// └─────────────────────────────────────────────────────────────────────────┘ +// Your agent, defined inline. Try changing the systemPrompt, the model tier +// ('small' is fast and cheap, 'large' is thorough), or the tools — add your own +// in shared/agent/src/tools/. const myReviewer = defineAgent({ name: "my-reviewer", model: resolveModelSpec("medium"), @@ -55,20 +49,12 @@ Return a short list of findings. Each finding has: If you find nothing, say so explicitly.`, }); -// ┌─────────────────────────────────────────────────────────────────────────┐ -// │ TASK WRAPPING — same pattern as code-review/index.ts. │ -// │ │ -// │ Wrapping the agent in task() gives you: │ -// │ • Isolation — runs in its own Render instance │ -// │ • Retries — transient LLM failures retry automatically │ -// │ • Traces — appears in the Render Dashboard with duration + logs │ -// │ │ -// │ Every handler takes a TaskContext first, then its own inputs. task() │ -// │ returns a definition — run it with ctx.run(definition, ...args). │ -// │ │ -// │ Try: add retry config, change the timeout, or force a failure: │ -// │ if (Math.random() < 0.5) throw new Error("flaky!"); │ -// └─────────────────────────────────────────────────────────────────────────┘ +// Task wrapping, the same pattern as code-review/index.ts. Wrapping the agent in +// task() gives it its own Render instance, automatic retries on transient LLM +// failures, and a Dashboard trace with duration and logs. +// +// Every handler takes a TaskContext first, then its own inputs. task() returns a +// definition — run it with ctx.run(definition, ...args). type Patches = Array<{ file: string; diff: string }>; const myReviewerTask = task(