-
Notifications
You must be signed in to change notification settings - Fork 3k
feat(cli): add daemon Todo stop guard #6945
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
doudouOUC
merged 6 commits into
QwenLM:main
from
doudouOUC:agent/daemon-todo-stop-guard
Jul 18, 2026
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
c72ce4d
feat(cli): add daemon Todo stop guard
doudouOUC 77a3b07
codex: address PR review feedback (#6945)
doudouOUC 460ca52
fix(cli): Preserve Stop hook output on mid-turn input
doudouOUC d77c72a
codex: address PR review feedback (#6945)
doudouOUC d1e718c
codex: address PR review feedback (#6945)
doudouOUC fe64ed2
codex: resolve PR merge conflict (#6945)
doudouOUC File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,179 @@ | ||
| # Daemon Todo Stop Guard | ||
|
|
||
| ## Problem | ||
|
|
||
| Daemon and ACP clients can keep a session alive after a model turn ends. When | ||
| the model has just written an unfinished top-level Todo list, a natural model | ||
| stop can leave the daemon request incomplete even though the session has enough | ||
| trusted state to continue. The client currently has no bounded, built-in way to | ||
| distinguish that case from an ordinary completed turn. | ||
|
|
||
| This design adds an opt-in daemon-only stop guard. It deliberately does not | ||
| change the TUI, the Core Todo tool, or the general agent loop. | ||
|
|
||
| ## Configuration and safety boundary | ||
|
|
||
| `experimental.todoStopGuard` defaults to `false`, requires a restart, and is | ||
| not shown in the TUI settings dialog. The guard is forced off in safe mode, | ||
| bare mode, and Approval `plan` mode. `disableAllHooks` does not disable the | ||
| built-in guard because it is not an external hook. | ||
|
|
||
| Each uninterrupted automatic-continuation stage may create at most two extra | ||
| primary-model streams. A mid-turn user message explicitly starts a fresh | ||
| two-attempt stage because it is new user input, while retry/continue and | ||
| background results retain the current stage's budget. Existing permission | ||
| checks, cancellation, token limits, loop protection, ACP grace periods, and | ||
| daemon resource limits remain authoritative. In particular, a disconnected | ||
| client never implies permission approval. | ||
|
|
||
| ## Trusted state | ||
|
|
||
| The CLI `Session` owns a small in-memory `DaemonTodoStopGuard` state machine. | ||
| It stores whether the current work chain is armed, the latest unfinished item | ||
| count, committed continuation attempts, suspension/queued-prompt state, and | ||
| whether exhaustion was already reported. The Session separately snapshots the | ||
| IDs of background agents, shells, monitors, and wakeups at the start of a work | ||
| chain, including terminal notifications and wakeups already queued at that | ||
| boundary. | ||
|
|
||
| Only a successful top-level `TodoWriteTool.execute()` result with the structured | ||
| `{ type: 'todo_list', todos: [...] }` envelope can arm the guard. The observation | ||
| happens after tool execution and status calculation, before Session | ||
| `PostToolUse` hooks. Arguments, replayed history, disk state, failed or | ||
| duplicate tool calls, sub-agent Todo lists, and discovered tools that shadow | ||
| the `todo_write` wire name are not trusted. The newest successful result | ||
| replaces the count; an empty or fully completed list disarms the guard | ||
| immediately. Disarming prevents another natural-stop continuation; it does not | ||
| truncate a tool loop already opened by a committed Guard stream. | ||
|
|
||
| A new ordinary user prompt starts an unarmed work chain and resets its | ||
| background baseline. It cannot inherit activation from an earlier request even | ||
| if Todo state remains in memory. Trusted retry/continue keeps the work chain | ||
| only while trusted unfinished Guard state still exists; after a trust-clearing | ||
| lifecycle event it starts with a fresh background baseline and must arm again. | ||
| A mid-turn user message keeps its activation and starts a fresh two-attempt | ||
| stage. This means the hard bound is two consecutive automatic streams without | ||
| new user input, not two streams across the entire lifetime of a work chain. | ||
| Cron and notification turns can establish their own chain through a successful | ||
| top-level Todo write; when they process background results for an armed chain, | ||
| they retain that chain's budget. A related background result is also a trusted | ||
| continuation that clears an API/network retry pause without clearing a hard | ||
| suspension. | ||
|
|
||
| The guard is not persisted. Rewind and history restoration clear trust, as do | ||
| branch/fork, a successful working-directory change, a new Session, disk | ||
| restoration, and daemon or agent restart. A live client attach to the same | ||
| Session keeps the in-memory state; changing models or non-Plan approval modes | ||
| does not by itself start a new work chain. A lifecycle invalidation also blocks | ||
| late tool results from the superseded live turn from re-arming the guard; the | ||
| next independent prompt or automatic turn establishes a fresh boundary. | ||
| Deferred automatic queues are released once an invalidated foreground prompt | ||
| settles, including when that prompt exits through an error path. | ||
|
|
||
| ## Stop ordering | ||
|
|
||
| The guard participates only at a natural model stop. When it is active, Session | ||
| applies this order: | ||
|
|
||
| 1. Drain mid-turn user messages. If any exist, skip Stop hooks and the guard, | ||
| reset the guard budget, and run the user continuation in the current loop. | ||
| 2. If the daemon FIFO contains a complete, non-aborted prompt, finish the | ||
| current request and mark the old chain as awaiting that prompt. A cancelled | ||
| queued request cannot later let background activity revive the old chain. | ||
| When the last queued prompt is aborted, the bridge explicitly tells the | ||
| live Session to terminate the awaiting guard and release unrelated automatic | ||
| queues. If one drain observes both a mid-turn message and a queued full | ||
| prompt, the mid-turn message runs first and FIFO priority remains in force | ||
| even if that continuation completes the Todo list or hard-stops the guard. | ||
| 3. On foreground turns, evaluate existing external Stop hooks with their | ||
| existing cap and error semantics. | ||
| 4. Evaluate the guard only when it is armed, not suspended or awaiting a queued | ||
| prompt, has unfinished items, is outside Approval `plan`, and has no relevant | ||
| background input. | ||
| 5. If both an external hook and the guard block the same stop, combine their | ||
| reasons into one continuation model call. Their counters remain independent. | ||
|
|
||
| Relevant background input is a still-live background agent, shell, monitor, or | ||
| `@wakeup` whose ID was not in the work-chain baseline, plus queued notifications | ||
| or wakeups with the same relationship. Background work and ordinary cron jobs | ||
| inherited from an older request do not block a new request. Automatic | ||
| cron/notification turns run the built-in guard only; they do not introduce | ||
| external Stop-hook calls. A related result retains the current budget, while an | ||
| old-task notification or ordinary cron turn is delayed until the active chain | ||
| can no longer resume, then starts an independent unarmed chain. Deferred | ||
| unrelated recurring cron fires are coalesced per task and bounded so a stalled | ||
| background dependency cannot grow the queue without limit. Daemon follow-up | ||
| suggestions are also suppressed while a Guard chain can still resume or a | ||
| complete FIFO prompt has priority, so unfinished work does not trigger a | ||
| competing suggestion-model call. | ||
|
|
||
| Hard terminal paths suspend the current work chain: user or permission | ||
| cancellation, `PostToolUse.shouldStop`, loop or repeated-call protection, token | ||
| limits, and the external Stop-hook cap. API and network errors preserve state | ||
| for an explicit trusted retry/continue. | ||
|
|
||
| ## Continuations and observability | ||
|
|
||
| The first guard continuation sends: | ||
|
|
||
| > [Todo Stop Guard] N todo item(s) are still pending or in progress. Continue executing the current task now. Do not ask the user whether to continue. If progress requires user input, use the structured question or permission flow. If progress depends on external state, report the blocker explicitly. | ||
|
|
||
| The second also sends: | ||
|
|
||
| > This is the final automatic continuation. Before ending, either complete/update the todos or report the completed progress and the exact blocker. | ||
|
|
||
| The counter is committed only after `responseStream` is successfully returned. | ||
| Cancellation, compaction failure, or token rejection before that point does not | ||
| consume an attempt; a later stream failure does. Free-form blocker text is not | ||
| parsed. A compaction failure suspends that guard chain so it cannot leave | ||
| automatic queues blocked behind an unreachable retry; when an external Stop | ||
| hook was coalesced, its reason may still continue under the hook's existing | ||
| semantics. The budget counts every primary-model stream attributable to the | ||
| guard, including a follow-up that sends tool results from the preceding guard | ||
| stream. If the second stream returns more tool calls, Session executes and | ||
| preserves their results but does not open a third guard-attributable stream. | ||
| If the first stream completes every Todo through a tool call, the remaining | ||
| attempt may send the tool result without another unfinished-Todo prompt so the | ||
| model can finish its response. Mid-turn input sponsors that tool-result send | ||
| instead and takes priority without consuming the remaining Guard attempt. | ||
| When that stream was coalesced with an external Stop hook, the hook's existing | ||
| tool loop may still send those results without another Guard prompt or Guard | ||
| attempt; enabling the Guard must not truncate an external hook continuation. | ||
|
|
||
| Each committed continuation emits a replayable discrete | ||
| `agent_message_chunk` with `_meta.source = 'todo_stop_guard'` and the attempt, | ||
| maximum attempt count, and unfinished count. Exhaustion similarly emits: | ||
|
|
||
| > [Todo Stop Guard] Automatic continuation stopped after 2 attempts; N todo item(s) remain unfinished. | ||
|
|
||
| Todo text is never included in guard telemetry. Normal usage metadata still | ||
| accounts for the additional calls. Replay compaction preserves Guard events | ||
| that carry both `qwenDiscreteMessage` and the Guard source independently, so it | ||
| does not merge attempts or discard their per-attempt metadata after the live | ||
| event ring rolls over. | ||
|
|
||
| ## Bridge compatibility | ||
|
|
||
| `craft/drainMidTurnQueue` adds optional `hasQueuedPrompt`. The bridge sets it | ||
| only when its pending-prompt list contains a complete entry whose state is | ||
| `queued` and whose abort signal is not aborted. Older Desktop/channel clients | ||
| may omit the field; Session treats omission as `false`. If the drain times out, | ||
| late responses may restore message contents, but their queued-prompt snapshot is | ||
| discarded because it may already be stale. | ||
|
|
||
| REST/SSE disconnect behavior and the event ring are unchanged. ACP HTTP retains | ||
| its existing ten-second grace period and replay path; grace expiry and explicit | ||
| close/cancel retain their current termination behavior. | ||
|
|
||
| ## Verification | ||
|
|
||
| Unit tests cover strict activation, lifecycle resets, suspension, budget and | ||
| stream-commit semantics, bridge queue reporting, configuration gates, Stop-hook | ||
| coalescing, and terminal paths. Concurrency tests cover prompt FIFO priority, | ||
| late drain recovery, background-baseline isolation, and automatic turns. | ||
| Daemon E2E testing covers prompt admission without an SSE subscriber and later | ||
| ring replay of the bounded attempts. Existing ACP transport regressions cover | ||
| reconnect within the grace window, grace expiry, and permission round trips; | ||
| the manual E2E plan also exercises those paths with the guard armed. With the | ||
| setting disabled, existing Stop-hook, cron, notification, and prompt behavior | ||
| must remain unchanged. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.