Skip to content
Closed
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
3 changes: 0 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
# Optional, both services: telemetry only. `npm run dev:postgres` starts this database.
DATABASE_URL=postgresql://postgres:postgres@localhost:55432/software_factory

# Both services: Render API key (the gateway starts tasks; workflows manage sandboxes and cancel runs).
RENDER_API_KEY=

Expand Down
11 changes: 4 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,13 @@ This file applies to the whole repository. Read

## What this is

software-factory turns a labeled GitHub issue into a spec, a draft PR, and a COMMENT review. Three claiming tasks (Triage, Spec-to-PR, PR Reviewer) run on Render Workflows, each holding a claim on its issue or PR while it works. Agents run in Render Sandboxes restored from a per-repository snapshot. The status comment on the issue or PR names the run that holds the claim. Postgres is optional and keeps an append-only `events` table for telemetry only.
software-factory turns a labeled GitHub issue into a spec, a draft PR, and a COMMENT review. Three claiming tasks (Triage, Spec-to-PR, PR Reviewer) run on Render Workflows, each holding a claim on its issue or PR while it works. Agents run in Render Sandboxes restored from a per-repository snapshot. The status comment on the issue or PR names the run that holds the claim. There is no database: GitHub and Workflows hold all state, and run telemetry is logged as JSON lines.

Two processes deploy:

- `app/entrypoints/gateway.ts`: the gateway web service. Verifies webhooks, routes each to one task, and calls `startTask` with the delivery ID as the idempotency key.
- `app/entrypoints/workflows.ts`: the Workflows service. Importing `app/workflows/index.ts` registers every task.

`app/sql/` holds the migrations, applied by `npm run db:migrate` as the gateway's pre-deploy command.

## Module map

Expand All @@ -45,10 +44,9 @@ app/
changeset/ Collects, validates, and publishes sandbox changes through the Git Data API
github/ REST client, App JWT and installation tokens, Git Data API, hidden comment markers
format/ Renders specs, questions, PR bodies, and reviews as GitHub markdown; sanitizes model text
events/ Optional best-effort emit() over the events table
sql/ Migrations
events/ emit(): run.finished and agent.finished as JSON lines on stdout
sandbox/ Shell scripts copied into the snapshot (run-agent, as-runner, verify, collect-changes, ...)
scripts/ setup-app, setup-github, doctor, migrate
scripts/ setup-app, setup-github, doctor
tests/ Vitest unit tests; fakes in tests/helpers/
```

Expand Down Expand Up @@ -86,7 +84,7 @@ Import another module through its `index.ts` barrel where one exists. A barrel e
Outcome names are snake_case.
- Quote every shell argument you build with `shq` (`app/sandbox/shell.ts`). Anything long or security-sensitive that runs in a sandbox belongs in a script in `sandbox/`, which CI checks with shellcheck.
- Model text that reaches GitHub goes through the helpers in `app/format/`.
- Task implementations take GitHub, sandboxes, `runAgent`, and run control as injectable dependencies. Tests never call live Render, GitHub, Postgres, or model APIs.
- Task implementations take GitHub, sandboxes, `runAgent`, and run control as injectable dependencies. Tests never call live Render, GitHub, or model APIs.
- Keep the suite focused; it currently has 100+ tests. Test durable behavior at process and trust
boundaries: authenticated inputs, user-visible outcomes, authority,
cleanup, and security invariants. Prefer one end-to-end happy path and the
Expand Down Expand Up @@ -116,7 +114,6 @@ each is enforced):
8. Automation ends at a draft PR and a COMMENT review. It never approves, requests changes, marks a PR ready, or merges.
9. Spec-to-PR implements the spec as it stood when the approval label was applied.
10. Every sandbox is created with a timeout and closed by `withSandbox` (in `finally`). A claiming task records its sandbox in the status comment, so the run or `/factory cancel` that stops it can terminate the sandbox: Render kills a cancelled task without running `finally`.
11. Postgres is telemetry only; no task behavior depends on it.

`tests/agents-catalog.test.ts` protects unique agent identities and tool authority. Change it only on purpose.

Expand Down
33 changes: 21 additions & 12 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Sandbox boundaries, credentials, or publishing.

## Local development

Local development requires Docker and Render CLI 2.29 or later. It still uses
Local development requires Render CLI 2.29 or later. It still uses
real Render Sandboxes, a real GitHub App, and a real model. Use a separate
test repository and preferably a separate App.

Expand All @@ -18,15 +18,9 @@ Fill `.env`, then start each process in its own terminal:

```bash
# Terminal 1
npm run dev:postgres

# Terminal 2
npm run db:migrate

# Terminal 3
npm run dev:workflows

# Terminal 4
# Terminal 2
RENDER_USE_LOCAL_DEV=true npm run dev:gateway
```

Expand Down Expand Up @@ -58,8 +52,8 @@ Task input schemas are in
npm run check
```

This runs Biome, TypeScript, and Vitest. Tests fake GitHub, Render, Postgres,
and model calls.
This runs Biome, TypeScript, and Vitest. Tests fake GitHub, Render, and model
calls.

When relevant, also run:

Expand Down Expand Up @@ -173,6 +167,21 @@ Never accept write destinations from model output.
Every GitHub write should update or reuse an App-authored artifact marker.
Workflows idempotency alone has a finite scope and is not sufficient.

## Add persistent state

The factory keeps no database: GitHub and Render Workflows hold the state it
needs, and telemetry goes to logs. When a feature needs its own data, such as
a spec library, budgets, or run history you query:

1. Add a Render Postgres database to `render.yaml` and wire `DATABASE_URL`
into the services that need it with `fromDatabase`.
2. Run migrations from the gateway's `preDeployCommand`, so each deploy
migrates before new code starts.
3. Use the internal connection string on Render. The external one is for
local tools and requires TLS.
4. Keep run coordination in the status comment. A database that also decides
who owns an issue becomes a second source of truth to reconcile.

## Code structure

```text
Expand All @@ -186,10 +195,10 @@ app/
sandbox/ Sandbox and snapshot lifecycle
changeset/ Candidate-tree validation and publishing
github/ App authentication, REST, Git Data, and markers
events/ Optional best-effort telemetry
events/ Run and agent telemetry as JSON log lines
schemas/ Cross-process Zod contracts
sandbox/ Runtime scripts copied into snapshots
scripts/ Setup, doctor, and migrations
scripts/ Setup and doctor
tests/ Unit and boundary tests with fakes
```

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ a verified draft pull request, and an automated code review. People approve
every transition; the factory never approves or merges changes.

It runs in your Render workspace using one web service, one Workflows
service, Postgres, and Render Sandboxes.
service, and Render Sandboxes.

**Start by [forking this repository](https://github.com/render-examples/software-factory/fork).**
The factory must deploy from your fork because `factory.config.ts` defines
Expand All @@ -28,7 +28,7 @@ pull request --factory:pr-review--> COMMENT review

GitHub remains where people discuss and approve work. Render Workflows owns
durable execution. Sandboxes contain model-directed tools and repository
code. Postgres is optional and only records telemetry.
code.

## Deploy

Expand Down
28 changes: 16 additions & 12 deletions app/agents/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,21 +57,25 @@ export async function runAgent<Tools extends readonly ToolName[], Output extends
const { sandbox, ...base } = opts;
const { provider, model } = modelFor(agent.model);
const timeoutMs = base.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const record = (type: string, payload: Record<string, unknown>) =>
void emit({ traceId: base.traceId, runId: base.runId, repo: base.repo, subject: base.subject, type, payload });
const finish = (outcome: string, summary: Summary, message?: string) =>
record("agent.finished", {
agentId: agent.id,
model: `${provider}/${model}`,
outcome,
turns: summary.turns,
usage: summary.usage,
costUsd: summary.costUsd,
...(summary.invalidLines ? { invalidLines: summary.invalidLines } : {}),
...(message ? { message } : {}),
emit({
traceId: base.traceId,
runId: base.runId,
repo: base.repo,
subject: base.subject,
type: "agent.finished",
payload: {
agentId: agent.id,
model: `${provider}/${model}`,
outcome,
turns: summary.turns,
usage: summary.usage,
costUsd: summary.costUsd,
...(summary.invalidLines ? { invalidLines: summary.invalidLines } : {}),
...(message ? { message } : {}),
},
});

record("agent.started", { agentId: agent.id, model: `${provider}/${model}`, tools: agent.tools });
let summary = emptySummary();
const failure = (code: AgentErrorCode, text: string) => new AgentError(agent.id, code, text);
try {
Expand Down
23 changes: 9 additions & 14 deletions app/claims/claim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ export async function withClaim(
work: (claim: Claim) => Promise<Outcome>,
): Promise<TaskResult> {
const run = new ClaimedRun(ctx, opts);
await run.recordStart();
try {
if (!(await run.authorized())) return await run.reject();
await run.acquire();
Expand Down Expand Up @@ -149,10 +148,6 @@ class ClaimedRun implements Claim {
return { sandbox, ...this.#event };
}

async recordStart(): Promise<void> {
await emit({ ...this.#event, type: "run.started", payload: { stage: this.#opts.task, actor: this.#opts.actor } });
}

authorized(): Promise<boolean> {
return checkPermission(this.gh, this.#opts.actor);
}
Expand All @@ -161,42 +156,42 @@ class ClaimedRun implements Claim {
async reject(): Promise<TaskResult> {
const summary = `@${this.#opts.actor} needs \`${config.minimumPermission}\` permission on this repository to start it.`;
await finishStatus(this.#status("rejected", summary));
return await this.#record("rejected", "permission_denied", summary);
return this.#record("rejected", "permission_denied", summary);
}

/** Stops the previous run and rewrites the status comment to name this one, then confirms no other run's write landed after it. */
async acquire(): Promise<void> {
const { task, title, runs } = this.#opts;
await takeOver({ gh: this.gh, subject: this.subject, task, title, runId: this.runId, runs });
await takeOver({ gh: this.gh, repo: this.repo.fullName, subject: this.subject, task, title, runId: this.runId, runs });
await this.checkpoint();
}

async finish(outcome: Outcome): Promise<TaskResult> {
const state = outcome.state ?? "done";
if (!(await this.#publish(state, outcome.summary, outcome.links))) throw new Superseded();
return await this.#record(state, outcome.outcome, outcome.summary);
return this.#record(state, outcome.outcome, outcome.summary);
}

/**
* A superseded run and an expected failure end the task normally. Anything else is reported on GitHub without
* its message, which can hold secrets or repository output, and rethrown so the task fails.
*/
async fail(error: unknown): Promise<TaskResult> {
if (error instanceof Superseded) return await this.#record("cancelled", "superseded", "a newer run took over.");
if (error instanceof Superseded) return this.#record("cancelled", "superseded", "a newer run took over.");
if (error instanceof ExpectedFailure) {
await this.#publish("failed", error.message);
return await this.#record("failed", error.outcome, error.message);
return this.#record("failed", error.outcome, error.message);
}
if (error instanceof AgentError && AGENT_STOPS[error.code]) {
// The agent's own message can quote repository output, so it goes to the event, not GitHub.
const summary = `the ${error.agentId} agent ${AGENT_STOPS[error.code]}.`;
await this.#publish("failed", summary);
return await this.#record("failed", "agent_stopped", summary, { error: error.message.slice(0, 500) });
return this.#record("failed", "agent_stopped", summary, { error: error.message.slice(0, 500) });
}
const summary = `something went wrong. Run \`${this.runId}\` has the details in the Render Dashboard.`;
await this.#publish("failed", summary).catch(() => undefined);
const message = error instanceof Error ? error.message : String(error);
await this.#record("failed", "error", summary, { error: message.slice(0, 500) });
this.#record("failed", "error", summary, { error: message.slice(0, 500) });
throw error;
}

Expand All @@ -215,9 +210,9 @@ class ClaimedRun implements Claim {
return await finishStatus(this.#status(state, summary, links));
}

async #record(state: TaskResult["state"], outcome: string, summary: string, extra: Record<string, unknown> = {}): Promise<TaskResult> {
#record(state: TaskResult["state"], outcome: string, summary: string, extra: Record<string, unknown> = {}): TaskResult {
const durationMs = Date.now() - this.#startedAt;
await emit({ ...this.#event, type: "run.finished", payload: { stage: this.#opts.task, state, outcome, durationMs, ...extra } });
emit({ ...this.#event, type: "run.finished", payload: { stage: this.#opts.task, state, outcome, durationMs, ...extra } });
return { state, outcome, summary, traceId: this.traceId, runId: this.runId };
}
}
1 change: 1 addition & 0 deletions app/claims/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export {
type StatusGitHub,
type StatusLink,
type StatusUpdate,
type StopContext,
type Subject,
stopRun,
subjectName,
Expand Down
32 changes: 27 additions & 5 deletions app/claims/status.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ClientError, Render } from "@renderinc/sdk";
import { emit } from "../events/index.js";
import { findMarkedComment, type GitHubClient, type IssueComment, type RunState, renderMarker, type StatusMarker } from "../github/index.js";
import { sandboxClient } from "../sandbox/index.js";

Expand Down Expand Up @@ -52,14 +53,32 @@ export async function readStatus(gh: StatusGitHub, number: number): Promise<{ co
return findMarkedComment(await gh.listIssueComments(number), "status", await gh.botLogin());
}

/** Who stops a run and why, for the `run.finished` event recorded on the stopped run's behalf. */
export interface StopContext {
/** "owner/name" */
repo: string;
/** "issue#12" or "pr#34" */
subject: string;
/** The task run doing the stopping. */
by: string;
outcome: "superseded" | "cancelled";
}

/**
* Stops the run a status marker records: cancels it if it's still active, then terminates the sandbox it recorded.
* Render kills a cancelled task without running its `finally`, so the sandbox would otherwise live until its
* timeout. Returns whether the run was still active.
* timeout, and the run never records how it ended; this records `run.finished` for it. Returns whether the run
* was still active.
*/
export async function stopRun(runs: RunControl, status: StatusMarker): Promise<boolean> {
export async function stopRun(runs: RunControl, status: StatusMarker, context: StopContext): Promise<boolean> {
const active = await isRunActive(runs, status.runId);
if (active) await cancelActiveRun(runs, status.runId);
if (active) {
await cancelActiveRun(runs, status.runId);
const { repo, subject, by, outcome } = context;
// Claiming tasks are started by the gateway, so each is its own root task run and its run ID is its trace ID.
const payload = { stage: status.stage, state: "cancelled", outcome, stoppedBy: by };
emit({ traceId: status.runId, runId: status.runId, repo, subject, type: "run.finished", payload });
}
if (status.sandboxId) {
const { sandboxId } = status;
await runs.terminateSandbox(sandboxId).catch((error) => warn("could not terminate the stopped run's sandbox", error, { sandboxId }));
Expand Down Expand Up @@ -94,16 +113,19 @@ async function cancelActiveRun(runs: RunControl, taskRunId: string): Promise<voi
*/
export async function takeOver(opts: {
gh: StatusGitHub;
/** "owner/name" */
repo: string;
subject: Subject;
task: string;
title: string;
runId: string;
runs?: RunControl;
}): Promise<{ commentId: number; previousRunId: string | null; cancelledPrevious: boolean }> {
const { gh, subject, task, title, runId } = opts;
const { gh, repo, subject, task, title, runId } = opts;
const current = await readStatus(gh, subject.number);
const previous = current && current.data.runId !== runId ? current.data : null;
const cancelledPrevious = previous ? await stopRun(opts.runs ?? workflowRuns(), previous) : false;
const stop = { repo, subject: subjectName(subject), by: runId, outcome: "superseded" } as const;
const cancelledPrevious = previous ? await stopRun(opts.runs ?? workflowRuns(), previous, stop) : false;
const body = statusBody({ task, title, runId, state: "running" });
const comment = current ? await gh.updateComment(current.comment.id, body) : await gh.createComment(subject.number, body);
await addWorkingLabel(gh, subject.number);
Expand Down
5 changes: 1 addition & 4 deletions app/entrypoints/gateway.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { serve } from "@hono/node-server";
import { Render } from "@renderinc/sdk";
import { requireEnv } from "../config/index.js";
import { closeEvents } from "../events/index.js";
import { createGateway } from "../gateway/index.js";

const port = Number(process.env.PORT ?? 3000);
Expand All @@ -23,8 +22,6 @@ console.log(`gateway listening on 0.0.0.0:${port}`);

for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.once(signal, () => {
server.close(() => {
void closeEvents().finally(() => process.exit(0));
});
server.close(() => process.exit(0));
});
}
Loading
Loading