You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[feat] Add manual run controls and human gates #1081
Retrying a leased or stopped step frees any lease and creates a fresh attempt.
Cancel stops pending or leased work and invalidates open leases.
Refusing approval skips the guarded step.
Choices expose choice and note.
Required gates cannot be auto-approved or agent-mediated.
Repeated and stale responses are rejected.
Mid-step questions remain a driver concern.
Verification
Focused retry, cancel, lease, gate, unattended, and channel-policy tests; build/typecheck/lint; one real-CLI E2E that pauses and responds or cancels from another process. Regenerate commands.md and update both workflow guides.
In #818, retry also frees a stuck lease by hand ("stuck → teamai workflow retry … → frees the lease by hand"). Here it only retries a stopped step. Without a timeout, a lease held by a driver that died could then only be cancelled. Maybe retry could cover a leased step too?
retry covers a leased step as well as a stopped step. It frees the lease and starts a new attempt. That is the manual path when a driver has died and no timeout exists yet.
cancel still stops the work. Automatic release on timeout stays in #1082.
Expression roots are a closed list:inputs.* and steps.<id>.outcome | end | outputs.*. choose needs steps.<id>.choice and .note added to the grammar, to the pull check that only ancestors are read, and to the readiness check (canRun).
Retry.stopped → pending is not enough on its own. The steps that were skipped because that step stopped (and any join skipped because all its dependencies were) must go back to pending, so the graph settles again.
"Stopped" means failed today. Expressions compare outcome with any string, so steps.x.outcome == 'cancelled' already parses, but nothing produces that value yet. cancel should make them stopped states in the engine's stopped() so the recovery rule and the run outcome treat them like failed.
Gates settle inside next. Like skips and end, they do not bump revision, so an open lease's report still matches.
Ends are reached eagerly. A ready end-only step settles before any lease. An approve or choose on a path to an end should keep that ordering, or change it on purpose.
Retry after the run ended. The usual case is a run that already failed. next answers done for any run that is not running (engine.ts:542), so retry has to reopen the run, not only the step. [feat] Publish privacy-preserving workflow run records and stats #1083 already requires that a retry does not duplicate records.
When respond counts as a channel with no agent. The skill driver ([feat] Add the session skill driver and invocable workflows #1086) answers gates by running teamai workflow respond from the agent's shell, the same command a person types. A conservative rule: respond counts as a TTY answer only when stdin is a TTY, and any other call answers optional gates only. That does not prove a person answered, because an agent can get a PTY, and Discussion: Workflow Runtime Integration #818 already accepts that limit. It does stop an ordinary agent shell call from answering a required gate.
Gates and parallel leases. With [feat] Add bounded loops and parallel for_each #1079, a run can have a gate open while other steps are ready. If next returns the gate first, the driver waits for a person while other items could keep building. next could return a lease when one is ready, and the gate when nothing else can run.
When a running step learns it was cancelled. The engine never calls a driver. A driver that waits for its tool before it talks to the engine again finds out at report, while the agent keeps spending tokens. The local driver works that way today. A driver can instead poll its lease while the tool runs and stop the tool. The help could say so, and the local driver could do it.
Who takes an interactive step. Only a driver that declares human takes it, and no built-in driver declares it yet. In implement-task, choosing pair would leave the run waiting with no error. Either the local driver runs the tool interactively in the step's workspace when it has a TTY, or next answers that no driver here can take the step.
#1079 takes three things from this issue, so they don't get built twice. Details are in the plan on #1079.
The cancelled status.[feat] Add bounded loops and parallel for_each #1079 adds it for leases cut off by an end or break, with lease_cancelled on a late report. Manual cancel here can reuse the status and the report answer.
Reading the previous pass. In a back_to: plan loop, plan can read steps.decide, and it gets the last pass, null on the first. Passes are archived, so re-pending decide keeps its note. This issue only needs to add choice and note to what a pass keeps.
Telling a running driver it was cancelled.next takes the attempts a driver is running and answers with the ones that are no longer leased. canTakeLease: false asks for that without taking new work, so a driver can poll while its tool runs and stop the tool. The local driver stops cancelled commands this way.
Three things from #1148 that retry and cancel have to decide:
Retry and pass numbers. A loop keeps a finished pass in the step's passes (repend) or moves old children to run.archive (archiveUnder), and the next pass gets a new number. If retry does the same, the failed attempt keeps its pass and its tokens in the run record, and { run, step, pass } stays unique for [feat] Add workflow scorers and recorded quality labels #1085. The cost is that steps.<id>.passes lists the failed attempt, so length(steps.review.passes) < 3 counts it. Resetting the step in place drops the attempt from the record instead.
Retry of an exhausted loop fails again. Step transitions keep iteration, and a pending loop owner with iteration >= max_iterations fails loop_exhausted before it runs (exhaustedAtStart). A workflow loop keeps its count in run.round. Retry has to reset these or say that it does not.
Cancel of pending work.cancel() in step-state.ts takes only a leased or running step, and an end skips pending steps. As skipped, a step's dependents still run and the run can succeed. As cancelled, it counts as stopped, so its dependents are skipped and the run fails, and the run record has no failure code because it takes the code from a failed step.
Activity
SaulMoro commentedon Oct 13, 2026
In #818,
retryalso frees a stuck lease by hand ("stuck → teamai workflow retry … → frees the lease by hand"). Here it only retries a stopped step. Without atimeout, a lease held by a driver that died could then only be cancelled. Maybe retry could cover a leased step too?hsuchifeng commentedon Oct 14, 2026
retrycovers a leased step as well as a stopped step. It frees the lease and starts a new attempt. That is the manual path when a driver has died and no timeout exists yet.cancelstill stops the work. Automatic release on timeout stays in #1082.SaulMoro commentedon Oct 14, 2026
Notes from #1078 (#1129) that affect this issue:
inputs.*andsteps.<id>.outcome | end | outputs.*.chooseneedssteps.<id>.choiceand.noteadded to the grammar, to the pull check that only ancestors are read, and to the readiness check (canRun).stopped → pendingis not enough on its own. The steps that were skipped because that step stopped (and any join skipped because all its dependencies were) must go back topending, so the graph settles again.failedtoday. Expressions compareoutcomewith any string, sosteps.x.outcome == 'cancelled'already parses, but nothing produces that value yet.cancelshould make them stopped states in the engine'sstopped()so the recovery rule and the run outcome treat them likefailed.next. Like skips andend, they do not bumprevision, so an open lease'sreportstill matches.approveorchooseon a path to an end should keep that ordering, or change it on purpose.The engine rules that span files are in
docs/dev/workflow-engine.md, added by #1129.SaulMoro commentedon Oct 15, 2026
Notes from checking this against #818 and the parallel leases in #1079:
teamai workflow resume r1, which runs the driver again and gets its open lease back.resumeis in [feat] Publish and resume complete workflow state across machines #1084, which covers handoff between machines and waits for [feat] Add workflow workspaces, branches, and session continuation #1080. Todaystartonly creates a new run. After the local driver stops, the only way back isnextandreportby hand. This issue could ownresumefrom local state, and [feat] Publish and resume complete workflow state across machines #1084 could extend it to snapshots. Driver identity also uses the working directory as given, so a resume from a subdirectory does not match. The repository's top-level directory would.nextanswersdonefor any run that is not running (engine.ts:542), soretryhas to reopen the run, not only the step. [feat] Publish privacy-preserving workflow run records and stats #1083 already requires that a retry does not duplicate records.respondcounts as a channel with no agent. The skill driver ([feat] Add the session skill driver and invocable workflows #1086) answers gates by runningteamai workflow respondfrom the agent's shell, the same command a person types. A conservative rule:respondcounts as a TTY answer only when stdin is a TTY, and any other call answers optional gates only. That does not prove a person answered, because an agent can get a PTY, and Discussion: Workflow Runtime Integration #818 already accepts that limit. It does stop an ordinary agent shell call from answering a required gate.nextreturns the gate first, the driver waits for a person while other items could keep building.nextcould return a lease when one is ready, and the gate when nothing else can run.report, while the agent keeps spending tokens. The local driver works that way today. A driver can instead poll its lease while the tool runs and stop the tool. The help could say so, and the local driver could do it.interactivestep. Only a driver that declareshumantakes it, and no built-in driver declares it yet. Inimplement-task, choosingpairwould leave the run waiting with no error. Either the local driver runs the tool interactively in the step's workspace when it has a TTY, ornextanswers that no driver here can take the step.SaulMoro commentedon Oct 15, 2026
#1079 takes three things from this issue, so they don't get built twice. Details are in the plan on #1079.
cancelledstatus. [feat] Add bounded loops and parallel for_each #1079 adds it for leases cut off by anendorbreak, withlease_cancelledon a late report. Manual cancel here can reuse the status and the report answer.back_to: planloop,plancan readsteps.decide, and it gets the last pass, null on the first. Passes are archived, so re-pendingdecidekeeps its note. This issue only needs to addchoiceandnoteto what a pass keeps.nexttakes the attempts a driver is running and answers with the ones that are no longer leased.canTakeLease: falseasks for that without taking new work, so a driver can poll while its tool runs and stop the tool. The local driver stops cancelled commands this way.SaulMoro commentedon Oct 16, 2026
Three things from #1148 that retry and cancel have to decide:
passes(repend) or moves old children torun.archive(archiveUnder), and the next pass gets a new number. If retry does the same, the failed attempt keeps its pass and its tokens in the run record, and{ run, step, pass }stays unique for [feat] Add workflow scorers and recorded quality labels #1085. The cost is thatsteps.<id>.passeslists the failed attempt, solength(steps.review.passes) < 3counts it. Resetting the step in place drops the attempt from the record instead.iteration, and a pending loop owner withiteration >= max_iterationsfailsloop_exhaustedbefore it runs (exhaustedAtStart). A workflow loop keeps its count inrun.round. Retry has to reset these or say that it does not.cancel()instep-state.tstakes only a leased or running step, and an end skips pending steps. Asskipped, a step's dependents still run and the run can succeed. Ascancelled, it counts as stopped, so its dependents are skipped and the run fails, and the run record has nofailurecode because it takes the code from a failed step.