Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
179 changes: 179 additions & 0 deletions docs/design/daemon-todo-stop-guard.md
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.
Comment thread
doudouOUC marked this conversation as resolved.

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.
15 changes: 8 additions & 7 deletions docs/users/configuration/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -547,13 +547,14 @@ LSP server configuration is done through `.lsp.json` files in your project root
>
> **Experimental features.** These toggles gate in-development capabilities and may change or be removed in future releases.

| Setting | Type | Description | Default |
| -------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| `experimental.cron` | boolean | Enable in-session cron/loop tools (`cron_create`, `cron_list`, `cron_delete`) so the model can create recurring prompts. Can be disabled via the `QWEN_CODE_DISABLE_CRON=1` environment variable. Requires restart. | `true` |
| `experimental.cronRecurringMaxAgeDays` | number | Days a recurring cron/loop job lives before auto-expiring (it fires one final time, then is deleted). Set to `0` to disable expiry so jobs run until deleted — useful for long-running daemon deployments. Can be overridden via the `QWEN_CODE_CRON_MAX_AGE_DAYS` environment variable. Requires restart. | `7` |
| `experimental.agentTeam` | boolean | Enable agent-team collaboration tools (`team_create`, `task_create`, `task_update`, `send_message`, etc.) for multi-agent coordination. Can also be enabled via `QWEN_CODE_ENABLE_AGENT_TEAM=1`. Requires restart. | `false` |
| `experimental.artifact` | boolean | Enable artifact tools. Enabled by default. In interactive, non-SDK sessions, the model can publish a self-contained HTML page as an interactive Artifact and open it in the browser. Non-SDK daemon sessions can use metadata-only `record_artifact`. Set this to `false` or use `QWEN_CODE_DISABLE_ARTIFACT=1` to disable both. Requires restart. | `true` |
| `experimental.emitToolUseSummaries` | boolean | Generate a short LLM-based label after each tool-call batch completes. See [Tool-Use Summaries](../features/tool-use-summaries). Requires a fast model to be configured (`fastModel`); silently skipped otherwise. Can be overridden per-session with `QWEN_CODE_EMIT_TOOL_USE_SUMMARIES=0` or `=1`. | `true` |
| Setting | Type | Description | Default |
| -------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- |
| `experimental.cron` | boolean | Enable in-session cron/loop tools (`cron_create`, `cron_list`, `cron_delete`) so the model can create recurring prompts. Can be disabled via the `QWEN_CODE_DISABLE_CRON=1` environment variable. Requires restart. | `true` |
| `experimental.todoStopGuard` | boolean | Allow daemon and ACP sessions to continue after a natural model stop when the current work chain successfully wrote an unfinished top-level Todo list. Adds at most two consecutive primary-model calls without new user input; mid-turn user input starts a fresh two-attempt stage. It is not restored after process restart and is forced off in safe, bare, and Approval `plan` modes. Requires restart. | `false` |
| `experimental.cronRecurringMaxAgeDays` | number | Days a recurring cron/loop job lives before auto-expiring (it fires one final time, then is deleted). Set to `0` to disable expiry so jobs run until deleted — useful for long-running daemon deployments. Can be overridden via the `QWEN_CODE_CRON_MAX_AGE_DAYS` environment variable. Requires restart. | `7` |
| `experimental.agentTeam` | boolean | Enable agent-team collaboration tools (`team_create`, `task_create`, `task_update`, `send_message`, etc.) for multi-agent coordination. Can also be enabled via `QWEN_CODE_ENABLE_AGENT_TEAM=1`. Requires restart. | `false` |
| `experimental.artifact` | boolean | Enable artifact tools. Enabled by default. In interactive, non-SDK sessions, the model can publish a self-contained HTML page as an interactive Artifact and open it in the browser. Non-SDK daemon sessions can use metadata-only `record_artifact`. Set this to `false` or use `QWEN_CODE_DISABLE_ARTIFACT=1` to disable both. Requires restart. | `true` |
| `experimental.emitToolUseSummaries` | boolean | Generate a short LLM-based label after each tool-call batch completes. See [Tool-Use Summaries](../features/tool-use-summaries). Requires a fast model to be configured (`fastModel`); silently skipped otherwise. Can be overridden per-session with `QWEN_CODE_EMIT_TOOL_USE_SUMMARIES=0` or `=1`. | `true` |

#### mcpServers

Expand Down
36 changes: 36 additions & 0 deletions docs/users/qwen-serve.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,42 @@ curl -X POST http://127.0.0.1:4170/session/$SESSION_ID/prompt \

The `curl -N` from step 4 will print frames as they arrive.

### Optional Todo Stop Guard

Long-running daemon clients can opt into a bounded continuation when the
current work chain successfully writes a top-level Todo list and then stops
with items still pending or in progress. Add this to `settings.json` and
restart the daemon:

```json
{
"experimental": {
"todoStopGuard": true
}
}
```

The guard adds at most two consecutive primary-model calls without new user
input. A mid-turn user message runs first and starts a fresh two-attempt stage;
retry/continue and related background results retain the current stage's
budget. Every call and the final exhaustion state appear as replayable
`session_update` events with `_meta.source: "todo_stop_guard"`; the metadata
includes the attempt and unfinished count but never Todo text. A queued full
prompt also runs first, and existing permission/cancellation rules are
Comment thread
doudouOUC marked this conversation as resolved.
unchanged.

While an armed chain waits on related background work, unrelated cron/loop
fires and old-task notifications are deferred. Recurring work is bounded and
coalesced per task until the chain yields.

The option defaults to `false`, requires restart, and is forced off in safe
mode, bare mode, and Approval `plan` mode. It is in-memory only: loading Todo
state from disk or restarting the daemon does not arm it. A new ordinary prompt
must successfully run its own top-level `todo_write`; retry/continue and live
client reattach keep the current in-memory work chain. Successfully changing
the session working directory clears it so an old Todo cannot resume in a new
workspace.

## Authentication

For anything beyond loopback, you **must** pass a bearer token:
Expand Down
Loading
Loading