/engineering-blog /software-factory-automations

A software factory is just a durable workflow

How we use DBOS to run long-lived agentic workflows

In the first post in our software factory series we built up a framework for how to think about software factories and how organizations should responsibly adopt them.

TL;DR: The software factory is just your existing SDLC, with as many steps automated as responsibly possible.

Cortex's software factory loop Strategic bets and triaged customer feedback feed product specs, which flow through tech specs, ticket breakdown, build, review and deploy. Production then loops back: monitoring and incidents create new tickets, while customers and their feedback route through triage back into planning. Strategicbets Product spec Tech spec Ticketbreakdown Build + PRs Review +merge Deploy Monitoring Incidents +learnings Customers Customerfeedback Triage
The factory is the entire workflow, with or without AI

Yeah, yeah — pretty diagram, what does that actually mean in practice?

Let’s dig into the technical details: how exactly Cortex’s open source factory, engrams, implements this idea.

The Tiniest Factory

First let’s focus on a subgraph of the above diagram: we start with a bug ticket in Jira and we want to ship a fix to production:

Cortex's early software factory A bug ticket becomes a build with PRs, then review and merge, then deploy. Jira ticket Write somecode Create PR PR isapproved PR is merged Code isdeployed

Let’s pretend we’ve never heard of AI — what would this look like for a human?

  1. A ticket comes in
  2. You write the code change and put up a pull request
  3. You ask people for reviews
  4. You shepherd the PR: answer comments, fix CI, rebase, ask again
  5. Once it has merged you deploy it to production
  6. You close the ticket

Abstract away the details, and this is just a workflow! A well-defined sequence of steps with an indeterminate time inbetween, moved along through a series of handoffs and triggers.

This idea is not a new one; businesses have been systematizing processes going back to Henry Ford’s Model T assembly line. This idea evolved into the discipline of Business Process Management (BPM) by the early aughts, and some of the earliest workflow execution engines were built with this use case in mind.

The difference now is that we have AI to participate in or fully take over each of those steps.

Durable Workflows as the Primitive

Going back to our tiniest factory: look where the time goes! A human or agent works on the code and puts up a pull request… then what?

Wait hours for a review, or bother reviewers for reviews after a socially acceptable amount of time. Change requests are raised on the PR, and then we work on it… then more reviews. Even when the code makes it to production, if there’s a process for rolling it out via feature flags, we must participate in that, and so on.

This shape of work breaks the usual ways of running code:

  • A request handler can’t hold it. Nothing should keep a connection, a thread, or a VM open for a day to wait on a reviewer.
  • A job queue doesn’t remember where it was. A retried job starts from the top, so it re-sends the prompt, re-posts the comment, re-opens the PR.
  • Cron and a status column turn into a hand-built state machine. Every new step is another state, another transition, another edge case when two events race.
  • Deploys to our BPM service land mid-flight. If a run lasts days, every deploy happens in the middle of hundreds of them.
  • Outside world triggers can repeat themselves. Slack and GitHub retry webhooks until they get a 2xx, so every input can arrive more than once.

A durable workflow is exactly the primitive built to solve this issue. Where modern workflow engines differ from BPM execution engines is that you can model your processes as ordinary code: a function that calls steps in order. The runtime checkpoints each step’s result to a database as it completes. If the process crashes or a deploy restarts it, the workflow resumes from its last completed step, and finished steps return their recorded result instead of running again. Waiting is a first-class operation: a workflow can park until a message arrives, holding nothing open while it waits.

That gives a factory the two guarantees it needs: every run resumes where it was, and every side effect happens once.

Our First Proto-Factory

engrams is our software factory. It runs Claude Code, Codex, or your own harness in a Firecracker microVM in your own cloud; idle agents are snapshotted to blob storage and cost almost nothing, and credentials never enter the VM. We spent a lot of time nailing down security, reliability, and cost effectiveness of that platform primitive, so that we could build the actual software factory product around it, where agentic sessions are “just another step” in a workflow you design.

But we didn’t launch an open workflow platform on day one. A blank canvas of workflow blocks makes a poor first impression, so we started with two experiences people would use every day, built to be good on their own:

  • PR review: Open a pull request and engrams reviews it. A finder agent reads the diff and proposes findings; a verifier agent checks each one and drops what it can’t confirm. The result is one GitHub review with inline comments. A new push supersedes the review in flight.
  • Slack threads: Mention the bot in a channel and a cloud agent session opens as you, with your credentials. The agent’s session is relayed into the thread: replies join the same session, and the agent’s questions arrive as Slack forms.

Automations in engrams

Now the software factory is just piecing together some of those same building blocks, with a handful more, to automate just about anything within your SDLC.

Picking apart just the PR reviewer: there are a series of steps that it does that are broadly applicable to other automations we may want to do:

  1. GitHub webhook trigger
  2. Spawn a VM running Claude Code with a prompt (to review the PR and write findings to a file on disk)
  3. Download (the findings) file from the VM
  4. Spawn a fresh VM running Claude Code with a prompt (to independently verify those findings)
  5. Post a GitHub review

Beyond just those blocks, we introduced:

  • Cron, manual, and webhook triggers
  • Basic control flow primitives (filter, branch, loop) to compose steps into a workflow
  • A QuickJS-in-WASM sandboxed JavaScript block, for more precise control flow
  • A primitive to use Jev, a trendy, dirt cheap decision model that we use to power the smart profile picker when you mention the @engrams Slack bot.

DBOS: Our Workflow Engine of Choice

We chose DBOS as our durable workflow engine because it is a simple, lightweight, and reliable engine that is easy to run in your own cloud. It is very important to us that engrams have as simple of a deployment model as possible, and the appeal of “all you need is Postgres” without the operational burden that its open source competitors suffer from was very attractive.

Plus, we had recent organizational experience dabbling with DBOS for our newly launched OpEx Review product.

DBOS runs embedded in our TypeScript product plane (on Bun), its system tables live in a dbos schema next to our Drizzle tables, and the engine launches before the HTTP server starts serving:

DBOS.setConfig({
  name: "engrams-orchestrator",
  systemDatabaseUrl: config.databaseUrl,
  systemDatabaseSchemaName: "dbos",
  executorID: dbosExecutorId(), // the pod name
  runAdminServer: false,
  logger: dbosLogger,
});
await DBOS.launch();

How an Automation Becomes a DBOS Workflow

To make blocks composable, we replaced both hand-written workflows with a single registered workflow, AutomationRunWorkflow, that interprets a block graph stored as data. Every automation, built-in or custom, runs through it.

export async function automationRunWorkflowImpl(input, deps = {}) {
  // Bump on ANY change to step naming, step order, recv semantics,
  // or finalize position. Lives in the registered body so a bump
  // rotates the DBOS application version.
  const ENGINE_STEP_CONTRACT = 4;
  const engine = deps.engine ?? productionEngineDeps(); // DBOS.runStep, DBOS.recv, ...
  return interpretAutomation({ ...input, contract: ENGINE_STEP_CONTRACT }, engine);
}

export const automationRunWorkflow = DBOS.registerWorkflow(
  automationRunWorkflowImpl, { name: "AutomationRunWorkflow" });

The body is intentionally small, because DBOS versions an application by the source of its registered workflow functions. The graph changes every time someone edits an automation, but the registered body almost never does. A few rules keep replay deterministic:

  1. Pin the definition. The first step loads the automation version pinned on the run and returns it as a checkpointed output. A replay walks the same graph even if an admin edits the automation mid-run.
  2. Name every step by its place in the graph. Steps are step:<framePath>:<attempt>, where the frame path encodes the block and loop iteration. Each loop pass and each retry gets its own checkpoint and its own row in our run history.
  3. Decide inside steps. Branch conditions, loop exits, and filters are evaluated inside a step, so a replay reads the checkpointed boolean instead of re-evaluating.
  4. Checkpoint the clock. Never, ever call non-deterministic functions like Date.now() in control flow.
  5. Retry in the engine. Attempt n runs as its own named step; errors carry a permanent flag, so a template error or a provider 4xx never retries.

A snapshot test asserts the exact ordered step names for linear, branch, loop, retry, deadline, stop, and supersede graphs. Any accidental change to the step sequence breaks CI, and an intentional one bumps ENGINE_STEP_CONTRACT.

Holding Nothing Open

Every automation run has exactly one mailbox, and the run id is the DBOS workflow id: autorun:<automationId>:<deliveryKey>. When a block needs to wait (for a session to go idle, for a Slack reply, for a signal from the agent), the interpreter parks in DBOS.recv. Nothing else is held: no socket, no lock — and notably — no VMs.

Compute nodes are the dominant cost for engrams deployments so we built our control plane so that it could efficiently tear down and rehydrate idle sessions.

This primitive fits very naturally with the DBOS model: a parked workflow is just a row in the system tables, just like a parked VM is just a row in Postgres, along with some files in blob storage, so that the next time a message arrives for that run, the engine wakes it up and resumes from its last completed step:

ONE MAILBOX PER RUNDBOS.send → DBOS.recv

SESSION_IDLEThe agent finished its turn. The VM can be snapshotted; the run only needed to hear that it went idle.

Everything that wakes a run is a DBOS.send to its workflow id

Anything that wants to wake a run sends to it:

  • A session listener sends session_idle and session_ended when the agent finishes a turn or exits
  • The agent itself sends signals through a tool
  • The dispatcher sends new events for runs that join them, plus supersede
  • Operators send stop

Idempotency, All the Way Down

Early on, we discovered that the hardest side effects to get a handle on were commands running inside an agent’s microVM: a git clone, a build, or a message to Claude Code. They take minutes, they aren’t idempotent, and the VM underneath them can be snapshotted, evicted, and restored on another host while they run.

A race condition we ran into: a PR review ran git clone inside a DBOS step. Mid-clone, a routine VM snapshot reset the guest’s connections! The command finished, but its result had nowhere to go.

The fix was to make VM operations behave like DBOS steps:

  • Every exec gets a deterministic id, so a replayed step asks for the same exec.
  • The guest journals each exec: output and exit status are written inside the VM, so a result survives a dropped connection, a snapshot, or a move to another host.
  • Exec is attach-or-start. Calling it again with the same id attaches to the running or finished command instead of spawning a second one.

Now a DBOS step that runs a command can safely re-run as a whole after any restart: the replay attaches to the original exec and gets the original result.

We used this same pattern for every side effect — because we can lean on DBOS for at-least-once delivery, with idempotency for every operation, we can guarantee effectively-once delivery for every automation:

Side effectDeterministic IDDeduped by
Run a command in the VMA deterministic exec idThe VM’s exec journal: attach or start
Send a prompt to the agentAn id from the triggering event, such as slack:<event_id>The harness in the VM, which remembers prompt ids across restarts and snapshots
Start a run from a webhookautorun:<automationId>:<provider>:<deliveryId> as the workflow idDBOS
A cron occurrenceThe occurrence epoch in the run idDBOS

Results

engrams is a software factory that “just works” only because of the durable workflow model that DBOS gives us. A block graph someone drew in an editor gets the same guarantees as code we wrote ourselves: it survives restarts and deploys, it can wait days for a reply, and every side effect, down to a command inside a VM, happens once. And it runs in the Postgres every engrams install already has.

If you’re building agents that act over hours or days, try engrams, or start with DBOS in your own Postgres!

$ end of post
subscribe

Get the latest from Cortex in your inbox

Engineering write-ups, product news, and research reports from the team. Usually once or twice a month.

● ~2 emails / month · no spam · unsubscribe anytime

prefer a reader? rss.xml →
careers

We're hiring engineers at Cortex. If these are the kinds of problems you want to work on, see what's open.

view open roles →