Pause workflow timeouts during human input - #693
Conversation
|
Reviewer note: the subtle part of this change is the transition boundary, not the timer itself. Please focus on these invariants:
The focused coverage is in |
There was a problem hiding this comment.
Pull request overview
This PR updates the workflow runtime to treat pending human input as a first-class “blocked” state, so that workflow stall detection and node timeouts don’t incorrectly fire while an interview is awaiting answers. It introduces run- and stage-scoped interview block tracking, shifts the stall watchdog to react to activity revisions instead of wall-clock polling, and clarifies timeout semantics in docs.
Changes:
- Add ref-counted run +
StageIdinterview block state and publish it viawatchto pause stall detection and exclude interview waits from node timeouts. - Replace stall watchdog wall-clock polling with an activity-revision subscription and restart the monotonic deadline after unblocking.
- Document that agent question waits do not consume node
timeout(and that human-nodetimeoutremains the response deadline).
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| lib/components/fabro-workflow/src/pipeline/execute/tests.rs | Adds regression tests for stall watchdog suspension/restart and node-timeout exclusion during interview waits. |
| lib/components/fabro-workflow/src/pipeline/execute.rs | Reworks stall watchdog into monitor_for_stall() driven by watch activity + interview block state. |
| lib/components/fabro-workflow/src/node_handler.rs | Introduces timeout_excluding_interview_wait() to pause a stage’s timeout budget during its own interview wait. |
| lib/components/fabro-workflow/src/interview_runtime.rs | Implements run/stage block refcounting and exposes block state via watch for time-budget control. |
| lib/components/fabro-workflow/src/handler/human.rs | Passes StageId into interview blocking so block state can be tracked per-stage. |
| lib/components/fabro-workflow/src/event/emitter.rs | Adds watch-based activity revisions so watchdogs can react to activity without wall-clock polling. |
| docs/public/workflows/stages-and-nodes.mdx | Documents that agent-question waiting time does not count toward node timeout. |
| docs/public/reference/dot-language.mdx | Clarifies node timeout semantics for agent vs human nodes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (1)
lib/components/fabro-workflow/src/interview_runtime.rs:121
RunInterviewBlocker::resolvedemitsRunUnblockedbefore updating the watch state. While the event is being dispatched, subscribers (stall watchdog / node timeout exclusion) will still see the run as blocked and may keep timeouts paused longer than intended. It also briefly makes the event stream inconsistent with the in-memory blocked state.
Update the watch state first, then emit RunUnblocked if the run is now unblocked.
fn resolved(&self, emitter: &Emitter, stage_id: &StageId) {
let _transition = self
.transitions
.lock()
.expect("interview transition mutex should not be poisoned");
let should_emit_unblocked = self.state.borrow().unresolved_interviews == 1;
if should_emit_unblocked {
emitter.emit(&Event::RunUnblocked);
}
self.state.send_modify(|state| state.resolve(stage_id));
}
Follow-up cleanup on the human-input timeout work. - Drop the `unresolved_interviews` counter from `InterviewBlockState`. It duplicated `blocked_stages`, which is non-empty exactly when the run is blocked. - Publish block state before emitting `run.blocked` / `run.unblocked` in both directions, so a listener reading `subscribe()` from an event callback never sees state that disagrees with the event. The watchdog still gets a full fresh deadline because it restarts on the unblock transition. - Stop panicking in `InterviewBlockState::resolve`. It runs from `Drop`, where a panic during unwind aborts the process. - Replace the emitter's `activity_revision` watch channel with a monotonic timestamp. `record_activity` runs on every agent stream delta, and the channel woke the watchdog task and re-armed its timer per event. The watchdog now samples `last_activity()` when its deadline fires and re-arms only if the run was active, so the hot path is one clock read and one relaxed store. - Remove the now-unused `last_event_at()` and `epoch_millis()`. - Collapse the duplicated blocked/unblocked `select!` arms in `monitor_for_stall` and `timeout_excluding_interview_wait` into one loop each, using a branch precondition to park the timer while blocked. - Handle a dropped block-state sender in `timeout_excluding_interview_wait` by falling back to a plain deadline instead of panicking, which also removes a potential busy loop. - Only compute `stage_id` when the node actually has a timeout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Ran a simplification pass over the branch. Net −18 lines, all 7,741 workspace tests pass, clippy and fmt clean. Removed redundant state
Hot path
Event/state ordering (addresses the Copilot comment) Both transitions now publish state before emitting, so a listener reading Panics
Duplication
Also: All three invariants from the reviewer note still hold, and the three named tests pass unchanged. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (3)
lib/components/fabro-workflow/src/interview_runtime.rs:113
RunInterviewBlocker::resolvedalso usesexpect(...)on the transitions mutex, so the same listener-panic poisoning risk applies during unblocking (and would prevent recovery on subsequent resolves).
let _transition = self
.transitions
.lock()
.expect("interview transition mutex should not be poisoned");
lib/components/fabro-workflow/src/interview_runtime.rs:90
RunInterviewBlocker::blockholds a poisonedstd::sync::Mutexguard viaexpect(...). Because the critical section includesemitter.emit(...)(which runs arbitrary listeners), a panic in any listener would poison this mutex and make all future block/unblock transitions panic, potentially breaking run execution.
This issue also appears on line 110 of the same file.
let _transition = self
.transitions
.lock()
.expect("interview transition mutex should not be poisoned");
lib/components/fabro-workflow/src/pipeline/execute.rs:60
monitor_for_stallexits permanently when theinterview_blockswatch channel closes (changed.is_err()), which disables stall detection instead of falling back to activity-only monitoring. This is inconsistent withtimeout_excluding_interview_wait(which falls back to a plain timeout when the blocker disappears) and could allow a hung run to never trip the watchdog if the sender is dropped unexpectedly.
changed = interview_blocks.changed() => {
if changed.is_err() {
return;
}
Second pass, from the remaining review findings. - Wrap the stall watchdog in a `StallWatchdog` type. The call site kept two parallel `Option`s derived from the same condition and threaded out an `Option<(CancellationToken, JoinHandle<()>)>`. `monitor_for_stall` also took two same-typed `CancellationToken` params pointing opposite directions, where swapping them compiles and yields a run that silently never stalls. - Rename `WorkflowAgentQuestionRuntime::stage_id` and `PendingAgentQuestionBatch::stage_id` to `node_id`. They hold `node.id`, and the previous commit put them two lines from `stage_scope.stage_id()`, which returns a real `StageId`. - Widen the two real-time interview tests. `node_timeout_excludes_ human_input_wait` allowed 20ms of active work against a 50ms budget, which is tight enough to flake under parallel nextest load. The blocked wait still outruns the timeout, so both still fail if the pause regresses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Second pass, from the remaining review findings.
Considered and skipped: wrapping the block-state receiver in a
|
|
Correction on my first comment's sizing of the emitter change, so the cost/benefit is accurate. I said the watch channel woke the watchdog "per token." Sender-side that's right, but receiver wakes coalesce — Sender-side per event, even with zero subscribers: an Exec output is not a factor either way — command streaming goes to Two things worth stating explicitly since they were the load-bearing details:
Also considered and skipped, both pre-existing and outside this diff:
|
Summary
timeoutbehavior as the response deadline and document the distinction.Implementation notes
RunInterviewBlockernow publishes ref-counted run andStageIdblock state.Validation
cargo nextest run --workspace— 7,683 passed, 203 skippedcargo +nightly-2026-04-14 clippy --workspace --all-targets --all-features -- -D warningscargo +nightly-2026-04-14 fmt --check --allgit diff --check