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 .changeset/brown-loops-race.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"warpforge": patch
---

A workflow no longer ends for good when one of its agents is lost. If an agent
process dies part-way through a stage — killed by something outside the run,
not by anything wrong with the work — the pipeline now pauses at that stage
instead of finishing as failed. Press Resume and it runs the stage again,
warned that the working copy may already hold partial changes. Previously the
run was over: resume was refused and the only way forward was a new task, even
when the work was already done.
10 changes: 10 additions & 0 deletions .changeset/eighty-pugs-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"warpforge": patch
---

Starting a task no longer pauses while its name is written. Naming a task runs
a short agent in the background, and the app used to wait on it before handling
anything else — so the first message, tool approvals, and other tasks all sat
still until the name came back. Naming now happens alongside your work, as do
installing an agent or a language server, which had the same problem and could
hold things up for much longer.
10 changes: 10 additions & 0 deletions .changeset/great-moons-tap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"warpforge": patch
---

The app now handles several requests at once instead of one at a time. A single
slow action — listing a large project, loading a diff, scanning for agents —
used to hold up everything else you did, so a tool approval could sit waiting
until the slow one finished. Requests that only read now run alongside each
other, and replies are sent without waiting on the network's send delay, which
takes tens of milliseconds off routine actions.
9 changes: 9 additions & 0 deletions .changeset/lazy-jars-cheer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"warpforge": patch
---

Starting a task in its own workspace copy no longer holds up everything else.
Setting that copy up takes a moment, and until now the whole app waited on it —
your other tasks' replies and approvals paused until the new task's workspace
was ready. The task now shows up on the board immediately and begins work as
soon as its workspace lands, while the rest of the app keeps moving.
9 changes: 9 additions & 0 deletions .changeset/olive-books-melt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"warpforge": patch
---

Branching a conversation now carries your uncommitted work across, including
when the original task runs in the project folder itself rather than its own
workspace copy. The branch used to start from the last commit in that case, so
edits you had not committed were missing from the conversation meant to
continue them.
8 changes: 8 additions & 0 deletions .changeset/olive-moons-race.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"warpforge": patch
---

Searching for files no longer freezes the rest of the app. On a large project
the search reads through every file, and until now everything else — agent
replies, approvals, service controls — stopped until it finished. Search now
runs out of the way, so the app keeps responding while it works.
6 changes: 6 additions & 0 deletions .changeset/plain-days-tell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"warpforge": patch
---

Merging a task's workspace copy back into your project no longer pauses the
rest of the app while git works.
10 changes: 10 additions & 0 deletions .changeset/quick-pans-shake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"warpforge": patch
---

Approving a tool call, sending a message, or starting a task no longer waits on
whatever else is happening. Previously, while an agent was streaming its answer,
the app saved every fragment as it arrived and everything else queued up behind
that — so an approval prompt could sit unresponsive for as long as the agent
kept typing, even in a different task. Saving now happens out of the way, and
the interface stays responsive while agents work.
11 changes: 11 additions & 0 deletions .changeset/small-horses-jump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"warpforge": patch
---

Long conversations no longer grow memory without limit. The app used to keep
every line of everything your agents had said in memory and reload it all on
start, so the more work agents did, the more memory the app held onto even when
it was only showing the latest exchange. It now keeps just what the current
view needs — the latest message and the most recent exchange — and loads the
rest only when you resume a session or open a project. Resuming a session
still shows each reply once, and nothing in the chat history is lost.
9 changes: 9 additions & 0 deletions .changeset/tall-melons-wave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"warpforge": patch
---

Warpforge no longer stops processes it did not start. When shutting down it used
to clear everything listening on the project's port range, which could take down
a server you were running yourself — or, when running warpforge's own tests, the
agents of the warpforge you were running them from. It now only stops the
services it started.
10 changes: 10 additions & 0 deletions .changeset/tidy-crabs-argue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"warpforge": patch
---

Viewing changes no longer slows the rest of the app down. The changes panel
refreshes on a timer, and each refresh used to hold everything else up while it
inspected the repository — with a task open, that was a steady drip of pauses
affecting agent replies and approvals. Reading diffs, file contents, file lists
and branches now happens alongside the rest of the app instead of in front of
it.
9 changes: 9 additions & 0 deletions .changeset/wise-cups-look.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"warpforge": patch
---

Committing, pushing, merging, switching branches, saving a file and opening a
pull request no longer pause the rest of the app while they run. Each of these
waits on git, and until now everything else — agent replies, approvals, your
other tasks — waited with it. They now run alongside your work, so a slow push
costs you the push and nothing else.
144 changes: 144 additions & 0 deletions docs/adr/0002-daemon-concurrency.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# 0002 — Daemon concurrency: non-blocking mailboxes, sharded per task

**Status:** accepted (2026-08-15)

## Context

The daemon started as one actor: a single `tokio::select!` loop in
`daemon/actor.rs`, owning all state and draining one `mpsc` mailbox. For the
scale it was written at — a couple of projects, one agent session — that was the
right call, and it made every state transition trivially race-free.

It stopped holding. `actor.rs` is past 7000 lines, `handle_command` awaits each
command inline, and the work behind those commands grew: worktree creation,
subprocess `git`, filesystem walks, SQLite writes. What users hit: starting a
task stalls the conversation, approving a tool call waits behind unrelated work
in a different task, the title generator delays the first turn. One queue for
everything.

Every source of head-of-line blocking below was confirmed in the code, not
inferred:

- **Blocking `rusqlite` on the actor's thread.** `emit_session` persists on
*every* streamed chunk from an agent, and `emit_session_unless_last_duplicate`
reads the last row back from disk before each one. While an agent streams, the
actor hammers the disk and nothing else in the daemon advances.
- **A second queue above the actor.** The WebSocket read loop in
`daemon/server.rs` awaits `dispatch` inline, so one connection serves one
request at a time. A tool approval is not even read off the socket until the
file search ahead of it returns.
- **Inline I/O in `handle_command`** — worktree create, diff, branch operations.
`diff::search_files` is a fully synchronous tree walk that reads every file.
- **A latent self-deadlock.** The actor sends to its own bounded mailbox
(`Command::ProbeAgent`). If that mailbox ever fills, the actor blocks on its
own send with nobody left to drain it.

ADR 0001 invariant 9 — "waits on a child's exit are bounded … the daemon actor
is single-threaded and awaits handlers inline" — is a workaround for this
architecture, not a property worth keeping.

## Decisions

**The actor loop never blocks and never awaits I/O.** A handler may read and
mutate in-memory state, then it either replies or hands the work to a task.
Results come back as ordinary messages. *Rejected:* case-by-case `tokio::spawn`
where a handler looks slow — that is what produced today's state, where four
handlers spawn and thirty do not, and no reader can tell which rule applies.

**Persistence is a write-behind actor on its own blocking thread.** It owns the
`rusqlite` connection, coalesces streamed session updates in memory, and flushes
batched transactions. Callers get fire-and-forget. *Rejected:* `spawn_blocking`
per write — it keeps one disk round-trip per streamed chunk, which is the actual
cost; the fix is batching, not moving the same work sideways. *Rejected:* an
async SQLite wrapper — same round-trip count, plus a dependency.

**Reads do not enter a mailbox.** Diffs, file listings, search, file contents and
snapshots are served from an `ArcSwap` state snapshot plus I/O on a worker. They
need a consistent *view*, not exclusive access. This removes roughly half the
`Command` variants from the write path. *Rejected:* keeping reads in the mailbox
for strict read-your-writes ordering — the UI already tolerates eventual
refresh, and it is what makes polled reads (the diff panel) cost the whole
daemon.

**Requests are concurrent per connection.** `server.rs` spawns each dispatch,
bounded by a semaphore, rather than awaiting it in the read loop.

**State is split by ownership, then sharded per task.** Global state (projects,
accounts, configured agents, services, port forwards) stays in one actor. Per-task
state (agent session, pending permissions, workflow run, worktree) moves to a
task actor with its own mailbox, supervised so a wedged task cannot take the
daemon with it. The global actor routes. *Rejected:* one actor with finer-grained
locks — it trades a queue for a lock graph and loses the property that makes the
actor model worth having.

**Control-plane messages never share a queue with data-plane.** Permission
answers, cancels and stops ride a separate channel, drained first in a `biased`
select. A user answering a permission prompt must not wait behind a stream of
agent output.

**Every message that mutates task state carries the task's epoch.** Handing work
to a task means results arrive after the world may have moved on; a result whose
epoch does not match is dropped. *Rejected:* checking only that the task still
exists — an id is reused across cancel-and-restart, and the stale write lands on
the new run.

**New machinery grows beside the old, and ownership moves in one step per
piece.** The new runtime lives under `daemon/runtime/`; the existing actor
delegates into it as each piece lands. *Rejected:* a parallel implementation kept
running alongside the old one behind a flag — two owners of the same mutable
state diverge, and the resulting bug reports are unreadable. Alongside means
*not yet wired*, never *wired twice*.

## Invariants

Named by module, because each one fails quietly.

1. **`daemon/actor.rs` handlers hold no `.await` on I/O.** git, filesystem,
subprocess and store calls are handed off. A handler that awaits I/O
reintroduces the whole class of bug this record exists for, and it will look
local and harmless in review.
2. **Nothing calls `store::*` from an actor loop.** The store is reachable only
through the persistence actor's channel. A direct call compiles, runs, and
silently puts a blocking disk write back on the hot path.
3. **An actor never `.await`s a send to its own mailbox.** Use `try_send` and
handle the full case, or a dedicated unbounded self-channel. This is a hard
deadlock, not a slowdown.
4. **Every reply path stays total.** Handing work to a task adds paths where a
`oneshot` sender can be dropped — a task that panics, an epoch mismatch, a
shard that was torn down. A dropped reply is a client promise that never
settles: a spinner that spins forever. Every early return sends something.
5. **Epoch is checked before mutation, not before dispatch.** The gap between
accepting a result and applying it is where the stale write lands.
6. **Snapshot publication is atomic per command.** Readers must never observe a
half-applied transition — publish once, after the handler completes, not on
each field it touches.
7. **Ordering guarantees are per task, not global.** Two commands for the same
task keep their order; commands for different tasks do not, and nothing may
assume they do. Workflow stage transitions are the place this will be
assumed by accident.
8. **A task shard's death is contained and observable.** Supervision restarts it
from persisted state and marks the task; a silent restart that loses queued
commands is worse than the freeze it replaced.

## Consequences

- ADR 0001 invariant 9 (bounded waits on child exit) loses its original
justification once handlers stop blocking the daemon. Bounded waits stay —
they are good hygiene — but they are no longer load-bearing for liveness.
- Read-your-writes is no longer automatic. A mutation followed immediately by a
read may observe the prior snapshot; flows that depend on it must await the
mutation's reply, not re-read.
- `actor.rs` stops being one file. Splitting by ownership is what makes the
400–500 line rule in `CLAUDE.md` reachable here; the file size is a symptom of
the undivided state, not a formatting problem.
- Debugging changes shape: a stall is no longer "the actor is busy" but "which
shard, which channel". Shard identity belongs in log lines from the start.
- More moving parts. This is only worth it because the single queue is now the
user-visible bottleneck; it would have been premature a year ago.

## Out of scope, deliberately

Event sourcing (replay, audit, undo) — a different investment with a different
payoff, and not a latency fix; the write-behind persistence actor here is a
prerequisite for it, not a competitor. Multi-process daemons. Distributing work
across machines. Replacing the broadcast event bus, which is not a bottleneck.
77 changes: 77 additions & 0 deletions docs/adr/0003-workflow-agent-loss.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# 0003 — Losing a stage's agent pauses a pipeline, it does not fail it

**Status:** accepted (2026-08-15)

## Context

A workflow stage whose child session ended called `workflow_finalize` with an
error. `RunState::Failed` is terminal: `is_active()` is false, and
`workflow_resume` answers "the pipeline is not paused". The run was over.

That is the wrong response to the failure that actually happens. The reported
case: an agent process was killed mid-stage by something unrelated to the work
(warpforge's own test suite, which kills every listener in the project's port
range — see the *Consequences* below). The user re-prompted the stage's task,
the session reconnected, and the agent finished the implementation. The work was
done and reviewable. The pipeline was still dead, and the only way forward was
to start a new task.

Losing the agent process is an infrastructure failure. The stage never got to
say whether the work was good, so the pipeline has no verdict — but it also has
no reason to conclude the run failed.

## Decisions

**A stage that loses its agent parks the run at the pause barrier**
(`RunState::Paused { next: <that stage> }`) instead of finalizing it. Resume
re-runs the stage. This applies to a stage child whose session ended, and to a
review round where every reviewer's agent died before producing a verdict —
both are the absence of a verdict, not a rejection.

*Rejected:* a new `WorkflowWaitKind` with retry/accept/stop controls. It is the
better long-term shape — in the reported case the work was already finished, so
"accept this stage and move to review" would have been the right answer — but it
needs a protocol addition and desktop work, and the pipeline being unrecoverable
at all is the part that hurts. The pause barrier already exists, the desktop
already renders it, and resuming already works.

*Rejected:* automatically retrying the stage. A dead agent is often dead for a
reason that will repeat, and a pipeline that silently re-runs stages burns
tokens without telling anyone.

**The re-run is told the working copy may already contain partial work**, via
`pending_guidance` — the same warning `restore_workflow_runs` gives a stage
interrupted by a daemon restart. A stage that assumes a clean tree will redo
work that is already there, or worse, conflict with it.

## Invariants

1. **This path is for a lost agent, not a bad outcome.** A stage that finishes
and produces a poor verdict goes through `workflow_stage_finished`. Only
`workflow_child_failed` — reached when a child's session ends — parks. Widen
it and a genuinely failing pipeline becomes an infinite pause loop.
2. **Parking must clear the stage's bookkeeping.** `active_children`, and for a
review round `review_pending` / `review_collected` / `reasked`. A resumed run
that still lists the dead child waits for a turn that will never end.
3. **The review round counter is given back when a review round parks**, for
the same reason `restore_workflow_runs` gives it back: spawning re-increments
it, and without the decrement a re-run reports "round 3/2" and lands straight
on the limit decision.
4. **ADR 0001 invariant 6 still holds and is narrower than it looks.** A stage
whose session *fails to start* must fail the run — no handle is inserted, so
no `TurnEnded` will ever arrive and nothing else would notice. A stage whose
session started and *then* died is this record's case.

## Consequences

- A pipeline can now sit paused indefinitely after an agent dies. That is
visible on the board (the parent goes to `Waiting` with pause controls) and is
the intended trade against silently burning tokens on retries.
- Resuming re-runs the whole stage. When the lost agent had already finished the
work — the reported case — the re-run is redundant. The rejected barrier
design is what fixes that; this record does not.
- Worth fixing separately: `cargo test` in this repo kills every process
listening on the project's port range (`kill_listeners_in_ranges`, reached
from the daemon teardown that tests exercise). That is what killed the agent
in the reported case, and it kills unrelated processes on the developer's
machine too.
2 changes: 2 additions & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,5 @@ stale and then misleads.
| ADR | Subject |
| --- | --- |
| [0001](0001-workflow-pipelines.md) | Workflow pipelines: deterministic engine, project-configured |
| [0002](0002-daemon-concurrency.md) | Daemon concurrency: non-blocking mailboxes, sharded per task |
| [0003](0003-workflow-agent-loss.md) | Losing a stage's agent pauses a pipeline, it does not fail it |
Loading
Loading