Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
fd635d2
docs: design the resumable step model and the typed human-request pause
EdmondDantes Jul 25, 2026
1dba331
feat(agent): persist the exchange at the human-question park
EdmondDantes Jul 25, 2026
84fe9df
feat(workflow): the base drives, records and resumes a #[StepAI] step
EdmondDantes Jul 25, 2026
38cf073
feat(workflow): a #[StepAI] step can carry a critic
EdmondDantes Jul 25, 2026
28a7679
feat(workflow): a #[StepAI] step extracts machine-readable params for…
EdmondDantes Jul 25, 2026
8c21ac9
refactor(workflow): a spent budget stops the run, it never asks the c…
EdmondDantes Jul 25, 2026
3806805
feat(run): a spent budget pauses the ticket for a person, it does not…
EdmondDantes Jul 25, 2026
64db028
refactor(workflow): quality pass over the resumable-step changes
EdmondDantes Jul 25, 2026
4822c62
fix(workflow): a back() into a #[StepAI] step carries its reason as g…
EdmondDantes Jul 25, 2026
d082aed
refactor(workflow): the generator drives its own steps declaratively …
EdmondDantes Jul 25, 2026
f828853
refactor(workflow): the library workflows go declarative (#[StepAI])
EdmondDantes Jul 25, 2026
2bc35f4
refactor(workflow): the recipe teaches generated solvers the declarat…
EdmondDantes Jul 25, 2026
432f79f
docs(workflow): anchor the recipe with one complete #[StepAI] method …
EdmondDantes Jul 25, 2026
0d95db0
test(workflow): migrate the suite's imperative ai() steps to #[StepAI]
EdmondDantes Jul 25, 2026
35e2bbb
refactor(workflow): delete the imperative ai() path
EdmondDantes Jul 25, 2026
dab5926
fix(workflow): a save-reject re-draft gets the full brief, not a bare…
EdmondDantes Jul 25, 2026
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
26 changes: 26 additions & 0 deletions dev/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,3 +418,29 @@ pass commits per note and writes each note's row last: an overtaken index is sta
Separately, `ATTR_TIMEOUT => 4` was removed — measured, pdo_sqlite's default busy timeout on this
build is 60000 ms, so that line lowered it fifteenfold while its comment claimed to raise it. The
same line is still in `ProjectStore::open()`.

---

## 2026-07-25 — A run that needs a person raises one typed request

**Decision.** A run that cannot proceed without a human raises ONE typed `request` —
`{ id, run, kind, prompt, payload, options }`, durable in the journal, resolved by a `resolution` that
names it by `id`. `WaitingHuman` stops being five undistinguished reasons (a parked question, a solver
to approve, a ticket to split, a spent budget, an exhausted strategy) and becomes the state a request
puts the ticket in; the board, the panel and the API all read the one shape. Budget is one such request:
`enforceBudget()`, when the total is spent, records a `budget` request and STOPS rather than calling the
ask channel — the person raises the limit out of band and resumes. `BudgetPolicy::Ask`, `parseExtraTokens`
and the in-run top-up are deleted. Designed in [`design/human-requests.md`](design/human-requests.md);
not yet built.

**Why.** `WaitingHuman` was already reached from five places for five different human jobs, all identical
on the board — the reason lived only in a `report()` string the dashboard never structured. And the
budget path was wrong twice: to handle "no tokens" it spent tokens (the ask channel's front tier is the
supervisor, a model call, which cannot authorize a budget anyway), and on a resumed run that call reached
the human gate before the parked worker and — because the gate matched answers to the run by a FIFO
cursor, not to the question by `id` — consumed the human's answer to the worker's question. Matching a
resolution to its request by `id` closes that at the root, for every kind.

**Open.** One `resolve` endpoint keyed by request id vs. per-kind endpoints (leaning one). Whether all
five sites convert at once or `question` + `budget` land first. Whether `IssueStatus` gains a per-kind
hint or the board reads the kind off the open request. See the design doc.
1 change: 1 addition & 0 deletions dev/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ file rather than overwriting the first, so the reasoning behind what was built s
|---|---|
| The knowledge base, as built | [`design/knowledge-base.md`](design/knowledge-base.md) |
| The knowledge base, second pass | [`design/knowledge-base-next.md`](design/knowledge-base-next.md) |
| A run's request to a person | [`design/human-requests.md`](design/human-requests.md) |

## Hot paths

Expand Down
141 changes: 141 additions & 0 deletions dev/design/human-requests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# A run's request to a person

A run that cannot go on without a human today lands the ticket in `WaitingHuman` and writes a line of
prose to explain — and it does this from five different places for five different reasons, every one of
which looks identical on the board. The person opens the ticket to discover what is even being asked.
This makes the pause a single typed thing: a run raises ONE request, the request says what it is and how
it can be answered, and everything downstream — the board, the panel, the API, resume — reads that one
shape.

## Why one shape

`WaitingHuman` is already overloaded. A run reaches it when the model asked a person a question and is
parked on the answer; when a generated solver waits to be approved before it runs; when a ticket judged
too big has no sub-issues and nobody has said what the pieces are; when triage ran out of strategies and
handed it back; and — once this lands — when the budget is spent. These are not the same request: the
person answers a question, approves code, writes sub-tickets, or raises a limit. But the ticket carries
none of that distinction. The reason lives in a `report()` string the dashboard never structured, so the
board shows a column of identical cards each meaning something different.

One typed request settles all of them at once, and it is the same shape the ask-gate already reaches for —
a durable `question`/`answer` pair — generalized so every other reason inherits the same durability and the
same resume for nothing.

## Shape

A request and its resolution, both durable in the run's journal:

```
request { id, run, kind, prompt, payload, options } -- what the run needs
resolution { ref -> request.id, data } -- what the person gave
```

The OPEN request of a run is its newest `request` with no `resolution` pointing back at it — exactly how
`openGate()` reads `question` against `answer` today. A resolution names its request by `id`, and that is
the whole point: a reply resolves the request it was given, never "the oldest unanswered one."

`question`/`answer` become `request(kind: question)` / `resolution`; the gate's `answeredAfter` + cursor
generalize to `resolvedAfter` + cursor over all kinds. Nothing about the durability machinery is new — it
is the gate's, widened.

## The pause has two forms, and the person sees neither

Every pause records the request and sets `WaitingHuman`. Underneath, how the run WAITS differs, and that
difference is an optimization the person never sees:

- **Live block.** The run coroutine is alive and parks on a channel for the resolution — the current gate:
the worker asked a question, the answer may arrive in seconds, and blocking lets the run continue
in-process without a relaunch. The journal is the durable fallback if the process dies while parked,
which is the whole of issue #87's fix.
- **Durable stop.** The run records the request and EXITS. There is nothing to wait on in-process — the
resolution is an out-of-band act (raise the budget, approve the solver) that may take a day, so holding a
coroutine open buys nothing. Resume relaunches the run, which reads its snapshot and the resolution and
carries on.

The person, the board, and the API see one thing either way: an open request of some kind, with options.

## Resolution and resume

A resolution is the durable input that lets resume proceed; it is not itself the resume. The run resumes
through the same snapshot-and-replay machinery every restart uses (`workflow_state` plus the recorded
exchange); the resolution is simply the fact the resumed run was blocked on. An answer becomes the next
turn of the parked exchange; a raised budget lets `enforceBudget` pass; an approval lets the solver run.
Same engine, different durable fact.

## The kinds

| kind | raised when | payload | options |
|---|---|---|---|
| `question` | the model needs a person to decide (the `[question]` gate) | the question text | answer (free text) |
| `budget` | the run's token/time total is spent | spent, limit, tree context | give +N and resume · stop |
| `approve-solver` | a generated solver is written but not yet run | the solver source | run · reject (with reason) |
| `split` | a ticket judged too big has no sub-issues | the ticket, and why it is too big | write sub-issues and continue |
| `strategy` | triage has no strategy left to escalate to | the failure reason | take it manually · reformulate · close |

The table is the map of today's scattered `setIssueStatus(WaitingHuman)` sites onto one mechanism. Each
row is a real place that reaches `WaitingHuman` now (`HttpGateSpeaker`, and `IssueRunner`'s
`ensureSolver` / `reportDecomposition` / `giveBackToProjectManager`); the change is that each RAISES a
typed request instead of setting a status and writing prose beside it.

## Budget is a request, not a question

The budget pause is why this document has the shape it does, and it carries a decision worth stating on
its own.

`enforceBudget()` runs when the run's total is spent. Under the old `BudgetPolicy::Ask` it reacted by
calling the ask channel — "enter extra tokens to continue" — which is wrong twice over. First, the ask
channel's front tier is the supervisor, an agent: to handle "there are no tokens" it spends tokens making
a model call, and the supervisor cannot authorize a budget anyway. Second, on a resumed run that call
reaches the human gate before the parked worker does, and the gate — matching answers to the run by a
FIFO cursor rather than to the question by `id` — hands the budget check the human's answer to the
WORKER's question. The answer is consumed, parsed as a token count, comes back zero, the run stops, and
the person's reply is gone.

So budget stops being an in-run question. `enforceBudget`, when the total is spent, raises a `budget`
request and STOPS — no channel, no model call. The ticket goes to `WaitingHuman` like any other request.
The person raises the limit out of band and resumes; on resume the budget is above zero, `enforceBudget`
passes, the run continues. `BudgetPolicy::Ask`, `parseExtraTokens`, and the in-run top-up are deleted.
Matching a resolution to its request by `id` closes the answer-stealing bug at the root, for every kind,
not only this one.

## The dashboard

- **Board.** A `WaitingHuman` card carries a badge of its request kind, so the person sees what is wanted
without opening it — a question, a budget, and a solver to approve are three different jobs and should
not look alike.
- **The request panel.** Opening the ticket shows the open request: its `prompt` (what is needed and
why), its `payload` as context (the question, the budget figures, the solver source, the split brief),
and its `options` as real controls — a text box for a question, a +N field with give/stop for a budget,
run/reject for a solver, a sub-issue editor for a split.
- **The bell.** A new open request rings the notification bell, on the channel that already carries run
events.
- **Live.** The request appears and clears over the board's live transport; resolving it takes the ticket
out of `WaitingHuman` without a reload.
- **History.** A resolved request stays in the ticket's timeline — the generalization of the
question/answer pair the chat renders today.

## The API

- `GET issue/{id}` includes the open request, if any: `{ kind, prompt, payload, options }`. The UI needs
nothing else to render the panel.
- `POST issue/{id}/resolve { requestId, ... }` supersedes the answer-only endpoint. It resolves a request
BY ID — which is what makes a resolution land on its own request — and rejects a body whose kind does
not match the open request.

## What this rests on

- **The resume machinery** — a run resuming into a recorded exchange, and, for a parked question,
continuing it from the resolution — is a sibling subject, captured with the two-step-kind cycle it
belongs to, not here.
- **The gate's answer-by-`id`** matching is the small change that both fixes the answer-stealing bug and
makes a typed resolution possible; the `ref` is already stored, only the read side ignores it.

## Open

- Whether `resolve` is one endpoint keyed by request id or a small set per kind. One endpoint is cleaner
and inherently id-addressed; per-kind endpoints read more explicitly. Leaning to one.
- Whether every current `WaitingHuman` site converts in one pass, or `question` + `budget` land first
(the two with a live path, and the two this discussion produced) and the rest follow as their UIs are
built.
- Whether `IssueStatus` gains a per-kind hint or the board reads the kind off the open request. Reading
the request keeps one source of truth; a status hint is cheaper to query. Not decided.
109 changes: 109 additions & 0 deletions dev/design/workflow-resume.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Resuming a run without re-running the model

A run must survive a crash and carry on — including a run paused waiting for a person. Today resume is
at STEP granularity only: a step is arbitrary imperative PHP that re-runs top to bottom on resume, and
the one thing stitched back is the first `ai()` call's history. A step parked mid-exchange on a human
question is not durably recoverable — the exchange at the park point is never persisted, so on resume the
model is asked again and the supervisor can answer in the person's place.

The fix is not "resume in the middle of a step" — PHP cannot serialise a paused call stack. It is to make
a step an atomic unit that either re-runs whole (cheap, deterministic) or replays from a record (the
model was expensive and must not be re-run). Then there is no middle to resume to.

## Two kinds of step

Distinguished by attribute — `#[Step]` already exists, `#[StepAI]` is added — so the base knows the kind
by reflection BEFORE the method runs, and the resume path decides skip / re-run / replay without invoking
the body.

- **`#[Step]` — a CODE step.** Pure, deterministic glue. May call `$this->tool(...)` any number of times.
On resume it is RE-RUN WHOLE: cheap, nothing recorded, no middle to come back to. Its one contract is
that re-running is safe (no non-idempotent side effect) — the sharpest asymmetry in the model, enforced
by discipline, not by the type.
- **`#[StepAI]` — an AI step.** A PURE method that builds a prompt from durable inputs and returns an
{@see AiStep} declaration (prompt, tools, agent, params); critic and maxRounds ride on the attribute.
It does no work and has no side effects — the base runs the ONE `ai()` exchange it declares. EXACTLY ONE
exchange per AI step: that single exchange is the atomic replay unit; two would force per-call recording,
the very complexity this removes. Interleaved computation goes into neighbouring CODE steps.

## The cycle

`run()` drives the steps in declaration order (both kinds), honouring `back()` exactly as today. Each is
handed to `step()`, which now branches at the top: a `#[StepAI]` method goes to `runAiStep()`, everything
else keeps the existing imperative path. The branch is the whole seam; the cycle is otherwise unchanged.

## Resume of an AI step

`runAiStep()` asks the exchange store what it already holds for this `(run, step)` and acts on it:

- **EMPTY** → first run: open the declared prompt.
- **SETTLED** (recorded, ending on a real assistant answer) → REPLAY: take the recorded final text, **no
model call**. A resume never re-buys a turn already produced.
- **PARKED** (recorded, ending on an unanswered `[question]`) → CONTINUE: the ask channel returns the
human's answer (from the journal, once matched by request id — see human-requests.md) and it becomes the
next turn. The model is not asked again; the supervisor is not consulted a second time.

Then the critic loop, unchanged in spirit — judge the AI output, while unhappy let the supervisor guide a
re-run, bounded by maxRounds — reusing the existing `critic()` / `superviseStep()`.

The prerequisite that makes PARKED possible: the turn loop persists the exchange AT THE PARK POINT, before
it blocks the ask channel on a `[question]`. Today the checkpoint fires only after a tool-turn, so a park
leaves nothing recorded. `DefaultTurnLoop::pendingQuestion()` reads the recorded tail back to tell SETTLED
from PARKED.

## The AI step's output

Reuses the existing handoff machinery: after the accepted work, the engine CONTINUES the step's own
conversation with a dedicated extraction request. Two sinks, one mechanism:

- **handoff** — the prose baton to the next step (what `formPendingHandoff()` already does).
- **param** — when a later CODE step needs a machine-readable value (a word, a path, an id), the same kind
of request asks the model for exactly that value and `setParam()` addresses it to that step, which reads
it with `param()`. Declared on the `AiStep` so deciding it is part of designing the step.

## The gaps, and how they close

Named so the machinery handles them rather than shipping them:

1. **The park checkpoint.** Add one checkpoint call in the turn loop's `[question]` branch, before it
blocks — otherwise PARKED has nothing to resume from.
2. **Extraction must not overwrite the work.** `extractParams()` and `formPendingHandoff()` continue the
step's conversation and would checkpoint into the same `(run, step)` exchange row, so a crash mid-
extraction leaves the row holding the extraction Q&A — which resume would replay as the work. They run
under a guard (the same shape as `$reviewing`) that suppresses that checkpoint.
3. **Artifacts on REPLAY.** `$this->artifacts[$step]` is transient, filled only by tool calls made THIS
process; a SETTLED replay makes none, so the critic and extraction see none. They are read back from
the journal on replay (`TraceReader`), never by re-executing the recorded `artifact` tool calls — that
would re-run the shell behind the evidence channel, which a free replay must not.
4. **The reviewer has no ask channel.** A critic's exchange should never park on a `[question]`; the
review palette is built without `EnvKey::Ask`, closing that off structurally rather than trying to make
a reviewer-park resumable.
5. **SETTLED vs PARKED is a durable flag, not a text sniff.** The checkpoint records WHY it fired (a
mid-turn checkpoint vs a park), so detection does not lean on finding the literal `[question]` in the
tail. (First cut may sniff; the flag is the honest version.)
6. **The critic round counter is durable.** Persisted with the snapshot so a crash mid-critic-loop does
not restart the count and let a step exceed maxRounds across incarnations.

## Coexistence and migration

`#[Step]` and `#[StepAI]` run side by side. The old imperative path is untouched, so every existing solver
and test keeps working; new work is written declaratively. The generator's prompt moves to the new rules
(a pre/post CODE step around a declarative AI step) once the machinery is proven. The old path — and the
duplicated critic loop coexistence leaves behind — is deleted when nothing writes imperative `ai()` steps
any more. The duplication is a named, temporary cost of not rewriting a load-bearing class in one motion.

## Sequence

Each increment lands green on its own.

1. **Park durability.** `DefaultTurnLoop` checkpoints at the park; `pendingQuestion()` reads it back.
Foundational, tiny, safe.
2. **The AI step.** `AiStep`, `#[StepAI]`, the `step()` branch, `runAiStep()` with EMPTY / SETTLED /
PARKED and the critic loop, coexisting. Gaps 2–4 closed here.
3. **Output extraction.** handoff (reused) + declared `param` extraction.
4. **Typed requests + budget.** The human-requests.md work: `request`/`resolution`, answer-by-id, budget
as a request. Gap 1 of human-requests (budget stops calling `ask()`).
5. **The generator.** Move `GenerateIssueWorkflow`'s prompt to pre/post-CODE + declarative-AI; migrate the
shipped workflows; then delete the old imperative path.

This document is the engine half; the person-facing half is [`human-requests.md`](human-requests.md).
Loading
Loading