Skip to content

Resumable #[StepAI] workflows, and delete the imperative ai() path (#87) - #90

Merged
EdmondDantes merged 16 commits into
mainfrom
feat/workflow-resume
Jul 25, 2026
Merged

Resumable #[StepAI] workflows, and delete the imperative ai() path (#87)#90
EdmondDantes merged 16 commits into
mainfrom
feat/workflow-resume

Conversation

@EdmondDantes

Copy link
Copy Markdown
Contributor

Why

Issue #87: the ask/wait-for-response flow did not survive a restart. A step parked on a human question recorded nothing durable at the park point, so a resumed run asked the model again and the supervisor could answer in the person's place. Fixing that properly meant making a step an atomic unit that either re-runs whole or replays from a record — which is what this branch builds, and then it removes the old imperative path it replaces.

The model

A step is now one of two kinds, told apart by attribute before the body runs:

  • #[Step] — a CODE step. Pure, deterministic glue; may call $this->tool(...); re-run whole on resume.
  • #[StepAI] — an AI step. A PURE method returning an AiStep declaration (prompt, tools, agent, params). The base runs the ONE declared exchange, records it, and on resume CONTINUES the recorded conversation — a crash while PARKED on a human question resumes from the answer, never re-asks.

Because a step is atomic there is no mid-step state to serialise: EMPTY → open the prompt; SETTLED → the run carries on; PARKED → the person's answer becomes the next turn.

What's here

  • Durable park + resume. The turn loop checkpoints the exchange AT the [question] park; pendingQuestion() reads it back so resume tells a park from a settled exchange. A parked run's ticket sits in WaitingHuman with the question in the journal; the answer is matched to the question by id.
  • Budget is a pause, not a failure. A spent budget marks the run and lands the ticket in WaitingHuman for a person to raise the limit and resume — it no longer calls the ask channel (which would spend tokens to report "no tokens" and, on a resumed run, steal the worker's answer).
  • back() carries its reason. A back() into a step hands it the reason via critique(), on both kinds.
  • Declarative output channels. A #[StepAI] hands work on via an artifact (recorded by the model through the artifact tool), the automatic handoff, or a ParamRequest addressed to a later CODE step.
  • The whole workflow layer goes declarative. GenerateIssueWorkflow, the library workflows (SuperviseWorkflow, FixBugWorkflow, the ReviewFileWorkflow example) and the generator's recipe all move to #[StepAI], so a generated or repaired solver never reaches for imperative ai().
  • The imperative ai() path is deleted. protected ai() and the machinery only it used (resumeHistory, stepHistory, the reentered-guarded exchange reload) are gone; judge() drives the critic exchange through runTurns() directly. WorkflowValidator now recognises #[StepAI] as a step (its checks keyed off #[Step\b, which does not match StepAI, so a declarative solver would have been rejected for "no step" and its critic bindings left unchecked) — with a test.
  • A save-reject re-draft gets the full brief. A validator rejection at save re-enters draft/repair from a fresh exchange, so the re-draft is handed the whole brief plus the complaint, not a bare one-line "fix it".

Testing

composer qa green: PHPStan level 8 clean, 380 tests, code style clean. The design lives in dev/design/workflow-resume.md and dev/design/human-requests.md; the decision in dev/DECISIONS.md (2026-07-25).

Closes #87.

Two sibling subjects from one investigation into the ask/wait flow: a run's
pause for a person becomes one typed request (design/human-requests.md), and a
step becomes an atomic unit that re-runs whole or replays from a record so there
is no mid-step resume to build (design/workflow-resume.md). DECISIONS records the
human-request decision; INDEX points at both.
The [question] turn is a no-tool turn the tool-turn checkpoint never reaches, so
a crash while parked left nothing recorded and a resume re-asked the model. The
loop now checkpoints before it blocks on the answer, and pendingQuestion() reads
that tail back to tell a park (continue from the answer) from a settled exchange
(replay). First increment of design/workflow-resume.md.
A step is now one of two kinds, told apart by attribute so the resume path
decides continue-vs-re-run before running the body. A #[StepAI] method is pure:
it returns an AiStep declaration and the base runs the one exchange it names.
On resume the base continues the recorded conversation instead of re-running
the model, and a crash while PARKED on a person's answer resumes by continuing
from that answer rather than asking again. Coexists with the imperative #[Step]
path, untouched, so every existing solver and test keeps working. Second
increment of design/workflow-resume.md.
The critic and its round cap ride on the attribute, so the resume path reads
them without running the body — the same reason the kind is an attribute. The
review loop is the imperative path's, reused: the two deterministic guards and
the AI critic are pulled into a shared verdictFor() so their wording cannot drift
between the two kinds, and the base drives an AI step's re-run by continuing the
prior exchange with the supervisor's guidance as its next turn. A reentered guard
keeps the critic's own ai() from reloading the step's exchange as its context.
Part of design/workflow-resume.md (increment 3).
… later code steps

An AiStep may declare ParamRequests: after the work settles, the base continues
the step's own conversation to ask the model for exactly that value and pins it
with setParam() for the named step, which reads it with param(). It is the handoff
mechanism addressed to a step in code rather than the next step's model. The
extraction runs under an $extracting guard so its turns are not checkpointed into
the step's exchange row — otherwise a crash mid-extraction would leave that row
holding the extraction Q&A, which a resume would replay as the step's work.
Completes increment 3 of design/workflow-resume.md.
…hannel

Reacting to "there are no tokens left" by calling the ask channel spent tokens
to ask a model whether to continue — the front tier is the supervisor, a model
call — and could not authorise a budget anyway; worse, on a resumed run that call
reached the human gate before the parked worker and consumed the human's answer.
So enforceBudget now just throws its resumable stop; BudgetPolicy, parseExtraTokens
and the CLAW_BUDGET_POLICY knob are gone. Turning a budget stop into a resumable
WaitingHuman pause the operator settles out of band is the next step. See
DECISIONS 2026-07-25 and design/human-requests.md.
… fail the run

A budget stop is marked (WorkflowException::budgetSpent) so the run path tells it
from the other deliberate stops. On it the ticket goes to WaitingHuman and the run
is left resumable — its status untouched — so raising the limit and running the
issue again picks it up where it stopped, rather than failing the run and handing
it back to triage, which would throw away work the budget only interrupted. Both
the solver and direct paths honour it. Advances design/human-requests.md (budget as
a request); the visible typed request + UI is still to come.
From a comment-and-quality review of the branch: the attempt fingerprint is one
private helper instead of two copies, so both step kinds decide "the same" the
same way; isAiStep() defers to aiAttribute() rather than reflecting a second time;
and the stale comments are corrected — the turn loop no longer claims a single
checkpoint site, and the class and DEFAULT_MAX_ROUNDS docs name the #[StepAI] kind
alongside #[Step].
…(#[StepAI])

understand/assess/draft/save move off the imperative ai() path onto #[StepAI]+#[Step].
State flows on the engine's channels, not fields: the plan and difficulty reach draft as
addressed params; draft's model records the solver via the artifact tool (a pure #[StepAI]
cannot call artifact()) and hands the source to save as a code param; save runs
define_workflow and, on a validator reject, re-drafts once via back() instead of an ai()
revise callback. Coexists with the old path — generated solvers and every other workflow
are untouched.
SuperviseWorkflow and the ReviewFileWorkflow example move off the imperative ai() path.
Supervise's repair declares its exchange and hands the corrected source to save as a code
param; save runs define_workflow and re-writes once via back() on a validator reject. The
example now shows the two-kind shape (CODE #[Step] + declarative #[StepAI] with an
attribute critic) instead of hand-rolled ai() calls and a critic-as-substep.
…ive shape

draftPrompt() now teaches #[StepAI] returning an AiStep (not #[Step]+$this->ai()): the
two kinds of step, output on channels (artifact via the model's artifact tool, handoff,
ParamRequest params), critics on #[StepAI(critic:)], and the model asking/verifying inside
the one declared exchange. The granularity philosophy (RECIPE) is style-agnostic and stays.
criticRules()'s 'real work' / 'placeholder' definitions move to the declarative shape too.
…example

A live generation against a real model adopted the declarative shape (#[StepAI]/AiStep) but
garbled the AiStep call syntax; a full copyable method body — one `return new AiStep(...);`,
PHP heredocs not Python triple-quotes — anchors it. The critic and validator already reject a
malformed draft, so this is a quality nudge, not the gate.
Every test that drove a step with $this->ai() now declares a #[StepAI] exchange (or, where
the ai() call was inert filler for a critic/budget probe, a CODE step that records the judged
artifact by hand). ProbeWorkflow reaches a model exchange the only way the declarative model
allows — a #[StepAI] the base runs, set by runAi(). Dropped one test that exercised TWO ai()
calls in one step: a #[StepAI] declares exactly one exchange, so that mechanic no longer
exists. No real $this->ai() caller remains in tests (two non-executed solver fixtures aside).
Nothing writes imperative #[Step]+$this->ai() steps any more, so the path goes:
- remove protected ai() and the machinery only it used (resumeHistory, stepHistory, the
  reentered-guarded loadExchange); the declarative runAiExchange resumes from the store directly.
- judge() drives the critic exchange through runTurns() (a fresh, review-palette turn) instead
  of ai(); saveGeneratedWorkflow() is gone, both callers having inlined define_workflow + back().
- WorkflowValidator recognises #[StepAI] as a step: its checks keyed off `#[Step\b`, which does
  not match StepAI, so a declarative solver was rejected for declaring 'no step' and its critic
  bindings went unchecked. Now covered, with a test.
- FixBugWorkflow (the last library solver) and every remaining teaching prompt
  (SuperviseWorkflow's repair recipe, DefineWorkflowTool's description) move to the declarative
  shape, so a generated or repaired solver never reaches for a method that is gone.
… complaint

Adversarial review found a regression: save's back('draft'/'repair') re-enters the step FRESH
(the exchange was cleared when it first finished), so the old critique branch — which handed the
model only the complaint on the assumption it still held its prior attempt — asked it to 'fix'
a class it could no longer see. It near-always failed the one repair pass. The re-draft now
prepends the complaint to the WHOLE brief (task/plan/recipe for the generator, broken source +
error for supervise), so the model rebuilds a correct class. The false 'still holds its previous
attempt' comments are corrected, and every dangling {@see ai()} doc-reference to the deleted
method is repointed to the model exchange / reviewedExchange.
@EdmondDantes
EdmondDantes merged commit 919206e into main Jul 25, 2026
2 checks passed
@EdmondDantes
EdmondDantes deleted the feat/workflow-resume branch July 25, 2026 16:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The ask/wait-for-response flow (model pauses for an answer) essentially does not work

1 participant