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
150 changes: 150 additions & 0 deletions docs/features/background-result-delivery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# Background result delivery

[中文](background-result-delivery.zh-CN.md)

A background execution and a user-facing report are separate facts. A scheduled
run may complete successfully without producing a report, while a failed run
still needs attention even if its task instructions normally request silence.
Reading the saved execution is a third operation; a history read failure does
not establish whether the task itself succeeded.

## Contract and ownership

The execution host admits a scheduled run with a versioned `backgroundDelivery`
descriptor in the original user-message metadata. Its identity contains the task,
turn, and execution generation. Ordinary submissions cannot acquire this
authority by copying metadata. The current producer is the native scheduler;
the report decisions are generic Runtime policy, independent of task names,
languages, workspace paths, platforms, or output phrases.

`core-types::background_delivery` owns the additive DTOs. The portable decisions
live in `agent-runtime::scheduler::result_delivery`. Core owns task admission,
run-scoped proposals, AI-provider access, persistence, and completion-event
wiring. Services project durable facts, and each presentation surface consumes
those facts. The controller never classifies results or reads another host's
files through a local fallback.

| Policy | Behavior |
| --- | --- |
| `inherit` | Follow the saved task instructions. Suppression requires explicit authorization in those instructions. This is the default for old jobs. |
| `always` | Publish every result. A model proposal cannot override it. |
| `conditional` | Permit conditional reports according to the conditions in the task instructions. |
| unknown policy | Keep execution data and publish the result. |

| Disposition | Meaning |
| --- | --- |
| `pending` | The run is executing and its report has not been decided. |
| `quiet` | Completed successfully, with no report required by its policy. |
| `report` | Publish the result, including conservative fallback when a decision is unavailable. |
| `attention` | Execution failed, was interrupted, or requires attention. |

Completion persists the decision before publishing the completion event. A
quiet decision needs a supported schema, matching run/generation, an eligible
policy, and a resolution timestamp. Failures and partial recovery override
quiet proposals. Restoring an interrupted execution increments the generation;
the earlier decision cannot hide the resumed execution. Successful recovery is
itself a visible lifecycle result. Earlier decisions remain in the original
turn's `backgroundDeliveryHistory`.

Observer checkpoints may contain the initial pending descriptor. Saving one
must preserve a decision already resolved by the host and its audit history.
Raw model text, thinking, tools, result bodies, and turn ordinals are retained.

## Structured and legacy model output

The scheduler's reminder explains `Cron` with `action=report`, a final
`disposition`, and a reason. The tool records a proposal for the current admitted
run; it does not finish, rerun, or modify the task. An explicit quiet proposal
also permits a clean, empty natural model ending. Thinking-only output,
interrupted streams, required tool interaction, and Stop hooks keep their
existing lifecycle behavior.

Models that still produce an acknowledgement such as "remaining quiet" receive
compatibility adjudication. The host makes one tool-free request through the
configured `fast` model binding. It supplies the saved task instructions, all
available current-turn assistant/tool text, the final output, the structured
proposal if present, and a previous bound assistant output when available.
Current output is untrusted evidence, not instructions for the adjudicator.

The response must be valid structured JSON, explicitly authorize quiet, and
state a clear final decision with a reason. Uncertainty, missing comparison
evidence, unexpected tools, invalid output, provider failure, excessive input,
or timeout retain a visible report. This is a semantic compatibility path;
there is no keyword or task-specific suppression rule. It judges reporting
intent and cannot independently verify the task's factual claims.

The compatibility request has a 30-second IO deadline and a 64-KiB input budget.
These constrain report adjudication, not the Agent loop or task execution.
Adjudication tokens are recorded separately in `decisionUsage`; the quiet
execution's expanded record displays that additional usage. They do not alter
the recorded task output or masquerade as task-model tokens. An unavailable or
uncertain decision and a failed decision write produce an English diagnostic
bound to the session and turn.

## Presentation, unread, and notifications

The Web UI and mobile transcript fold eligible pending/quiet executions into
an execution receipt. Expanding it exposes the original task and output. Search
can expand the Web UI receipt. Outstanding permissions and questions are always
presented directly; a failure or unsupported descriptor stays visible.

The session's latest execution may advance past its latest unread report. The
host stores the unread result identity in additive custom metadata and projects
it as `SessionActivitySummary.unreadTurn`. Old summaries fall back to
`lastTurn`. A quiet run creates no unread result and does not clear previous
unread work or user-attention state. Read acknowledgements target the reported
turn and generation, so acknowledging an older result cannot clear a newer one.
An older observer also cannot recreate unread work for a known quiet completion.

Desktop notifications use the same disposition. Actual failure events are
subscribed as well as completion events, with duplicate terminal notifications
coalesced for the same result. Existing focus, user preference, and child-session
rules still govern OS delivery. Weixin proactive output consumes the persisted
decision after the persistence fence, suppresses quiet acknowledgements, and
keeps background execution errors visible. Human-initiated bot turns keep their
ordinary reply path.

## History recovery

The history boundary says that records could not be read, rather than implying
that newer content is still being generated. `Resync records` reads a fresh
snapshot from the session's owning host and then re-evaluates the requested
boundary. It does not submit an Agent turn or rerun the scheduled task. Reaching
the authoritative latest boundary clears a residual error.

Surface activation, session identity, presentation generation, and request
tickets fence asynchronous outcomes. Navigation or a newer recovery request
invalidates the earlier request. Persisted snapshots cannot shorten streaming
output. Viewport changes continue through the existing pager commit and anchor
owners. Failed reads log a reason and target ordinal even when detailed history
diagnostics are disabled; repeated identical failures are coalesced.

Detached dispatch history is owned by the target's event replay. Its controller
does not offer the snapshot-resync action, and the store explicitly rejects a
request that would read controller storage. This does not implement quiet
report policy for detached job completion or other producers; those producers
need their own admitted contract and surface handling before adopting it.

## Upgrade and remote boundaries

Job policy, completion delivery, and unread identity are additive, with defaults
for legacy persisted jobs and events. Unknown descriptors remain readable and
visible. No migration deletes, resets, or rewrites user output.

Peer hosts and Remote Connect advertise `background_result_delivery_v1`.
Setting policy on a peer checks that capability; the editor disables policy
selection for older peers and saves other fields through the established path.
Older consumers that do not understand the new fields continue to receive raw
execution content.

| Scenario | Verification boundary |
| --- | --- |
| Local workspace | Runtime decisions, disk-backed completion/checkpoint/recovery, unread contracts, Web UI reduction and notifications. |
| Remote workspace | The run and record use existing execution-host/session-storage ownership. Real SSH or Docker execution requires host validation; local fixtures do not establish it. |
| Remote control | Remote history projection and real Chrome mobile rendering/event reduction, plus the existing host-stream browser suite with a simulated host/relay. No live IM delivery is exercised. |
| Peer Device | Capability negotiation, older-peer rejection, source synchronization and cancellation are contract-tested. No live second-device execution is exercised. |
| Detached Dispatch | Controller snapshot fallback is explicitly rejected; existing target replay remains its recovery path. |

Focused verification commands live in the owning module guides. Semantic model
accuracy still needs evaluation against the configured provider; parser and
policy tests cannot prove it.
62 changes: 62 additions & 0 deletions docs/features/background-result-delivery.zh-CN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# 后台任务的结果投递

[English](background-result-delivery.md)

一次后台运行需要分别回答三个问题:任务执行得怎样、结果是否需要报告、保存的记录是否读取成功。任务执行成功可以无需汇报;记录读取失败也不能据此断言任务执行失败。

## 投递规则

| 设置 | 含义 |
| --- | --- |
| 按任务说明 | 沿用任务内容里的报告要求;只有明确允许不报告时才能静默。旧任务默认使用此设置。 |
| 每次报告 | 每次结果都投递,模型不能用“保持静默”覆盖此设置。 |
| 符合条件才报告 | 按任务内容里写明的条件决定是否汇报。 |
| 无法识别的设置 | 保留原值和运行记录,显示结果。 |

运行所属主机记录 `pending / quiet / report / attention` 四种投递状态:尚未判定、无需汇报、需要报告、需要关注。判定绑定任务 ID、运行 ID 和执行代次,先保存,再发送完成事件。执行失败或中断优先于模型的静默提议;判定不明确、判定服务不可用或判定保存失败时,继续显示结果。

普通对话不能通过复制元数据获得后台运行的权限。当前接入的是原生定时任务调度器;判定逻辑不依赖某条任务的名字、日志、语言或固定措辞。其他后台生产者需要接入相同的运行身份和消费合同,不能直接复用一个未经验证的静默标记。

## 兼容模型输出

模型可以用 `Cron action=report` 提交结构化投递提议。这个操作只记录当前运行的提议,不重新执行任务,也不结束任务。明确提交静默提议后,模型正常返回空内容也可以完成;思考未完成、流中断、需要回答的问题、权限确认和 Stop hook 仍使用现有处理机制。

模型仍然回复“保持静默”的说明时,主机通过配置的 `fast` 模型进行一次无工具的兼容判定。输入包含本次保存的任务要求、当前运行中可用的模型和工具文本、最终输出、结构化提议,以及可用的上一轮输出。模型输出是待判断的证据,不能成为判定器的新指令。

只有明确授权静默、明确没有满足报告条件,并返回有效结构化结论时,才收起报告。信息不足、矛盾、异常工具调用、格式错误、超时等情况均显示结果。这里采用语义判定,没有针对“保持静默”或某条任务的关键词屏蔽。它判断报告意图,不能代替对任务事实的独立核验。

兼容判定最多等待 30 秒,输入预算为 64 KiB。这是投递阶段的 IO 预算,不限制 Agent 执行循环。额外用量单独记入 `decisionUsage`,可在静默运行的展开记录中查看;原任务的输出和用量记录保持原义。

## 展示和未读

- 无需报告的运行显示为可展开的执行记录,默认收起模型的静默说明和工具内容。展开后仍可查看原任务和原始输出;搜索命中时会展开。
- 失败、未知版本、待回答问题和待确认权限保持可见。
- 静默运行不产生新未读,也不清除先前未读或待关注状态。
- 最近一次执行与未读结果分别保存。例如 11 点静默执行完成后,9 点尚未读的结果仍指向 9 点;读取它也不能清除后来 13 点的新结果。
- 恢复执行使用新的代次。旧的静默决定不能遮住恢复结果;恢复完成作为执行状态的变化需要报告,旧决定保留在审计历史里。

桌面通知消费同一个投递结论,并同时订阅完成和独立失败事件。同一次终止结果的重复事件会合并。窗口焦点、通知设置及子会话规则仍然有效。微信主动投递在记录保存完成后读取判定,静默说明不发送,后台执行错误保持可见;用户主动发起的 bot 对话继续使用原回复路径。

## 记录读取失败后的恢复

历史边界提示改为“记录读取失败”,不再用“暂未准备好”暗示模型还在生成内容。

“重新同步记录”会从会话所属主机读取新快照,再判断边界之外是否还有记录。它不会重跑定时任务,也不会发送新的 Agent 请求。如果所属主机确认已经到达最新边界,就清除残留错误。

异步读取同时校验设备、会话、展示代次和请求票据。旧请求不能覆盖新的同步结果,保存的快照不能缩短正在显示的流式输出,视口更新继续由现有分页提交和锚点机制负责。失败原因和目标序号会进入日志,重复的同类失败会合并记录。

Detached Dispatch 的记录由目标主机的事件重放负责,控制端不提供本地快照同步入口,避免误读控制端文件。本次没有为 detached job 或其他后台生产者增加静默投递策略。

## 分层和兼容

DTO 位于 `core-types`,纯判定位于 `agent-runtime::scheduler::result_delivery`;Core 负责运行接入、模型服务、持久化和事件接线;Services 投影保存的事实;Web UI、手机和 bot 负责各自展示。判定发生在执行所属主机,不依赖控制端保持连接。

旧任务、旧事件和旧未读数据均有默认读取路径。未知策略字符串保留,未知投递版本保持可见,不删除或重置用户记录。较早的观察端保存不能把已确定的投递结论改回待判定,也不能丢掉旧代次的审计记录。

Peer Host 和 Remote Connect 声明 `background_result_delivery_v1` 能力。对端不支持时,编辑器禁用新策略设置,其他字段继续沿用旧路径;API 在发送新策略字段前检查能力。旧客户端仍能收到原始运行内容。

## 验证边界

已覆盖纯判定、磁盘完成与检查点、恢复代次、旧格式往返、未读收据、通知、远端记录投影、Peer 能力及旧主机拒绝,以及真实 Chrome 中三种屏宽下的手机展示。Host Stream 浏览器测试使用模拟主机和 relay。

真实 SSH / Docker、第二台设备、真实 IM 投递和配置模型的语义判定准确率尚未实测;本地和合同测试不能证明这些场景已经端到端通过。各模块的针对性验证命令见所属 `AGENTS.md`。
1 change: 1 addition & 0 deletions src/apps/cli/src/agent/runtime_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3283,6 +3283,7 @@ mod dual_backend_behavior_tests {
success: Some(true),
finish_reason: Some("fixture-complete".to_string()),
has_final_response: Some(true),
background_delivery: None,
},
None,
)
Expand Down
1 change: 1 addition & 0 deletions src/apps/cli/src/dispatch/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,7 @@ mod tests {
success: Some(true),
finish_reason: Some("stop".to_string()),
has_final_response: Some(true),
background_delivery: None,
};
assert_eq!(
terminal_outcome(&completed, "turn-1"),
Expand Down
2 changes: 2 additions & 0 deletions src/apps/cli/src/modes/exec/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,7 @@ fn every_exec_terminal_event_is_deferred_until_exec_settlement() {
success: Some(true),
finish_reason: Some("complete".to_string()),
has_final_response: Some(true),
background_delivery: None,
};
let failed = AgenticEvent::DialogTurnFailed {
session_id: "session-1".to_string(),
Expand Down Expand Up @@ -629,6 +630,7 @@ async fn interrupted_turn_buffers_tail_projection_before_a_completed_race() {
success: Some(true),
finish_reason: Some("complete".to_string()),
has_final_response: Some(true),
background_delivery: None,
},
AgenticEventPriority::Critical,
))
Expand Down
1 change: 1 addition & 0 deletions src/apps/cli/src/shared_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1966,6 +1966,7 @@ mod tests {
success: Some(true),
finish_reason: None,
has_final_response: Some(true),
background_delivery: None,
};
route_agent_event(&completed, "parent-session", &routes);
assert_eq!(routes.lock().expect("routes").len(), 2);
Expand Down
1 change: 1 addition & 0 deletions src/apps/desktop/src/api/event_coalescer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,7 @@ mod tests {
success: Some(true),
finish_reason: Some("stop".to_string()),
has_final_response: Some(true),
background_delivery: None,
});

assert_eq!(events.len(), 2);
Expand Down
Loading
Loading