diff --git a/FEATURES.md b/FEATURES.md index 5c24fc6..d562e48 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -33,8 +33,8 @@ - [x] **OpenCode Runtime 定制**:GoodBuddy 管理的内置 OpenCode 可发现原生 Agents、Tools、Commands、LSP、Formatters、MCP、Skills、Prompts 与 Resources;Tools 单独显示读取、文件修改、命令、网络、Agent 编排等类型、来源及 Ask/Execute 可用性,并隐藏 OpenCode 内部 `invalid` 与 GoodBuddy 临时 MCP 工具。支持保存默认 Agent、每次请求覆盖 Agent、通过原生 SDK 执行 Command、显示上下文用量并调用原生 Compact;外部 OpenCode Server 只报告连接状态,不宣称原生清单可读。任意插件安装、Session Share、自动 Worktree 和 OpenCode 原生会话持久化仍不开放。 - [x] **Continue Runtime 定制**:提供静态配置中的原生 Rules、Prompt 模板与 MCP 清单,以及可编辑的 GoodBuddy Rules/Prompt 配置预设;聊天可按请求选择预设和填入可继续编辑的 Prompt。当前 Continue Host 没有可信的静态原生 Tool 发现接口,且使用隔离的 `CONTINUE_GLOBAL_DIR`,因此界面明确标记 Tools 不支持静态发现,也不把 Host 实际不会加载的工作区或用户 Skills 冒充原生能力;GoodBuddy 分配的 Skills 仍按请求暂存执行。Continue 临时 Host 不复用原生会话压缩,手动压缩由 GoodBuddy 摘要模型完成并验证持久化摘要覆盖范围;Agent 交互提问转换为统一问答卡片。Resources、Hooks、后台 Job 和 Continue 原生会话管理继续暂缓。 - [x] **Runtime 原生清单语义**:原生能力以 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 个页签展示;清单状态独立于 Runtime 连通性,区分完整、部分、不可用、仅连接和不支持。DeepSeek Harness 通过 Host Registry 枚举有界的内置/插件 Tools 与 Skills,显示真实 Ask/Execute 边界,并排除 GoodBuddy 按请求分配的 Skills、Web/MCP 代理。 -- [ ] **Runtime 监督栏目**(规划中):在应用级助手工作栏的固定 Runtime 栏目统一承载 OpenCode、Continue 和 DeepSeek Harness 的 Subagent 控制、后台 Job、Workflow/Hook、长任务与原生会话监督;当前会话只提供默认目标,用户可以改为固定其他 Run,Composer 只保留对当前消息生效的高频上下文选择。 -- [ ] **可执行 Subagent**(规划中):提供显式 Execute 委派,限制嵌套、并行、Token、时间和工具权限,在助手工作栏固定的 Runtime 栏目显示父子状态、取消入口和审计归属。 +- [ ] **Runtime 监督栏目**(规划中):在应用级助手工作栏的固定 Runtime 栏目统一承载 OpenCode、Continue 和 DeepSeek Harness 的 Task 级委派、后台执行、Workflow/Hook、长任务与原生会话监督;用户只选择 Conversation 或 Task,Job/Run 保持内部,不形成树或独立操作对象。 +- [ ] **可执行 Subagent**(规划中):提供显式 Execute 委派,限制嵌套、并行、Token、时间和工具权限,在助手工作栏固定的 Runtime 栏目按 Task 聚合状态、取消入口和审计归属。 ### Skills、MCP 与知识库 @@ -57,7 +57,7 @@ ### 工作管理、长期协作与工作流 - [x] **任务、活动与成果**:集中管理任务状态、审计活动和独立成果文件;普通聊天回复只保留在会话中,不再自动复制到成果栏,已有重复聊天 Markdown 从成果列表隐藏但不物理删除。Token 用量按 Runtime 与模型归类,并针对 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;活动按会话分组并默认收起,避免长历史占满页面。 -- [ ] **Task Center 完善**(规划中):保留现有 Task Center,不先建设独立 Automation Center;Task 与唯一 Conversation 一对一,Task Center 只索引 Task,并补齐范围、状态、最近进展、需要关注和直接打开。普通 Conversation、Job、Run、工具步骤与心跳事项不作为顶层 Task。详见 [Task Center PRD](./docs/prd/task-and-job/task-center-prd.md)。 +- [x] **Task 与定制任务体验**:每个产品级 Task 只关联一条 Conversation,一条 Conversation 可承载多个 Task;左侧会话列表通过行首展开按钮显示带任务图标的 Task 子项,父会话行不重复任务标签,UI 只展示到 Task,不暴露 Job/Run 层级。新建定制任务可关联当前或新 Conversation,默认 Execute 并沿用 Runtime、工具和审批边界;重复触发复用同一 Task,文本结果回写 Conversation,独立文件和图片保留为成果。Task Center 继续作为完整索引,不建设独立 Automation Center。当前计划触发支持单次、每日和每周;高级时区、Cron、事件触发与重试治理仍按 PRD 逐步实现。详见 [Task Center PRD](./docs/prd/task-and-job/task-center-prd.md) 和 [Scheduled Task PRD](./docs/prd/task-and-job/scheduled-task-prd.md)。 - [x] **记忆与智能心跳**:当前提供周期回顾、建议记忆、洞察、后续任务和可审计运行轨迹。 - [x] **智能心跳入口与范围改善**:将“智能心跳 > 心跳计划”作为完整配置的唯一权威入口,支持创建和编辑 Global 或指定一个、多个 Project 的计划;旧单项目配置无损迁移,项目级记忆与行动输出必须显式指定范围内的 Project。Task Center 和设置不再复制心跳表单。“未来分区记忆”仍只是尚待独立设计的长期方向。详见 [智能心跳 PRD](./docs/prd/smart-heartbeat/smart-heartbeat-prd.md)。 - [ ] **通用监督**(规划中):通过固定监督栏目观察用户选择的会话、任务、自动化或实验对象,提供带证据的评论与人工介入请求,但不自动发言、批准工具或切换 Execute。详见 [会话监督 PRD](./docs/prd/supervision/conversation-supervision-prd.md)。 diff --git a/UI-DESIGN.md b/UI-DESIGN.md index 74687da..9d7604b 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -282,6 +282,15 @@ - 一级导航与最近会话之间、最近会话与底部账户区之间必须有可见结构分隔线。浅色主题使用 `--border-default`,深色主题可在可辨前提下使用 `--border-subtle`。 - 当前导航项和当前会话必须同时使用至少三种信号中的两种:强调背景、可见边框、图标或文字强调。浅色主题的当前项优先使用更完整的蓝色选中表面和较高字重。 - 未选中项保持平整,不为每一行添加卡片边框或阴影。悬停反馈不得强于选中状态。 +- 关联 Task 的 Conversation 在行最左侧显示独立展开按钮,父会话行不重复任务标签或数量; + 展开后的每个 Task 子项使用任务图标标识身份,展开层级只到 Task,不展示 Job、Subjob 或 Run。 +- 会话标题保持单行且不挤压时间或操作按钮。标题实际溢出时,悬停会话行才在标题的固定裁切 + 区域内平滑横向滑动以展示完整名称;移开后快速复位。未溢出标题不产生位移动效, + `prefers-reduced-motion` 下禁用滑动并保留完整标题提示。 +- 新建 Task 后可以首次自动展开所属 Conversation;用户手动折叠后保持其选择,后台状态变化 + 只更新状态提示,不强制展开或抢占焦点。 +- Task 子项的任务图标保持稳定,运行中、等待审批、失败和暂停同时使用本地化状态文字, + 不能只靠图标颜色表达状态。 - 账户与设置入口固定在侧栏底部。已有稳定设置入口时,不在顶栏重复提供同一入口。 ### 6.9 助手工作栏 @@ -292,7 +301,8 @@ - 默认固定提供 Task Center、监督、Runtime、终端、进程、工作区、浏览器、成果和上下文九个标准栏目。 - Task Center 是 Task 的单例应用级索引,不使用“跟随 / 固定目标”多实例模式。每个 Task - 与唯一 Conversation 一对一绑定,列表不得复制会话内容,也不得把 Job/Run 单独提升为 Task。 + 只关联一条 Conversation,一条 Conversation 可以关联多个 Task;列表不得复制会话内容, + 也不得把 Job/Run 提升为可导航 UI 对象。 - 其他可绑定目标的栏目独立支持“跟随当前上下文”和“固定到指定对象”。当前会话、项目和 Runtime 只提供默认目标,不能成为进入栏目或切换目标的前提。 - 能力、连接和内容可以动态变化,栏目入口不能随之自动隐藏。不可用状态必须说明原因、影响和可执行入口。 - 用户可以主动排序或隐藏栏目,并可恢复默认布局;应用不能用用户偏好机制实现自动能力裁剪。 @@ -491,7 +501,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - 模式、模型或工具权限属于上下文控制,不与页面导航页签混用。 - 模型、专家角色、工作模式、OpenCode Agent、Continue 预设和 Runtime 快捷操作使用统一的上下文单选菜单,并保持菜单互斥、键盘可达和选中状态明确。 - 输入区第一行工具栏只承载附件、语音、知识范围、专家角色、工作模式、Runtime 选择和发送等通用操作。OpenCode Agent、Continue 预设及 Runtime 快捷操作必须放入其下方独立的 Runtime 专属功能行,通过可见分组名称、顶部边界和差异化表面与通用操作分层;该行只承载对当前消息生效的高频选择,当前 Runtime 没有可选专属功能时不保留空行。 -- OpenCode、Continue 和 DeepSeek Harness 后续的 Subagent 层级与取消、后台 Job 队列/进度/结果、Workflow/Hook 运行、长任务暂停/恢复/终止及原生会话监督统一进入应用级助手工作栏固定的“Runtime”栏目,不加入 Composer。栏目入口始终存在;内部可选区域按用户所选目标和 Runtime 的真实能力显示,不为未支持能力渲染空卡片或成排禁用按钮。切换跟随目标时必须清理上一归属的监督状态,固定目标则保持不变。 +- OpenCode、Continue 和 DeepSeek Harness 后续的 Task 级委派与取消、后台执行进度/结果、Workflow/Hook 运行、长任务暂停/恢复/终止及原生会话监督统一进入应用级助手工作栏固定的“Runtime”栏目,不加入 Composer。栏目入口始终存在;只选择 Conversation 或 Task,并按 Task 聚合执行状态,不显示 Job/Run 树或独立操作菜单。内部可选区域按用户所选目标和 Runtime 的真实能力显示,不为未支持能力渲染空卡片或成排禁用按钮。切换跟随目标时必须清理上一归属的监督状态,固定目标则保持不变。 - 设置中心只管理持久 Runtime 配置、默认值和能力清单;右侧 Runtime 栏目管理用户当前跟随或固定目标的生命周期。两处不得复制同一实时操作,栏目中的高风险操作仍须就地确认并保留取消、权限、用量和活动审计。 - Runtime Prompt 快捷操作只把模板填入输入草稿,用户可以继续编辑;OpenCode Command 由 Runtime 原生 API 执行,输入框只承载可选参数,不以普通斜杠文本冒充执行。 - Agent 回复进行中锁定模型、专家角色、工作模式和 Runtime 定制选择器,并关闭已打开的上下文菜单;回复结束或停止后再恢复选择,避免界面状态与本次运行实际使用的上下文不一致。 @@ -509,6 +519,10 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - 搜索、范围和时间筛选位于筛选工具栏。 - 行项目统一显示标题、范围、最近更新时间和必要状态。 - 侧栏最近会话的更新时间在当天显示本地时间,非当天显示本地日期;非当年记录必须同时显示年份。悬停时间信息时提供完整日期和时间。 +- 侧栏中有关联 Task 的 Conversation 显示行首展开按钮,父行不显示任务标签或数量;展开后 + 最多直接显示 3 个 Task,“查看全部 N 个任务”打开该 Conversation 的完整任务区。 +- Task 子项以任务图标开头,并显示名称、本地化的 Ask/Execute、计划摘要和聚合状态。点击 + 子项打开同一 Conversation 并定位 Task,不继续展开 Job/Run。 - 删除入口使用 `danger-ghost`,并按数据可恢复性执行确认或撤销策略。 ### 13.3 知识库 @@ -534,10 +548,16 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - 保留现有助手工作栏入口,首期在窄栏内适度完善,不先扩张成新的独立一级页面。 - 只展示 Task;普通 Conversation、Job、Run、工具步骤、Subagent 和心跳事项不独立占行。 -- 每项显示名称、Global 或 Project 范围、状态、最近进展、最近真实活动时间及需要关注信息。 -- 点击列表项直接打开 Task Conversation,不显示第二份内容载体。 +- 每项显示名称、关联 Conversation、Global 或 Project 范围、Ask/Execute、状态、最近进展、 + 最近真实活动时间及需要关注信息。 +- 点击列表项打开关联 Conversation 并定位该 Task,不显示第二份内容载体。 - 需要关注、进行中、已暂停和已结束使用共享 `SegmentedControl`;窄栏不足时单行滚动。 -- 完整消息、长错误、活动和成果留在 Task Conversation、Runtime、活动记录和成果查看器中,不撑高列表。 +- 完整消息留在 Conversation;长错误、工具、审批和成果按 Task 关联到 Runtime、活动记录和 + 成果查看器,不撑高列表,也不显示 Job/Run 树。 +- “新建定制任务”使用共享 Modal,明确选择当前或新 Conversation。聊天入口默认当前 + Conversation,Task Center 入口默认新 Conversation;选择必须持续可见。 +- 创建 Modal 默认 Execute,并允许主动切换 Ask。Execute 持续显示实际 Runtime、Project、 + 工作目录、工具和审批摘要;不支持工具时不得静默降级。 ### 13.6 运行记录 @@ -644,7 +664,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - [ ] 最近对话迁移到 `standard`,统一搜索、范围、时间和删除行为。 - [ ] 知识库迁移到 `master-detail`,清除内联浅色样式并补齐窄窗口单面板流程。 - [x] 智能心跳使用 `dashboard`,保留概览、建议、轨迹和计划,并在计划中支持 Global / 多 Project 范围。 -- [ ] 在现有工作栏中完善任务中心,统一范围、状态、最近进展和筛选,不新建平行任务平台。 +- [x] 在现有工作栏中完善任务中心,统一范围、状态、最近进展和筛选,不新建平行任务平台。 - [ ] 活动记录迁移到 `dashboard`,统一导航、筛选和表格行为。 - [ ] 设置中心使用共享分类定义与 `SettingsCategoryHeader`,将保存与测试操作统一放到分类页头右侧,并把成功反馈接入应用通知。 - [ ] 文档解析设置统一聊天附件与知识库的解析预设、OCR 状态、转换状态、隐私限制和真实文件测试。 diff --git a/docs/architecture/automation-platform-architecture.md b/docs/architecture/automation-platform-architecture.md index 32cd396..948d34b 100644 --- a/docs/architecture/automation-platform-architecture.md +++ b/docs/architecture/automation-platform-architecture.md @@ -15,8 +15,9 @@ GoodBuddy 当前已经具备若干长期助手能力,但它们仍是彼此分离的功能: -1. Scheduled Task 支持单次、每日和每周触发。一个 Task 绑定一条持续 Conversation, - 每次触发在其中创建 Job/Run 并保存进展和成果。 +1. 当前 Schedule 已支持单次、每日和每周触发,并在创建时绑定稳定产品级 Task 与真实 + Conversation;重复触发复用同一身份,文本结果写回 Conversation,独立文件和图片保存为 + Artifact。IANA 时区、Cron、事件触发、租约、重试和完整 Job/Run 抽象仍待实现。 2. 当前智能心跳支持全局或项目范围的每日、每周回顾,读取有界会话、任务和已确认记忆, 生成摘要、记忆建议和后续任务。 3. 专家执行的 Job 支持有限并发和只读综合,但没有实验变量、重复运行、统一指标和结果晋升。 @@ -92,8 +93,9 @@ SQLite、FTS 和可选本地向量已经足够支撑第一阶段。只有出现 ### 3.1 用户目标 -- 在现有 Task Center 中找到 Scheduled、Event 和 Goal Task,并直接打开 Task Conversation。 -- 清楚知道 Task 的触发原因、当前目标、Job 状态、预算和停止条件。 +- 在现有 Task Center 中找到 Scheduled、Event 和 Goal Task,并直接打开关联 Conversation + 和对应 Task。 +- 清楚知道 Task 的触发原因、当前目标、聚合执行状态、预算和停止条件。 - 在一个工作台中观察多个候选运行,并追溯结论到原始证据。 - 为重要会话启用独立监督,及时发现偏题、遗漏、矛盾、证据不足和风险。 - 知道每条记忆属于哪个范围、从哪里产生、何时有效以及被哪些运行使用。 @@ -102,7 +104,8 @@ SQLite、FTS 和可选本地向量已经足够支撑第一阶段。只有出现 ### 3.2 产品目标 - 复用现有 Project、Conversation、Task、Run、Artifact、Approval 和 Notification 能力。 -- 保持一个 Task 只绑定一条 Conversation,不为同一项工作建立第二份内容载体。 +- 保持每个 Task 只关联一条 Conversation,同时允许一条 Conversation 承载多个 Task,不为 + 同一项工作建立第二份内容载体。 - 为所有后台工作提供统一的幂等、租约、恢复、取消、预算和审计语义。 - 保持 Ask 只读,Execute 继续经过现有能力和审批控制。 - 保持本地优先,应用退出后不虚假承诺后台持续执行。 @@ -127,34 +130,36 @@ SQLite、FTS 和可选本地向量已经足够支撑第一阶段。只有出现 以下模型是 Scheduled Task、Goal Task 和实验共享的技术基础,不要求新增独立 Automation Center。`AutomationPlan` 是 Task 的计划配置,`Job` 是 Task 内执行单位, -`Run` 是执行尝试。用户主要通过 Task Center 和 Task Conversation 理解工作。智能心跳 +`Run` 是执行尝试。用户主要通过 Task Center、左侧会话 Task 列表和关联 Conversation +理解工作;当前 UI 不展示 Job/Run 层级。智能心跳 不属于此模型。 ### 5.1 核心实体 ```text -Task ── Conversation - ├─ AutomationPlan(可选) - │ ├─ TriggerPolicy - │ ├─ ObjectiveSet - │ ├─ ExecutionProtocol - │ ├─ BudgetPolicy - │ ├─ ApprovalPolicy - │ ├─ SupervisorPolicy - │ └─ MemoryBinding - └─ Job - ├─ Run - ├─ Subjob - ├─ Observation - ├─ SupervisorDecision - ├─ Artifact - ├─ Metric - └─ MemoryCandidate +Conversation + └─ Task 0..N + ├─ AutomationPlan(可选) + │ ├─ TriggerPolicy + │ ├─ ObjectiveSet + │ ├─ ExecutionProtocol + │ ├─ BudgetPolicy + │ ├─ ApprovalPolicy + │ ├─ SupervisorPolicy + │ └─ MemoryBinding + └─ Job + ├─ Run + ├─ Subjob + ├─ Observation + ├─ SupervisorDecision + ├─ Artifact + ├─ Metric + └─ MemoryCandidate ``` | 实体 | 职责 | | --- | --- | -| `Task` | 用户可见工作单位,与唯一 Conversation 一对一绑定 | +| `Task` | 用户可见工作单位,只关联一条 Conversation;Conversation 可以承载多个 Task | | `Job` | Task 内部一次步骤、触发、并行分支或委派执行 | | `Run` | Task/Job 的一次执行尝试和审计记录 | | `AutomationPlan` | Task 的可编辑计划配置,描述做什么、为何做、何时做和允许做什么 | @@ -180,8 +185,8 @@ Task ── Conversation | `goal_loop` | 围绕目标重复执行“观察、计划、行动、评估” | | `experiment` | 生成隔离候选 Run,按统一协议评估和比较 | -会话监督不是独立 Task。它是可附着到 Conversation、Task、Job/Run 或 Experiment 的 -`SupervisorPolicy` 和监督会话。 +会话监督不是独立 Task。用户选择 Conversation、Task 或 Experiment 作为监督对象; +`SupervisorPolicy` 可以在内部观察所属 Job/Run 事件,但当前 UI 不把它们作为独立目标。 ### 5.3 运行快照 @@ -198,7 +203,8 @@ Task ── Conversation - 预算和并发限制。 - 审批策略。 -运行开始后的设置变化只影响下一次 Run。用户可以查看当前 Run 与最新 Plan 的差异。 +运行开始后的设置变化只影响下一次 Run。用户可以在 Task 执行记录中查看当次快照与最新 +Plan 的差异,但 Run 不作为独立导航对象。 ## 6. 统一状态模型 @@ -250,28 +256,28 @@ inactive → observing → attention_required → paused → resolved ```text Trigger - → AutomationCoordinator 声明 Run - → RunQueue 按优先级和预算排队 - → AutomationExecutor 在所属 Task 内创建或恢复 Job + → AutomationCoordinator 在所属 Task 内声明 Job + → ExecutionQueue 按优先级和预算排队 + → AutomationExecutor 为 Job 创建或恢复 Run → Runtime 执行 → Supervisor 观察 → Evaluator 计算指标 → 结果、证据和候选记忆入库 - → 用户审查或后续 Run + → 用户审查或后续执行 ``` `AutomationCoordinator` 只负责触发、声明和恢复,不直接调用模型。执行仍通过 Job 和 -Runtime 边界完成,Job 的用户可见结果汇入所属 Task Conversation。 +Runtime 边界完成,用户可见结果通过所属 Task 汇入关联 Conversation。 ### 7.2 优先级 默认优先级从高到低: 1. 用户正在等待的前台对话。 -2. 用户手动启动的 Run。 -3. 等待批准后恢复的 Run。 -4. 到期 Scheduled Task 的 Job。 -5. 目标循环和实验 Run。 +2. 用户手动启动的 Task 执行。 +3. 等待批准后恢复的 Task 执行。 +4. 到期 Scheduled Task 的执行。 +5. 目标循环和实验执行。 6. 心跳回顾、记忆巩固和维护。 后台任务必须可被背压延后。延后记录为 `deferred`,不得丢失,也不得在系统恢复空闲时一次性 @@ -414,7 +420,7 @@ Runtime 边界完成,Job 的用户可见结果汇入所属 Task Conversation 监督统一进入应用级助手工作栏中固定且始终可访问的“监督”栏目,不再保留“独立可折叠右栏” 和“动态新增页签”两种实现。栏目默认跟随当前上下文,用户可以固定到其他 Conversation、 -Task、Job/Run 或实验 Run;详细范围与交互契约见 +Task 或实验对象;详细范围与交互契约见 [通用助手工作栏与执行空间 PRD](../prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md)。 ## 13. 安全与隐私 @@ -433,7 +439,7 @@ Task、Job/Run 或实验 Run;详细范围与交互契约见 ## 14. 可观测性 -每个 Run 至少展示: +每次 Task 执行的内部 Job/Run 记录至少保存: - 触发来源和计划版本。 - 计划目标和当前 `goalStatus`。 @@ -447,7 +453,8 @@ Task、Job/Run 或实验 Run;详细范围与交互契约见 - 产生的候选记忆或学习产物。 - 重试、延后、中断和恢复原因。 -不得只显示一个模糊的“自动化成功率”而隐藏失败 Run、跳过 Run 或无结论 Run。 +UI 在 Task 下呈现上述信息的有界摘要和活动,不提供 Job/Run 树或独立导航。不得只显示一个 +模糊的“自动化成功率”而隐藏失败、跳过或无结论的 Task 执行。 ## 15. 建议的数据模型增量 @@ -474,8 +481,8 @@ experiment_runs ``` 现有 `schedules`、`schedule_runs`、`heartbeat_configs`、`heartbeat_runs`、 -`heartbeat_entries`、`tasks` 和 `runs` 不应一次性重写。Schedule 可渐进绑定一个持续 -Task/Conversation,旧 child-task 字段可兼容映射到 Job/Subjob;心跳数据保持独立,不得 +`heartbeat_entries`、`tasks` 和 `runs` 不应一次性重写。Schedule 可渐进建立稳定 Task 与 +Conversation 关联,旧 child-task 字段可兼容映射到 Job/Subjob;心跳数据保持独立,不得 静默转成 `AutomationPlan` 或顶层 Task。未来分区记忆完成设计前,不新增迁移目标。 ## 16. 分阶段实施 @@ -484,8 +491,9 @@ Task/Conversation,旧 child-task 字段可兼容映射到 Job/Subjob;心跳 - 固定 Task、Conversation、Job、Subjob、Run、Plan、Goal、Protocol、Supervisor、 Observation、Memory Candidate 等概念。 -- 明确 Task 与 Conversation 一对一,Task Center 只是索引,不复制内容。 -- 为现有 Scheduled Task 和专家 Job 建立统一活动视图。 +- 明确 Task N:1 Conversation 关系、左侧行首展开按钮与 Task 子项图标,以及 Task Center + 索引边界,不复制内容。 +- 为现有 Scheduled Task 和专家执行建立按 Task 聚合的活动视图。 - 明确当前心跳保持独立,未来分区记忆尚待设计。 - 补充触发来源、运行版本、预算和读写范围展示。 @@ -538,9 +546,12 @@ Task/Conversation,旧 child-task 字段可兼容映射到 Job/Subjob;心跳 ## 18. 总体验收标准 - [ ] 智能心跳保持独立,不作为 Task 类型;未来分区记忆尚未设计。 -- [ ] Task Center 只索引 Task;每个 Task 只绑定一条 Conversation。 +- [ ] Task Center 只索引 Task;每个 Task 只关联一条 Conversation,一条 Conversation 可以 + 关联多个 Task。 +- [ ] 当前 UI 只展示到 Task,不提供 Job/Subjob/Run 树或独立导航。 - [ ] Scheduled Task 的重复触发和并行 Job 不创建新的顶层 Task。 -- [ ] 每个自动 Run 都能解释触发原因、目标、范围、预算、状态和结果。 +- [ ] 每次自动执行的内部 Run 都记录触发原因、目标、范围、预算、状态和结果,并在所属 + Task 下提供有界可观测信息。 - [ ] Ask 自动化无法调用写工具或产生外部副作用。 - [ ] Execute 自动化不能绕过现有审批、主机执行策略和能力控制。 - [ ] 会话监督默认只评论,不能替用户发言或批准工具。 diff --git a/docs/design/cross-platform-assistant-product-design.md b/docs/design/cross-platform-assistant-product-design.md index 5c009c0..64106d5 100644 --- a/docs/design/cross-platform-assistant-product-design.md +++ b/docs/design/cross-platform-assistant-product-design.md @@ -51,6 +51,9 @@ - 任何即将发送给模型的上下文都必须可见、可预览、可移除。 - 模型输出不等同于执行授权,工具权限由独立权限层判定。 - 核心体验保持一致,受系统限制的能力采用渐进增强和明确降级。 +- 不静默替换用户选择的 Provider、模型、Runtime、数据处理位置、工作模式、权限范围或 + 质量档位。语义等价的内部恢复可以自动进行;涉及隐私、成本、能力或可感知质量的替代 + 路径必须明确显示并由用户决定。 - 安全、权限、更新签名和数据生命周期属于基础能力,不延期补做。 ### 2.5 当前非目标 @@ -589,13 +592,15 @@ ### 5.13 Task 与自动化 -每个 Task 与唯一 Conversation 一对一绑定;Task Center 只是 Task 的索引,不复制会话 -内容。内部步骤、委派、并行分支和定时触发使用 Job/Subjob,执行尝试使用 Run,它们留在 -所属 Task 中。 +每个 Task 只关联一条 Conversation,一条 Conversation 可以承载多个 Task;关联只增加 +Task 身份和左侧行首展开入口,不改变 Conversation 类型或复制会话内容。内部步骤、委派、 +并行分支和定时触发使用 Job/Subjob,执行尝试使用 Run,但当前 UI 只展示到 Task。 #### P1 功能 - 将多步工具调用保存为 Task。 +- 新建定制 Task 时选择当前或新 Conversation,默认 Execute,并明确显示 Runtime、Project、 + 工作目录、工具和审批范围。 - 执行前展示步骤计划、输入和权限。 - 逐步执行、暂停、取消和人工检查点。 - 失败重试和从安全检查点继续。 @@ -622,6 +627,8 @@ - 系统通知和应用内通知。 - 保留 Task Center 作为 Task 入口,显示范围、状态、最近进展和需要关注信息。 +- 左侧会话列表对关联 Task 显示行首展开按钮,展开后的 Task 子项使用任务图标,父行不重复 + 任务标签,展开层级只到 Task。 - 普通 Conversation、Job、Run、工具步骤和智能心跳记录不作为顶层 Task。 - 生成完成、任务完成、任务失败和等待确认。 - 未读数量、全部已读和按类别过滤。 diff --git a/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md b/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md index a40b38a..c1fe85d 100644 --- a/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md +++ b/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md @@ -14,10 +14,10 @@ ## 1. 背景 GoodBuddy 已经在聊天右侧提供上下文、工作区、浏览器和成果面板,也已经具备 -Runtime 事件、Git 变更、文件预览、成果存储、受控浏览器和专家 Job 等基础能力。后续还 +Runtime 事件、Git 变更、文件预览、成果存储、受控浏览器和专家执行等基础能力。后续还 计划增加: -- Conversation 和 Task/Job Run 的独立监督。 +- Conversation、Task 和实验的独立监督。 - OpenCode、Continue 和 DeepSeek Harness 的 Runtime 生命周期监督。 - 用户可直接使用的终端和受管进程。 - HTML 等成果的即时安全预览。 @@ -87,8 +87,9 @@ Runtime - 应用不得根据当前项目、会话、Runtime、主机或探测结果无提示地增删能力目录项。 - 用户主动打开、关闭、排序和停靠面板实例;应用不默认同时挂载全部能力。 -- Task Center 是 Task 的单例应用级索引。每个 Task 与唯一 Conversation 一对一;Task - Center 不复制会话内容,也不显示普通 Conversation、Job、Run 或心跳事项。 +- Task Center 是 Task 的单例应用级索引。每个 Task 只关联一条 Conversation,一条 + Conversation 可以关联多个 Task;Task Center 不复制会话内容,也不显示普通 Conversation、 + Job、Run 或心跳事项。 - 当前能力、连接、数据和空状态可以动态变化。 - 能力不可用时,目录项或已打开面板显示原因、影响和可执行的配置或切换入口,不能只通过隐藏表示。 - 用户可以在设置中调整目录顺序;恢复默认布局恢复标准目录与默认打开面板,不强制打开全部能力。 @@ -100,8 +101,8 @@ Runtime 目标的能力使用当前会话、项目、Runtime 和主机帮助新面板实例初始定位,但这些上下文不是 使用门槛: -- 监督默认选择当前会话,用户可以改选其他 Conversation、Task、Job/Run 或实验 Run。 -- Runtime 默认跟随当前会话,用户可以固定到其他活动或历史 Run。 +- 监督默认选择当前会话,用户可以改选其他 Conversation、Task 或实验对象。 +- Runtime 默认跟随当前会话或 Task;执行事件可以查看,但 Job/Run 不作为独立选择对象。 - 终端默认使用当前项目执行空间,用户可以新建本机或远程终端。 - 工作区默认显示当前项目目录,用户可以打开其他本机目录或远程目录。 - 成果和上下文默认使用当前范围,用户可以切换到项目、全局或其他允许范围。 @@ -210,7 +211,7 @@ Runtime 当前聊天右栏过渡实现保留 Task Center、上下文、工作区、浏览器和成果五个横向页签时,必须 单行横向滚动,不能自动隐藏或缩写到不可辨认。Task Center 继续作为 Task 的现有入口; -审批定位到所属 Task、Job 或 Runtime。智能心跳不作为工作栏页签,其报告、建议、历史和完整配置统一归属 +审批定位到所属 Task 或 Runtime。智能心跳不作为工作栏页签,其报告、建议、历史和完整配置统一归属 “智能心跳”菜单入口。当前阶段不新增独立自动化中心。 ### 6.3 工作栏尺寸 @@ -227,16 +228,22 @@ Runtime Task Center 保留为工作栏中的稳定入口,并在现有基础上适度完善: -- 只索引 Task;每个 Task 与唯一 Conversation 一对一绑定。 -- 显示名称、Global 或 Project 范围、状态、最近进展、真实活动时间和需要关注信息。 -- 点击 Task 直接打开其 Conversation。 -- 完整消息、Job、Run、工具、Subagent、审批和成果分别留在 Task Conversation、Runtime、活动记录和 - 成果查看器中,不在窄栏复制。 +- 只索引 Task;每个 Task 只关联一条 Conversation,一条 Conversation 可以关联多个 Task。 +- 显示名称、关联 Conversation、Global 或 Project 范围、Ask/Execute、状态、最近进展、 + 真实活动时间和需要关注信息。 +- 点击 Task 打开其关联 Conversation 并定位 Task。 +- 完整消息留在 Conversation;工具、Subagent、审批、错误和成果按 Task 关联到 Runtime、 + 活动记录和成果查看器中,不在窄栏复制,也不呈现 Job/Run 树。 - 智能心跳的报告、建议、历史和配置不进入任务中心。 任务中心是单例索引,不使用其他能力的“跟随 / 固定目标”多实例模型。后台状态可以更新 徽标和排序,但不能自动打开面板或抢占用户当前工作。 +主侧栏最近会话为关联 Task 提供轻量入口:父会话行只显示行首展开按钮,Task 子项使用 +任务图标和本地化摘要,展开层级只到 Task;点击 Task 子项打开同一 Conversation 并定位。 +新建定制任务使用 Modal 选择当前或新 +Conversation,默认 Execute,并持续显示 Runtime、Project、工作目录、工具和审批摘要。 + 详细产品边界以 [Task Center PRD](../task-and-job/task-center-prd.md) 和 [Task 与 Job 统一领域模型](../task-and-job/task-and-job-model.md) 为准。 @@ -248,8 +255,8 @@ Task Center 保留为工作栏中的稳定入口,并在现有基础上适度 监督能力在目录中稳定可发现;用户打开面板实例后可以选择: - 普通会话。 -- Task 或 Job/Run。 -- 实验 Run 或实验整体。 +- Task。 +- 实验或实验结果。 - 后续支持的文档分析和其他可监督对象。 监督面板包含: @@ -269,20 +276,21 @@ Runtime 能力统一监督直连模型、OpenCode、Continue、DeepSeek Harness 共同区域: -- Runtime、模型连接、会话或 Run 身份。 +- Runtime、模型连接、Conversation 或 Task 身份。 - 活动请求、状态、耗时、用量和取消。 - 工具、审批、问题、上下文压缩和错误。 - 跳转完整活动记录和持久设置。 可选区域: -- Subagent 父子关系和取消。 -- 后台 Job 队列、进度、结果和终止。 +- Task 级委派状态和取消。 +- Task 级后台执行进度、结果和终止。 - Todo、Workflow 和 Hook 运行。 - 原生会话、暂停、恢复、压缩或释放。 可选区域不可用时,用一段有操作路径的状态说明替代空卡片。用户可以在面板中切换 Runtime -或目标 Run,不要求先回到聊天 Composer。 +或 Conversation / Task 目标,不要求先回到聊天 Composer。内部 Job/Run 事件按 Task +聚合,不提供 Job/Run 选择器、树、页面或独立操作菜单。 ### 7.4 终端 @@ -353,7 +361,7 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发 ### 7.8 成果 -成果面板统一显示全局、项目、Conversation、Task/Job Run 和监督显式产生的独立成果: +成果面板统一显示全局、项目、Conversation、Task 执行和监督显式产生的独立成果: - Markdown、纯文本和 JSON。 - 图片和图表。 @@ -365,8 +373,9 @@ Artifact、自动化和监督生成的独立输出,以及用户手动导入或 升级前已经自动保存的普通对话 Markdown 可以从成果列表中隐藏,但不应通过升级迁移物理 删除用户数据库内容。 -用户可以切换范围、搜索、预览、查看来源、导出或打开关联对象。成果必须保留项目、会话、 -Conversation、Run、创建者、MIME、大小、校验值和时间等可用归属。 +用户可以切换范围、搜索、预览、查看来源、导出或打开关联对象。成果必须保留项目、 +Conversation、Task、内部 Run、创建者、MIME、大小、校验值和时间等可用归属;界面按 +Task 呈现来源,不把 Run 作为导航对象。 #### HTML 即时预览 @@ -388,8 +397,8 @@ Conversation、Run、创建者、MIME、大小、校验值和时间等可用归 - 浏览器、工作区文件和授权目录。 - Runtime、监督或自动化显式绑定的其他上下文。 -每项显示来源、范围、大小、发送状态和用途。用户可以预览、移除或清空。固定到历史 Run -时,上下文只读展示不可变快照;跟随当前 Composer 时才允许编辑下一次请求的上下文。 +每项显示来源、范围、大小、发送状态和用途。用户可以预览、移除或清空。查看 Task 的历史 +执行上下文时只读展示不可变快照;跟随当前 Composer 时才允许编辑下一次请求的上下文。 ## 8. 范围和选择模型 @@ -410,7 +419,8 @@ type WorkbarCapabilityId = type WorkbarTargetRef = | { type: 'conversation'; id: string } - | { type: 'run'; id: string } + | { type: 'task'; id: string } + | { type: 'experiment'; id: string } | { type: 'project'; id: string } | { type: 'workspace'; id: string } | { type: 'runtime-session'; id: string } @@ -510,7 +520,7 @@ type ManagedLifecycleState = | 'interrupted' ``` -每个 Runtime 会话、Job、终端或受管进程公开: +每个 Runtime 会话、Task 级执行、终端或受管进程公开: - GoodBuddy 受管 ID。 - 类型、来源和父子关系。 @@ -533,7 +543,8 @@ type ManagedControl = ``` 界面不能因为状态枚举中存在某个动作就假设所有 Runtime 都支持。Main 根据当前受管对象和 -能力重新验证动作。 +能力重新验证动作。内部 Job/Run 快照可以支持聚合与审计,但不能成为 +`WorkbarTargetRef` 或独立 UI 对象。 ## 11. 数据与契约建议 @@ -695,7 +706,7 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。 - 远程租约、事件重放和幂等重连。 - 更多远程系统和架构。 - PDF、Office 和数据成果预览。 -- Conversation、Task/Job Run 和实验的完整监督。 +- Conversation、Task 和实验的完整监督。 - 用户可导入导出工作栏布局和主机非敏感配置。 ## 17. 验收标准 @@ -714,7 +725,7 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。 ### 17.2 通用使用 - [ ] 没有项目时仍可创建终端、选择工作区、打开浏览器和查看成果。 -- [ ] 监督可以作用于普通 Conversation、Task、Job/Run 和后续实验对象,不假设编程语境。 +- [ ] 监督可以作用于普通 Conversation、Task 和后续实验对象,不假设编程语境。 - [ ] 工作区不是 Git 仓库时仍可浏览文件。 - [ ] Runtime 不支持某项原生能力时仍可从目录打开面板并获得准确说明。 @@ -731,7 +742,7 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。 ### 17.4 生命周期与恢复 -- [ ] Runtime、终端、Job 和进程具有权威 Main 快照和有序增量事件。 +- [ ] Runtime、终端、Task 级执行和进程具有权威 Main 快照和有序增量事件。 - [ ] 取消、终止、失败、断线和应用退出都有确定终态。 - [ ] 切换跟随目标后不显示上一对象的过期状态。 - [ ] 固定目标的订阅在页面切换后保持,关闭时正确释放。 diff --git a/docs/prd/task-and-job/scheduled-task-prd.md b/docs/prd/task-and-job/scheduled-task-prd.md index 606be80..44a6a8d 100644 --- a/docs/prd/task-and-job/scheduled-task-prd.md +++ b/docs/prd/task-and-job/scheduled-task-prd.md @@ -4,74 +4,177 @@ | 项目 | 内容 | | --- | --- | -| 状态 | 当前基础能力已存在,扩展调度设计中 | -| 版本 | 0.4 | +| 状态 | 首期稳定 Task 生命周期与创建体验已实现;高级触发和执行治理待实施 | +| 版本 | 0.6 | | 日期 | 2026-08-19 | | 依赖 | [Task 与 Job 统一领域模型](./task-and-job-model.md) | | 相关架构 | [自动化平台总体设计](../../architecture/automation-platform-architecture.md) | ## 1. 产品定义 -Scheduled Task 是带时间或事件触发器的 Task。它不是 Schedule 定义与临时 Task 的松散组合, -也不创建第二条 Conversation。 +Scheduled Task 是带时间或事件触发器的 Task。每个 Scheduled Task 关联一条 Conversation; +一条 Conversation 可以同时承载多个 Task。 创建 Scheduled Task 时: -1. 创建一个 Task。 -2. 为该 Task 创建唯一 Conversation。 -3. 保存 Schedule/Trigger Binding。 -4. 每次触发在同一 Task 内创建新的 Job 和 Run。 -5. 将面向用户的进展和结果持续写回同一 Task Conversation。 +1. 用户选择关联当前 Conversation 或创建新 Conversation。 +2. 系统创建一个 Task,并保存稳定 `conversationId` 和 Schedule/Trigger Binding。 +3. 每次触发在同一 Task 内创建新的 Job 和 Run。 +4. 面向用户的文本进展和结果写回关联 Conversation,并标明 Task 来源。 +5. 独立交付物保存为 Artifact,并由结果消息引用。 -因此,一个每日任务在 Task Center 中始终是一条 Task,而不是每天新增一条 Task。 +因此,一个每日任务在 Task Center 中始终是一条 Task,而不是每天新增一条 Task;左侧会话 +列表通过行首展开按钮和带任务图标的子项呈现其关联。 -## 2. 当前能力 +## 2. 当前能力与差距 -GoodBuddy 当前支持单次、每日和每周触发固定 Ask 提示,并持久化计划、Task、运行状态和成果。 -近期改进不得破坏现有数据、错过执行结算、暂停、立即运行和应用退出行为。 +GoodBuddy 当前已实现首期统一生命周期: + +- 创建 Modal 可以关联当前 Conversation 或原子创建新 Conversation,不修改当前 + Conversation 的标题和既有消息。 +- 每个 Schedule 绑定一个稳定产品级 Task 和 Conversation;重复触发复用同一身份,不再 + 为每次触发创建新的顶层 Task。 +- 默认选择 Execute,并允许用户主动切换 Ask;不支持工具执行时明确禁用 Execute。 +- 单次、每日和每周计划支持暂停、恢复、立即运行、应用重启恢复和最多 4 个独立计划并发。 +- 文本结果和失败写回关联 Conversation 并带 Task 来源;独立文件和图片继续保存为 Artifact。 +- 左侧 Conversation 列表、Conversation Task 区和 Task Center 使用同一产品 Task;普通 + 模型请求、Subagent、委派和 Smart Heartbeat 内部 Task 不进入产品索引。 +- v22 迁移保留 Schedule 配置和历史运行,并为旧计划补齐稳定 Task 与 Conversation。 + +尚未实现的高级能力包括 IANA 时区与 DST 墙上时间、每月/工作日/受限 Cron、事件触发、 +可配置错过执行策略、租约、重试与结果未知治理、完整预算和权限快照,以及面向内部 +Job/Subjob/Run 的统一持久化抽象。当前每日和每周按既有 UTC 间隔递推。 ## 3. 目标 - 支持单次、每日、每周、每月、工作日和受限 Cron。 - 支持 Task 完成、失败、Conversation 完成等内部事件触发。 -- 允许自然语言生成结构化草稿,但必须由用户检查后启用。 +- 创建时明确选择当前或新 Conversation。 +- 默认使用 Execute,并允许用户主动切换到 Ask。 +- 冻结 Project、Runtime、工作目录、工具、知识、记忆和审批范围。 - 提供时区、错过执行、幂等、租约、重试、恢复、取消、预算和审计。 -- 让所有重复触发复用同一 Task Conversation。 +- 让所有重复触发复用同一 Task 和 Conversation 关联。 - 为一次触发建立清晰 Job/Run,而不是创建新的顶层 Task。 ## 4. 非目标 - 不提供任意脚本和循环的通用 DAG 编辑器。 -- 不允许模型生成并直接执行任意 Shell、SQL 或无限频率 Cron。 +- 不允许模型生成并直接启用任意 Shell、SQL 或无限频率 Cron。 - 不承诺应用退出后继续运行。 -- 不允许计划静默扩大权限、目录、知识或记忆范围。 +- 不允许后台计划静默扩大权限、目录、知识、记忆或网络范围。 - 不把 Smart Heartbeat 变成 Scheduled Task。 -- 不把每次触发或重试显示为新的 Task。 +- 不把每次触发、重试、Job 或 Run 显示为新的顶层 Task。 +- 不在左侧会话列表继续展开 Job、Subjob 或 Run。 -## 5. 创建与配置 +## 5. 创建入口与 Modal -用户可以输入自然语言意图: +Task Center 和 Conversation 操作都可以提供“新建定制任务”,但共用同一个 Modal,不在 +窄侧栏长期展开完整表单。 ```text -每周五下午 5 点总结本项目本周完成和失败的工作, -列出下周三个优先事项,不要修改文件。 +新建定制任务 +创建一个可以按计划自动运行,并持续记录在会话中的任务 + +任务名称 * +[ 每周项目总结 ] + +任务要求 * +[ 总结本周完成和失败的工作,并列出下周优先事项。 ] + +关联会话 +◉ 当前会话 + 产品发布讨论 · GoodBuddy Desktop · 已有 2 个任务 + +○ 新建会话 + 为任务创建一条新会话,默认标题为任务名称 + +执行模式 +[ Execute ] [ Ask ] + +运行频率 +[ 单次 ] [ 每日 ] [ 每周 ] [ 每月 ] [ 工作日 ] [ Cron ] + +首次运行 [ 2026-08-21 ] [ 17:00 ] +时区 [ Asia/Shanghai ▾ ] + +执行范围 +GoodBuddy Desktop · OpenCode · 项目工作目录 +8 个工具可用 · 高风险操作需要审批 [编辑] + + [取消] [创建任务] ``` -模型只生成草稿: +### 5.1 Conversation 选择 -- 名称和说明。 -- 时间或事件触发器。 -- 工作模式和 Runtime 建议。 -- Project、知识、记忆、目录和工具范围。 -- 输入、输出和通知。 -- 预算、并发和错过执行策略。 +- 从当前聊天发起时默认选择当前 Conversation。 +- 从 Task Center 发起时默认选择新 Conversation。 +- 当前选择必须持续可见,不能根据入口静默决定后隐藏。 +- 关联当前 Conversation 不修改其标题、既有消息和普通聊天能力。 +- 当前 Conversation 已有关联 Task 时,显示 Task 数量和共享上下文说明。 +- 新 Conversation 默认使用 Task 名称作为标题,用户可以单独修改。 +- 远程通道、归档、正在删除或 Project 不匹配的 Conversation 不可选择,并显示原因。 -用户确认后,系统一次性创建 Task、Conversation 和 Schedule Binding。编辑计划只影响后续 -Job;已启动 Run 使用冻结快照。 +### 5.2 创建摘要 -## 6. 触发器 +提交前显示确定性摘要: -### 6.1 时间触发 +```text +✓ 为当前 Conversation 新增一个 Task +✓ 在左侧会话列表显示“任务 3” +✓ 默认以 Execute 模式运行 +✓ 每周五 17:00 自动执行此 Task +✓ 文本结果写入当前 Conversation +✓ 独立交付物保存到成果 +``` + +创建 Task、可选新 Conversation、关联关系和 Schedule Binding 必须在 Main 中原子提交。 +失败时保持 Modal 和用户输入,不只显示短暂通知。提交期间锁定重复操作。 + +### 5.3 Modal 行为与无障碍 + +- 使用 `role="dialog"`、`aria-modal="true"`、稳定标题和说明关联。 +- 打开后聚焦首个必填字段,Tab 焦点限制在 Modal 内。 +- Escape 在未提交时关闭并恢复触发按钮焦点。 +- 窄窗口使用接近全宽布局,保留 `16px` 外边距。 +- 字段错误靠近字段;非字段异步错误保留在 Modal 内并提供重试。 + +## 6. 工作模式、Runtime 与工具 + +### 6.1 默认 Execute + +创建 Modal 默认选择 Execute: + +- Execute 可以调用当前 Runtime 与 Project 已启用、且被 Task 快照允许的工具。 +- Ask 保持 Runtime 边界只读,只能调用允许的只读能力。 +- 所选 Runtime 不支持工具执行时,不能静默降级为 Ask;用户必须更换 Runtime 或主动选择 + Ask。 +- Modal 持续显示实际 Runtime、Project、工作目录和权限摘要。 + +### 6.2 权限快照 + +Task 创建时冻结: + +- Project 和工作目录。 +- Runtime 与模型选择。 +- 工作模式。 +- Skills、MCP、知识库、记忆和上下文范围。 +- 可用工具与审批策略。 +- 预算、并发和输出限制。 + +后续设置变化不修改已启动 Run。编辑 Task 配置只影响后续 Job。 + +### 6.3 审批 + +- Execute 继续遵守当前 Runtime、GoodBuddy 原生能力和工具审批控制。 +- 已启用且按现有策略允许自动执行的工具可以在后台运行。 +- 需要额外确认的动作进入 `waiting_approval`,暂停所属 Job 并发送应用内及桌面通知。 +- 用户批准后继续同一个 Job/Run;拒绝后按协议失败、跳过或请求调整。 +- 定时触发不能把高风险、越界或未授权动作转换成自动批准。 +- 结果未知的外部副作用进入 `outcome_unknown`,不得自动重试。 + +## 7. 触发器 + +### 7.1 时间触发 ```ts type TimeTrigger = @@ -96,10 +199,12 @@ type TimeTrigger = } ``` +“工作日”是 `weekly` 的周一至周五预设,不增加新的持久化触发类型。 + 受限 Cron 使用五字段,不支持秒、年份、宏、`L`、`W`、`#` 或供应商扩展。Main 负责解析, 默认最小间隔为 15 分钟,并展示未来五次触发时间。 -### 6.2 事件触发 +### 7.2 事件触发 后续支持: @@ -112,27 +217,80 @@ type TimeTrigger = 事件触发配置来源范围、确定性过滤、去重窗口、冷却时间和并发上限。基础匹配不调用模型。 -### 6.3 手动触发 +### 7.3 手动触发 “立即运行”在当前 Task 内创建独立 Job 和 Run,不改变下一次计划时间,不创建新 Task。 重复点击使用调用级幂等键去重。 -## 7. 一次触发的对象关系 +## 8. 一次触发的对象关系 ```text -Scheduled Task - ├─ Conversation(持续复用) - ├─ Schedule Binding - └─ Job: scheduled_occurrence - └─ Run +Conversation + └─ Scheduled Task + ├─ Schedule Binding + └─ Job: scheduled_occurrence + └─ Run ``` - `scheduledFor` 和计划版本形成幂等键。 - 同一 Scheduled Task 默认最多一个活动 occurrence Job。 -- 若允许并行 occurrence,它们仍属于同一 Task Conversation,并由协调器有序汇总。 -- 重试产生新 Run,不产生新 Task 或新 Job。 +- 若允许并行 occurrence,它们仍属于同一 Task,并由协调器有序写回关联 Conversation。 +- 重试产生新 Run,不产生新 Task 或新 occurrence Job。 -## 8. 错过执行 +## 9. 左侧会话列表 + +普通 Conversation 保持单行。包含 Task 的 Conversation 显示行首展开按钮: + +```text +▾ 产品发布讨论 10:24 + + ▣ 每周进度总结 + 每周五 17:00 · Execute · 下次 8 月 21 日 + + ▣ 发布前检查 + 单次 · Execute · 等待确认 +``` + +- 父会话行不重复显示任务标签或数量;Task 身份只在展开后的子项中使用稳定任务图标表达。 +- 点击 Conversation 标题打开聊天;点击 Task 子项打开同一 Conversation 并定位到该 Task。 +- 新建 Task 成功后首次自动展开。用户手动折叠后持久化其选择,后台运行不强制展开。 +- 默认最多直接显示 3 个 Task;“查看全部 N 个任务”打开该 Conversation 的完整 Task 区。 +- Task 子项显示本地化的模式、计划和状态文字;状态不能只靠任务图标颜色表达。 +- 左侧只展开 Task;当前产品 UI 的其他区域也不提供 Job/Run 树或独立导航。 + +## 10. Conversation 内呈现 + +打开包含 Task 的 Conversation 后,顶部提供可折叠 Task 条: + +```text +本会话有 2 个任务 +[每周进度总结] [发布前检查] [管理任务] +``` + +选中 Task 后显示: + +- 名称、状态和模式。 +- 计划、下次执行和未来预览。 +- 最近一次执行结果。 +- “立即运行”“暂停”“编辑计划”等操作。 +- 需要审批时的明确恢复入口。 + +每条自动结果消息显示 Task 名称、触发来源和时间。普通文本作为消息保存;文件、图片、PDF 和 +其他独立交付物保存为 Artifact,并从消息引用。多个 Task 并发时,最终文本以完整消息写入, +不能把流式 Token 无序混入同一消息时间线。 + +## 11. Task Center + +Task Center 显示 Scheduled Task 的范围、关联 Conversation、状态、模式、最近进展、需要 +关注和下次触发时间: + +- 点击条目打开关联 Conversation,并定位到该 Task。 +- “立即运行”在内部创建 Job/Run,但 UI 仍只呈现 Task,不改变计划时间。 +- 暂停只阻止新 Job,不取消已经完成的外部副作用。 +- Task Center 是完整索引;左侧展开列表只是最近 Conversation 下的轻量入口。 +- 不新增平行 Automation Center。 + +## 12. 错过执行 | 策略 | 行为 | | --- | --- | @@ -142,85 +300,56 @@ Scheduled Task 默认补跑最多 3 次、最多回溯 7 天。补跑同样受 Task 的并发、权限和预算控制。 -## 9. 时区和夏令时 +## 13. 时区和夏令时 - 保存 IANA 时区,不保存固定 UTC 偏移。 - 春季不存在的本地时间在当日第一个有效分钟触发。 - 秋季重复时间只触发一次。 - 系统时区变化不自动修改计划时区。 -- UI 显示计划时区、本机时区差异和未来触发时间。 +- UI 显示计划时区、本机时区差异和未来五次触发时间。 -## 10. Ask、Execute 与审批 +## 14. 预算、恢复和删除 -第一阶段保持 Ask: +每个 Scheduled Task 配置最大 Job 耗时、模型/Token/工具调用、成果大小、活动 Job 数和后台 +优先级。前台请求优先,后台达到上限时记录 `deferred`。 -- Runtime 边界只读。 -- 不写文件、不执行命令、不发送消息、不修改远程数据。 -- 输出写回 Task Conversation;独立交付物才进入成果。 - -Execute 按顺序开放: - -1. 有人值守,沿用逐工具审批。 -2. 预批准低风险工具和参数范围。 -3. 经过专项验证的内置无人值守模板。 - -高风险、越界或未预授权动作进入 `waiting_approval`,不能因定时触发而绕过策略。 - -## 11. 预算与背压 - -每个 Scheduled Task 配置: - -- 最大 Job 耗时。 -- 最大模型、Token 和工具调用。 -- 最大成果大小。 -- 最大活动 Job 数。 -- 后台优先级。 - -前台请求优先。后台达到上限时延后并记录 `deferred`,不能挤占用户正在等待的请求,也不能 -在恢复空闲时一次释放全部积压。 - -## 12. 重试、恢复和取消 - -- 瞬时、无副作用失败可以有界重试。 +- 瞬时且没有未知副作用的失败可以有界重试。 - 配置、权限和范围错误不重试。 -- 外部副作用结果未知时进入 `outcome_unknown`,不自动重试。 - 应用退出将活动 Job/Run 标记为 `interrupted`。 -- 暂停计划只阻止新 Job,不假装取消已发生的外部操作。 - 取消 Task 必须传播到活动 Job、Subjob 和 Runtime。 +- 删除 Schedule 只停止后续触发,不删除 Task、Conversation 或历史。 +- 删除 Task 停止其计划并移除关联,默认保留 Conversation 和既有消息。 +- 删除 Conversation 前显示关联 Task 数量,并先处理活动 Job。 -## 13. 界面 - -Task Center 显示 Scheduled Task 的范围、状态、最近进展、需要关注和下次触发时间。点击条目 -打开同一 Task Conversation。 - -Task 内可查看: - -- 计划和触发器。 -- 下次执行和未来预览。 -- 每次 occurrence Job。 -- Run、审批、活动和成果。 - -不新增平行 Automation Center。 - -## 14. 兼容迁移 +## 15. 兼容迁移 现有 Schedule、Schedule Run、Task 和 Conversation 数据渐进关联: - 保留现有计划 ID、启停状态、下次时间和历史。 -- 为每个现有计划建立或绑定一个持续 Task Conversation。 +- 为每个现有 Schedule 创建一个稳定产品级 Task。 +- 旧 Schedule 不猜测绑定已有用户 Conversation;为其创建新的关联 Conversation。 - 历史每次执行映射为该 Task 下的 occurrence Job/Run。 -- 迁移不得复制消息、成果或顶层 Task。 +- 旧执行产生的 Task 行在映射成功后不再作为产品级 Task 索引,但其状态、活动和成果继续 + 通过迁移后的 Job/Run 归属保留。 +- 旧文本 Artifact 可以保留,但迁移不得把它们重复写成新消息。 +- 迁移不得复制用户消息、独立成果或顶层 Task。 -## 15. 验收标准 +## 16. 验收标准 -- [ ] 创建 Scheduled Task 只创建一个 Task 和一个 Conversation。 -- [ ] 重复触发始终复用该 Task Conversation。 -- [ ] 每次触发创建 Job/Run,不创建新的顶层 Task。 +- [x] 创建 Scheduled Task 可以选择当前或新 Conversation。 +- [x] 关联当前 Conversation 不修改其标题、类型或既有消息。 +- [x] 一条 Conversation 可以在左侧展开一个或多个 Task。 +- [x] 默认工作模式为 Execute,且用户可以主动选择 Ask。 +- [ ] Execute 能调用快照允许的工具,但不能绕过 Runtime 和审批控制。 +- [x] 不支持工具的 Runtime 不会让 Execute 静默降级。 +- [x] 重复触发始终复用同一 Task 和 Conversation 关联。 +- [x] 每次触发创建内部运行记录,不创建新的顶层 Task。 +- [x] 内部运行记录只用于执行和审计,不在 UI 中显示为独立层级。 - [ ] 支持单次、每日、每周、每月、工作日和受限 Cron。 - [ ] UI 显示计划时区和未来五次触发时间。 - [ ] 夏令时不会造成漂移或双跑。 - [ ] 错过执行按配置跳过、补一次或有界补跑。 -- [ ] 手动运行不改变下次计划时间。 -- [ ] Ask 在 Runtime 边界拒绝写操作和外部副作用。 +- [x] 手动运行不改变下次计划时间。 +- [x] 文本结果只写入 Conversation,独立交付物才进入成果。 +- [ ] Task Center 和桌面通知可以打开正确 Conversation 并定位 Task。 - [ ] 应用重启不自动重放结果未知的副作用。 -- [ ] Task Center 不因重复触发新增条目。 diff --git a/docs/prd/task-and-job/task-and-job-model.md b/docs/prd/task-and-job/task-and-job-model.md index d2961c2..c1f3e84 100644 --- a/docs/prd/task-and-job/task-and-job-model.md +++ b/docs/prd/task-and-job/task-and-job-model.md @@ -4,8 +4,8 @@ | 项目 | 内容 | | --- | --- | -| 状态 | 产品边界已确认,部分能力待实施 | -| 版本 | 0.1 | +| 状态 | 产品边界与 Scheduled Task 首期已实现;通用 Job/Run 能力待实施 | +| 版本 | 0.3 | | 日期 | 2026-08-19 | | 适用产品 | GoodBuddy 桌面端 | | 文档角色 | Task、Conversation、Job、Run 与 Subagent 的权威定义 | @@ -14,25 +14,34 @@ ### 1.1 Task -Task 是用户明确创建或由已启用计划创建的工作单位,也是 Task Center 的顶层对象。 +Task 是用户明确创建或确认的工作单位,也是 Task Center 的顶层对象。 -- 创建 Task 就创建一条新的 Conversation。 -- Task 的内容载体是这条 Conversation,不再维护第二份任务正文或消息时间线。 -- 打开 Task 就打开其 Conversation。 -- Task 的目标、状态、范围、计划、Job、审批、活动和成果都围绕同一 Conversation 组织。 -- 一个 Task 在生命周期内保持同一个 `taskId` 和 `conversationId` 绑定。 +- 每个 Task 必须关联且只关联一条 Conversation。 +- 创建 Task 时,用户可以关联当前 Conversation,也可以同时创建一条新 Conversation。 +- 关联当前 Conversation 不改变其对象类型、标题、既有消息或普通聊天能力,只增加 Task + 关联及其可见入口。 +- Task 的目标、状态、范围、计划、Job、审批、活动和成果使用独立 Task 身份保存。 +- 打开 Task 会打开关联的 Conversation,并定位或展开对应 Task。 +- 一个 Task 在生命周期内保持稳定的 `taskId` 和 `conversationId` 关联。 +- 删除 Task 默认停止其计划并移除关联,不删除 Conversation 或既有消息。 -普通 Conversation 不自动成为 Task。用户只是聊天时,不应因为存在模型调用或工具步骤就 -产生顶层 Task。 +普通模型请求、工具调用或 Runtime Run 不自动成为产品级 Task。只有用户明确创建、确认或 +由已启用产品流程创建的工作,才进入 Task Center 和 Conversation 的 Task 列表。 ### 1.2 Conversation -Conversation 是 Task 的交互和内容载体: +Conversation 是用户消息、助手消息和面向用户结果的内容容器,不因为关联 Task 而变成另一 +种 Conversation: -- 保存用户消息、助手消息和面向用户的进展。 -- 承载同一 Task 内多个 Job 的可理解汇总。 -- 不让并行 Job 直接无序写入同一消息流;由 Task 协调器合并进展和结果。 -- 删除、归档和切换范围时遵循 Task 的生命周期规则。 +- 一条 Conversation 可以不关联 Task,也可以关联一个或多个 Task。 +- 多个 Task 可以共享同一 Conversation 的可见上下文,但各自拥有独立配置、计划、权限 + 快照、状态、Job、Run 和成果引用。 +- Conversation 标题与 Task 名称相互独立。创建或重命名 Task 不静默修改现有会话标题。 +- 左侧会话列表根据显式 Task 关联显示行首展开按钮;父会话行不重复任务标签,展开后的 + Task 子项使用任务图标,并只展开到 Task 层。 +- 并行 Job 不直接无序写入消息流;进度留在各自 Task/Job 状态中,最终文本以带来源元数据 + 的完整消息写入 Conversation。 +- 删除 Conversation 前必须说明关联 Task 数量,并先停止或结算仍活动的 Job。 ### 1.3 Job @@ -40,9 +49,10 @@ Job 是 Task 内部的执行单位,不是新的顶层 Task: - 一次计划触发、一个执行步骤、一项专家委派或一组并行工作都可以是 Job。 - 一个 Task 可以串行或并行运行多个 Job。 -- 所有 Job 仍属于同一个 Task 和同一条 Conversation。 +- 所有 Job 仍属于同一个 Task,并通过该 Task 关联的 Conversation 呈现用户可见结果。 - Job 可以有自己的状态、预算、Runtime、执行者、输入快照和成果引用。 -- Job 不进入 Task Center;它显示在 Task 的时间线、活动或 Runtime 视图中。 +- Job 不进入 Task Center、左侧会话列表或独立详情页。当前产品 UI 的对象层级止于 Task; + 活动和 Runtime 只按 Task 展示有界执行事件、工具、审批与错误,不呈现 Job 树。 ### 1.4 Subjob @@ -50,16 +60,17 @@ Subjob 是 Job 的子执行单元。它用于分解和并发,不创建新的 T - 父 Job 负责合并 Subjob 结果。 - 取消父 Job 必须传播到仍活动的 Subjob。 -- Subjob 不能扩大父 Job 的项目、目录、工具、知识、记忆或审批范围。 +- Subjob 不能扩大父 Job 的 Project、目录、工具、知识、记忆或审批范围。 - 深度、数量、并发、时间、Token 和输出大小必须有界。 ### 1.5 Run -Run 是 Task 或 Job 的一次执行尝试和审计记录,不是用户工作对象: +Run 是 Job 或 Subjob 的一次执行尝试和审计记录,不是用户工作对象: - 重试、恢复或手动重新运行可以产生新的 Run。 -- Run 冻结当次配置、范围、预算和 Runtime。 -- Run 进入活动记录和审计,不进入 Task Center。 +- Run 冻结当次配置、范围、预算、Runtime 和权限策略。 +- Run 进入内部审计;当前 UI 可以显示某次 Task 执行的时间、状态和活动,但不把 Run 呈现为 + 可导航的产品对象。 - `completed` 只表示该次执行按协议结束,不必然表示 Task 目标达成。 ### 1.6 Subagent @@ -68,84 +79,164 @@ Subagent 是执行 Job 或 Subjob 的受限执行者,不是对象层级: - 专家、Agent Runtime 或其他执行器可以承担 Job。 - Subagent 不自动拥有独立 Task 或 Conversation。 -- Subagent 输出先回到所属 Job,再由 Task 协调器写入同一 Conversation。 +- Subagent 输出先回到所属 Job,再由 Task 协调器写入关联 Conversation。 ## 2. 对象关系 ```text -Task 1 ── 1 Conversation - │ - ├─ Schedule / Trigger Binding(可选) - ├─ Job 1 - │ ├─ Run 1..N - │ └─ Subjob 0..N - ├─ Job 2(可与 Job 1 并行) - └─ Artifact / Approval / Activity / Notification +Conversation 1 ── 0..N Task + │ + ├─ Schedule / Trigger Binding(可选) + ├─ Job 1 + │ ├─ Run 0..N + │ └─ Subjob 0..N + │ └─ Run 0..N + ├─ Job 2(可与 Job 1 并行) + └─ Artifact / Approval / Activity / Notification +``` + +从 Task 方向看: + +```text +Task N ── 1 Conversation ``` 不允许: ```text -Task → 第二条 Conversation +Task → 没有关联 Conversation +Task → 同时关联多条 Conversation Job → 新建顶层 Task Subagent → 自动新建 Conversation -Run → 出现在 Task Center +Job / Run → 成为可独立导航的 UI 对象 ``` -## 3. Scheduled Task +## 3. 创建 Task -Scheduled Task 仍然是 Task,而不是独立的自动化对象: +创建定制 Task 时必须明确选择 Conversation: -1. 用户创建 Scheduled Task。 -2. 系统创建一个 Task 和一个 Conversation,并保存 Schedule/Trigger Binding。 +```text +关联当前 Conversation +或 +创建新 Conversation +``` + +- 从当前聊天发起时,默认选择当前 Conversation。 +- 从 Task Center 发起时,默认选择新 Conversation。 +- 选择当前 Conversation 时持续显示会话标题、Project 和已有 Task 数量。 +- 选择新 Conversation 时,默认使用 Task 名称作为会话标题,但允许用户修改。 +- Task、Conversation 关联和可选 Schedule Binding 必须在 Main 中原子创建或回滚。 + +## 4. Scheduled Task + +Scheduled Task 仍然是 Task,而不是 Schedule 定义和临时 Task 的松散组合: + +1. 用户选择当前或新 Conversation。 +2. 系统创建一个 Task,建立稳定 `conversationId` 关联,并保存 Schedule/Trigger Binding。 3. 到期时在该 Task 内创建新的 Job 和 Run。 -4. 每次触发的进展和结果汇入同一个 Task Conversation。 -5. 编辑计划影响后续 Job,不修改已经启动的 Run。 +4. 每次触发的进展和文本结果写入同一关联 Conversation。 +5. 独立文件、图片和其他交付物保存为 Artifact,并从结果消息引用。 +6. 编辑计划影响后续 Job,不修改已经启动的 Run。 同一 Scheduled Task 默认串行触发。需要并行时,应显式允许多个 Job 并发,并继续使用同一 -Conversation,而不是复制 Task。 +Task 和 Conversation 关联,而不是复制顶层 Task。 -## 4. 状态分层 +## 5. 消息归属 + +Task 产生的用户可见消息至少记录: + +```ts +type TaskMessageMetadata = { + taskId: string + jobId: string + runId: string + trigger: 'manual' | 'scheduled' | 'event' | 'goal' +} +``` + +同一 Conversation 关联多个 Task 时: + +- 消息持续显示来源 Task 名称。 +- 点击左侧展开项或 Task Center 条目可以定位对应 Task 和近期消息。 +- 任务筛选只改变定位和高亮,不隐藏用户未主动筛选的普通消息。 +- 多个活动 Job 的流式细节进入各自活动记录,最终文本有界持久化后再写入 Conversation。 + +## 6. 状态分层 | 层级 | 典型状态 | 用户在哪里看到 | | --- | --- | --- | -| Task | queued、running、waiting_approval、paused、completed、failed、cancelled、interrupted | Task Center、Task Conversation | -| Job | queued、running、waiting、completed、failed、cancelled | Task 时间线、活动、Runtime | -| Run | claimed、running、completed、failed、cancelled、interrupted、budget_exceeded | 活动与审计 | +| Task | idle、queued、running、waiting_approval、paused、completed、failed、cancelled、interrupted | Task Center、左侧会话展开项、Conversation | +| Job | queued、running、waiting_approval、completed、failed、cancelled | 内部协调与审计,不作为 UI 对象 | +| Run | claimed、running、completed、failed、cancelled、interrupted、budget_exceeded、outcome_unknown | 内部执行与审计,不作为 UI 对象 | Task 状态由当前目标和所属 Job 聚合得出,但不能用“任一 Job 完成”直接推断 Task 完成。 +Conversation 折叠行只显示其关联 Task 中最高优先级的关注状态: -## 5. 兼容映射 +```text +waiting_approval > failed > running > paused > idle +``` + +## 7. UI 展示边界 + +当前产品 UI 的对象层级统一止于 Task: + +- 左侧会话列表展开到 Task。 +- Task Center 只索引 Task。 +- Conversation 顶部任务区只选择和管理 Task。 +- 活动与 Runtime 可以展示 Task 的执行时间、工具、审批、错误、成果和状态事件,但不显示 + Job/Subjob 树,不提供 Job/Run 路由、列表或独立操作菜单。 +- “立即运行”“重试”和“恢复”在 UI 上都是 Task 操作;Job/Run 只在内部创建和审计。 + +## 8. 左侧 Conversation Task 列表 + +左侧最近会话列表是轻量发现入口,不替代 Task Center: + +- 无 Task 的 Conversation 保持现有单行样式。 +- 有 Task 的 Conversation 显示行首展开按钮,父会话行不重复任务标签或数量。 +- 展开后只显示带任务图标和本地化摘要的 Task,不继续显示 Job、Subjob 或 Run。 +- 新建 Task 成功后首次自动展开;用户手动折叠后保持选择,后台状态变化不强制展开。 +- 默认最多直接显示 3 个 Task;“查看全部 N 个任务”打开该 Conversation 的完整 Task 区。 +- Task 子项的任务图标表示身份;运行、审批、失败和暂停同时使用本地化状态文字。 +- 删除最后一个关联 Task 后,Conversation 的展开按钮自动消失。 + +## 9. 兼容映射 当前代码和旧文档中的对象按以下方式收敛: | 旧概念 | 目标概念 | | --- | --- | | 自动任务 | Scheduled Task、Event Task 或 Goal Task | -| 自动会话 | 删除该独立概念,使用 Task Conversation | +| 自动会话 | 删除该独立概念,使用关联 Conversation | | 子任务、Child Task | Job 或 Subjob | | 专家子任务 | 由专家 Subagent 执行的 Job/Subjob | -| 多任务并行 | 一个 Task 内多个并行 Job;确实独立的用户目标才创建多个 Task | +| 多任务并行 | 一个或多个 Task 下的并行 Job;根据用户目标和 Conversation 归属明确建模 | | Schedule Run | Scheduled Task 内的 Job Run | -| Automation Run | Task 或 Job 的 Run | +| Automation Run | Task 所属 Job 或 Subjob 的 Run | +| 普通请求 Task 行 | 内部执行/审计记录,不自动成为产品级 Task | 数据库字段可以在兼容期保留旧名称,但新产品文案、PRD 和新增契约必须使用本模型。 -## 6. 安全和数据要求 +## 10. 安全和数据要求 -- Main 验证 Task、Conversation、Job、Run 和 Project 的归属链。 -- Renderer 不能把任意 Job 绑定到其他 Task 或 Conversation。 -- Job/Subjob 继承父级能力上限,只能缩小,不能扩大。 +- Main 验证 Conversation、Task、Job、Run 和 Project 的完整归属链。 +- Task 只能关联同一 Project 范围内允许使用的 Conversation。 +- Renderer 不能把任意 Task 或 Job 绑定到其他 Project 的 Conversation。 +- Job/Subjob 继承 Task 的能力上限,只能缩小,不能扩大。 +- Execute Task 冻结 Runtime、工作目录、工具和审批策略;后台触发不能扩大权限。 - 并行输出先有界持久化,再按确定顺序汇总到 Conversation。 -- 取消、超时、审批和应用退出必须沿 Task → Job → Subjob → Runtime 传播。 -- 用户删除 Task 时,先处理活动 Job,再按数据保留规则清理关联对象。 +- 取消、超时、审批和应用退出必须沿 Task → Job / Subjob → Run → Runtime 传播。 +- 删除 Task 默认保留 Conversation 和消息;删除 Conversation 必须处理其全部关联 Task。 -## 7. 验收原则 +## 11. 验收原则 -- [ ] 创建 Task 时只创建一条对应 Conversation。 -- [ ] Scheduled Task 的重复触发复用同一 Task Conversation。 -- [ ] 一个 Task 可以在同一 Conversation 下运行多个并行 Job。 -- [ ] Job、Subjob、Run 和 Subagent 不进入 Task Center。 +- [ ] 每个 Task 只关联一条 Conversation。 +- [ ] 一条 Conversation 可以关联零个、一个或多个 Task。 +- [ ] 创建 Task 可以选择当前 Conversation 或新 Conversation,且不会改变当前会话类型。 +- [ ] 左侧会话列表通过行首按钮展开带任务图标和本地化摘要的 Task,但不展开 Job/Run。 +- [ ] 当前 UI 不提供 Job、Subjob 或 Run 的独立列表、树、路由或操作菜单。 +- [ ] Scheduled Task 的重复触发复用同一 Task 和 Conversation 关联。 +- [ ] 一个 Task 可以运行多个串行或并行 Job。 +- [ ] Job、Subjob、Run 和 Subagent 不进入 Task Center,也不成为其他可导航 UI 对象。 +- [ ] Task 消息可以通过 `taskId`、`jobId` 和 `runId` 追溯来源。 - [ ] 并行 Job 不直接无序写入 Conversation。 - [ ] 取消和权限范围能够沿层级正确传播。 -- [ ] 新文档不再把 Job/Subjob 定义为新的顶层 Task。 diff --git a/docs/prd/task-and-job/task-center-prd.md b/docs/prd/task-and-job/task-center-prd.md index 26f12f2..097283c 100644 --- a/docs/prd/task-and-job/task-center-prd.md +++ b/docs/prd/task-and-job/task-center-prd.md @@ -4,56 +4,117 @@ | 项目 | 内容 | | --- | --- | -| 状态 | 设计中 | -| 版本 | 0.1 | +| 状态 | Scheduled Task 首期已实现;Goal/Event Task 与完整操作待实施 | +| 版本 | 0.3 | | 日期 | 2026-08-19 | | 依赖 | [Task 与 Job 统一领域模型](./task-and-job-model.md) | | 界面归属 | [通用助手工作栏与执行空间](../assistant-experience/assistant-workbar-and-execution-spaces-prd.md) | ## 1. 产品定义 -Task Center 是所有 Task 的应用级单例索引。它不是第二份任务数据,也不是 Automation -Center。点击条目直接打开 Task 自身的 Conversation。 +Task Center 是所有产品级 Task 的应用级单例索引,不是 Automation Center,也不复制 +Conversation 内容。点击条目打开其关联 Conversation,并定位或展开对应 Task。 + +每个 Task 只关联一条 Conversation;一条 Conversation 可以关联零个、一个或多个 Task。 +Conversation 不因为关联 Task 而改变对象类型。 ## 2. 收录边界 收录: -- 用户明确创建的 Task。 +- 用户明确创建或确认的 Task。 - Scheduled Task、Event Task 和 Goal Task。 - 未来由用户确认创建的其他顶层 Task。 不收录: -- 普通 Conversation。 +- 没有显式 Task 关联的普通 Conversation。 +- 普通模型请求或工具调用产生的内部执行记录。 - Job、Subjob、Run、工具步骤或 Subagent。 - Smart Heartbeat 配置、报告和建议。 - 仅用于审计的活动记录。 +当前产品 UI 的对象层级止于 Task。Task Center、左侧会话列表和 Conversation 任务区都不显示 +Job/Subjob/Run 树、独立详情或路由。 + ## 3. 列表信息 每条 Task 至少显示: - 名称和 Global / Project 范围。 +- 关联 Conversation 标题。 - Task 类型和触发来源。 +- Ask / Execute 模式。 - 当前聚合状态。 - 最近一次面向用户的进展。 - 最近活动时间。 -- 等待审批、失败或需要关注数量。 +- 等待审批、失败或需要关注状态。 - 下次计划时间(如适用)。 +Task 行只显示聚合后的用户状态,不要求用户理解内部 Job/Run。 + ## 4. 交互 -- 点击条目打开 Task Conversation。 +- 点击条目打开关联 Conversation,并定位到该 Task。 - 支持按需要关注、进行中、已暂停、已结束筛选。 -- 支持暂停、恢复、取消和打开详情,但不在窄栏复制完整 Job 时间线。 +- 支持立即运行、暂停、恢复、取消、编辑和删除。 - 后台变化更新状态和徽标,不自动抢占当前页面。 -- Task 的计划、Job、Run、审批和成果在 Task 自身或对应活动视图管理。 +- 立即运行、重试和恢复在 UI 上都是 Task 操作,内部 Job/Run 不单独显示。 +- 完整消息留在 Conversation;工具、审批和错误可以在活动或 Runtime 中按 Task 查看; + 独立交付物在成果中查看。 -## 5. 验收标准 +## 5. 左侧 Conversation Task 列表 -- [ ] Task Center 只展示 Task。 -- [ ] 点击 Task 不会跳转到另一条内容相同的附属 Conversation。 -- [ ] Job/Subjob/Run 不会重复成为顶层条目。 -- [ ] Scheduled Task 显示下次时间,但每次触发不新增 Task 条目。 -- [ ] Smart Heartbeat 不进入 Task Center。 +左侧最近会话列表承担轻量 Task 发现,不替代 Task Center: + +```text +▾ 产品发布讨论 10:24 + + ▣ 每周进度总结 + 每周五 17:00 · Execute · 下次 8 月 21 日 + + ▣ 发布前检查 + 单次 · Execute · 等待确认 +``` + +- 无 Task 的 Conversation 保持现有单行样式。 +- 有 Task 时在行最左侧显示独立展开按钮;父会话行不重复显示任务标签或数量。 +- 会话标题溢出时保持时间和操作区固定;悬停会话行后,标题在自身裁切区域内横向滑动展示 + 完整名称。未溢出标题不滑动,减少动态效果偏好下使用完整标题提示而不产生位移。 +- 展开后每个 Task 子项使用任务图标,并显示本地化的模式、计划和状态;不显示 Job、 + Subjob、Run 或工具步骤。 +- 点击 Conversation 标题打开聊天;点击 Task 打开同一 Conversation 并定位该 Task。 +- 新建 Task 后首次自动展开;用户手动折叠后保持选择。 +- 后台状态变化不强制展开;Task 状态持续显示在展开后的子项和 Task Center 中。 +- 默认最多显示 3 个 Task;“查看全部 N 个任务”打开该 Conversation 的完整任务区。 + +## 6. Conversation 任务区 + +包含 Task 的 Conversation 顶部显示可折叠任务区: + +```text +本会话有 2 个任务 +[每周进度总结] [发布前检查] [管理任务] +``` + +选择 Task 后显示名称、模式、聚合状态、计划、下次执行、最近结果和 Task 级操作。工具、审批、 +错误和成果通过 Task 关联显示,但不暴露 Job/Run 层级。 + +## 7. 删除关系 + +- 删除 Schedule 只停止后续触发,不删除 Task、Conversation 或历史。 +- 删除 Task 停止其计划并移除关联,默认保留 Conversation 和既有消息。 +- 删除最后一个 Task 后,左侧 Conversation 的展开按钮消失。 +- 删除 Conversation 前必须显示关联 Task 数量,并先停止或结算活动执行。 + +## 8. 验收标准 + +- [x] Task Center 只展示产品级 Task。 +- [x] 一条 Conversation 可以关联并展开多个 Task。 +- [x] 点击 Task 打开正确 Conversation 并定位到对应 Task。 +- [x] 左侧会话列表通过独立展开按钮显示带任务图标和本地化摘要的 Task 子项。 +- [x] 当前 UI 不显示 Job/Subjob/Run 树或独立页面。 +- [x] Scheduled Task 显示下次时间,但每次触发不新增 Task 条目。 +- [x] 普通模型请求和工具调用不会误显示为 Task。 +- [ ] 删除 Task 默认保留 Conversation 和既有消息。 +- [x] Smart Heartbeat 不进入 Task Center。 diff --git a/docs/roadmap/long-term-assistant-roadmap.md b/docs/roadmap/long-term-assistant-roadmap.md index e915f54..ea288c4 100644 --- a/docs/roadmap/long-term-assistant-roadmap.md +++ b/docs/roadmap/long-term-assistant-roadmap.md @@ -60,14 +60,18 @@ GoodBuddy 应能够: #### 任务中心 - 保留 Task Center 作为工作栏中的稳定入口,不先建设平行的独立任务或自动化平台。 -- 每个 Task 与唯一 Conversation 一对一绑定;打开 Task 就打开该 Conversation。 +- 每个 Task 只关联一条 Conversation,一条 Conversation 可以关联多个 Task;打开 Task + 就打开关联 Conversation 并定位该 Task。 - Task Center 只索引 Task,显示范围、状态、最近进展和需要关注信息。 - 普通 Conversation、Job、Run、工具步骤、Subagent 和智能心跳事项不作为顶层 Task。 +- 左侧最近会话对关联 Task 显示行首展开按钮和带任务图标的子项,父行不重复任务标签, + 当前 UI 只展开到 Task。 #### 监督、Runtime 与进程 -- 监督展示所选会话、任务、自动化或实验的带证据评论和介入请求。 -- Runtime 展示所选会话或 Run 的工具、Subagent、后台 Job、Workflow/Hook 和生命周期。 +- 监督展示所选 Conversation、Task 或实验的带证据评论和介入请求。 +- Runtime 按所选 Conversation 或 Task 聚合工具、委派、后台执行、Workflow/Hook 和生命周期, + 不提供 Job/Run 树或独立操作对象。 - 进程只展示并控制 GoodBuddy 创建、托管或明确接管的进程。 - 待审批和高风险状态在所有栏目中持续可见,但不无条件抢占当前栏目。 @@ -132,7 +136,8 @@ GoodBuddy 应能够: ### 4.3 后台 Task 与 Job -- Task 拥有唯一 Conversation;内部步骤、委派、并行分支和重复触发使用 Job/Subjob。 +- Task 只关联一条 Conversation,Conversation 可以承载多个 Task;内部步骤、委派、并行 + 分支和重复触发使用 Job/Subjob,但当前 UI 不展示这些内部层级。 - Task 状态:排队、运行、等待审批、暂停、完成、失败、取消、中断。 - 应用隐藏后 Job 可以继续运行,应用退出后不承诺继续执行。 - 重启时将未完成 Run 标记为中断,并允许用户恢复。 @@ -280,7 +285,8 @@ GoodBuddy 应能够: ### 阶段 2:Task 与 Job - 持久化 Task、Job、Run 和事件。 -- 在现有 Task Center 补齐范围、状态、最近进展、需要关注和直接打开 Task Conversation。 +- 在现有 Task Center 补齐范围、状态、最近进展、需要关注和直接打开关联 Conversation。 +- 在左侧最近会话增加行首展开按钮和 Task 子项图标,展开层级止于 Task。 - 取消、重试、恢复和审批收件箱。 - 托盘状态和桌面通知。 @@ -325,9 +331,11 @@ GoodBuddy 应能够: ### 8.3 Task -- 每个 Task 与唯一 Conversation 一对一绑定,用户无需理解第二层内容载体。 +- 每个 Task 只关联一条 Conversation,一条 Conversation 可以承载多个 Task,关联不改变 + Conversation 类型或复制内容。 - Task Center 入口保留,普通 Conversation、Job、Run 和心跳事项不会混入顶层列表。 - 每个 Task 显示范围、状态、最近进展和需要关注信息,并可直接打开其 Conversation。 +- 左侧会话列表显示行首展开按钮和 Task 子项图标;当前 UI 不展示 Job/Subjob/Run 层级。 - 事件持久化后再展示。 - 取消、失败、重试和应用重启均有确定状态。 - 审批在全局右侧栏可见。 diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index 9683fd0..9ee4ee1 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -98,7 +98,7 @@ describe('AssistantDatabase', () => { database.close() }) - it('migrates existing databases to schema version 20', async () => { + it('migrates existing databases to schema version 22', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-assistant-migration-') ) @@ -127,7 +127,7 @@ describe('AssistantDatabase', () => { user_version: number } ).user_version - ).toBe(21) + ).toBe(22) expect( current .prepare( @@ -231,7 +231,7 @@ describe('AssistantDatabase', () => { user_version: number } ).user_version - ).toBe(21) + ).toBe(22) expect( current .prepare( @@ -269,6 +269,131 @@ describe('AssistantDatabase', () => { current.close() }) + it('backfills one stable Task and Conversation for each v21 schedule', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-stable-schedule-migration-') + ) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'assistant.sqlite') + const initial = new AssistantDatabase(databasePath) + initial.initialize('C:\\Workspace') + const project = initial.listProjects()[0]! + const legacySchedule = initial.createSchedule({ + projectId: project.id, + title: '旧版每日报告', + prompt: '生成每日报告', + workMode: 'ask', + recurrence: 'daily', + nextRunAt: '2026-08-20T00:00:00.000Z' + }) + initial.close() + + const legacyTaskId = + '00000000-0000-4000-8000-000000000221' + const legacyRunId = + '00000000-0000-4000-8000-000000000222' + const raw = new DatabaseSync(databasePath) + raw.exec('BEGIN IMMEDIATE') + raw + .prepare('DELETE FROM tasks WHERE id = ?') + .run(legacySchedule.taskId) + raw + .prepare('DELETE FROM conversations WHERE id = ?') + .run(legacySchedule.conversationId) + raw + .prepare( + `INSERT INTO tasks + (id, project_id, conversation_id, schedule_id, parent_task_id, + expert_id, routing_mode, title, instructions, origin, status, + priority, work_mode, progress, created_at, started_at, + completed_at, error, visible) + VALUES (?, ?, ?, NULL, NULL, NULL, NULL, ?, ?, 'schedule', + 'completed', 0, 'ask', NULL, ?, ?, ?, NULL, 1)` + ) + .run( + legacyTaskId, + project.id, + `schedule:${legacySchedule.id}`, + legacySchedule.title, + legacySchedule.prompt, + '2026-08-18T00:00:00.000Z', + '2026-08-18T00:00:00.000Z', + '2026-08-18T00:01:00.000Z' + ) + raw + .prepare( + `INSERT INTO schedule_runs + (id, schedule_id, scheduled_for, task_id, status) + VALUES (?, ?, ?, ?, 'completed')` + ) + .run( + legacyRunId, + legacySchedule.id, + '2026-08-18T00:00:00.000Z', + legacyTaskId + ) + raw.exec(` + DROP INDEX IF EXISTS idx_tasks_schedule; + PRAGMA user_version = 21; + COMMIT; + `) + raw.close() + + const migrated = new AssistantDatabase(databasePath) + migrated.initialize('C:\\Workspace') + const [schedule] = migrated.listSchedules(project.id) + expect(schedule).toMatchObject({ + id: legacySchedule.id, + title: legacySchedule.title, + taskId: expect.any(String), + conversationId: expect.any(String) + }) + expect(schedule!.taskId).not.toBe(legacyTaskId) + expect( + migrated.getConversation(schedule!.conversationId) + ).toMatchObject({ + projectId: project.id, + title: legacySchedule.title, + messages: [] + }) + expect(migrated.listTasks()).toEqual([ + expect.objectContaining({ + id: schedule!.taskId, + conversationId: schedule!.conversationId, + scheduleId: schedule!.id, + status: 'idle' + }) + ]) + migrated.close() + + const inspected = new DatabaseSync(databasePath) + expect( + inspected.prepare('PRAGMA user_version').get() + ).toEqual({ user_version: 22 }) + expect( + inspected + .prepare( + `SELECT parent_task_id, visible + FROM tasks + WHERE id = ?` + ) + .get(legacyTaskId) + ).toEqual({ + parent_task_id: schedule!.taskId, + visible: 0 + }) + expect( + inspected + .prepare( + `SELECT name + FROM sqlite_master + WHERE type = 'index' AND name = 'idx_tasks_schedule'` + ) + .get() + ).toEqual({ name: 'idx_tasks_schedule' }) + inspected.close() + }) + it('backfills checklist todos when migrating existing magic notes', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-magic-todo-migration-') @@ -997,6 +1122,26 @@ describe('AssistantDatabase', () => { recurrence: 'daily', nextRunAt: '2026-07-31T00:00:00.000Z' }) + expect(schedule).toMatchObject({ + projectId: project.id, + workMode: 'ask', + taskId: expect.any(String), + conversationId: expect.any(String) + }) + expect( + database.listTasks().find((task) => task.id === schedule.taskId) + ).toMatchObject({ + conversationId: schedule.conversationId, + scheduleId: schedule.id, + origin: 'schedule', + status: 'idle' + }) + expect(database.getConversation(schedule.conversationId)).toMatchObject({ + id: schedule.conversationId, + projectId: project.id, + title: '每日摘要', + messages: [] + }) const [claim] = database.claimDueSchedules( new Date('2026-07-31T00:01:00.000Z') ) @@ -1011,7 +1156,6 @@ describe('AssistantDatabase', () => { database.completeScheduleRun( claim!.runId, 'completed', - undefined, new Date('2026-07-31T00:01:00.000Z') ) expect(database.listSchedules(project.id)[0]).toMatchObject({ @@ -1019,6 +1163,89 @@ describe('AssistantDatabase', () => { nextRunAt: '2026-08-01T00:00:00.000Z', lastRunAt: '2026-07-31T00:01:00.000Z' }) + expect( + database.listTasks().find((task) => task.id === schedule.taskId) + ).toMatchObject({ + status: 'idle', + completedAt: undefined + }) + const manualClaim = database.claimScheduleNow(schedule.id) + expect(manualClaim.schedule).toMatchObject({ + taskId: schedule.taskId, + conversationId: schedule.conversationId + }) + expect(() => database.claimScheduleNow(schedule.id)).toThrow( + '已有一次运行正在进行' + ) + database.completeScheduleRun( + manualClaim.runId, + 'completed', + new Date('2026-07-31T00:02:00.000Z') + ) + database.appendConversationMessage({ + conversationId: schedule.conversationId, + role: 'assistant', + content: '今日任务状态正常', + status: '定时任务', + task: { + id: schedule.taskId, + title: schedule.title + } + }) + expect( + database.getConversation(schedule.conversationId).messages + ).toEqual([ + expect.objectContaining({ + content: '今日任务状态正常', + task: { + id: schedule.taskId, + title: schedule.title + } + }) + ]) + const sharedConversationSchedule = database.createSchedule({ + projectId: project.id, + conversationId: schedule.conversationId, + title: '每周复盘', + prompt: '复盘本周任务', + workMode: 'execute', + recurrence: 'weekly', + nextRunAt: '2027-01-01T00:00:00.000Z' + }) + expect(sharedConversationSchedule.conversationId).toBe( + schedule.conversationId + ) + expect( + database + .listTasks() + .filter( + (task) => + task.conversationId === schedule.conversationId && + task.origin === 'schedule' + ) + ).toHaveLength(2) + const countsBeforeFailedCreate = { + schedules: database.listSchedules().length, + tasks: database.listTasks().length, + conversations: database.listConversations().length + } + expect(() => + database.createSchedule({ + projectId: project.id, + conversationId: + '00000000-0000-4000-8000-000000000299', + title: '不应创建', + prompt: '无效对话', + workMode: 'execute', + recurrence: 'once', + nextRunAt: '2027-01-02T00:00:00.000Z' + }) + ).toThrow('所选对话不存在或不可用于任务') + expect({ + schedules: database.listSchedules().length, + tasks: database.listTasks().length, + conversations: database.listConversations().length + }).toEqual(countsBeforeFailedCreate) const overdue = database.createSchedule({ projectId: project.id, title: '过期摘要', @@ -1033,7 +1260,6 @@ describe('AssistantDatabase', () => { database.completeScheduleRun( overdueClaim!.runId, 'completed', - undefined, new Date('2026-07-31T00:01:00.000Z') ) expect( @@ -1043,6 +1269,22 @@ describe('AssistantDatabase', () => { ).toMatchObject({ nextRunAt: '2026-08-01T00:00:00.000Z' }) + database.removeSchedule(sharedConversationSchedule.id) + expect( + database + .listSchedules(project.id) + .some((item) => item.id === sharedConversationSchedule.id) + ).toBe(false) + expect( + database + .listTasks() + .find((task) => task.id === sharedConversationSchedule.taskId) + ).toMatchObject({ + conversationId: schedule.conversationId, + scheduleId: undefined, + status: 'completed', + workMode: 'execute' + }) database.close() }) @@ -1083,7 +1325,6 @@ describe('AssistantDatabase', () => { recovered.completeScheduleRun( reclaimed!.runId, 'completed', - undefined, new Date('2026-08-13T00:02:00.000Z') ) expect(recovered.listSchedules()[0]).toMatchObject({ @@ -1091,9 +1332,45 @@ describe('AssistantDatabase', () => { enabled: false, lastRunAt: '2026-08-13T00:02:00.000Z' }) + expect(() => + recovered.setScheduleEnabled(schedule.id, true) + ).toThrow('已执行的一次性计划不能恢复自动运行') recovered.close() }) + it('claims a bounded batch of independent due schedules', async () => { + const database = await createDatabase() + const scheduleIds = Array.from({ length: 3 }, (_, index) => + database.createSchedule({ + title: `批量任务 ${index + 1}`, + prompt: `执行批量任务 ${index + 1}`, + workMode: 'execute', + recurrence: 'once', + nextRunAt: '2026-08-13T00:00:00.000Z' + }).id + ) + + const firstBatch = database.claimDueSchedules( + new Date('2026-08-13T00:01:00.000Z'), + 2 + ) + expect(firstBatch).toHaveLength(2) + expect( + new Set(firstBatch.map((claim) => claim.schedule.id)).size + ).toBe(2) + + const secondBatch = database.claimDueSchedules( + new Date('2026-08-13T00:01:00.000Z'), + 2 + ) + expect(secondBatch).toHaveLength(1) + expect(scheduleIds).toContain(secondBatch[0]?.schedule.id) + for (const claim of [...firstBatch, ...secondBatch]) { + database.completeScheduleRun(claim.runId, 'completed') + } + database.close() + }) + it('durably interrupts active tasks with completion times and audit events on startup', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-assistant-recovery-') @@ -1847,9 +2124,27 @@ describe('AssistantDatabase', () => { role: 'user', content: '远程消息' }) + const linkedSchedule = database.createSchedule({ + conversationId: localId, + title: '随会话删除的任务', + prompt: '整理会话', + workMode: 'execute', + recurrence: 'daily', + nextRunAt: '2027-01-01T00:00:00.000Z' + }) expect(database.deleteLocalConversation(localId)).toBe(true) expect(database.deleteLocalConversation(localId)).toBe(false) + expect( + database + .listSchedules() + .some((schedule) => schedule.id === linkedSchedule.id) + ).toBe(false) + expect( + database + .listTasks() + .some((task) => task.id === linkedSchedule.taskId) + ).toBe(false) expect(() => database.getConversation(localId) ).toThrow('对话不存在') diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index 00dd2dc..f545881 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -3,7 +3,8 @@ import { DatabaseSync } from 'node:sqlite' import { conversationSnapshotSchema, expertCreateSchema, - normalizeInteractiveWorkMode + normalizeInteractiveWorkMode, + scheduleCreateSchema } from '../../shared/assistant-contracts' import type { AssistantArtifact, @@ -89,6 +90,7 @@ type TaskRow = { id: string project_id: string | null conversation_id: string | null + schedule_id: string | null parent_task_id: string | null expert_id: string | null routing_mode: AssistantTask['routingMode'] | null @@ -96,6 +98,7 @@ type TaskRow = { instructions: string origin: AssistantTask['origin'] status: AssistantTask['status'] + work_mode: LegacyWorkMode progress: number | null created_at: string started_at: string | null @@ -182,6 +185,7 @@ type MessageMetadata = { sourceReferences?: ConversationSnapshot['messages'][number]['sourceReferences'] knowledgeRetrieval?: ConversationSnapshot['messages'][number]['knowledgeRetrieval'] artifactIds?: string[] + task?: ConversationSnapshot['messages'][number]['task'] attachments?: ConversationSnapshot['messages'][number]['attachments'] } @@ -242,6 +246,11 @@ type ScheduleRow = { updated_at: string } +type ScheduleWithTaskRow = ScheduleRow & { + task_id: string + conversation_id: string +} + export type ClaimedSchedule = { schedule: AssistantSchedule runId: string @@ -407,13 +416,18 @@ function toTask(row: TaskRow): AssistantTask { id: row.id, projectId: row.project_id ?? undefined, conversationId: row.conversation_id ?? undefined, + scheduleId: row.schedule_id ?? undefined, parentTaskId: row.parent_task_id ?? undefined, expertId: row.expert_id ?? undefined, routingMode: row.routing_mode ?? undefined, title: row.title, instructions: row.instructions, origin: row.origin, - status: row.status, + status: + row.origin === 'schedule' && row.status === 'queued' + ? 'idle' + : row.status, + workMode: normalizeInteractiveWorkMode(row.work_mode), progress: row.progress ?? undefined, createdAt: row.created_at, startedAt: row.started_at ?? undefined, @@ -507,21 +521,30 @@ function toMemory(row: MemoryRow): AssistantMemory { } } -function toSchedule(row: ScheduleRow): AssistantSchedule { +function toSchedule(row: ScheduleWithTaskRow): AssistantSchedule { const template = JSON.parse(row.task_template_json) as { title: string prompt: string workMode: LegacyWorkMode + runtimeSelection?: unknown } + const runtimeSelection = agentRuntimeSelectionSchema.safeParse( + template.runtimeSelection + ) const recurrence = JSON.parse(row.recurrence_json) as { type: AssistantSchedule['recurrence'] } return { id: row.id, projectId: row.project_id ?? undefined, + taskId: row.task_id, + conversationId: row.conversation_id, title: template.title, prompt: template.prompt, - workMode: 'ask', + workMode: normalizeInteractiveWorkMode(template.workMode), + runtimeSelection: runtimeSelection.success + ? runtimeSelection.data + : undefined, recurrence: recurrence.type, nextRunAt: row.next_run_at, enabled: row.enabled === 1, @@ -858,6 +881,7 @@ function toConversationSnapshot( sourceReferences: metadata.sourceReferences, knowledgeRetrieval: metadata.knowledgeRetrieval, artifactIds: metadata.artifactIds, + task: metadata.task, attachments: metadata.attachments } }) @@ -879,6 +903,7 @@ function serializeConversationMessageMetadata( sourceReferences: message.sourceReferences, knowledgeRetrieval: message.knowledgeRetrieval, artifactIds: message.artifactIds, + task: message.task, attachments: message.attachments }) } @@ -1324,7 +1349,13 @@ export class AssistantDatabase { `SELECT COUNT(*) AS count FROM tasks WHERE project_id = ? AND visible = 1 - AND status IN ('queued', 'running', 'waiting_approval', 'paused')` + AND ( + status IN ('running', 'waiting_approval') + OR ( + origin != 'schedule' + AND status IN ('queued', 'paused') + ) + )` ) .get(projectId) as { count: number } if (activeTaskCount.count > 0) { @@ -1649,7 +1680,8 @@ export class AssistantDatabase { `UPDATE conversations SET project_id = ?, runtime_selection_json = ?, knowledge_retrieval_mode = ?, context_state_json = ?, - title = ?, status = 'active', updated_at = ? + title = ?, status = 'active', + updated_at = MAX(updated_at, ?) WHERE id = ? AND channel IS NULL` ) const findMessage = database.prepare( @@ -1777,24 +1809,68 @@ export class AssistantDatabase { deleteLocalConversation(conversationId: string): boolean { const database = this.requireDatabase() - const conversation = database - .prepare('SELECT channel FROM conversations WHERE id = ?') - .get(conversationId) as - | { channel: ProjectChannel | null } - | undefined - if (!conversation) { - return false - } - if (conversation.channel) { - throw new Error('远程对话不能作为本地对话删除') - } - return ( + database.exec('BEGIN IMMEDIATE') + try { + const conversation = database + .prepare('SELECT channel FROM conversations WHERE id = ?') + .get(conversationId) as + | { channel: ProjectChannel | null } + | undefined + if (!conversation) { + database.exec('COMMIT') + return false + } + if (conversation.channel) { + throw new Error('远程对话不能作为本地对话删除') + } + const activeTask = database + .prepare( + `SELECT 1 + FROM tasks + WHERE conversation_id = ? + AND status IN ('running', 'waiting_approval') + LIMIT 1` + ) + .get(conversationId) + if (activeTask) { + throw new Error('对话仍有正在运行的任务,请先停止任务') + } database + .prepare( + `DELETE FROM notifications + WHERE task_id IN ( + SELECT id FROM tasks WHERE conversation_id = ? + ) OR schedule_id IN ( + SELECT schedule_id + FROM tasks + WHERE conversation_id = ? AND schedule_id IS NOT NULL + )` + ) + .run(conversationId, conversationId) + database + .prepare( + `DELETE FROM schedules + WHERE id IN ( + SELECT schedule_id + FROM tasks + WHERE conversation_id = ? AND schedule_id IS NOT NULL + )` + ) + .run(conversationId) + database + .prepare('DELETE FROM tasks WHERE conversation_id = ?') + .run(conversationId) + const deleted = database .prepare( 'DELETE FROM conversations WHERE id = ? AND channel IS NULL' ) .run(conversationId).changes === 1 - ) + database.exec('COMMIT') + return deleted + } catch (error) { + database.exec('ROLLBACK') + throw error + } } getOrCreateRemoteConversation(input: { @@ -1873,37 +1949,50 @@ export class AssistantDatabase { return this.getConversation(id) } - appendRemoteConversationMessage(input: { + appendConversationMessage(input: { conversationId: string role: 'user' | 'assistant' content: string + state?: 'complete' | 'error' status?: string attachments?: ConversationSnapshot['messages'][number]['attachments'] artifactIds?: string[] + task?: ConversationSnapshot['messages'][number]['task'] }): void { const database = this.requireDatabase() const now = Date.now() - const sequence = database - .prepare( - `SELECT COALESCE(MAX(sequence), -1) + 1 AS sequence - FROM messages - WHERE conversation_id = ?` - ) - .get(input.conversationId) as { sequence: number } database.exec('BEGIN IMMEDIATE') try { + const conversation = database + .prepare( + `SELECT id + FROM conversations + WHERE id = ? AND status = 'active'` + ) + .get(input.conversationId) + if (!conversation) { + throw new Error('对话不存在或不可用') + } + const sequence = database + .prepare( + `SELECT COALESCE(MAX(sequence), -1) + 1 AS sequence + FROM messages + WHERE conversation_id = ?` + ) + .get(input.conversationId) as { sequence: number } database .prepare( `INSERT INTO messages (id, conversation_id, request_id, role, content, state, sequence, metadata_json, created_at) - VALUES (?, ?, NULL, ?, ?, 'complete', ?, ?, ?)` + VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?)` ) .run( randomUUID(), input.conversationId, input.role, input.content, + input.state ?? 'complete', sequence.sequence, JSON.stringify({ createdAt: now, @@ -1913,10 +2002,23 @@ export class AssistantDatabase { : {}), ...(input.artifactIds?.length ? { artifactIds: input.artifactIds } - : {}) + : {}), + ...(input.task ? { task: input.task } : {}) }), new Date(now).toISOString() ) + database + .prepare( + `DELETE FROM messages + WHERE id IN ( + SELECT id + FROM messages + WHERE conversation_id = ? + ORDER BY sequence DESC + LIMIT -1 OFFSET 500 + )` + ) + .run(input.conversationId) database .prepare( 'UPDATE conversations SET updated_at = ? WHERE id = ?' @@ -1929,6 +2031,17 @@ export class AssistantDatabase { } } + appendRemoteConversationMessage(input: { + conversationId: string + role: 'user' | 'assistant' + content: string + status?: string + attachments?: ConversationSnapshot['messages'][number]['attachments'] + artifactIds?: string[] + }): void { + this.appendConversationMessage(input) + } + claimChannelEvent( channel: string, accountId: string, @@ -2971,12 +3084,14 @@ export class AssistantDatabase { status: AssistantTask['status'], error?: string ): void { + const storedStatus = status === 'idle' ? 'queued' : status const terminal = [ 'completed', 'failed', 'cancelled', 'interrupted' - ].includes(status) + ].includes(storedStatus) + const now = new Date().toISOString() const result = this.requireDatabase() .prepare( `UPDATE tasks @@ -2985,16 +3100,22 @@ export class AssistantDatabase { WHEN ? = 'running' AND started_at IS NULL THEN ? ELSE started_at END, - completed_at = CASE WHEN ? THEN ? ELSE completed_at END + completed_at = CASE + WHEN ? THEN ? + WHEN ? IN ('queued', 'running', 'waiting_approval', 'paused') + THEN NULL + ELSE completed_at + END WHERE id = ?` ) .run( - status, + storedStatus, error ?? null, - status, - new Date().toISOString(), + storedStatus, + now, terminal ? 1 : 0, - new Date().toISOString(), + now, + storedStatus, taskId ) if (result.changes !== 1) { @@ -3400,123 +3521,350 @@ export class AssistantDatabase { const rows = projectId ? this.requireDatabase() .prepare( - `SELECT * FROM schedules - WHERE project_id = ? - ORDER BY next_run_at` + `SELECT s.*, t.id AS task_id, + t.conversation_id AS conversation_id + FROM schedules s + INNER JOIN tasks t ON t.schedule_id = s.id + WHERE s.project_id = ? + ORDER BY s.next_run_at` ) .all(projectId) : this.requireDatabase() - .prepare('SELECT * FROM schedules ORDER BY next_run_at') + .prepare( + `SELECT s.*, t.id AS task_id, + t.conversation_id AS conversation_id + FROM schedules s + INNER JOIN tasks t ON t.schedule_id = s.id + ORDER BY s.next_run_at` + ) .all() - return (rows as ScheduleRow[]).map(toSchedule) + return (rows as ScheduleWithTaskRow[]).map(toSchedule) } createSchedule(input: ScheduleCreateInput): AssistantSchedule { - const id = randomUUID() + const parsed = scheduleCreateSchema.parse(input) + const database = this.requireDatabase() + const scheduleId = randomUUID() + const taskId = randomUUID() const now = new Date().toISOString() - this.requireDatabase() - .prepare( + database.exec('BEGIN IMMEDIATE') + try { + let projectId = parsed.projectId ?? null + let conversationId = parsed.conversationId + let runtimeSelectionJson: string | null = null + if (conversationId) { + const conversation = database + .prepare( + `SELECT c.id, c.project_id, c.runtime_selection_json + FROM conversations c + LEFT JOIN projects p ON p.id = c.project_id + WHERE c.id = ? + AND c.status = 'active' + AND c.channel IS NULL + AND (p.id IS NULL OR p.status = 'active')` + ) + .get(conversationId) as + | { + id: string + project_id: string | null + runtime_selection_json: string | null + } + | undefined + if (!conversation) { + throw new Error('所选对话不存在或不可用于任务') + } + if ( + projectId && + projectId !== conversation.project_id + ) { + throw new Error('任务项目与所选对话不一致') + } + projectId = conversation.project_id + runtimeSelectionJson = conversation.runtime_selection_json + } else { + if (projectId) { + const project = database + .prepare( + `SELECT runtime_selection_json + FROM projects + WHERE id = ? AND status = 'active' AND kind = 'user'` + ) + .get(projectId) as + | { runtime_selection_json: string | null } + | undefined + if (!project) { + throw new Error('任务项目不存在或不可用') + } + runtimeSelectionJson = project.runtime_selection_json + } + conversationId = randomUUID() + database + .prepare( + `INSERT INTO conversations + (id, project_id, runtime_selection_json, work_mode, title, + status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'active', ?, ?)` + ) + .run( + conversationId, + projectId, + runtimeSelectionJson, + parsed.workMode, + parsed.title, + now, + now + ) + } + database + .prepare( `INSERT INTO schedules (id, project_id, task_template_json, timezone, recurrence_json, next_run_at, missed_run_policy, enabled, last_run_at, created_at, updated_at) VALUES (?, ?, ?, 'UTC', ?, ?, 'run_once', 1, NULL, ?, ?)` - ) - .run( - id, - input.projectId ?? null, - JSON.stringify({ - title: input.title, - prompt: input.prompt, - workMode: input.workMode - }), - JSON.stringify({ type: input.recurrence }), - input.nextRunAt, - now, - now - ) - return this.getSchedule(id) + ) + .run( + scheduleId, + projectId, + JSON.stringify({ + title: parsed.title, + prompt: parsed.prompt, + workMode: parsed.workMode, + runtimeSelection: + parseRuntimeSelection(runtimeSelectionJson) + }), + JSON.stringify({ type: parsed.recurrence }), + parsed.nextRunAt, + now, + now + ) + database + .prepare( + `INSERT INTO tasks + (id, project_id, conversation_id, schedule_id, + parent_task_id, expert_id, routing_mode, title, + instructions, origin, status, work_mode, progress, + created_at, started_at, completed_at, error) + VALUES (?, ?, ?, ?, NULL, NULL, NULL, ?, ?, + 'schedule', 'queued', ?, NULL, ?, NULL, NULL, NULL)` + ) + .run( + taskId, + projectId, + conversationId, + scheduleId, + parsed.title, + parsed.prompt, + parsed.workMode, + now + ) + database.exec('COMMIT') + } catch (error) { + database.exec('ROLLBACK') + throw error + } + return this.getSchedule(scheduleId) } setScheduleEnabled(scheduleId: string, enabled: boolean): void { - const result = this.requireDatabase() - .prepare( - `UPDATE schedules - SET enabled = ?, updated_at = ? - WHERE id = ?` - ) - .run(enabled ? 1 : 0, new Date().toISOString(), scheduleId) - if (result.changes !== 1) { - throw new Error('定时任务不存在') + const database = this.requireDatabase() + const now = new Date().toISOString() + database.exec('BEGIN IMMEDIATE') + try { + const schedule = database + .prepare( + `SELECT recurrence_json, last_run_at + FROM schedules + WHERE id = ?` + ) + .get(scheduleId) as + | { + recurrence_json: string + last_run_at: string | null + } + | undefined + if (!schedule) { + throw new Error('定时任务不存在') + } + const recurrence = JSON.parse(schedule.recurrence_json) as { + type?: string + } + if ( + enabled && + recurrence.type === 'once' && + schedule.last_run_at + ) { + throw new Error('已执行的一次性计划不能恢复自动运行') + } + const result = database + .prepare( + `UPDATE schedules + SET enabled = ?, updated_at = ? + WHERE id = ?` + ) + .run(enabled ? 1 : 0, now, scheduleId) + if (result.changes !== 1) { + throw new Error('定时任务不存在') + } + database + .prepare( + `UPDATE tasks + SET status = ?, completed_at = NULL, + error = CASE WHEN ? = 1 THEN NULL ELSE error END + WHERE schedule_id = ? + AND status NOT IN ('running', 'waiting_approval')` + ) + .run( + enabled ? 'queued' : 'paused', + enabled ? 1 : 0, + scheduleId + ) + database.exec('COMMIT') + } catch (error) { + database.exec('ROLLBACK') + throw error } } removeSchedule(scheduleId: string): void { - const result = this.requireDatabase() - .prepare('DELETE FROM schedules WHERE id = ?') - .run(scheduleId) - if (result.changes !== 1) { - throw new Error('定时任务不存在') + const database = this.requireDatabase() + const now = new Date().toISOString() + database.exec('BEGIN IMMEDIATE') + try { + const activeRun = database + .prepare( + `SELECT 1 + FROM schedule_runs + WHERE schedule_id = ? AND status = 'running' + LIMIT 1` + ) + .get(scheduleId) + if (activeRun) { + throw new Error('定时任务正在运行,完成后才能删除计划') + } + database + .prepare( + `UPDATE tasks + SET schedule_id = NULL, + status = CASE + WHEN status IN ('running', 'waiting_approval') + THEN status + ELSE 'completed' + END, + completed_at = CASE + WHEN status IN ('running', 'waiting_approval') + THEN completed_at + ELSE ? + END + WHERE schedule_id = ?` + ) + .run(now, scheduleId) + const result = database + .prepare('DELETE FROM schedules WHERE id = ?') + .run(scheduleId) + if (result.changes !== 1) { + throw new Error('定时任务不存在') + } + database.exec('COMMIT') + } catch (error) { + database.exec('ROLLBACK') + throw error } } - claimDueSchedules(now = new Date()): ClaimedSchedule[] { + claimDueSchedules(now = new Date(), limit = 4): ClaimedSchedule[] { const database = this.requireDatabase() const nowIso = now.toISOString() + const safeLimit = Math.max(1, Math.min(16, Math.trunc(limit))) database.exec('BEGIN IMMEDIATE') try { + const claims: ClaimedSchedule[] = [] const pending = database .prepare( - `SELECT sr.id AS run_id, s.* + `SELECT sr.id AS run_id, s.*, t.id AS task_id, + t.conversation_id AS conversation_id FROM schedule_runs sr INNER JOIN schedules s ON s.id = sr.schedule_id + INNER JOIN tasks t ON t.schedule_id = s.id WHERE sr.status = 'pending' + AND ( + s.project_id IS NULL OR EXISTS ( + SELECT 1 FROM projects p + WHERE p.id = s.project_id + AND p.status = 'active' + AND p.kind = 'user' + ) + ) ORDER BY sr.scheduled_for - LIMIT 1` + LIMIT ?` ) - .get() as (ScheduleRow & { run_id: string }) | undefined - if (pending) { - database - .prepare( - `UPDATE schedule_runs - SET status = 'running' - WHERE id = ? AND status = 'pending'` - ) - .run(pending.run_id) - database.exec('COMMIT') - return [{ - schedule: toSchedule(pending), - runId: pending.run_id - }] + .all(safeLimit) as Array< + ScheduleWithTaskRow & { run_id: string } + > + const claimPending = database + .prepare( + `UPDATE schedule_runs + SET status = 'running' + WHERE id = ? AND status = 'pending'` + ) + for (const row of pending) { + if (claimPending.run(row.run_id).changes === 1) { + claims.push({ + schedule: toSchedule(row), + runId: row.run_id + }) + } } - const row = database - .prepare( - `SELECT * FROM schedules - WHERE enabled = 1 AND next_run_at <= ? - ORDER BY next_run_at - LIMIT 1` - ) - .get(nowIso) as ScheduleRow | undefined - if (!row) { - database.exec('COMMIT') - return [] - } - const schedule = toSchedule(row) - const runId = randomUUID() - const inserted = database - .prepare( + const remaining = safeLimit - claims.length + if (remaining > 0) { + const due = database + .prepare( + `SELECT s.*, t.id AS task_id, + t.conversation_id AS conversation_id + FROM schedules s + INNER JOIN tasks t ON t.schedule_id = s.id + WHERE s.enabled = 1 AND s.next_run_at <= ? + AND ( + s.project_id IS NULL OR EXISTS ( + SELECT 1 FROM projects p + WHERE p.id = s.project_id + AND p.status = 'active' + AND p.kind = 'user' + ) + ) + AND NOT EXISTS ( + SELECT 1 + FROM schedule_runs active + WHERE active.schedule_id = s.id + AND active.status IN ('pending', 'running') + ) + ORDER BY s.next_run_at + LIMIT ?` + ) + .all(nowIso, remaining) as ScheduleWithTaskRow[] + const insertRun = database.prepare( `INSERT OR IGNORE INTO schedule_runs - (id, schedule_id, scheduled_for, task_id, status) - VALUES (?, ?, ?, NULL, 'running')` + (id, schedule_id, scheduled_for, task_id, status) + VALUES (?, ?, ?, ?, 'running')` ) - .run(runId, schedule.id, schedule.nextRunAt) - if (inserted.changes !== 1) { - database.exec('COMMIT') - return [] + for (const row of due) { + const schedule = toSchedule(row) + const runId = randomUUID() + if ( + insertRun.run( + runId, + schedule.id, + schedule.nextRunAt, + schedule.taskId + ).changes === 1 + ) { + claims.push({ schedule, runId }) + } + } } database.exec('COMMIT') - return [{ schedule, runId }] + return claims } catch (error) { database.exec('ROLLBACK') throw error @@ -3527,20 +3875,52 @@ export class AssistantDatabase { const schedule = this.getSchedule(scheduleId) const database = this.requireDatabase() const runId = randomUUID() - database - .prepare( - `INSERT INTO schedule_runs - (id, schedule_id, scheduled_for, task_id, status) - VALUES (?, ?, ?, NULL, 'running')` - ) - .run(runId, scheduleId, new Date().toISOString()) + const baseTime = Date.now() + database.exec('BEGIN IMMEDIATE') + try { + const activeRun = database + .prepare( + `SELECT 1 + FROM schedule_runs + WHERE schedule_id = ? + AND status IN ('pending', 'running') + LIMIT 1` + ) + .get(scheduleId) + if (activeRun) { + throw new Error('定时任务已有一次运行正在进行') + } + let inserted = false + for (let offset = 0; offset < 10 && !inserted; offset += 1) { + inserted = + database + .prepare( + `INSERT INTO schedule_runs + (id, schedule_id, scheduled_for, task_id, status) + VALUES (?, ?, ?, ?, 'running') + ON CONFLICT(schedule_id, scheduled_for) DO NOTHING` + ) + .run( + runId, + scheduleId, + new Date(baseTime + offset).toISOString(), + schedule.taskId + ).changes === 1 + } + if (!inserted) { + throw new Error('定时任务正在启动,请稍后重试') + } + database.exec('COMMIT') + } catch (error) { + database.exec('ROLLBACK') + throw error + } return { schedule, runId } } completeScheduleRun( runId: string, status: 'completed' | 'failed', - taskId: string | undefined, now = new Date() ): void { const database = this.requireDatabase() @@ -3549,13 +3929,16 @@ export class AssistantDatabase { try { const row = database .prepare( - `SELECT s.*, sr.scheduled_for + `SELECT s.*, sr.scheduled_for, + t.id AS task_id, + t.conversation_id AS conversation_id FROM schedule_runs sr INNER JOIN schedules s ON s.id = sr.schedule_id + INNER JOIN tasks t ON t.schedule_id = s.id WHERE sr.id = ? AND sr.status = 'running'` ) .get(runId) as - | (ScheduleRow & { scheduled_for: string }) + | (ScheduleWithTaskRow & { scheduled_for: string }) | undefined if (!row) { throw new Error('定时任务运行记录不存在或已完成') @@ -3563,10 +3946,10 @@ export class AssistantDatabase { database .prepare( `UPDATE schedule_runs - SET task_id = ?, status = ? + SET status = ? WHERE id = ?` ) - .run(taskId ?? null, status, runId) + .run(status, runId) const schedule = toSchedule(row) if (row.scheduled_for === row.next_run_at) { const next = new Date(row.scheduled_for) @@ -3588,11 +3971,12 @@ export class AssistantDatabase { database .prepare( `UPDATE schedules - SET enabled = ?, next_run_at = ?, last_run_at = ?, updated_at = ? + SET enabled = CASE WHEN ? = 1 THEN 0 ELSE enabled END, + next_run_at = ?, last_run_at = ?, updated_at = ? WHERE id = ? AND next_run_at = ?` ) .run( - schedule.recurrence === 'once' ? 0 : 1, + schedule.recurrence === 'once' ? 1 : 0, schedule.recurrence === 'once' ? schedule.nextRunAt : next.toISOString(), @@ -3610,6 +3994,49 @@ export class AssistantDatabase { ) .run(nowIso, nowIso, schedule.id) } + const updatedSchedule = database + .prepare('SELECT enabled FROM schedules WHERE id = ?') + .get(schedule.id) as { enabled: number } + const finalTaskStatus = + status === 'failed' + ? 'failed' + : updatedSchedule.enabled === 1 + ? 'queued' + : schedule.recurrence === 'once' + ? 'completed' + : 'paused' + database + .prepare( + `UPDATE tasks + SET status = ?, + completed_at = CASE + WHEN ? IN ('completed', 'failed') THEN ? + ELSE NULL + END + WHERE id = ?` + ) + .run( + finalTaskStatus, + finalTaskStatus, + nowIso, + row.task_id + ) + database + .prepare( + `INSERT INTO task_events + (task_id, run_id, kind, payload_json, created_at) + VALUES (?, NULL, 'status', ?, ?)` + ) + .run( + row.task_id, + JSON.stringify({ + status: + finalTaskStatus === 'queued' + ? 'idle' + : finalTaskStatus + }), + nowIso + ) database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') @@ -3617,27 +4044,6 @@ export class AssistantDatabase { } } - bindScheduleRunTask(scheduleId: string, taskId: string): void { - this.requireDatabase() - .prepare( - `UPDATE schedule_runs - SET task_id = ? - WHERE schedule_id = ? AND status = 'running' AND task_id IS NULL` - ) - .run(taskId, scheduleId) - } - - getScheduleRunTaskId(runId: string): string | undefined { - const row = this.requireDatabase() - .prepare( - `SELECT task_id - FROM schedule_runs - WHERE id = ?` - ) - .get(runId) as { task_id: string | null } | undefined - return row?.task_id ?? undefined - } - private assertHeartbeatProjectIds(projectIds: string[]): void { if (projectIds.length === 0) { return @@ -4744,8 +5150,14 @@ export class AssistantDatabase { private getSchedule(scheduleId: string): AssistantSchedule { const row = this.requireDatabase() - .prepare('SELECT * FROM schedules WHERE id = ?') - .get(scheduleId) as ScheduleRow | undefined + .prepare( + `SELECT s.*, t.id AS task_id, + t.conversation_id AS conversation_id + FROM schedules s + INNER JOIN tasks t ON t.schedule_id = s.id + WHERE s.id = ?` + ) + .get(scheduleId) as ScheduleWithTaskRow | undefined if (!row) { throw new Error('定时任务不存在') } @@ -4940,12 +5352,12 @@ export class AssistantDatabase { const version = database .prepare('PRAGMA user_version') .get() as { user_version: number } - if (version.user_version > 21) { + if (version.user_version > 22) { throw new Error( `当前 GoodBuddy 不支持助理数据库版本 ${version.user_version},请升级应用后重试` ) } - if (version.user_version === 21) { + if (version.user_version === 22) { return } if (version.user_version < 1) { @@ -5926,6 +6338,230 @@ export class AssistantDatabase { throw error } } + if (version.user_version < 22) { + database.exec('BEGIN IMMEDIATE') + try { + const schedules = database + .prepare( + `SELECT id, project_id, task_template_json, enabled, + created_at, updated_at + FROM schedules + ORDER BY created_at ASC, id ASC` + ) + .all() as Array< + Pick< + ScheduleRow, + | 'id' + | 'project_id' + | 'task_template_json' + | 'enabled' + | 'created_at' + | 'updated_at' + > + > + const findTask = database.prepare( + `SELECT id, conversation_id + FROM tasks + WHERE schedule_id = ? + ORDER BY created_at ASC, id ASC + LIMIT 1` + ) + const findConversation = database.prepare( + `SELECT id, runtime_selection_json + FROM conversations + WHERE id = ? + AND project_id IS ? + AND status = 'active' + AND channel IS NULL` + ) + const projectRuntime = database.prepare( + 'SELECT runtime_selection_json FROM projects WHERE id = ?' + ) + const insertConversation = database.prepare( + `INSERT INTO conversations + (id, project_id, runtime_selection_json, work_mode, title, + status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'active', ?, ?)` + ) + const insertTask = database.prepare( + `INSERT INTO tasks + (id, project_id, conversation_id, schedule_id, + parent_task_id, expert_id, routing_mode, title, + instructions, origin, status, progress, created_at, + started_at, completed_at, error) + VALUES (?, ?, ?, ?, NULL, NULL, NULL, ?, ?, + 'schedule', ?, NULL, ?, NULL, NULL, NULL)` + ) + const updateTaskConversation = database.prepare( + `UPDATE tasks + SET conversation_id = ?, project_id = ?, + parent_task_id = NULL, title = ?, instructions = ?, + origin = 'schedule', status = ?, work_mode = ?, + completed_at = NULL, error = NULL, visible = 1 + WHERE id = ?` + ) + const attachHistoricalTasks = database.prepare( + `UPDATE tasks + SET parent_task_id = ?, visible = 0 + WHERE id IN ( + SELECT task_id + FROM schedule_runs + WHERE schedule_id = ? AND task_id IS NOT NULL + ) + AND id != ? + AND parent_task_id IS NULL + AND schedule_id IS NULL` + ) + const bindUnclaimedRuns = database.prepare( + `UPDATE schedule_runs + SET task_id = ? + WHERE schedule_id = ? AND task_id IS NULL` + ) + const freezeScheduleRuntime = database.prepare( + `UPDATE schedules + SET task_template_json = ? + WHERE id = ?` + ) + + for (const schedule of schedules) { + let template: { + title?: unknown + prompt?: unknown + workMode?: LegacyWorkMode + } = {} + try { + template = JSON.parse( + schedule.task_template_json + ) as typeof template + } catch { + // Keep malformed legacy schedules migratable. Normal reads will + // continue to reject an invalid template if it is edited or run. + } + const title = + typeof template.title === 'string' && + template.title.trim() + ? template.title.trim().slice(0, 120) + : '定时任务' + const prompt = + typeof template.prompt === 'string' && + template.prompt.trim() + ? template.prompt.trim().slice(0, 100_000) + : title + const workMode = normalizeInteractiveWorkMode( + template.workMode + ) + const existing = findTask.get(schedule.id) as + | { id: string; conversation_id: string | null } + | undefined + const existingConversation = existing?.conversation_id + ? (findConversation.get( + existing.conversation_id, + schedule.project_id + ) as + | { + id: string + runtime_selection_json: string | null + } + | undefined) + : undefined + const conversationId = existingConversation + ? existing!.conversation_id! + : randomUUID() + let runtimeSelectionJson = + existingConversation?.runtime_selection_json ?? null + if (!existingConversation) { + runtimeSelectionJson = schedule.project_id + ? ( + projectRuntime.get(schedule.project_id) as + | { + runtime_selection_json: string | null + } + | undefined + )?.runtime_selection_json ?? null + : null + insertConversation.run( + conversationId, + schedule.project_id, + runtimeSelectionJson, + workMode, + title, + schedule.created_at, + schedule.updated_at + ) + } + const taskId = existing?.id ?? randomUUID() + if (existing) { + updateTaskConversation.run( + conversationId, + schedule.project_id, + title, + prompt, + schedule.enabled === 1 ? 'queued' : 'paused', + workMode, + taskId + ) + } else { + insertTask.run( + taskId, + schedule.project_id, + conversationId, + schedule.id, + title, + prompt, + schedule.enabled === 1 ? 'queued' : 'paused', + schedule.created_at + ) + } + freezeScheduleRuntime.run( + JSON.stringify({ + title, + prompt, + workMode, + runtimeSelection: + parseRuntimeSelection(runtimeSelectionJson) + }), + schedule.id + ) + attachHistoricalTasks.run(taskId, schedule.id, taskId) + bindUnclaimedRuns.run(taskId, schedule.id) + } + + database.exec(` + UPDATE tasks AS duplicate + SET parent_task_id = ( + SELECT stable.id + FROM tasks stable + WHERE stable.schedule_id = duplicate.schedule_id + ORDER BY stable.created_at ASC, stable.id ASC + LIMIT 1 + ), + schedule_id = NULL, + visible = 0 + WHERE duplicate.schedule_id IS NOT NULL + AND duplicate.id <> ( + SELECT stable.id + FROM tasks stable + WHERE stable.schedule_id = duplicate.schedule_id + ORDER BY stable.created_at ASC, stable.id ASC + LIMIT 1 + ); + UPDATE tasks + SET visible = 0 + WHERE schedule_id IS NULL + AND origin IN ('user', 'schedule', 'delegation', 'subagent'); + CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_schedule + ON tasks(schedule_id) + WHERE schedule_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_schedule_runs_status + ON schedule_runs(status, scheduled_for); + PRAGMA user_version = 22; + COMMIT; + `) + } catch (error) { + database.exec('ROLLBACK') + throw error + } + } } private requireDatabase(): DatabaseSync { diff --git a/src/main/assistant/heartbeat-database.test.ts b/src/main/assistant/heartbeat-database.test.ts index 1f9bacc..34e000f 100644 --- a/src/main/assistant/heartbeat-database.test.ts +++ b/src/main/assistant/heartbeat-database.test.ts @@ -102,7 +102,7 @@ describe('AssistantDatabase heartbeat persistence', () => { ).count check.close() migrated.close() - expect(version).toBe(21) + expect(version).toBe(22) expect(heartbeatTableCount).toBe(4) }) diff --git a/src/main/assistant/subagent-service.ts b/src/main/assistant/subagent-service.ts index ccc6857..c5b3453 100644 --- a/src/main/assistant/subagent-service.ts +++ b/src/main/assistant/subagent-service.ts @@ -165,7 +165,8 @@ export class SubagentService { instructions: input.parentRequest.prompt, workMode: 'ask', origin: 'subagent', - status: 'queued' + status: 'queued', + visible: false }) this.emit(input, { childTaskId, diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index f581502..8c5c3ad 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -2668,7 +2668,14 @@ describe('registerIpcHandlers agent terminal state', () => { updatedAt: Date.now(), messages: [] })), + appendConversationMessage: vi.fn(), appendRemoteConversationMessage: vi.fn(), + completeScheduleRun: vi.fn(), + claimScheduleNow: vi.fn(), + listSchedules: vi.fn(() => []), + createSchedule: vi.fn(), + setScheduleEnabled: vi.fn(), + removeSchedule: vi.fn(), upsertModelUsageCall: vi.fn(), clearAssistantData: vi.fn(), listExperts: vi.fn<() => Array>>(() => []), @@ -2799,6 +2806,140 @@ describe('registerIpcHandlers agent terminal state', () => { senderFrame: webContents.mainFrame }) + it('defaults custom scheduled Tasks to Execute', async () => { + const harness = createHarness({ + runtimeId: 'continue', + capability: 'chat', + supportsToolExecution: true, + async *run() { + yield { type: 'done', requestId: 'unused' } as const + } + }) + const conversationId = + '00000000-0000-4000-8000-000000000705' + const createInput = { + projectId: '00000000-0000-4000-8000-000000000401', + conversationId, + title: '每周汇总', + prompt: '汇总本周进展', + recurrence: 'weekly' as const, + nextRunAt: '2026-08-21T09:00:00.000Z' + } + + await electronMocks.handlers.get( + ipcChannels.schedulesCreate + )?.(trustedEvent(harness.webContents), createInput) + + expect( + harness.assistantDatabase.createSchedule + ).toHaveBeenCalledWith({ + ...createInput, + workMode: 'execute' + }) + await harness.dispose() + }) + + it('reuses a scheduled Task and writes text results to its Conversation', async () => { + const taskId = '00000000-0000-4000-8000-000000000701' + const conversationId = + '00000000-0000-4000-8000-000000000702' + const scheduleId = + '00000000-0000-4000-8000-000000000703' + const runId = '00000000-0000-4000-8000-000000000704' + const runtime = { + runtimeId: 'model', + capability: 'chat', + supportsToolExecution: true, + async *run( + request: { requestId: string }, + _signal: AbortSignal, + authorize: ( + input: { + scopeKey: string + title: string + description: string + } + ) => Promise + ) { + expect(request.requestId).toBe(runId) + await authorize({ + scopeKey: 'workspace.write', + title: '写入工作区', + description: '更新状态文件' + }) + yield { + requestId: request.requestId, + type: 'text', + delta: '每日状态正常' + } as const + yield { + requestId: request.requestId, + type: 'done' + } as const + } + } + const harness = createHarness(runtime) + const schedule = { + id: scheduleId, + projectId: '00000000-0000-4000-8000-000000000401', + taskId, + conversationId, + title: '每日状态', + prompt: '汇总状态', + workMode: 'execute' as const, + recurrence: 'daily' as const, + nextRunAt: '2026-08-20T00:00:00.000Z', + enabled: true, + createdAt: '2026-08-19T00:00:00.000Z', + updatedAt: '2026-08-19T00:00:00.000Z' + } + harness.assistantDatabase.claimScheduleNow.mockReturnValue({ + schedule, + runId + }) + harness.approvalBroker.request.mockResolvedValue('once') + + await electronMocks.handlers.get( + ipcChannels.schedulesRunNow + )?.(trustedEvent(harness.webContents), scheduleId) + + await vi.waitFor(() => + expect( + harness.assistantDatabase.completeScheduleRun + ).toHaveBeenCalledWith(runId, 'completed') + ) + expect( + harness.assistantDatabase.updateTaskStatus + ).toHaveBeenCalledWith(taskId, 'running') + expect(harness.approvalBroker.request).toHaveBeenCalledWith( + expect.objectContaining({ + requestId: taskId, + conversationId + }), + expect.any(AbortSignal), + expect.any(Function) + ) + expect( + harness.assistantDatabase.appendConversationMessage + ).toHaveBeenCalledWith({ + conversationId, + role: 'assistant', + content: '每日状态正常', + status: '定时任务', + task: { + id: taskId, + title: '每日状态' + } + }) + expect( + harness.assistantDatabase.createTextArtifact + ).not.toHaveBeenCalled() + expect(harness.webContents.send).toHaveBeenCalledWith( + ipcChannels.conversationsChanged + ) + await harness.dispose() + }) + it('publishes Runtime usage as context metrics with one settings read', async () => { const runtime = { runtimeId: 'continue', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index f361f6d..b6304b7 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -156,8 +156,7 @@ import { expertCreateSchema, type AssistantSchedule, type AssistantArtifact, - type ConversationAttachment, - type WorkMode + type ConversationAttachment } from '../shared/assistant-contracts' import { CHANNEL_LIMITS, @@ -1158,7 +1157,8 @@ export function registerIpcHandlers( title: '智能心跳回顾', instructions: '根据有界本地输入生成智能心跳报告', workMode: 'ask', - origin: 'assistant' + origin: 'assistant', + visible: false }) let output = '' let completed = false @@ -1242,28 +1242,52 @@ export function registerIpcHandlers( ) } } - - const executeSchedule = async ( - schedule: Omit & { - workMode: WorkMode - }, - origin: 'schedule' | 'delegation' | 'channel' = 'schedule', - externalSignal?: AbortSignal, - remoteContext?: { - channel: keyof typeof projectChannelLabels - channelLabel: string - senderDisplay: string - projectId: string - projectName: string - rootPath: string - conversationId: string - runtimeSelection: AgentRuntimeSelection - followConfiguredAgentRuntime?: boolean - runtime?: AgentRuntime - taskId?: string - contextIds?: string[] - resultFileRequested?: boolean + const publishConversationChange = (): void => { + if (!window.isDestroyed()) { + window.webContents.send(ipcChannels.conversationsChanged) } + } + + type ExecutionTemplate = Omit< + AssistantSchedule, + 'taskId' | 'conversationId' + > + type ChannelExecutionContext = { + channel: keyof typeof projectChannelLabels + channelLabel: string + senderDisplay: string + projectId: string + projectName: string + rootPath: string + conversationId: string + runtimeSelection: AgentRuntimeSelection + followConfiguredAgentRuntime?: boolean + runtime?: AgentRuntime + taskId: string + contextIds?: string[] + resultFileRequested?: boolean + } + type TaskWorkExecution = + | { + origin: 'schedule' + schedule: AssistantSchedule + scheduleRunId: string + externalSignal?: AbortSignal + } + | { + origin: 'delegation' + schedule: ExecutionTemplate + externalSignal?: AbortSignal + } + | { + origin: 'channel' + schedule: ExecutionTemplate + externalSignal: AbortSignal + remoteContext: ChannelExecutionContext + } + + const executeTaskWork = async ( + input: TaskWorkExecution ): Promise<{ status: 'completed' | 'failed' output?: string @@ -1271,13 +1295,24 @@ export function registerIpcHandlers( attachments?: ChannelMediaAttachment[] artifactIds?: string[] }> => { + const { origin, schedule } = input + const externalSignal = input.externalSignal + const remoteContext = + input.origin === 'channel' ? input.remoteContext : undefined if (shuttingDown || executionPaused) { return { status: 'failed', error: '应用正在退出' } } if (externalSignal?.aborted) { return { status: 'failed', error: '请求已取消' } } - const requestId = remoteContext?.taskId ?? randomUUID() + const taskId = + input.origin === 'schedule' + ? input.schedule.taskId + : input.origin === 'channel' + ? input.remoteContext.taskId + : randomUUID() + const requestId = + input.origin === 'schedule' ? input.scheduleRunId : taskId const controller = new AbortController() const abortFromExternal = (): void => { controller.abort(externalSignal?.reason) @@ -1287,22 +1322,27 @@ export function registerIpcHandlers( }) activeRequests.set(requestId, controller) const runtimeConversationId = - remoteContext?.conversationId ?? `${origin}:${schedule.id}` - if (remoteContext?.taskId) { - assistantDatabase.updateTaskStatus(requestId, 'running') + remoteContext?.conversationId ?? + (input.origin === 'schedule' + ? input.schedule.conversationId + : undefined) ?? + `${origin}:${schedule.id}` + if (input.origin !== 'delegation') { + assistantDatabase.updateTaskStatus(taskId, 'running') } else { assistantDatabase.createTask({ - id: requestId, + id: taskId, projectId: schedule.projectId, conversationId: runtimeConversationId, title: schedule.title, instructions: schedule.prompt, workMode: schedule.workMode, - origin: origin === 'channel' ? 'delegation' : origin + origin: 'delegation', + visible: false }) } if (origin === 'schedule') { - assistantDatabase.bindScheduleRunTask(schedule.id, requestId) + publishConversationChange() } let output = '' let completed = false @@ -1313,7 +1353,7 @@ export function registerIpcHandlers( onError: (error) => controller.abort(error), onEvent: (event) => { assistantDatabase.appendTaskEvent( - requestId, + taskId, event.type, event ) @@ -1324,7 +1364,9 @@ export function registerIpcHandlers( remoteContext?.runtime ?? (await resolveRequestRuntime({ projectId: schedule.projectId, - runtimeSelection: remoteContext?.runtimeSelection, + runtimeSelection: + remoteContext?.runtimeSelection ?? + schedule.runtimeSelection, workspaceOverride: remoteContext?.rootPath, followConfiguredAgentRuntime: remoteContext?.followConfiguredAgentRuntime @@ -1389,9 +1431,12 @@ export function registerIpcHandlers( return channelToolPolicy === 'policy' ? 'deny' : 'once' } assistantDatabase.updateTaskStatus( - requestId, + taskId, 'waiting_approval' ) + if (origin === 'schedule') { + publishConversationChange() + } const settings = await settingsStore.getPolicySettings() try { return await approvalBroker.request( @@ -1401,7 +1446,8 @@ export function registerIpcHandlers( settings.toolApproval === 'policy' ? 'policy' : undefined, - requestId, + requestId: + origin === 'schedule' ? taskId : requestId, conversationId: runtimeConversationId }, controller.signal, @@ -1417,7 +1463,10 @@ export function registerIpcHandlers( ) } finally { if (!controller.signal.aborted) { - assistantDatabase.updateTaskStatus(requestId, 'running') + assistantDatabase.updateTaskStatus(taskId, 'running') + if (origin === 'schedule') { + publishConversationChange() + } } } } @@ -1445,14 +1494,21 @@ export function registerIpcHandlers( agentRuntimeSelected ? undefined : authorize )) { if (agentEvent.type === 'model-usage') { - persistModelUsage(agentEvent) + persistModelUsage({ + ...agentEvent, + requestId: taskId, + callId: + origin === 'schedule' + ? `${requestId}:${agentEvent.callId}` + : agentEvent.callId + }) continue } const taskEvent = agentEvent.type === 'generated-image' ? persistGeneratedImage(agentEvent, { projectId: schedule.projectId, - taskId: requestId, + taskId, title: schedule.title }) : agentEvent @@ -1553,15 +1609,35 @@ export function registerIpcHandlers( }) } } - if (origin !== 'channel' && output.trim()) { + if ( + input.origin === 'schedule' && + (output.trim() || artifactIds.length > 0) + ) { + assistantDatabase.appendConversationMessage({ + conversationId: input.schedule.conversationId, + role: 'assistant', + content: + output.trim() || + '任务已完成,独立成果已保存到成果工作栏。', + status: '定时任务', + ...(artifactIds.length > 0 ? { artifactIds } : {}), + task: { + id: taskId, + title: schedule.title + } + }) + publishConversationChange() + } else if (origin === 'delegation' && output.trim()) { assistantDatabase.createTextArtifact({ projectId: schedule.projectId, - taskId: requestId, + taskId, title: schedule.title, content: output }) } - assistantDatabase.updateTaskStatus(requestId, 'completed') + if (origin !== 'schedule') { + assistantDatabase.updateTaskStatus(taskId, 'completed') + } showDesktopNotificationWhenUnfocused(window, { title: origin === 'channel' @@ -1571,7 +1647,7 @@ export function registerIpcHandlers( origin === 'channel' ? '结果已回复,并保存到远程通道会话。' : origin === 'schedule' - ? '结果已保存到 GoodBuddy 成果工作栏。' + ? '结果已写入关联对话。' : '结果已保存到成果工作栏和委派记录。' }) return { @@ -1586,10 +1662,24 @@ export function registerIpcHandlers( eventBuffer.flush() const message = safeRuntimeError(error, '定时任务执行失败') assistantDatabase.updateTaskStatus( - requestId, + taskId, controller.signal.aborted ? 'cancelled' : 'failed', message ) + if (input.origin === 'schedule') { + assistantDatabase.appendConversationMessage({ + conversationId: input.schedule.conversationId, + role: 'assistant', + content: message, + state: 'error', + status: '定时任务失败', + task: { + id: taskId, + title: schedule.title + } + }) + publishConversationChange() + } showDesktopNotificationWhenUnfocused(window, { title: origin === 'channel' @@ -1720,34 +1810,71 @@ export function registerIpcHandlers( yield { requestId: request.requestId, type: 'done' } } - let scheduleTickRunning = false - const runDueSchedules = async (): Promise => { - if (scheduleTickRunning || shuttingDown || executionPaused) { + const maximumConcurrentScheduleRuns = 4 + let scheduleClaimRunning = false + let activeScheduleRuns = 0 + const launchDueSchedules = (): void => { + if ( + scheduleClaimRunning || + shuttingDown || + executionPaused || + activeScheduleRuns >= maximumConcurrentScheduleRuns + ) { return } - scheduleTickRunning = true + scheduleClaimRunning = true try { - for (const claim of assistantDatabase.claimDueSchedules()) { - const result = await trackExecution( - executeSchedule(claim.schedule) - ) - assistantDatabase.completeScheduleRun( - claim.runId, - result.status, - assistantDatabase.getScheduleRunTaskId(claim.runId) - ) - } - if (!shuttingDown && !executionPaused) { - await trackExecution(heartbeatService.processDue()) + const claims = assistantDatabase.claimDueSchedules( + new Date(), + maximumConcurrentScheduleRuns - activeScheduleRuns + ) + activeScheduleRuns += claims.length + for (const claim of claims) { + const execution = (async () => { + const result = await executeTaskWork({ + origin: 'schedule', + schedule: claim.schedule, + scheduleRunId: claim.runId + }) + assistantDatabase.completeScheduleRun( + claim.runId, + result.status + ) + publishConversationChange() + })() + void trackExecution(execution) + .catch(() => undefined) + .finally(() => { + activeScheduleRuns -= 1 + launchDueSchedules() + }) } } finally { - scheduleTickRunning = false + scheduleClaimRunning = false } } - const scheduleInterval = setInterval(() => { - void trackExecution(runDueSchedules()).catch(() => undefined) - }, 30_000) - void trackExecution(runDueSchedules()).catch(() => undefined) + let heartbeatTickRunning = false + const runDueHeartbeats = async (): Promise => { + if ( + heartbeatTickRunning || + shuttingDown || + executionPaused + ) { + return + } + heartbeatTickRunning = true + try { + await heartbeatService.processDue() + } finally { + heartbeatTickRunning = false + } + } + const runDueWork = (): void => { + launchDueSchedules() + void trackExecution(runDueHeartbeats()).catch(() => undefined) + } + const scheduleInterval = setInterval(runDueWork, 30_000) + runDueWork() const delegationEndpoint = process.env.GOODBUDDY_DELEGATION_ENDPOINT?.trim() const delegationToken = @@ -1768,18 +1895,23 @@ export function registerIpcHandlers( assistantDatabase.markDelegationDelivered(taskId) }, onTask: (task) => - trackExecution(executeSchedule({ - id: task.id, - projectId: task.projectId, - title: task.title, - prompt: task.prompt, - workMode: task.workMode, - recurrence: 'once', - nextRunAt: new Date().toISOString(), - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - }, 'delegation')) + trackExecution( + executeTaskWork({ + origin: 'delegation', + schedule: { + id: task.id, + projectId: task.projectId, + title: task.title, + prompt: task.prompt, + workMode: task.workMode, + recurrence: 'once', + nextRunAt: new Date().toISOString(), + enabled: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + } + }) + ) }) : undefined remoteDelegation?.start() @@ -1927,7 +2059,8 @@ export function registerIpcHandlers( title: `${channelLabel}远程请求`, instructions: executionPrompt, workMode: parsed.workMode, - origin: 'delegation' + origin: 'delegation', + visible: false }) publishRemoteActivity({ requestId: remoteTaskId, @@ -2021,8 +2154,9 @@ export function registerIpcHandlers( const now = new Date().toISOString() const result = await trackExecution( - executeSchedule( - { + executeTaskWork({ + origin: 'channel', + schedule: { id: randomUUID(), projectId: project.id, title: `${channelLabel}远程请求`, @@ -2034,9 +2168,8 @@ export function registerIpcHandlers( createdAt: now, updatedAt: now }, - 'channel', - signal, - { + externalSignal: signal, + remoteContext: { channel, channelLabel, senderDisplay, @@ -2053,7 +2186,7 @@ export function registerIpcHandlers( message.text ) } - ) + }) ) const responseText = result.output?.trim() || @@ -2408,7 +2541,8 @@ export function registerIpcHandlers( conversationId: request.conversationId, title: parsedRequest.prompt.slice(0, 120), instructions: parsedRequest.prompt, - workMode: request.workMode ?? 'ask' + workMode: request.workMode ?? 'ask', + visible: false }) } catch (error) { knowledgeGateway?.revoke(knowledgeCapabilityToken) @@ -4255,12 +4389,14 @@ export function registerIpcHandlers( value.scheduleId, value.enabled ) + publishConversationChange() } ) registerHandler(ipcChannels.schedulesRemove, (event, input: unknown) => { assertTrustedSender(event, window) assistantDatabase.removeSchedule(assistantIdSchema.parse(input)) + publishConversationChange() }) registerHandler(ipcChannels.schedulesRunNow, (event, input: unknown) => { @@ -4271,15 +4407,19 @@ export function registerIpcHandlers( const claim = assistantDatabase.claimScheduleNow( assistantIdSchema.parse(input) ) - void trackExecution(executeSchedule(claim.schedule)) - .then((result) => { - assistantDatabase.completeScheduleRun( - claim.runId, - result.status, - assistantDatabase.getScheduleRunTaskId(claim.runId) - ) + const execution = (async () => { + const result = await executeTaskWork({ + origin: 'schedule', + schedule: claim.schedule, + scheduleRunId: claim.runId }) - .catch(() => undefined) + assistantDatabase.completeScheduleRun( + claim.runId, + result.status + ) + publishConversationChange() + })() + void trackExecution(execution).catch(() => undefined) }) registerHandler(ipcChannels.heartbeatsList, (event, input: unknown) => { diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 4c53cd4..5970ff2 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -15,7 +15,12 @@ import type { DesktopApi } from '../../shared/contracts' import type { ApplicationSettings } from '../../shared/application-settings-contracts' -import type { AssistantProject } from '../../shared/assistant-contracts' +import type { + AssistantProject, + AssistantSchedule, + AssistantTask, + ConversationSnapshot +} from '../../shared/assistant-contracts' import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts' const speechRecognitionMocks = vi.hoisted(() => ({ @@ -397,6 +402,8 @@ const api: DesktopApi = { create: vi.fn(async (input) => ({ ...input, id: crypto.randomUUID(), + taskId: crypto.randomUUID(), + conversationId: input.conversationId ?? crypto.randomUUID(), enabled: true, createdAt: '2026-07-31T00:00:00.000Z', updatedAt: '2026-07-31T00:00:00.000Z' @@ -778,6 +785,8 @@ describe('App', () => { vi.mocked(api.conversations.onChanged) .mockReset() .mockReturnValue(() => undefined) + vi.mocked(api.tasks.list).mockReset().mockResolvedValue([]) + vi.mocked(api.schedules.list).mockReset().mockResolvedValue([]) api.channels = undefined newConversationListener = undefined beforeQuitListener = undefined @@ -899,6 +908,130 @@ describe('App', () => { ).not.toBeInTheDocument() }) + it('discovers product Tasks through their Conversation without exposing Runs', async () => { + const conversationId = + '00000000-0000-4000-8000-000000000821' + const taskId = '00000000-0000-4000-8000-000000000822' + const scheduleId = + '00000000-0000-4000-8000-000000000823' + const conversation: ConversationSnapshot = { + id: conversationId, + projectId, + title: '产品发布讨论', + updatedAt: Date.now(), + messages: [] + } + const task: AssistantTask = { + id: taskId, + projectId, + conversationId, + scheduleId, + title: '每周发布总结', + instructions: '总结本周发布进度', + origin: 'schedule', + status: 'idle', + createdAt: '2026-08-19T00:00:00.000Z' + } + const schedule: AssistantSchedule = { + id: scheduleId, + projectId, + taskId, + conversationId, + title: task.title, + prompt: task.instructions, + workMode: 'execute', + recurrence: 'weekly', + nextRunAt: '2026-08-21T09:00:00.000Z', + enabled: true, + createdAt: task.createdAt, + updatedAt: task.createdAt + } + vi.mocked(api.conversations.list).mockResolvedValue([conversation]) + vi.mocked(api.tasks.list).mockResolvedValue([task]) + vi.mocked(api.schedules.list).mockResolvedValue([schedule]) + + render() + + const toggle = await screen.findByLabelText( + '展开或折叠“产品发布讨论”中的 1 个任务' + ) + const conversationButton = + toggle.parentElement?.querySelector('.conversation-item') + expect(toggle.nextElementSibling).toBe(conversationButton) + expect( + conversationButton?.querySelector('.conversation-item__title') + ).toHaveAttribute('title', '产品发布讨论') + expect(screen.queryByText('任务 1')).not.toBeInTheDocument() + fireEvent.click(toggle) + const taskTitle = await screen.findByText('每周发布总结', { + selector: '.conversation-task-child__title' + }) + const taskChild = taskTitle.closest('button') + expect(taskChild).not.toBeNull() + expect( + taskChild?.querySelector('.conversation-task-child__icon') + ).toHaveClass('conversation-task-child__icon--idle') + expect( + taskChild?.querySelector('.conversation-task-child__meta') + ).toHaveTextContent('Execute · 每周 · 空闲') + expect(screen.queryByText('Run')).not.toBeInTheDocument() + fireEvent.click(taskChild!) + const taskRegion = await screen.findByRole('region', { + name: '当前会话的任务' + }) + expect(within(taskRegion).getByText('Execute')).toBeInTheDocument() + }) + + it('routes scheduled Task approvals to the associated Conversation', async () => { + const conversationId = + '00000000-0000-4000-8000-000000000831' + const taskId = '00000000-0000-4000-8000-000000000832' + vi.mocked(api.conversations.list).mockResolvedValue([ + { + id: conversationId, + projectId, + title: '发布审批会话', + updatedAt: Date.now(), + messages: [] + } + ]) + vi.mocked(api.tasks.list).mockResolvedValue([ + { + id: taskId, + projectId, + conversationId, + scheduleId: '00000000-0000-4000-8000-000000000833', + title: '发布任务', + instructions: '更新发布文件', + origin: 'schedule', + status: 'running', + createdAt: '2026-08-19T00:00:00.000Z' + } + ]) + render() + await screen.findAllByText('发布审批会话') + await waitFor(() => expect(api.tasks.list).toHaveBeenCalled()) + + act(() => { + agentListener?.({ + requestId: taskId, + type: 'approval', + approvalId: '00000000-0000-4000-8000-000000000834', + title: '请求写入工作区', + description: '更新发布文件', + toolName: 'write_file', + argumentSummary: 'release.md', + allowPermanent: false + }) + }) + + expect(await screen.findAllByText('请求写入工作区')) + .not.toHaveLength(0) + expect(screen.getByText('仅此次')).toBeInTheDocument() + expect(screen.getByLabelText('任务结果:发布任务')) + .toBeInTheDocument() + }) + it('schedules lazy workspace routes for idle preloading', () => { render() @@ -928,7 +1061,7 @@ describe('App', () => { expect(api.memory.list).toHaveBeenCalledWith(projectId) expect(api.memory.list).toHaveBeenCalledWith() expect(api.schedules.list).toHaveBeenCalledOnce() - expect(api.schedules.list).toHaveBeenCalledWith(projectId) + expect(api.schedules.list).toHaveBeenCalledWith() expect(api.heartbeats.list).toHaveBeenCalledOnce() }) }) @@ -6737,7 +6870,9 @@ describe('App', () => { expect( screen.getByRole('tab', { name: '任务中心' }) ).toHaveAttribute('aria-selected', 'true') - expect(screen.getByText('自动化')).toBeInTheDocument() + expect( + screen.getByRole('heading', { name: '任务索引' }) + ).toBeInTheDocument() expect(screen.queryByText('最近任务')).not.toBeInTheDocument() fireEvent.click(screen.getByRole('tab', { name: '上下文' })) expect( diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 1fddd6a..13c7744 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -4,6 +4,7 @@ import { Check, CheckCircle2, ChevronDown, + ChevronRight, CircleAlert, CircleHelp, Copy, @@ -13,6 +14,7 @@ import { HeartPulse, Info, Library, + ListTodo, LoaderCircle, Maximize2, MessageSquarePlus, @@ -158,6 +160,13 @@ import { type PendingSidebarApproval, type SidebarArtifact } from './RightAssistantSidebar' +import { + CustomTaskDialog, + type CustomTaskDestination +} from './CustomTaskDialog' +import { ConversationTaskStrip } from './ConversationTaskStrip' +import { OverflowMarquee } from './OverflowMarquee' +import { findTaskSchedule } from './TaskScheduleActions' import type { SettingsCategoryId } from './settings-categories' import goodbuddyDarkIcon from './assets/goodbuddy-dark.png' import goodbuddyLightIcon from './assets/goodbuddy-light.png' @@ -665,6 +674,7 @@ function ChatHistoryPane({ onVisibleMessageCountChange, quickActions, scrollSnapshot, + taskStrip, visibleMessageCount }: { active: boolean @@ -703,6 +713,7 @@ function ChatHistoryPane({ ) => void quickActions: ChatQuickAction[] scrollSnapshot?: ChatScrollSnapshot + taskStrip?: ReactNode visibleMessageCount: number }): React.JSX.Element { const { t } = useTranslation('app') @@ -911,6 +922,7 @@ function ChatHistoryPane({ hidden={!active} inert={!active} > + {taskStrip}
typeof artifactId === 'string' ))) && + (entry.task === undefined || + (typeof entry.task === 'object' && + entry.task !== null && + typeof (entry.task as Record).id === + 'string' && + typeof (entry.task as Record).title === + 'string')) && (entry.attachments === undefined || (Array.isArray(entry.attachments) && entry.attachments.length <= 8 && @@ -1180,6 +1199,7 @@ function toConversationMessage(message: Message): ConversationMessage { sourceReferences: message.sourceReferences, knowledgeRetrieval: message.knowledgeRetrieval, artifactIds: message.artifactIds, + task: message.task, attachments: message.attachments } } @@ -1256,6 +1276,55 @@ function mergeArtifacts( ) } +function mergePersistedConversations( + current: readonly Conversation[], + incoming: readonly ConversationSnapshot[], + persistedLocal: Map +): Conversation[] { + const incomingById = new Map( + incoming.map((conversation) => [conversation.id, conversation]) + ) + const currentById = new Map( + current.map((conversation) => [conversation.id, conversation]) + ) + const merged = incoming.map((conversation): Conversation => { + if (conversation.remote) { + return conversation + } + const local = currentById.get(conversation.id) + if (!local || local.remote) { + persistedLocal.set(conversation.id, conversation) + return conversation + } + const localMessageById = new Map( + local.messages.map((message) => [message.id, message]) + ) + const serverMessageIds = new Set( + conversation.messages.map((message) => message.id) + ) + const messages = [ + ...conversation.messages.map( + (message) => localMessageById.get(message.id) ?? message + ), + ...local.messages.filter( + (message) => !serverMessageIds.has(message.id) + ) + ].slice(-500) + const next = + local.updatedAt > conversation.updatedAt + ? { ...local, messages } + : { ...conversation, messages } + persistedLocal.set(conversation.id, conversation) + return next + }) + for (const conversation of current) { + if (!incomingById.has(conversation.id)) { + merged.push(conversation) + } + } + return merged.sort((left, right) => right.updatedAt - left.updatedAt) +} + function getProjectDefaultRuntimeSelection( project: AssistantProject | undefined, settings: RuntimeSettings @@ -1722,6 +1791,7 @@ function ComposerMenuSelect({ function App(): React.JSX.Element { const { i18n, t } = useTranslation('app') + const { t: tWorkspace } = useTranslation('workspace') const tRef = useRef(t) useEffect(() => { tRef.current = t @@ -1758,6 +1828,7 @@ function App(): React.JSX.Element { const [projects, setProjects] = useState([]) const projectsRef = useRef(projects) const [assistantTasks, setAssistantTasks] = useState([]) + const assistantTasksRef = useRef(assistantTasks) const [tokenUsage, setTokenUsage] = useState(emptyTokenUsage) const [workspaceChanges, setWorkspaceChanges] = @@ -1778,6 +1849,13 @@ function App(): React.JSX.Element { const [assistantSchedules, setAssistantSchedules] = useState< AssistantSchedule[] >([]) + const [selectedAssistantTaskId, setSelectedAssistantTaskId] = + useState() + const [expandedTaskConversationIds, setExpandedTaskConversationIds] = + useState>(() => new Set()) + const [customTaskDialog, setCustomTaskDialog] = useState<{ + defaultDestination: CustomTaskDestination + }>() const [assistantHeartbeats, setAssistantHeartbeats] = useState< AssistantHeartbeatConfig[] >([]) @@ -2344,6 +2422,10 @@ function App(): React.JSX.Element { projectsRef.current = projects }, [projects]) + useEffect(() => { + assistantTasksRef.current = assistantTasks + }, [assistantTasks]) + useEffect(() => { resizeComposerTextarea(inputRef.current) }, [input]) @@ -2977,6 +3059,48 @@ function App(): React.JSX.Element { deferredSearchQuery, searchConversationSnapshot ]) + const productAssistantTasks = useMemo( + () => + assistantTasks.filter( + (task) => !task.parentTaskId && task.origin === 'schedule' + ), + [assistantTasks] + ) + const tasksByConversation = useMemo(() => { + const grouped = new Map() + for (const task of productAssistantTasks) { + if (!task.conversationId) { + continue + } + const existing = grouped.get(task.conversationId) ?? [] + existing.push(task) + grouped.set(task.conversationId, existing) + } + for (const tasks of grouped.values()) { + tasks.sort((left, right) => + right.createdAt.localeCompare(left.createdAt) + ) + } + return grouped + }, [productAssistantTasks]) + const conversationTitles = useMemo( + () => + new Map( + conversations.map((conversation) => [ + conversation.id, + getConversationDisplayTitle( + conversation, + t('conversation.defaultTitle') + ) + ]) + ), + [conversations, t] + ) + const projectNames = useMemo( + () => + new Map(projects.map((project) => [project.id, project.name])), + [projects] + ) const pendingSidebarApprovals = useMemo( () => conversations.flatMap((conversation) => @@ -3310,6 +3434,94 @@ function App(): React.JSX.Element { (event: AgentEvent): void => { const run = activeRuns.current.get(event.requestId) if (!run) { + if (event.type !== 'approval') { + return + } + const attachScheduledApproval = ( + task: AssistantTask | undefined + ): void => { + if ( + !task?.conversationId || + task.origin !== 'schedule' + ) { + return + } + setAssistantTasks((current) => + current.map((candidate) => + candidate.id === task.id + ? { ...candidate, status: 'waiting_approval' } + : candidate + ) + ) + if ( + activeConversationIdRef.current !== task.conversationId + ) { + setUnreadConversationIds((current) => { + const next = new Set(current) + next.add(task.conversationId!) + return next + }) + } + setConversations((current) => + current.map((conversation) => { + if (conversation.id !== task.conversationId) { + return conversation + } + const existing = conversation.messages.find( + (message) => + message.approval?.id === event.approvalId + ) + if (existing) { + return conversation + } + return { + ...conversation, + updatedAt: Date.now(), + messages: [ + ...conversation.messages, + { + id: crypto.randomUUID(), + role: 'assistant', + content: event.title, + createdAt: Date.now(), + state: 'complete', + task: { + id: task.id, + title: task.title + }, + approval: { + id: event.approvalId, + title: event.title, + description: event.description, + toolName: event.toolName, + argumentSummary: event.argumentSummary, + allowPermanent: event.allowPermanent + } + } + ] + } + }) + ) + } + const task = assistantTasksRef.current.find( + (candidate) => candidate.id === event.requestId + ) + if (task) { + attachScheduledApproval(task) + } else { + void window.goodbuddy.tasks + .list() + .then((tasks) => { + setAssistantTasks(tasks) + assistantTasksRef.current = tasks + attachScheduledApproval( + tasks.find( + (candidate) => candidate.id === event.requestId + ) + ) + }) + .catch(() => undefined) + } return } @@ -3998,9 +4210,69 @@ function App(): React.JSX.Element { } let active = true let refreshSequence = 0 - const remove = window.goodbuddy.conversations.onChanged(() => { - const sequence = ++refreshSequence - void window.goodbuddy.conversations + let refreshTimer: number | undefined + let refreshInFlight = false + let refreshQueued = false + const queueRefresh = (): void => { + refreshSequence += 1 + refreshQueued = true + if (refreshInFlight || refreshTimer !== undefined) { + return + } + refreshTimer = window.setTimeout(() => { + refreshTimer = undefined + refresh() + }, 50) + } + const refresh = (): void => { + if (refreshInFlight) { + refreshQueued = true + return + } + refreshInFlight = true + refreshQueued = false + const sequence = refreshSequence + const tasksRefresh = window.goodbuddy.tasks + .list() + .then((tasks) => { + if (!active || sequence !== refreshSequence) { + return + } + setAssistantTasks(tasks) + setActivityRecords((current) => + reconcileActivityRecords( + current, + tasks, + new Set(activeRuns.current.keys()) + ) + ) + }) + .catch(() => { + if (active && sequence === refreshSequence) { + notify({ + tone: 'error', + message: tRef.current('notices.taskHistoryReadFailed'), + dedupeKey: 'task-lifecycle-refresh' + }) + } + }) + const schedulesRefresh = window.goodbuddy.schedules + .list() + .then((schedules) => { + if (active && sequence === refreshSequence) { + setAssistantSchedules(schedules) + } + }) + .catch(() => { + if (active && sequence === refreshSequence) { + notify({ + tone: 'error', + message: tRef.current('notices.schedulesReadFailed'), + dedupeKey: 'schedule-lifecycle-refresh' + }) + } + }) + const conversationsRefresh = window.goodbuddy.conversations .list() .then((persisted) => { if (!active || sequence !== refreshSequence) { @@ -4045,17 +4317,16 @@ function App(): React.JSX.Element { dedupeKey: 'remote-channel-message' }) } - const local = conversationsRef.current.filter( - (conversation) => !conversation.remote - ) - setConversations( - [...remote, ...local].sort( - (left, right) => right.updatedAt - left.updatedAt + setConversations((current) => + mergePersistedConversations( + current, + persisted, + persistedLocalConversationsRef.current ) ) }) .catch(() => { - if (active) { + if (active && sequence === refreshSequence) { notify({ tone: 'error', message: tRef.current( @@ -4065,9 +4336,23 @@ function App(): React.JSX.Element { }) } }) - }) + void Promise.allSettled([ + tasksRefresh, + schedulesRefresh, + conversationsRefresh + ]).finally(() => { + refreshInFlight = false + if (active && refreshQueued) { + queueRefresh() + } + }) + } + const remove = window.goodbuddy.conversations.onChanged(queueRefresh) return () => { active = false + if (refreshTimer !== undefined) { + window.clearTimeout(refreshTimer) + } remove() } }, [conversationStoreReady]) @@ -4327,7 +4612,7 @@ function App(): React.JSX.Element { return } void window.goodbuddy.schedules - .list(activeProjectId) + .list() .then(setAssistantSchedules) .catch(() => notify({ @@ -5726,11 +6011,14 @@ function App(): React.JSX.Element { updateMessage(conversationId, messageId, (message) => ({ ...message, approval: undefined, - status: approved - ? tRef.current('chat.approval.executing', { - decision: decisionLabel - }) - : tRef.current('chat.approval.denied') + status: + approved && message.task + ? undefined + : approved + ? tRef.current('chat.approval.executing', { + decision: decisionLabel + }) + : tRef.current('chat.approval.denied') })) } catch { updateMessage(conversationId, messageId, (message) => ({ @@ -6109,6 +6397,163 @@ function App(): React.JSX.Element { setView('chat') } + const openAssistantTask = (task: AssistantTask): void => { + if (!task.conversationId) { + notify({ + tone: 'info', + message: t('notices.conversationDeleted') + }) + return + } + const conversation = conversations.find( + (candidate) => candidate.id === task.conversationId + ) + if (!conversation) { + notify({ + tone: 'info', + message: t('notices.conversationDeleted') + }) + return + } + if (task.projectId) { + const project = projects.find( + (candidate) => candidate.id === task.projectId + ) + setActiveProjectId(task.projectId) + if (project) { + setWorkMode( + normalizeInteractiveWorkMode(project.defaultWorkMode) + ) + } + } + setSelectedAssistantTaskId(task.id) + setExpandedTaskConversationIds((current) => { + const next = new Set(current) + next.add(conversation.id) + return next + }) + setActiveId(conversation.id) + setView('chat') + } + + const openCustomTaskDialog = ( + defaultDestination: CustomTaskDestination + ): void => { + if (!activeProject || activeProject.kind !== 'user') { + notify({ + tone: 'info', + message: t('customTask.errors.projectUnavailable') + }) + return + } + setCustomTaskDialog({ defaultDestination }) + } + + const createCustomTask = async ( + input: Parameters[0] + ): Promise => { + if (input.conversationId) { + persistLocalConversationChanges() + await conversationPersistenceQueueRef.current + } + const schedule = await window.goodbuddy.schedules.create(input) + setAssistantSchedules((current) => [ + schedule, + ...current.filter((item) => item.id !== schedule.id) + ]) + setSelectedAssistantTaskId(schedule.taskId) + setExpandedTaskConversationIds((current) => { + const next = new Set(current) + next.add(schedule.conversationId) + return next + }) + + const [conversationResult, taskResult, scheduleResult] = + await Promise.allSettled([ + window.goodbuddy.conversations.list(), + window.goodbuddy.tasks.list(), + window.goodbuddy.schedules.list() + ]) + if (conversationResult.status === 'fulfilled') { + setConversations((current) => + mergePersistedConversations( + current, + conversationResult.value, + persistedLocalConversationsRef.current + ) + ) + } else { + notify({ + tone: 'error', + message: t('notices.remoteConversationRefreshFailed'), + dedupeKey: 'custom-task-conversation-refresh' + }) + } + if (taskResult.status === 'fulfilled') { + setAssistantTasks(taskResult.value) + } + if (scheduleResult.status === 'fulfilled') { + setAssistantSchedules(scheduleResult.value) + } + if ( + taskResult.status === 'rejected' || + scheduleResult.status === 'rejected' + ) { + notify({ + tone: 'error', + message: t('notices.taskHistoryReadFailed'), + dedupeKey: 'custom-task-discovery-refresh' + }) + } + if (schedule.projectId) { + const project = projects.find( + (candidate) => candidate.id === schedule.projectId + ) + setActiveProjectId(schedule.projectId) + if (project) { + setWorkMode( + normalizeInteractiveWorkMode(project.defaultWorkMode) + ) + } + } + setActiveId(schedule.conversationId) + setView('chat') + return schedule + } + + const runAssistantSchedule = async ( + scheduleId: string + ): Promise => { + await window.goodbuddy.schedules.runNow(scheduleId) + notify({ + tone: 'success', + message: t('notices.scheduleStarted') + }) + } + + const setAssistantScheduleEnabled = async ( + scheduleId: string, + enabled: boolean + ): Promise => { + await window.goodbuddy.schedules.setEnabled(scheduleId, enabled) + setAssistantSchedules((current) => + current.map((schedule) => + schedule.id === scheduleId + ? { ...schedule, enabled } + : schedule + ) + ) + } + + const removeAssistantSchedule = async ( + scheduleId: string + ): Promise => { + await window.goodbuddy.schedules.remove(scheduleId) + setAssistantSchedules((current) => + current.filter((schedule) => schedule.id !== scheduleId) + ) + } + const clearLocalData = async (): Promise => { conversationPersistencePausedRef.current = true try { @@ -6432,7 +6877,16 @@ function App(): React.JSX.Element {

{t('sidebar.recent')}

- {filteredConversations.map((conversation) => ( + {filteredConversations.map((conversation) => { + const conversationTasks = + tasksByConversation.get(conversation.id) ?? [] + const tasksExpanded = + expandedTaskConversationIds.has(conversation.id) + const conversationTitle = getConversationDisplayTitle( + conversation, + t('conversation.defaultTitle') + ) + return (
+ {conversationTasks.length > 0 && ( + + )} + + ) + })} + {conversationTasks.length > 3 && ( +
  • + +
  • + )} + + )}
    - ))} + ) + })} {filteredConversations.length === 0 && (

    {activeProject?.kind === 'channel' && @@ -6883,6 +7413,35 @@ function App(): React.JSX.Element { scrollSnapshot={ chatScrollSnapshots[conversation.id] } + taskStrip={ + !conversation.remote ? ( + + openCustomTaskDialog('current') + } + onRemoveSchedule={removeAssistantSchedule} + onRunSchedule={runAssistantSchedule} + onSelectTask={setSelectedAssistantTaskId} + onSetScheduleEnabled={ + setAssistantScheduleEnabled + } + schedules={assistantSchedules} + selectedTaskId={ + (tasksByConversation.get(conversation.id) ?? []) + .some( + (task) => + task.id === selectedAssistantTaskId + ) + ? selectedAssistantTaskId + : undefined + } + tasks={ + tasksByConversation.get(conversation.id) ?? [] + } + /> + ) : undefined + } visibleMessageCount={ visibleMessageCounts[conversation.id] ?? messageRenderBatchSize @@ -8348,14 +8907,41 @@ function App(): React.JSX.Element {

    )} + {customTaskDialog && activeProject?.kind === 'user' && ( + setCustomTaskDialog(undefined)} + onCreate={createCustomTask} + projectId={activeProject.id} + projectName={activeProject.name} + runtimeLabel={activeRuntimeLabel} + supportsToolExecution={Boolean( + runtime?.supportsToolExecution + )} + workspaceLabel={ + activeProject.rootPath || t('customTask.scope.noWorkspace') + } + /> + )} openCustomTaskDialog('new')} schedules={assistantSchedules} + selectedTaskId={selectedAssistantTaskId} + tasks={productAssistantTasks} + projectNames={projectNames} onClose={() => setAssistantSidebarOpen(false)} onInteractBrowser={async () => { if (!activeId) { @@ -8392,13 +8978,6 @@ function App(): React.JSX.Element { }) } }} - onCreateSchedule={async (input) => { - const schedule = await window.goodbuddy.schedules.create({ - ...input, - projectId: activeProjectId || undefined - }) - setAssistantSchedules((current) => [schedule, ...current]) - }} onImportArtifacts={async () => { const imported = await window.goodbuddy.artifacts.importFiles( activeProjectId || undefined @@ -8423,12 +9002,8 @@ function App(): React.JSX.Element { ) }} onRemoveAttachment={removeAttachment} - onRemoveSchedule={async (scheduleId) => { - await window.goodbuddy.schedules.remove(scheduleId) - setAssistantSchedules((current) => - current.filter((schedule) => schedule.id !== scheduleId) - ) - }} + onOpenTask={openAssistantTask} + onRemoveSchedule={removeAssistantSchedule} onRespondApproval={(approval, decision) => { void respondToApproval( approval.conversationId, @@ -8437,13 +9012,8 @@ function App(): React.JSX.Element { decision ) }} - onRunSchedule={async (scheduleId) => { - await window.goodbuddy.schedules.runNow(scheduleId) - notify({ - tone: 'success', - message: t('notices.scheduleStarted') - }) - }} + onRunSchedule={runAssistantSchedule} + onSetScheduleEnabled={setAssistantScheduleEnabled} onListWorkspaceDirectory={listWorkspaceDirectory} onLoadWorkspaceFile={loadWorkspaceFile} onOpenWorkspaceEntry={openWorkspaceEntry} diff --git a/src/renderer/src/ChatTimeline.test.tsx b/src/renderer/src/ChatTimeline.test.tsx index aa73bc2..db4cc17 100644 --- a/src/renderer/src/ChatTimeline.test.tsx +++ b/src/renderer/src/ChatTimeline.test.tsx @@ -160,6 +160,41 @@ describe('ChatTimeline', () => { ).toBeInTheDocument() }) + it('labels scheduled result messages with Task provenance', () => { + const messages: Message[] = [ + { + id: 'scheduled-result', + role: 'assistant', + content: '今日状态正常', + task: { + id: '00000000-0000-4000-8000-000000000831', + title: '每日状态' + }, + createdAt: 1_775_000_000_000, + state: 'complete' + } + ] + + render( + + ) + + expect( + screen.getByLabelText('任务结果:每日状态') + ).toHaveTextContent('每日状态') + }) + it('shows every parallel expert output in its own expandable card', () => { const messages: Message[] = [ { diff --git a/src/renderer/src/ChatTimeline.tsx b/src/renderer/src/ChatTimeline.tsx index 3bc2350..d424655 100644 --- a/src/renderer/src/ChatTimeline.tsx +++ b/src/renderer/src/ChatTimeline.tsx @@ -3,6 +3,7 @@ import { Download, FileText, Library, + ListTodo, ShieldCheck, TerminalSquare, UserRound @@ -64,6 +65,7 @@ export type Message = { sourceReferences?: KnowledgeSearchReference[] knowledgeRetrieval?: KnowledgeRetrievalStatus artifactIds?: string[] + task?: ConversationMessage['task'] attachments?: ConversationAttachment[] } @@ -374,6 +376,17 @@ function ChatMessageRowView({ {message.role === 'assistant' ? 'GoodBuddy' : t('chat.user')} + {message.task && ( + + + )} {formatTime(message.createdAt, locale)} {message.attachments && message.attachments.length > 0 && ( diff --git a/src/renderer/src/ConversationTaskStrip.test.tsx b/src/renderer/src/ConversationTaskStrip.test.tsx new file mode 100644 index 0000000..c7543e1 --- /dev/null +++ b/src/renderer/src/ConversationTaskStrip.test.tsx @@ -0,0 +1,90 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor +} from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ConversationTaskStrip } from './ConversationTaskStrip' + +afterEach(cleanup) + +describe('ConversationTaskStrip', () => { + it('shows Task details and controls the stable schedule', async () => { + const taskId = '00000000-0000-4000-8000-000000000811' + const scheduleId = + '00000000-0000-4000-8000-000000000812' + const conversationId = + '00000000-0000-4000-8000-000000000813' + const onRunSchedule = vi.fn(async () => undefined) + const onSetScheduleEnabled = vi.fn(async () => undefined) + const onRemoveSchedule = vi.fn(async () => undefined) + + render( + + ) + + expect(screen.getByText('每日状态', { selector: 'strong' })) + .toBeInTheDocument() + expect(screen.getByText('Execute')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '立即运行' })) + await waitFor(() => + expect(onRunSchedule).toHaveBeenCalledWith(scheduleId) + ) + fireEvent.click(screen.getByRole('button', { name: '暂停' })) + await waitFor(() => + expect(onSetScheduleEnabled).toHaveBeenCalledWith( + scheduleId, + false + ) + ) + fireEvent.click( + screen.getByRole('button', { name: '删除计划' }) + ) + expect(onRemoveSchedule).not.toHaveBeenCalled() + fireEvent.click( + screen.getByRole('button', { + name: '确认删除“每日状态”的计划' + }) + ) + await waitFor(() => + expect(onRemoveSchedule).toHaveBeenCalledWith(scheduleId) + ) + }) +}) diff --git a/src/renderer/src/ConversationTaskStrip.tsx b/src/renderer/src/ConversationTaskStrip.tsx new file mode 100644 index 0000000..82fa8db --- /dev/null +++ b/src/renderer/src/ConversationTaskStrip.tsx @@ -0,0 +1,218 @@ +import { + ChevronDown, + ChevronUp, + CircleAlert, + ListTodo, + Plus +} from 'lucide-react' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { + AssistantSchedule, + AssistantTask +} from '../../shared/assistant-contracts' +import { + findTaskSchedule, + TaskScheduleActions +} from './TaskScheduleActions' + +type ConversationTaskStripProps = { + locale: string + onCreate: () => void + onRemoveSchedule: (scheduleId: string) => Promise + onRunSchedule: (scheduleId: string) => Promise + onSelectTask: (taskId: string) => void + onSetScheduleEnabled: ( + scheduleId: string, + enabled: boolean + ) => Promise + schedules: AssistantSchedule[] + selectedTaskId?: string + tasks: AssistantTask[] +} + +export function ConversationTaskStrip({ + locale, + onCreate, + onRemoveSchedule, + onRunSchedule, + onSelectTask, + onSetScheduleEnabled, + schedules, + selectedTaskId, + tasks +}: ConversationTaskStripProps): React.JSX.Element { + const { t } = useTranslation('workspace') + const [manuallyExpanded, setManuallyExpanded] = useState(false) + const [collapsedTaskId, setCollapsedTaskId] = useState('') + const [actionError, setActionError] = useState('') + const selectedTask = useMemo( + () => + tasks.find((task) => task.id === selectedTaskId) ?? tasks[0], + [selectedTaskId, tasks] + ) + const selectedSchedule = selectedTask + ? findTaskSchedule(selectedTask, schedules) + : undefined + const expanded = + manuallyExpanded || + (Boolean(selectedTaskId) && collapsedTaskId !== selectedTaskId) + + return ( +
    +
    + + +
    + {expanded && ( +
    + {tasks.length === 0 ? ( +

    + {t('taskStrip.empty')} +

    + ) : ( + <> +
    + {tasks.map((task) => ( + + ))} +
    + {selectedTask && ( +
    +
    + + {selectedTask.title} + + {t(`task.status.${selectedTask.status}`)} + + + {selectedTask.status === 'failed' && ( +
    +
    +
    +
    {t('task.fields.mode')}
    +
    + {selectedSchedule + ? t(`task.mode.${selectedSchedule.workMode}`) + : selectedTask.workMode + ? t(`task.mode.${selectedTask.workMode}`) + : t('task.mode.unavailable')} +
    +
    +
    +
    {t('task.fields.schedule')}
    +
    + {selectedSchedule + ? t( + `sidebar.tasks.schedule.recurrence.${selectedSchedule.recurrence}` + ) + : t('task.schedule.none')} +
    +
    +
    +
    {t('task.fields.nextRun')}
    +
    + {selectedSchedule + ? new Date( + selectedSchedule.nextRunAt + ).toLocaleString(locale) + : t('task.notAvailable')} +
    +
    +
    +
    {t('task.fields.outcome')}
    +
    + {selectedTask.error ?? + (selectedTask.completedAt + ? t('task.completedAt', { + time: new Date( + selectedTask.completedAt + ).toLocaleString(locale) + }) + : t('task.noOutcome'))} +
    +
    +
    + {selectedSchedule && ( +
    + +
    + )} +
    + )} + + )} + {actionError && ( +

    + {actionError} +

    + )} +
    + )} +
    + ) +} diff --git a/src/renderer/src/CustomTaskDialog.test.tsx b/src/renderer/src/CustomTaskDialog.test.tsx new file mode 100644 index 0000000..5e7ed61 --- /dev/null +++ b/src/renderer/src/CustomTaskDialog.test.tsx @@ -0,0 +1,127 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor +} from '@testing-library/react' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { CustomTaskDialog } from './CustomTaskDialog' + +const stylesheet = readFileSync( + join(process.cwd(), 'src', 'renderer', 'src', 'styles.css'), + 'utf8' +) + +afterEach(cleanup) + +function futureLocalDateTime(): string { + const date = new Date(Date.now() + 24 * 60 * 60 * 1_000) + const local = new Date( + date.getTime() - date.getTimezoneOffset() * 60_000 + ) + return local.toISOString().slice(0, 16) +} + +describe('CustomTaskDialog', () => { + it('keeps dialog spacing and form controls inside their layout bounds', () => { + expect(stylesheet).toMatch(/--space-5:\s*20px;/u) + expect(stylesheet).toMatch( + /\.custom-task-dialog,\s*\.custom-task-dialog \*,\s*\.custom-task-dialog \*::before,\s*\.custom-task-dialog \*::after\s*\{[^}]*box-sizing:\s*border-box;/u + ) + expect(stylesheet).toMatch( + /\.custom-task-dialog__content\s*\{[^}]*padding:\s*var\(--space-5\);[^}]*overflow-y:\s*auto;[^}]*scrollbar-gutter:\s*stable;/u + ) + expect(stylesheet).toMatch( + /@media \(max-height:\s*720px\)\s*\{[\s\S]*?\.custom-task-dialog__actions\s*\{[^}]*padding:\s*var\(--space-3\) var\(--space-5\);/u + ) + }) + + it('defaults to Execute and keeps the current Conversation association', async () => { + const conversationId = + '00000000-0000-4000-8000-000000000801' + const onCreate = vi.fn(async (input) => ({ + ...input, + id: '00000000-0000-4000-8000-000000000802', + taskId: '00000000-0000-4000-8000-000000000803', + conversationId, + enabled: true, + createdAt: '2026-08-19T00:00:00.000Z', + updatedAt: '2026-08-19T00:00:00.000Z' + })) + + render( + + ) + + expect( + screen.getByRole('dialog', { name: '新建定制任务' }) + ).toHaveAttribute('aria-modal', 'true') + expect(screen.getByRole('button', { name: 'Execute' })).toHaveAttribute( + 'aria-pressed', + 'true' + ) + expect(screen.getByRole('button', { name: '当前会话' })).toHaveAttribute( + 'aria-pressed', + 'true' + ) + expect(screen.getByLabelText('任务名称')).toHaveFocus() + + fireEvent.change(screen.getByLabelText('任务名称'), { + target: { value: '每周项目总结' } + }) + fireEvent.change(screen.getByLabelText('任务要求'), { + target: { value: '总结完成和失败的工作' } + }) + fireEvent.change(screen.getByLabelText('首次运行'), { + target: { value: futureLocalDateTime() } + }) + fireEvent.click(screen.getByRole('button', { name: '创建任务' })) + + await waitFor(() => expect(onCreate).toHaveBeenCalledOnce()) + expect(onCreate).toHaveBeenCalledWith( + expect.objectContaining({ + conversationId, + title: '每周项目总结', + prompt: '总结完成和失败的工作', + workMode: 'execute', + recurrence: 'once' + }) + ) + }) + + it('uses Ask when the selected Runtime cannot execute tools', () => { + render( + + ) + + expect(screen.getByRole('button', { name: 'Execute' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Ask' })).toHaveAttribute( + 'aria-pressed', + 'true' + ) + expect(screen.getByRole('button', { name: '当前会话' })).toBeDisabled() + }) +}) diff --git a/src/renderer/src/CustomTaskDialog.tsx b/src/renderer/src/CustomTaskDialog.tsx new file mode 100644 index 0000000..edca282 --- /dev/null +++ b/src/renderer/src/CustomTaskDialog.tsx @@ -0,0 +1,379 @@ +import { CalendarClock, FolderKanban, ShieldCheck, X } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import type { + AssistantSchedule, + ScheduleCreateInput +} from '../../shared/assistant-contracts' +import { activateModalFocus, trapTabFocus } from './dialog-focus' +import { SegmentedControl } from './WorkspacePrimitives' + +export type CustomTaskDestination = 'current' | 'new' + +type CustomTaskDialogProps = { + currentConversationAvailable: boolean + currentConversationId?: string + defaultDestination: CustomTaskDestination + projectId?: string + projectName: string + runtimeLabel: string + workspaceLabel: string + supportsToolExecution: boolean + onClose: () => void + onCreate: (input: ScheduleCreateInput) => Promise +} + +function toLocalDateTimeValue(date: Date): string { + const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000) + return local.toISOString().slice(0, 16) +} + +export function CustomTaskDialog({ + currentConversationAvailable, + currentConversationId, + defaultDestination, + projectId, + projectName, + runtimeLabel, + workspaceLabel, + supportsToolExecution, + onClose, + onCreate +}: CustomTaskDialogProps): React.JSX.Element { + const { t } = useTranslation('app') + const dialogRef = useRef(null) + const titleRef = useRef(null) + const [title, setTitle] = useState('') + const [prompt, setPrompt] = useState('') + const [destination, setDestination] = useState( + defaultDestination === 'current' && currentConversationAvailable + ? 'current' + : 'new' + ) + const [workMode, setWorkMode] = + useState( + supportsToolExecution ? 'execute' : 'ask' + ) + const [recurrence, setRecurrence] = + useState('once') + const [nextRunAt, setNextRunAt] = useState(() => + toLocalDateTimeValue(new Date(Date.now() + 60 * 60 * 1_000)) + ) + const [errors, setErrors] = useState< + Partial> + >({}) + const [submitting, setSubmitting] = useState(false) + + useEffect( + () => activateModalFocus(() => titleRef.current), + [] + ) + + const destinationOptions = useMemo( + () => [ + { + value: 'current' as const, + label: t('customTask.destination.current'), + disabled: !currentConversationAvailable + }, + { + value: 'new' as const, + label: t('customTask.destination.new') + } + ], + [currentConversationAvailable, t] + ) + + const submit = async (): Promise => { + const nextErrors: typeof errors = {} + if (!title.trim()) { + nextErrors.title = t('customTask.errors.title') + } + if (!prompt.trim()) { + nextErrors.prompt = t('customTask.errors.instructions') + } + if ( + destination === 'current' && + (!currentConversationAvailable || !currentConversationId) + ) { + nextErrors.destination = t('customTask.errors.destination') + } + const runAt = new Date(nextRunAt) + if (!nextRunAt || Number.isNaN(runAt.getTime())) { + nextErrors.nextRunAt = t('customTask.errors.time') + } else if (runAt.getTime() <= Date.now()) { + nextErrors.nextRunAt = t('customTask.errors.futureTime') + } + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) { + return + } + + setSubmitting(true) + try { + await onCreate({ + ...(projectId ? { projectId } : {}), + ...(destination === 'current' && currentConversationId + ? { conversationId: currentConversationId } + : {}), + title: title.trim(), + prompt: prompt.trim(), + workMode, + recurrence, + nextRunAt: runAt.toISOString() + }) + onClose() + } catch (reason) { + setErrors((current) => ({ + ...current, + form: + reason instanceof Error + ? reason.message + : t('customTask.errors.create') + })) + } finally { + setSubmitting(false) + } + } + + return createPortal( +
    { + if (event.target === event.currentTarget && !submitting) { + onClose() + } + }} + > +
    { + if (event.key === 'Escape' && !submitting) { + event.preventDefault() + onClose() + } else { + trapTabFocus(event, dialogRef.current) + } + }} + ref={dialogRef} + role="dialog" + tabIndex={-1} + > +
    +
    + + +

    {t('customTask.title')}

    +

    + {t('customTask.description')} +

    +
    + +
    + +
    + + +