Commit 2979c50968632e07a5791daed3ca725cd065080d
Merge branch 'main' of gitlab-wuwei.hexpm.com:yibo/lemonboss
Showing
34 changed files
with
2227 additions
and
721 deletions
docs/README.md
| @@ -51,10 +51,12 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d | @@ -51,10 +51,12 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d | ||
| 51 | | [29-dream-design.md](./todo/system-design/29-dream-design.md) | **dream(经验汇流)**— ✅ 已实现。定时整理所有 agent 在所有公司的记忆,提炼后跨公司沉淀(不泄漏公司信息)。四条取舍已拍板并实现:去敏独立判定、四落点分层写入(经验/`SKILL.md`/`soul.md`/`agent.md`,受管区块+三道闸门)、每日定时(串行+空转保护)、引用真伪自动查、REST+控制台入口(`/api/dream/*`、`/dream`)。`innovator-evolver` 真 CLI 跑通一次。**仍缺的**:「结论对不对」只能人抽查;「套进 org/work graph 来跑」评估后用户选了不做(作用域不匹配,见文内 §5.7) | | 51 | | [29-dream-design.md](./todo/system-design/29-dream-design.md) | **dream(经验汇流)**— ✅ 已实现。定时整理所有 agent 在所有公司的记忆,提炼后跨公司沉淀(不泄漏公司信息)。四条取舍已拍板并实现:去敏独立判定、四落点分层写入(经验/`SKILL.md`/`soul.md`/`agent.md`,受管区块+三道闸门)、每日定时(串行+空转保护)、引用真伪自动查、REST+控制台入口(`/api/dream/*`、`/dream`)。`innovator-evolver` 真 CLI 跑通一次。**仍缺的**:「结论对不对」只能人抽查;「套进 org/work graph 来跑」评估后用户选了不做(作用域不匹配,见文内 §5.7) | |
| 52 | | [30-task-delegation-reliability-gaps.md](./todo/system-design/30-task-delegation-reliability-gaps.md) | **任务委派可靠性现状**— 📝 调研记录,非设计方案。DevLoop `runUnit`、Company 招聘装配、Graph 执行三条链路核对结论一致:都是「失败预算耗尽 → 停等人工」,没有「卡住了主动通知人」这一层;DevLoop 当前还默认走 mock,真实 CLI 成功率无实证数据。2026-08-23 追加真实实例并已修:`infra_flake` 原地重跑没有熔断线,真撞上过一次确定性死循环(节点把诊断结论误判成自己的执行结果,反复重跑却从不路由到诊断建议的目标节点);已加 `infra_flake_count` 列 + 硬顶 3 次熔断,连续判 infra_flake 超过硬顶就改判 blocked 交给人工 | | 52 | | [30-task-delegation-reliability-gaps.md](./todo/system-design/30-task-delegation-reliability-gaps.md) | **任务委派可靠性现状**— 📝 调研记录,非设计方案。DevLoop `runUnit`、Company 招聘装配、Graph 执行三条链路核对结论一致:都是「失败预算耗尽 → 停等人工」,没有「卡住了主动通知人」这一层;DevLoop 当前还默认走 mock,真实 CLI 成功率无实证数据。2026-08-23 追加真实实例并已修:`infra_flake` 原地重跑没有熔断线,真撞上过一次确定性死循环(节点把诊断结论误判成自己的执行结果,反复重跑却从不路由到诊断建议的目标节点);已加 `infra_flake_count` 列 + 硬顶 3 次熔断,连续判 infra_flake 超过硬顶就改判 blocked 交给人工 | |
| 53 | | [31-work-graph-fail-fallback-coverage.md](./todo/system-design/31-work-graph-fail-fallback-coverage.md) | work graph 兜底边覆盖不全 — ✅ 已完成(除入口节点外强制兜底边,`planner.json`/`executor-fanout.json` 补 6 条边),978/978 单测通过;2026-08-21 又用真实 LLM 起了一条新任务做端到端回归验证,`org-planner-sdd` 自判失败后自动打回 `org-planner-prd`(3 次,零人工介入),修复确认生效 | | 53 | | [31-work-graph-fail-fallback-coverage.md](./todo/system-design/31-work-graph-fail-fallback-coverage.md) | work graph 兜底边覆盖不全 — ✅ 已完成(除入口节点外强制兜底边,`planner.json`/`executor-fanout.json` 补 6 条边),978/978 单测通过;2026-08-21 又用真实 LLM 起了一条新任务做端到端回归验证,`org-planner-sdd` 自判失败后自动打回 `org-planner-prd`(3 次,零人工介入),修复确认生效 | |
| 54 | +| [39-boss-agent-task-supervision.md](./todo/system-design/39-boss-agent-task-supervision.md) | 📝 **设计评估中,未实现**。评估用户提出的五阶段方案(雇 boss agent 监督 goal/task 看板、独立任务看板页、心跳挨个调度、org/work graph 产出的 bug/新任务回写看板、公司级目标代码目录)。结论:P2 看板已实现、P1/P3 心跳与 `ceo` 本体基础设施已有大半、P5 的 `linked-existing` 模式已在 `path-resolver.js` 完整实现只差公司级默认值,**真正从零开始的是 P4(回写闭环)**——当前没有任何 agent 工具能读写 goal/task 板。建议顺序倒过来先建 P4 地基 | | ||
| 54 | | [37-org-graph-department-scoping.md](./todo/system-design/37-org-graph-department-scoping.md) | 📝 **设计讨论,调用点扫描已完成,尚未立项实现**。现状 org graph 按 `company_id` 单例(`27-P2` 标题原文即"公司级"),塞不下 HR/财务/市场这类跟"交付流水线"无关的公司常设职能——`system/agents/hr/`("招·留·管·退")本体已存在却从没被装进任何图,全靠 fire-and-forget chat 招聘、无图执行审计轨迹。提议把 org graph 作用域从 `company_id` 升级为 `(company_id, department_id)`,一家公司可装配多张部门图,每张仍保持人工固定/慢变/可审计。全仓库调用点扫描结论:改造面是最初估算的 4 倍——真正的单点真相在 `work-graph-db.js:readDefGraph` 的裸 SQL 不变式(不在服务层),还牵涉两个易混淆服务文件间必须字节级一致的重复唯一索引、独立的 `graph-seed-service.js` 对账子系统、`company-hire-org-layer.test.js` 专属测试、`orggraph-planning`/`task-planning` 两份 agent 人设文档的自我认知改写 | | 55 | | [37-org-graph-department-scoping.md](./todo/system-design/37-org-graph-department-scoping.md) | 📝 **设计讨论,调用点扫描已完成,尚未立项实现**。现状 org graph 按 `company_id` 单例(`27-P2` 标题原文即"公司级"),塞不下 HR/财务/市场这类跟"交付流水线"无关的公司常设职能——`system/agents/hr/`("招·留·管·退")本体已存在却从没被装进任何图,全靠 fire-and-forget chat 招聘、无图执行审计轨迹。提议把 org graph 作用域从 `company_id` 升级为 `(company_id, department_id)`,一家公司可装配多张部门图,每张仍保持人工固定/慢变/可审计。全仓库调用点扫描结论:改造面是最初估算的 4 倍——真正的单点真相在 `work-graph-db.js:readDefGraph` 的裸 SQL 不变式(不在服务层),还牵涉两个易混淆服务文件间必须字节级一致的重复唯一索引、独立的 `graph-seed-service.js` 对账子系统、`company-hire-org-layer.test.js` 专属测试、`orggraph-planning`/`task-planning` 两份 agent 人设文档的自我认知改写 | |
| 55 | | [36-prd-agent-methodology-training-feasibility.md](./todo/system-design/36-prd-agent-methodology-training-feasibility.md) | 📝 **调研 + 阶段一/二均已完成**。回应"落地页太单薄是不是该用 Innovator 先训练专业 agent、再用 Dream 持续迭代"——认同方向。**阶段一**:`graph-engine.js` 结算节点后把 `success`/`fail:<reason>` 写进本体 `memory.md`(此前只有人类对话路径会写,Dream 天天空转)。**阶段二**:新建独立试验节点 `prd-methodology-trial` 走真实 Hunt→Digest→Evolve,2 轮收敛(96/97 分,真实基线 50 分),三个真实历史项目反推验证方法论有效;过程中连带修复同一个 2026-08-16 改名批次遗留的 5 处 `innovator`→`org-innovator` 死链(后端两处硬编码、前端"重新训练"按钮、`/api/agent/list` 展示 id 来源的四个 `config.json`),以及 `AgentScaffold` 默认模板一处裸相对路径缺陷。结论:FDE 对方法论类角色确实管用,但收敛速度/验收严谨度依赖任务书显式写明的自纠正要求,非免人工黑盒。环节 3(训练产物 → 雇佣本体转换)尚未开工 | | 56 | | [36-prd-agent-methodology-training-feasibility.md](./todo/system-design/36-prd-agent-methodology-training-feasibility.md) | 📝 **调研 + 阶段一/二均已完成**。回应"落地页太单薄是不是该用 Innovator 先训练专业 agent、再用 Dream 持续迭代"——认同方向。**阶段一**:`graph-engine.js` 结算节点后把 `success`/`fail:<reason>` 写进本体 `memory.md`(此前只有人类对话路径会写,Dream 天天空转)。**阶段二**:新建独立试验节点 `prd-methodology-trial` 走真实 Hunt→Digest→Evolve,2 轮收敛(96/97 分,真实基线 50 分),三个真实历史项目反推验证方法论有效;过程中连带修复同一个 2026-08-16 改名批次遗留的 5 处 `innovator`→`org-innovator` 死链(后端两处硬编码、前端"重新训练"按钮、`/api/agent/list` 展示 id 来源的四个 `config.json`),以及 `AgentScaffold` 默认模板一处裸相对路径缺陷。结论:FDE 对方法论类角色确实管用,但收敛速度/验收严谨度依赖任务书显式写明的自纠正要求,非免人工黑盒。环节 3(训练产物 → 雇佣本体转换)尚未开工 | |
| 56 | | [35-validation-rigor-and-body-readiness-proposal.md](./todo/system-design/35-validation-rigor-and-body-readiness-proposal.md) | 📝 **复盘 + 改进方案,四条提案全部已实施**。承接 31/32/33/34 号的复盘结论:任务结果差主要是架构问题(完整性闸门 `missingBodyDefinition` 只在招聘时查、图执行侧完全没有第二道防线——新发现)+ 一个具体的招聘装配可靠性 bug,不是 agent 能力问题。提案 A:本体不完整时图执行前置判 `fail_reason=body_not_ready`,不扣预算。提案 D:`goal-contract.js` 的 `evidence_hint` 命中浏览器类关键词的维度必须有证据支撑,否则判不过。提案 C:新增 `scripts/visual-acceptance-check.mjs` 复用检查工具。提案 B:控制台「验收证据」小节展示每个维度有没有证据支撑 | | 57 | | [35-validation-rigor-and-body-readiness-proposal.md](./todo/system-design/35-validation-rigor-and-body-readiness-proposal.md) | 📝 **复盘 + 改进方案,四条提案全部已实施**。承接 31/32/33/34 号的复盘结论:任务结果差主要是架构问题(完整性闸门 `missingBodyDefinition` 只在招聘时查、图执行侧完全没有第二道防线——新发现)+ 一个具体的招聘装配可靠性 bug,不是 agent 能力问题。提案 A:本体不完整时图执行前置判 `fail_reason=body_not_ready`,不扣预算。提案 D:`goal-contract.js` 的 `evidence_hint` 命中浏览器类关键词的维度必须有证据支撑,否则判不过。提案 C:新增 `scripts/visual-acceptance-check.mjs` 复用检查工具。提案 B:控制台「验收证据」小节展示每个维度有没有证据支撑 | |
| 57 | | [34-validator-scored-without-real-deliverable.md](./todo/system-design/34-validator-scored-without-real-deliverable.md) | 📝 **调研记录,checklist 已查完**。最初以为 `org-coder-impl` 从未产出任何 HTML/CSS,后来发现真实网站其实存在于 `graph_files` 索引不覆盖的「项目根」目录(已顺手修:控制台「产物」面板现在会展示项目根)。追加查清 validator 有没有真的做浏览器级检查——中途自我订正一次:不是"没工具做不到"(`bash` 能走到仓库自带的 Playwright,另一条对照任务证明可行),是**没人教它这么做**;抽查另外两个项目根后发现**验证严谨度完全不稳定**(同类任务有的做了真实 Playwright 级验证、有的只做静态正则、有的没验证就被取消),根因很可能是新招聘的 `unit-tester` 本体撞上了 [32 号文档](./todo/system-design/32-hire-body-fill-reliability.md) 那类"装配没填充完"的真实样本,不是平台工具限制 | | 58 | | [34-validator-scored-without-real-deliverable.md](./todo/system-design/34-validator-scored-without-real-deliverable.md) | 📝 **调研记录,checklist 已查完**。最初以为 `org-coder-impl` 从未产出任何 HTML/CSS,后来发现真实网站其实存在于 `graph_files` 索引不覆盖的「项目根」目录(已顺手修:控制台「产物」面板现在会展示项目根)。追加查清 validator 有没有真的做浏览器级检查——中途自我订正一次:不是"没工具做不到"(`bash` 能走到仓库自带的 Playwright,另一条对照任务证明可行),是**没人教它这么做**;抽查另外两个项目根后发现**验证严谨度完全不稳定**(同类任务有的做了真实 Playwright 级验证、有的只做静态正则、有的没验证就被取消),根因很可能是新招聘的 `unit-tester` 本体撞上了 [32 号文档](./todo/system-design/32-hire-body-fill-reliability.md) 那类"装配没填充完"的真实样本,不是平台工具限制 | |
| 59 | +| [40-agent-chat-streaming-interrupt.md](./todo/system-design/40-agent-chat-streaming-interrupt.md) | 📝 **需求文档,讨论中,未实现**。回应"前端刷新不及时/没有打字机效果,要不要把 SSE 换成 WebSocket"——排查后结论是传输层不是瓶颈(对照 hermes-webui/hermes-agent/deepseek-harness 三个参考实现,全部走 SSE,不用 WebSocket)。真正的三个独立缺口:① 打字机不流畅(ACP chunk 粒度太粗,方案是前后端都做匀速切片/渲染缓冲);② 执行中无法打断并接上新消息(现状"先 Stop 再发"两步,方案是深度 1 的打断-排队,顺带发现服务端对同 convId 并发缺闸门这个现存 bug);③ 串台已在 `9b1c3f1` 修复但没有回归测试兜底 | | ||
| 58 | | [33-needs-gate-role-filter-bug.md](./todo/system-design/33-needs-gate-role-filter-bug.md) | ✅ **已修复**。`executor.json`/`executor-fanout.json` 里 `produces:["log"]` 声明了一个 test/integration 类节点永远不会被索引到的 kind,导致 `org-coder-validator` 被 `missing_required_input` 反复误判 blocked;改成 `required:false`(对齐 `validator.json` 既有写法)。文档记录了一条**走过的弯路**(按 role 过滤 `inboundContracts` 会削弱 T-mt25ecbd 的产出闸门,已撤回),发现于 31 号文档的真实回归验证过程中,与兜底边问题无关 | | 60 | | [33-needs-gate-role-filter-bug.md](./todo/system-design/33-needs-gate-role-filter-bug.md) | ✅ **已修复**。`executor.json`/`executor-fanout.json` 里 `produces:["log"]` 声明了一个 test/integration 类节点永远不会被索引到的 kind,导致 `org-coder-validator` 被 `missing_required_input` 反复误判 blocked;改成 `required:false`(对齐 `validator.json` 既有写法)。文档记录了一条**走过的弯路**(按 role 过滤 `inboundContracts` 会削弱 T-mt25ecbd 的产出闸门,已撤回),发现于 31 号文档的真实回归验证过程中,与兜底边问题无关 | |
| 59 | 61 | ||
| 60 | ## todo/future/(暂缓,非当前重心) | 62 | ## todo/future/(暂缓,非当前重心) |
docs/todo/system-design/39-boss-agent-task-supervision.md
0 → 100644
| 1 | +# 39. Boss Agent 任务看板监督 + 公司目标代码目录 — 需求评估与设计草案 | ||
| 2 | + | ||
| 3 | +> **状态**:📝 设计评估中,未实现。本文档是对用户口述五阶段方案的落地可行性评估 + 修订后的设计草案,不是最终定案。 | ||
| 4 | +> **日期**:2026-08-30 | ||
| 5 | +> **背景**:承接对话讨论——goal/task 两级模型([27-P3C](../../done/system-design/27-p3c-goal-task-model.md))已落地,但推进它的是纯确定性代码(`task-scheduler.js` + `transitionRun` 钩子),没有任何 agent 在持续监督这块看板、处理异常、把执行中发现的新问题回写成新任务。用户提出五阶段方案补上这一层,并追加公司级目标代码目录需求。 | ||
| 6 | +> **读者须知**:本文档大量引用现有代码路径核实"已有 vs 缺失",结论以文内给出的具体函数/文件为准;这些引用会随代码演进过期,落地前请重新核实。 | ||
| 7 | + | ||
| 8 | +--- | ||
| 9 | + | ||
| 10 | +## 一、先说结论 | ||
| 11 | + | ||
| 12 | +用户的五阶段里,**P2(任务看板)本质已完成,P1/P3 的基础设施已有大半,P4 是真正的新增核心,P5 的机制已经实现了 70%,只是没有暴露到公司层面。** 真正从零开始的工作量比五个"phase"暗示的要小得多,但顺序建议大改:**P4(回写闭环)应该提到最前面**,因为它才是这次讨论最初的诉求("自己发现问题、自己重新规划"),P1/P3 的 boss agent 只有在 P4 的工具/数据通路建好之后才有事可做。 | ||
| 13 | + | ||
| 14 | +| 用户的 phase | 名义工作量 | 实际现状 | 建议 | | ||
| 15 | +|---|---|---|---| | ||
| 16 | +| P1 雇佣通用 boss agent | 新建一个角色 + 训练 | 心跳基础设施、`ceo` 本体、Innovator 训练方法论**全部已有** | 复用 `ceo`,不新建角色;工作量收窄成"写 heartbeat.md + 工具" | | ||
| 17 | +| P2 任务看板独立页面 | 一个新系统 | goal/task 两级看板**已实现**(`GoalTaskPane.vue`),只是嵌在控制台里 | 降级成 UI 改造,非必须,可排最后 | | ||
| 18 | +| P3 心跳挨个决定下一个 task | 新的调度逻辑 | 挨个执行**已经是** `task-scheduler.js` 在做,且做得更好(事件驱动、零成本、已测试) | **不要重建调度**;心跳的职责收窄为"异常判断",且应事件触发而非纯轮询 | | ||
| 19 | +| P4 bug/新任务回写看板 | 一条新闭环 | **不存在**——没有任何 agent 可读写 goal/task 板的工具,四分类失败归因(30 号文档)目前只导向"重试或人工介入",不导向"开新任务" | **真正的核心工作,优先做** | | ||
| 20 | +| P5 公司目标代码目录 | 新能力 | DevLoop 的 `path-resolver.js` 已实现 `sandbox` / `linked-new` / `linked-existing` 三种模式,**`linked-existing`(接管已有目录)已经是完整实现**,只是没有公司级默认值,也没打通到 org/work graph(非 DevLoop)路径 | 补一个公司级默认配置 + 核实非 DevLoop 路径是否需要同样机制 | | ||
| 21 | + | ||
| 22 | +--- | ||
| 23 | + | ||
| 24 | +## 二、逐 phase 现状核实 | ||
| 25 | + | ||
| 26 | +### P1:每个公司雇佣 boss agent,system agent 层训练通用版本 | ||
| 27 | + | ||
| 28 | +**已有的基础设施**(全部实地核实过): | ||
| 29 | + | ||
| 30 | +1. **通用心跳机制**:`server/services/heartbeat-service.js`。状态落在 `system/agents/{aid}/{cid}/heartbeat.json`(公司态目录,不是共享本体——这条边界是踩过坑之后加的,见文件头注释),本体 `config.json.heartbeat` 只是只读默认建议间隔。`_triggerHeartbeat()` 按 `setInterval` 定时给 agent 发一条 `[SYSTEM HEARTBEAT]` 系统消息(走正常的 `/api/agent/{agentId}/chat`),要求 agent "**根据你的身份和 heartbeat.md 规则**……如果一切正常,无事发生,请直接回复『心跳正常』",带 30 分钟会话互斥防重叠。**这套机制现成可用,不用重建。** | ||
| 31 | + - ✅ **修正一处之前的误判**:原以为 prompt 里"扫描全局 OKR 目标状态和节点"那句硬编码需要改代码才能适配新 goal/task 模型。查了 `system/agents/` 下已有 **35 个**本体带 `heartbeat.md`(如 `org-innovator/heartbeat.md`),内容五花八门、完全跟 OKR 无关,说明触发消息里那句"扫描 OKR"从来就只是**默认示例文案**,各 agent 早就按自己 `heartbeat.md` 里写的规则各干各的。**结论:`heartbeat-service.js` 大概率零改动**,只需要新建 `system/agents/ceo/heartbeat.md`,写清楚"去查 goal/task 板、按 §四.9 的规则判断",agent 读到自己的 `heartbeat.md` 就会照着做,不需要动服务端代码。落地时找一个现有 `heartbeat.md` 生效的真实案例复核一次这个推断,再动手写 `ceo` 的那份。 | ||
| 32 | +2. **本体已存在**:`system/agents/ceo/`(`type: "cxo"`,"Chief Executive Officer",`heartbeat.enabled: false` 默认关)。同时存在 `system/agents/master/`(`type: "master"`,**全局唯一**,`/api/master/chat`,负责建公司/招聘等跨公司操作)。两者定位不同:`master` 是平台层面的单例助手,`ceo` 才是"每个公司一个"的角色。 | ||
| 33 | +3. **训练方法论已验证**:`36-prd-agent-methodology-training-feasibility.md` 已经用 Innovator(Hunt→Digest→Evolve)训练出过一个方法论型 agent(`prd-methodology-trial`),2 轮收敛、96/97 分,方法论对这类"判断型"角色确实管用。可以照搬这条路径训练 `ceo` 的监督方法论,而不是手写一份 prompt 拍脑袋。 | ||
| 34 | + | ||
| 35 | +**结论**:不建议新建"boss"角色。**复用 `system/agents/ceo/`**——它的岗位定义(CEO)本来就该管"任务有没有卡住、要不要重新分解",另起一个语义重叠的角色只会重犯这个仓库反复踩过的"名字歧义"坑(`27-naming-conventions.md` 通篇在防这个)。P1 的实际工作量是:① 给 `ceo` 补一份 `heartbeat.md`(监督规则);② 给它挂上 P4 需要的工具(见下);③ 默认 `heartbeat.enabled: true`(但见 P3 的成本讨论,建议先不无脑轮询);④ 可选:用 Innovator 训练它的监督方法论。 | ||
| 36 | + | ||
| 37 | +**缺口 A**:目前**没有任何 agent 工具能读写 `goals`/`tasks` 表或操作 graph run**(搜了 `server/src/tools/` 全目录,零命中)。`ceo` 就算被唤醒也无事可做——这是 P1 能不能生效的真正前提,见 P4。 | ||
| 38 | + | ||
| 39 | +**缺口 B(新发现,之前漏了):`ceo` 现在没有被雇进任何公司,`hire()` 也没有任何自动触发点会调它**。查了 `hire(companyId, roleName)`(`company-service.js:422`)——它只查本体目录 + `company_agents` 表,**不查 org graph**,机制上雇一个不在图里的角色完全没问题(数据模型层面,"雇了"和"进图"是两件独立的事)。但目前**没有任何代码路径会主动调 `hire(companyId, 'ceo')`**——它不在三份 org-graph-templates 里,也没有类似 `orggraph-planning` 那样的自动招聘钩子替它兜底。`37-org-graph-department-scoping.md` 里记录过一模一样的处境:`hr/` 本体"已经真实存在……只是从没被装进任何图……今天完全是编外人员"——`ceo` 现在是同款处境,**不是有先例可抄,是同一个坑还没人填**。P1 必须显式加一步:① 公司创建流程里(不管选哪个 org-graph-template)固定 `hire(companyId, 'ceo')` 一次;② 存量公司需要一次性回填脚本,遍历现有公司补雇。这条不做,前面说的"复用 ceo"就是一句空话——压根没有 `ceo` 实例在跑。 | ||
| 40 | + | ||
| 41 | +--- | ||
| 42 | + | ||
| 43 | +### P2:任务看板独立页面 | ||
| 44 | + | ||
| 45 | +**已实现**([27-P3C](../../done/system-design/27-p3c-goal-task-model.md),✅ 2026-08-20):`web/src/components/graph-console/GoalTaskPane.vue` + `goal-task-model.js`,挂在 `/:companyId/graph-console` 左栏。goal/task 两级状态、优先级排队位次、颜色语义全部已有,REST 是 `server/routes/company.js` 的 `GET/POST /api/company/:cid/goals` 系列。 | ||
| 46 | + | ||
| 47 | +**用户要的"独立页面"** 目前是控制台的一个侧栏,不是路由级独立页面。这是纯 UI 改造,**不涉及数据模型或后端**,随时可做,风险最低。 | ||
| 48 | + | ||
| 49 | +**"subtask" 层**:当前**没有独立的 subtask 表**,`task` 就是看板能管理的最细颗粒度。task 再往下是 `graph_runs`(work graph 节点,可能含嵌套子图),这层在中间的执行画布(`WorkGraphCanvas`)里看,不在左栏。 | ||
| 50 | + | ||
| 51 | +> ⚠️ **不建议新增一张 subtask 表**去对应 work graph 节点。这个仓库已经因为"两套任务体系不统一"吃过至少两次苦头(P3C §十二"批 5"整章在处理这个债,`27-p3c-batch3-handover.md` 也记过一次事故)。如果要在看板上展示"这个 task 内部具体在跑哪几步",应该是**只读地把该 task 当前 `current_run_id` 对应的 `graph_run_nodes` 渲染成一份"子步骤列表"**,复用现有表和现有执行画布的数据源,不新建写路径。 | ||
| 52 | + | ||
| 53 | +#### P2 决定:与 OKR 页面的关系(已拍板,2026-08-30) | ||
| 54 | + | ||
| 55 | +排查发现现状比预期乱:**OKR 相关 UI 现在有两份几乎逐行相同的重复实现**——`web/src/views/OkrDashboard.vue`(路由 `/okr`,`requireAdmin`,零导航入口的孤儿页面)和 `CompanyDashboard.vue` 里挂着 "goals" 名字、实际渲染 "Goals (Full OKR Tree)" 的 tab——两者都是旧 OKR 树(`O-xxx`,owner + progress%),是"OKR 正名"(`27-naming-conventions.md` §六)前端没跟着改完的遗留物,前端两处都调 `/api/okr/*`,和 P3C 的新 goal/task 模型完全不是一回事。 | ||
| 56 | + | ||
| 57 | +**已拍板**:**不合并**,OKR 这套前端 UI**直接废弃**,新任务看板**另起一个独立页面**。具体动作: | ||
| 58 | + | ||
| 59 | +1. **新增路由**:仿照 `GraphConsole` 已经立好的先例(`/:id/graph-console` 是独立路由,不挤在 `CompanyDetail` 的 `:tab(...)` 白名单里),新增 `/:id/task-board`(命名待定)→ 新建 `web/src/views/TaskBoard.vue`。**不要塞进 `:tab(...)` 正则**,理由与 `graph-console` 当年的注释一致:任务看板信息密度高,值得一个整页。 | ||
| 60 | +2. **复用现有逻辑,不重写数据层**:`GoalTaskPane.vue` + `goal-task-model.js` 现在已经封装了 goal/task 的状态映射、排序、颜色语义(且有专门的单测 `goal-task-model.test.js` 兜底)。新页面应该是把这个组件从 `GraphConsole` 的侧栏布局里"拔出来"放大铺满整页,**不是重新实现一遍**。`GraphConsole` 侧栏可以保留一份精简版(点某个 task 仍需跳转去看它的执行图),也可以改成跳转链接指向新页面——两种都行,实现时再定。 | ||
| 61 | +3. **废弃 OKR 前端**:删 `/okr` 路由 + `OkrDashboard.vue`;删 `CompanyDashboard.vue` 里那个挂着 "goals" 名字实为 OKR 树的 tab;路由 `:tab(...)` 白名单里的 `goals`/`okr` 两个值一并摘掉,避免留死链接。 | ||
| 62 | +4. **导航入口**:新页面必须挂真实导航入口(`AppNav` 或公司详情页的显眼位置),不能重蹈 `/okr` 那种"孤儿页面"的覆辙。 | ||
| 63 | +5. **鉴权**:`okr.js` 当年挂 `requireAdmin` 是因为它是孤儿页面、没做归属校验的权宜之计(`38-auth-multi-tenant-isolation.md`),**不是"任务看板天生该管理员专属"**。新路由要按 `company.js` 等其他公司级路由的标准做法,走 `canAccessCompany` 归属校验,不能照抄 `requireAdmin`。 | ||
| 64 | + | ||
| 65 | +**范围之外、本次不动**(留作独立的后续决定,不在这次 P2 改造里顺手做):`/api/okr/*` 后端路由、`okr` 相关 SQLite 表、`goals.okr_node_id` 这个可选外键字段——前端 UI 废弃不等于后端子系统也要一起拆,那是一次单独的、影响面更大的退役决定(牵涉数据迁移/存量数据处理),需要单独评估,这里先只处理前端。 | ||
| 66 | + | ||
| 67 | +--- | ||
| 68 | + | ||
| 69 | +### P3:心跳挨个决定下一个 task | ||
| 70 | + | ||
| 71 | +**这条按用户原话实现是在重建一个已经存在、已经测试、且更优的系统。** `task-scheduler.js` 的队列排序口径是 `priority → seq → created_at`(`ORDER BY priority, seq, created_at`),出队靠 `transitionRun` 这个唯一漏斗事件触发,不是轮询;v1 约束 `max_parallel_tasks`(`company.json`,缺省 1)决定"挨个"还是"并发几个",`server/tests/unit/task-slots-parallel.test.js` 覆盖了 >1 的情况。这套机制零 LLM 成本、毫秒级触发、已经过多轮真实回归验证(`31-work-graph-fail-fallback-coverage.md` 的真实 LLM 端到端案例)。 | ||
| 72 | + | ||
| 73 | +**成本账**:`heartbeat-service.js` 现在的实现是**每一次 tick 都真的发一次 LLM 调用**,只是 prompt 要求"没事就回复 `[心跳正常]`"——省不了那次调用的 token/延迟。如果 N 家公司、每家每 5 分钟一次心跳,24 小时就是 N×288 次 LLM 调用,绝大多数只会换回一句"心跳正常"。 | ||
| 74 | + | ||
| 75 | +**修订建议**:boss agent 心跳**只负责调度器处理不了的判断题**,而且触发方式改成"事件优先、轮询兜底",不是纯定时轮询: | ||
| 76 | + | ||
| 77 | +1. 判断题清单(调度器管不了、需要 LLM 判断的): | ||
| 78 | + - task 长时间 `blocked` 或 `failed`(预算耗尽/依赖死锁)——要不要重新分解、要不要升级给人类; | ||
| 79 | + - 同一 goal 下多个 task 出现优先级冲突(业务变化导致原定顺序不再合理)——要不要调整 `priority`; | ||
| 80 | + - `task-planning` 3 轮降级成单任务(P3C §四.9 的降级路径)——要不要人工介入重新审视这条 goal 的描述。 | ||
| 81 | +2. **优先事件驱动**:在 `transitionRun`/`settleGoal` 这类既有钩子里,遇到上述判断题触发的状态变化(比如 task 转 `blocked`)时,**主动**唤醒 `ceo` 处理这一条,而不是让它等下一次心跳才发现。心跳退化成**低频兜底**(比如 30 分钟一次,只扫"有没有钩子漏掉的异常"),不是主决策路径——这与现有 `task-scheduler.js` 头部注释里"兜底扫描周期"的设计哲学完全一致,照抄这个模式即可。 | ||
| 82 | + | ||
| 83 | +--- | ||
| 84 | + | ||
| 85 | +### P4:org/work graph 产生的新 bug / 新 task 回写看板,形成闭环 | ||
| 86 | + | ||
| 87 | +**这是五个 phase 里唯一真正从零开始、也是这次讨论最初诉求("自己发现问题、自己重新规划")真正要落地的地方。** | ||
| 88 | + | ||
| 89 | +**现有的失败归因机制**(`30-task-delegation-reliability-gaps.md`):`BugTriager`/`validator-triager` 类节点已经在做四分类——`contract_mismatch` / `impl_bug` / `spec_ambiguous` / `infra_flake`——但**四条路径的终点目前都是"原地重试"或"预算耗尽后 blocked 等人工"**,没有一条走向"生成一条新 task/goal"。 | ||
| 90 | + | ||
| 91 | +**需要新增的两个通路**: | ||
| 92 | + | ||
| 93 | +1. **确定性通路(不需要 agent 判断)**:把四分类里"显然该开新任务"的情形直接在 `routeFailure`(`graph-engine.js`)里接一条规则,而不是等 boss agent 心跳才处理——比如 `spec_ambiguous` 在 goal 级预算内重试超过阈值,直接生成一条 `status='draft'`、`created_by='system'`、挂在同一 `goal_id` 下的新 task,附上失败节点的诊断产物作为新 task 的 `description` 素材,人工/`ceo` 再审。这条不需要 LLM 判断"要不要开新任务",只需要判断"开一条什么样的"——后者才是可选的 agent 介入点。 | ||
| 94 | +2. **主观通路(需要 agent 判断,这是 P1 的 `ceo`/工具的用武之地)**:agent 在执行任务过程中发现的、**不在当前 task 描述范围内**的问题(比如 coder 顺手发现一个无关的历史 bug),需要一个**工具**让它"提一条新任务"而不是把不相关的修复硬塞进当前 task 的产物里。这是当前完全没有的能力。 | ||
| 95 | + | ||
| 96 | +**必须先建的地基**——查完代码,发现比"包一层工具"更底层的东西还没有,得先排清楚三件事,顺序不能反: | ||
| 97 | + | ||
| 98 | +#### 地基 0(阻塞项):`ceo` 现在物理上调不通这套 REST API | ||
| 99 | + | ||
| 100 | +`server/src/tools/tool-registry.js` 开头写得明明白白——**这是"Master Agent 工具注册中心"**,`[TOOL_CALL: xxx]` 这套 JSON 文本协议是 Master 专属的自定义解析层,`server/src/tools/devloop/` 下另有一套 DevLoop 专属的 tool-registry。`ceo` 这类普通 org node agent(走标准 Claude CLI 子进程)**两套都不属于**——它能用的只有 Claude CLI 原生的 Bash/curl,具体调用哪个接口靠 `tools.md` 里的**大白话 + curl 示例**(`task-planning/tools.md` 就是这个写法的范本)。所以"task-board-tools.js" 这个提法本身**不成立**——不需要新写一个 JS 工具模块,需要写的是一份 `system/agents/ceo/tools.md`(现在是空文件)。 | ||
| 101 | + | ||
| 102 | +但更麻烦的是:`server/routes/company.js` 顶部 `router.use(requireAuth)`(14 行),**goals/tasks 全系列端点只认人类 JWT**,没有走 `agent-chat.js` 那样的 `requireAuthOrInternal`。也就是说,就算 `ceo` 的 `tools.md` 写得再clear,它发出的每一条 curl **现在都会静默 401**——这正是最近几个 commit(`d48425a` selfChat 自环漏带内部令牌、`9e58656` bin/lemon.js task report/dispatch 401)反复修的那一类坑,company.js 这次还没打这个补丁。**这是全部 P4 工作的前提阻塞项**,必须先给 goals/tasks 这组路由加上 `requireAuthOrInternal`(或者更收紧一点,只给这几条挂内部令牌白名单),agent 才有物理上调得通的可能。 | ||
| 103 | + | ||
| 104 | +#### 地基 1:`POST /:companyId/tasks` 没有"草稿"模式,agent 提的任务会立刻烧预算 | ||
| 105 | + | ||
| 106 | +现有 `POST /:companyId/tasks`(`company.js:2077`)建完 task 行**无条件紧接着调 `enqueueTaskRun` + `startNextIfSlotFree`**——语义是"下达即执行",服务的是"人类/外部 agent 派工"这个存量场景(代码注释原话:"存量调用方的心智是下达即执行")。这条路径不适合 `ceo` 自主提的新任务:`task-planning` 拆出来的 task 尚且要先落 `draft`、人点"全部下达"才转 `queued`(P3C §四.9 的既定哲学——"一次 LLM 猜测直接烧 5 份预算"是要避免的事),`ceo` 一次判断失误直接把新任务怼进队列烧钱,风险比 task-planning 更高(task-planning 只在 goal 刚建时跑一次,`ceo` 是持续在线判断)。 | ||
| 107 | + | ||
| 108 | +**需要的改动很小**:给 `POST /:companyId/tasks` 加一个 `draft: true`(或 `status: 'draft'`)入参,为真时只调 `wdb.createTask()` 落库,跳过 `enqueueTaskRun`/`markGoalDispatched`/`startNextIfSlotFree` 三步,返回 `status: 'draft'`。已有的 `POST /:companyId/tasks/:taskId/enqueue`(`company.js:2275`,`draft → queued`)原样复用,不用新建。默认行为(不传这个参数)完全不变,纯加法,不影响存量调用方。 | ||
| 109 | + | ||
| 110 | +#### 地基 2:`ceo` 的 `tools.md` 写什么 | ||
| 111 | + | ||
| 112 | +在地基 0/1 落地后,照抄 `task-planning/tools.md` 的写法(先说"大概率不需要工具"、给出何时才用、给出 curl 示例、专辟一节"你不该用的工具"防止越权)给 `ceo` 写一份,覆盖: | ||
| 113 | + | ||
| 114 | +- **读**:`GET /:cid/goals?status=active`(扫哪些 goal 在跑)、`GET /:cid/tasks?status=blocked` / `?status=failed`(找卡住的)、`GET /:cid/goals/:goalId`(看某 goal 下全部 task + run 摘要) | ||
| 115 | +- **写**:`PATCH /:cid/tasks/:taskId`(改 `priority`)、`POST /:cid/tasks`(带 `draft:true`,提新任务)、`POST /:cid/tasks/:taskId/enqueue`(把自己或别人提的草稿正式下达——这一步要不要也留给人类点,是个开放问题,见 §四) | ||
| 116 | +- **明确不该用**:`org-graph/replan`、招聘类端点(这些是 HR/`orggraph-planning` 的事,`ceo` 判断"缺角色"之后应该是发通知/提 task,不是自己动手改组织结构) | ||
| 117 | + | ||
| 118 | +#### 确定性通路(routeFailure 里那条规则)不走这套 REST | ||
| 119 | + | ||
| 120 | +30 号文档四分类里"显然该开新任务"的情形(比如 `spec_ambiguous` 重试超阈值),是 `graph-engine.js` 服务端内部代码,**直接调 `wdb.createTask()`**(同进程函数调用),不必经过 HTTP、不必带内部令牌,落库同样是 `draft`。这条通路和 `ceo` 的 agent 通路是并行的两条腿,共享同一个"落 draft、不自动下达"的规则,但触发方式不同(一个是引擎钩子里的确定性规则,一个是 agent 主观判断)。 | ||
| 121 | + | ||
| 122 | +**先做地基 0(鉴权)→ 地基 1(draft 模式)→ 地基 2(`tools.md`),P1/P3 的 boss agent 才有事可干**——三步不做完,`ceo` 被心跳唤醒了也只能干瞪眼,或者干瞪眼都算好的,更可能是每次尝试都吃一个静默 401。 | ||
| 123 | + | ||
| 124 | +--- | ||
| 125 | + | ||
| 126 | +### P5:公司级目标代码目录("接管已有工作目录") | ||
| 127 | + | ||
| 128 | +**核实结论:这个能力在 DevLoop 层面已经完整实现,只是没有公司级默认值,也没有确认是否覆盖非 DevLoop 的执行路径。** | ||
| 129 | + | ||
| 130 | +`server/services/devloop/path-resolver.js` 的 `allocateProjectRoot()` 已支持三种模式(`server/routes/devloop.js` 建项目时传 `mode`/`target_path`): | ||
| 131 | + | ||
| 132 | +| mode | 语义 | 现状 | | ||
| 133 | +|---|---|---| | ||
| 134 | +| `sandbox` | 自动分配到 `data/companies/{cid}/projects/{pid}/`(lemonboss 内部) | ✅ 已实现,默认值 | | ||
| 135 | +| `linked-new` | 用户指定一个**空/新建**的绝对路径作为项目根,登记进 `~/.devloop/linked-projects.json` | ✅ 已实现 | | ||
| 136 | +| `linked-existing` | 用户指定一个**已存在**的目录,接管为项目根 | ✅ **已实现,不是文件头注释说的"预留接口拒收"**——代码实测有完整校验:路径必须存在且是目录、不能已经是别的 DevLoop 项目(`project.json` 冲突检测)、若有 `.git` 必须 working tree 干净(防止 agent 的 `git add -A` 把用户脏改动卷进自动 commit)。文件头那句注释是过期文档,**建议顺手修正**。 | | ||
| 137 | + | ||
| 138 | +这正是用户要的"像 Claude Code 选工作目录一样接管一个已有目录",**核心机制已经有**,且安全校验(黑名单敏感路径、git 干净树)已经比较完善。 | ||
| 139 | + | ||
| 140 | +**org/work graph(非 DevLoop)路径已核实**,结论比预期更好也更麻烦,分开说: | ||
| 141 | + | ||
| 142 | +**好消息:不是两套系统**。`graph-engine.js` 有自己的 `ensureProjectRoot(ctx)`(3476 行),文件头注释写明"位置的分配**复用 DevLoop 的 `path-resolver.allocateProjectRoot`(sandbox 模式)**,不新造一套"——`allocateRunProjectRoot()`(3458 行)内部真的就是调 `require('./devloop/path-resolver').allocateProjectRoot({ company_id, mode: 'sandbox' })`。org-coder 类节点的交付物落点、`graph_files` 索引、`34号文档` 提到的"项目根",指的都是这同一套机制,不是 `locator.taskDir`(那是 agent 自己的运行 cwd,`_applyRuntimeScope` 分配的,两者不是一回事——agent 在 `locator.taskDir` 里跑,但写产物是写去 `ctx.projectRoot`,这个区分在 `graph-engine.js` 3736 行的"越界写入"检测里能看到:写到项目根之外会被判越界)。**结论:P5 只需要改一处入口(`allocateRunProjectRoot`),不需要在 org graph 侧另建一套。** | ||
| 143 | + | ||
| 144 | +**坏消息:现在的分配粒度是"每个 root run 一份全新 sandbox",不是"每个公司一份持久目录"**。`allocateRunProjectRoot()` 硬编码 `mode: 'sandbox'`,且**没有任何"复用已有项目根"的入参**——`ensureProjectRoot()` 的幂等只在**同一个 root run 内**成立(先读 `project-root.json` 再决定要不要新分配),跨 run(也就是跨 task)**永远分配一个新 `pid`**,哪怕是同一个公司、同一个 goal 下的两个 task,也会落进两个互不相干的空目录。这比"没法接管外部目录"更根本——现在**同一家公司自己的两个任务都不共享代码**,遑论接管一个外部已有仓库。 | ||
| 145 | + | ||
| 146 | +**P5 真正要做的三件事**(比原先设想的"补个配置项"要深一层): | ||
| 147 | + | ||
| 148 | +1. **公司级配置**:`company.json` 加 `target_dir`(绝对路径,可空)+ `target_mode`(`linked-new` / `linked-existing`,配了 `target_dir` 才有意义)。 | ||
| 149 | +2. **改 `allocateRunProjectRoot(run)`**:读 `run.company_id` 对应的 `company.json.target_dir`。**配了就不再"每 run 分配新 pid",而是所有该公司的 run 一律解析到同一个 `root_path`**(跳过 `path-resolver` 的 pid/registry 那一整套,直接返回固定路径,首次访问时按 `target_mode` 做一次 `linked-existing`/`linked-new` 的校验——现成的校验逻辑,`path-resolver.js` 261-310 行原样复用,只是只做一次,不是每个 run 各做一次);没配 `target_dir` 就走现在的 `sandbox` 老路径,行为不变。 | ||
| 150 | +3. **⚠️ 并发风险,必须在设计里显式处理**:一旦多个 task 共享同一个 `target_dir`,就与前面讨论过的"跨 task 并发"(`max_parallel_tasks > 1`)正面相撞——两个 coder agent 同时写同一个 git 仓库会冲突,`linked-existing` 现有的"进入时 working tree 必须干净"只是一次性校验,不是运行期互斥锁。**建议 v1 做法**:`company.json.target_dir` 非空时,该公司的 `max_parallel_tasks` 强制钉死为 1(在 `maxActiveRootRuns()` 里加一条:读到 `target_dir` 就忽略/覆盖用户配的 `max_parallel_tasks`),从根上排除并发写冲突,比引入 git worktree/分支合并那一整套复杂度便宜得多。`ponytail`: 这是刻意的简化,天花板是"一家公司同一时间只能有一个任务真正动代码";真要多任务并发写同一个仓库,需要 git worktree + 完成时合并这一层,属于独立工作量,不在这次范围内。 | ||
| 151 | + | ||
| 152 | +**建议**:P5 独立于 P1-P4 推进(数据面完全不相关),优先级可以更高——但工作量比最初评估要大一档,不是"公司级配置 + 一次核实"就完,是"公司级配置 + 改分配粒度 + 并发约束联动"三件套。 | ||
| 153 | + | ||
| 154 | +--- | ||
| 155 | + | ||
| 156 | +## 三、修订后的建议顺序 | ||
| 157 | + | ||
| 158 | +用户原始顺序是 P1→P2→P3→P4,本文档建议倒过来做地基: | ||
| 159 | + | ||
| 160 | +``` | ||
| 161 | +第一步:P4 地基 —— 建 task-board-tools(读写 goal/task 的 agent 工具) | ||
| 162 | + + routeFailure 里接确定性的「四分类 → 自动开新 task」规则 | ||
| 163 | +第二步:P1 收窄版 —— 给 ceo 配 heartbeat.md(监督规则,认新 goal/task 模型不是旧 OKR) | ||
| 164 | + + 挂上第一步的工具 | ||
| 165 | +第三步:P3 收窄版 —— 心跳只做异常判断,事件触发优先、轮询兜底、绝不重做调度 | ||
| 166 | +第四步:P4 完整版 —— 把「主观判断要不要开新 task」的部分交给已经装好工具的 ceo | ||
| 167 | +第五步:P2(UI) —— 独立任务看板页面,随时可插入,风险最低 | ||
| 168 | +并行轨:P5 —— 公司级默认 target dir 配置 + 核实 org graph 交付物落点是否需要同样机制 | ||
| 169 | +``` | ||
| 170 | + | ||
| 171 | +理由:P1/P3 的 boss agent 在 P4 地基建好之前没有可操作的对象;P2 是纯 UI,不卡任何人;P5 数据面独立,可以完全并行推进。 | ||
| 172 | + | ||
| 173 | +--- | ||
| 174 | + | ||
| 175 | +## 四、开放问题(已拍板,2026-08-30) | ||
| 176 | + | ||
| 177 | +1. ~~`ceo` 会不会和已有流水线角色冲突~~ **已核实排除**:`system/org-graph-templates/{dev,content,ops}.json` 三份模板全文搜索 `ceo`,零命中——`ceo` 现在没有出现在任何一张图里,是完全没被使用的孤儿本体。给它加监督职责没有冲突。 | ||
| 178 | +2. **P4 确定性通路新开 task 的 `report_to`:继承原 goal 的 `report_to`**。`okr_node_id` 不用单独决定——它按 P3C §四.7 既定规则只挂在 `goals` 表,新 task 挂在同一个 `goal_id` 下自动沿用该 goal 的 OKR 归属,不存在"新开一份"的问题。 | ||
| 179 | +3. **心跳成本策略:事件触发为主 + 低频兜底轮询**(不加"异常持续够久才唤醒"的抖动过滤,先简单落地)。主路径挂在 `transitionRun` 等既有钩子上,异常状态一出现就唤醒;另加一个量级在 30 分钟左右的低频轮询兜底,防钩子覆盖不到的漏网情况——直接照抄 `task-scheduler.js` 头部注释里"兜底扫描周期"那个既有设计哲学,不重新发明。 | ||
| 180 | +4. **`ceo` 可以全权自主 `enqueue` 自己提的 draft task,不需要等人类点**——这是五个选项里最激进的一档,明确高于 `task-planning`(§四.9 定案:产出永远先落 draft,人点"全部下达")的自主权限。**这个选择意味着安全网必须落在别处,不能靠"人工审核"这一层**:落地时要确保 `max_parallel_tasks`(防止一次性并发太多)、`DEFAULT_TASK_BUDGET`(防止单任务无限烧钱)、goal 级 `org-validator` 收敛验收(防止"完成"了但没真正达成)这几道既有闸门都乘上了 `ceo` 自主创建的 task,不能因为创建路径变了就绕开——这几道闸门本来就是数据层/调度器层强制的(不挂在"谁创建的"这个字段上),照理天然覆盖,但落地时要专门写一条回归测试钉死这一点。 | ||
| 181 | +5. **P5 org graph 交付物落点调研:已做完**,结论见 §二 P5 小节——不是两套系统(复用同一个 `path-resolver`),但分配粒度是"每个 task 一份全新 sandbox",同公司任务之间都不共享代码,比预想的工作量更大,且与"跨 task 并发"直接联动,需要一条并发约束兜底。 | ||
| 182 | +6. **P4 确定性通路的实际挂载点:已实现并确认(2026-08-30)**。文档原先设想挂在 DevLoop 的四分类(`spec_ambiguous`/`contract_mismatch`)上——但那是 DevLoop 专属词汇,`graph-engine.js` 的 `routeFailure` 用的是另一套(`impl_bug`/`input_underspecified`/`skill_defect`/`infra_flake`),根本没有这两个取值。实现时改挂在 `handleBackwardEdge` 的双层硬顶熔断(单边 `loop_count>3` 或 run 级 `backward_count>5` → `force_terminated`)上——**任何**原因导致的反复回退不收敛都触发,不按 `fail_reason` 精筛。**已确认维持这个实现,不额外收窄**。副作用是良性的:`infra_flake` 走的是完全独立的原地重跑代码路径(不经过 `handleBackwardEdge`),结构上天然不会触发这条自动开新任务的规则,不需要专门加排除逻辑。落点函数:`graph-engine.js` 的 `maybeProposeReplanTask()`,挂在 `handleBackwardEdge` 熔断分支里(约 2131-2133 行)。 |
docs/todo/system-design/40-agent-chat-streaming-interrupt.md
0 → 100644
| 1 | +# 40-agent-chat-streaming-interrupt. Agent Chat 打字机平滑 + 打断-排队 + 串台加固 | ||
| 2 | + | ||
| 3 | +> **日期**: 2026-08-30 | ||
| 4 | +> **状态**: 📝 需求文档,讨论中,尚未实现 | ||
| 5 | +> **触发**: 用户反馈"前端刷新不及时、没有打字机效果",怀疑要不要把传输层从 HTTP 换成 WebSocket | ||
| 6 | +> 来解决打字机效果、多 Agent/多聊天切换、执行中无法打断这几个问题。经排查现状代码 + | ||
| 7 | +> 对照 [hermes-webui](file:///Users/yibo/Documents/lemonAI/hermes-webui)、 | ||
| 8 | +> [hermes-agent](file:///Users/yibo/Documents/lemonAI/hermes-agent)、 | ||
| 9 | +> [deepseek-harness](file:///Users/yibo/Documents/lemonAI/deepseek-harness) 三个参考实现, | ||
| 10 | +> 结论是**传输层不是瓶颈,不需要引入 WebSocket**——三个参考项目全部走 SSE/HTTP 流式, | ||
| 11 | +> 没有一个用 WebSocket。真正的差距是另外三件独立的事,本文档逐一给方案。 | ||
| 12 | + | ||
| 13 | +--- | ||
| 14 | + | ||
| 15 | +## 起因 | ||
| 16 | + | ||
| 17 | +现状排查发现:LemonBoss 的 Agent Chat **已经是 SSE 流式**,不是普通 HTTP 一问一答—— | ||
| 18 | +`web/src/views/AgentChat.vue:941` 用 `fetch().body.getReader()` 逐行解析 | ||
| 19 | +`event:`/`data:`,后端 `server/routes/agent-chat.js:2542`(`POST /:agentId/chat`)用 | ||
| 20 | +`setupSSE(res)` 把 Claude CLI 子进程吐出的 ACP `agent_message_chunk` 事件实时转发给前端。 | ||
| 21 | +多聊天"串台"(切换 Agent 时旧流写串到新 Agent 状态里)已经在 `9b1c3f1` 修复:每个 Agent | ||
| 22 | +一份独立的 `chatStates` reactive 状态 + `requestAgentId` 快照,事件处理函数(`handleEvent`, | ||
| 23 | +`AgentChat.vue:991`)全程用 `isCurrent()` 校验"这条流是不是还对应当前选中的 Agent"。 | ||
| 24 | + | ||
| 25 | +对照 hermes-webui(`api/gateway_chat.py` 的 `_stream_writeback_is_current`)和 | ||
| 26 | +deepseek-harness(`interruptByParent`:中止当前 turn → 新消息进 FIFO → 排队执行), | ||
| 27 | +确认这套"per-stream id + is-current 校验"就是业界通用解法,LemonBoss 已经用对了模式, | ||
| 28 | +不需要换传输层来重新解决一遍。 | ||
| 29 | + | ||
| 30 | +真正剩下的三个独立问题: | ||
| 31 | + | ||
| 32 | +1. **打字机不流畅**——不是传输问题,是 ACP chunk 粒度太粗(Claude CLI 一次吐一大段,不是 | ||
| 33 | + 逐字/逐 token),SSE 本身能把这段文本秒级送达前端,但前端拿到手就是一大坨,没有"逐字吐出" | ||
| 34 | + 的观感。 | ||
| 35 | +2. **执行中无法打断并接上新消息**——现状是"先 Stop 再发"两步操作,且发送框在 | ||
| 36 | + `chatState.sending` 为真时整个被 `disabled`(`AgentChat.vue:192`),物理上打不了字。 | ||
| 37 | + deepseek-harness 有专门的打断-排队状态机,LemonBoss 没有。 | ||
| 38 | +3. **串台已修,但没有回归测试兜底**——`9b1c3f1` 是一次性修复,`isCurrent()` 校验散落在 | ||
| 39 | + `handleEvent` 十几个分支里,没有测试盯着,将来改动容易在某个分支漏掉校验又退回去。 | ||
| 40 | + | ||
| 41 | +--- | ||
| 42 | + | ||
| 43 | +## 一、现状代码地图(后续设计都基于这份) | ||
| 44 | + | ||
| 45 | +| 组件 | 位置 | 现状行为 | | ||
| 46 | +|------|------|----------| | ||
| 47 | +| 前端发送/接收 | `AgentChat.vue:908-969`(`handleSend`) | `fetch()` 开流,`st.sending=true` 期间 textarea `disabled`(`:192`),流结束才 `sending=false` | | ||
| 48 | +| 前端事件分发 | `AgentChat.vue:991-1042`(`handleEvent`) | 按 `requestAgentId === agentId.value` 做 `isCurrent()` 校验,非当前 Agent 的流只落状态不刷 UI | | ||
| 49 | +| 后端 SSE 入口 | `agent-chat.js:2542`(`POST /:agentId/chat`) | 每次请求 `while(loopCount<10)` 循环喂 prompt 给 ACP 协议,`onUpdate` 把 `agent_message_chunk` 原样透传成一条 SSE `agent_output` 事件 | | ||
| 50 | +| 后端并发闸门 | `agent-chat.js:2552-2568`(`agentChatLocks`,键是 `agentId`) | 同一 Agent、**不同** convId 并发请求直接 `429` 拒绝;同一 Agent、**相同** convId 的并发请求**不会**被这段拒绝——第二个请求会直接覆盖锁往下跑,等于两个循环同时喂同一个 ACP 会话。目前靠前端 `disabled` 挡住,服务端没有闸门(见下文风险①) | | ||
| 51 | +| 停止端点 | `agent-chat.js:1505`(`POST /:agentId/chat/:convId/stop`) | 调用 `closeSession(key, reason)`(`agent-chat.js:840`),即 `entry.protocol.disconnect()` 杀掉常驻 CLI 子进程,是"硬中止",不接排队 | | ||
| 52 | +| 锁释放 | `agent-chat.js:2880`(`finally` 块) | 请求 handler 退出时统一 `agentChatLocks.delete(agentId)` + `res.end()`;中止后原请求是否会立刻走到这里,取决于 `protocol.disconnect()` 是否让 handler 内部 `await` 的 ACP 调用抛错——**需要在实现阶段用真实 CLI 验证一次**(见风险②) | | ||
| 53 | + | ||
| 54 | +--- | ||
| 55 | + | ||
| 56 | +## 二、方案一:打字机平滑(两端都做) | ||
| 57 | + | ||
| 58 | +**根因**:ACP `agent_message_chunk` 的粒度由 Claude CLI 决定,一次可能带几十到几百字符, | ||
| 59 | +SSE 转发是逐 chunk 转发(`agent-chat.js:2628` `sendSSE('agent_output', ...)`),前端收到 | ||
| 60 | +就是这一整段直接拼进 `st.streamContent`,没有二次拆分,所以观感是"一坨一坨蹦出来"而不是 | ||
| 61 | +打字机。 | ||
| 62 | + | ||
| 63 | +### 2.1 后端:转发前按字符数切片 | ||
| 64 | + | ||
| 65 | +在 `agent-chat.js:2624` 的 `onUpdate` 里,把 `update.content.text` 按固定步长(比如 8-12 | ||
| 66 | +字符一片)拆成多条 `sendSSE('agent_output', ...)`,而不是一次性整段发送。这是唯一需要碰 | ||
| 67 | +后端的点,改动局限在这一个回调函数内,不影响日志落盘(`logMessageChunk` 仍然整段写,只是 | ||
| 68 | +发给前端的部分被拆片)。 | ||
| 69 | + | ||
| 70 | +### 2.2 前端:渲染节流缓冲 | ||
| 71 | + | ||
| 72 | +在 `handleEvent` 收到 `agent_output` 时(`AgentChat.vue:1015`),不直接把整段 `data.message` | ||
| 73 | +拼进 `st.streamContent`,改成推进一个逐字符渲染队列,用 `requestAnimationFrame` 或 | ||
| 74 | +`setInterval` 按固定速率(比如 30-50 字符/秒)从队列取字符追加显示。这样即使后端某次真的 | ||
| 75 | +一次性吐了一大段(比如网络抖动攒批),前端也能把它"抹平"成匀速的打字机效果,不依赖后端切片 | ||
| 76 | +的粒度是否稳定。 | ||
| 77 | + | ||
| 78 | +两端都做的原因:后端切片解决"数据到达的最小单位太粗",前端缓冲解决"到达节奏不均匀"(网络 | ||
| 79 | +延迟、CLI 输出突发)——只做一端在极端情况下(比如后端切了片但网络一次性把好几个 SSE 事件 | ||
| 80 | +一起送到)还是会看起来一卡一卡。 | ||
| 81 | + | ||
| 82 | +--- | ||
| 83 | + | ||
| 84 | +## 三、方案二:打断-排队状态机 | ||
| 85 | + | ||
| 86 | +### 3.1 交互设计 | ||
| 87 | + | ||
| 88 | +保留现有 **Stop 按钮**(`AgentChat.vue:193`,⏹)不变——纯中止,点了就是杀会话,不接后续。 | ||
| 89 | + | ||
| 90 | +新增:**执行中解锁输入框**,允许在 `chatState.sending === true` 时继续打字。此时发送按钮 | ||
| 91 | +的语义从"发送"变成"**打断并发送**",两个操作在视觉上要能一眼区分开——用户分不清点了哪个, | ||
| 92 | +后果是"以为消息接上了,其实这轮直接被丢掉": | ||
| 93 | + | ||
| 94 | +- **Stop 按钮**(`AgentChat.vue:193`,⏹):图标/文案不变,补一句 tooltip「仅停止,不发送 | ||
| 95 | + 新消息」。 | ||
| 96 | +- **发送按钮在 sending 态下**:换一个视觉上明显不同的图标/配色(暖色调,区别于平时发送用的 | ||
| 97 | + 主色),tooltip 改为「发送并打断当前回复」。 | ||
| 98 | +- **打断成功后**,在对话记录里插入一条不打断阅读的**分隔标记**(细线 + 小字,类似 | ||
| 99 | + "— 已打断上一条回复 —"),让用户回看聊天记录时能看懂"上一条为什么没说完"——这是 | ||
| 100 | + ChatGPT / Claude.ai 自己在"stop and continue"场景下的通用做法。 | ||
| 101 | + | ||
| 102 | +点击"打断并发送"后的具体流程: | ||
| 103 | + | ||
| 104 | +1. 前端把发送按钮切到"打断中…"状态,锁住重复点击(防止手速快连点两次触发两轮打断); | ||
| 105 | +2. 调用现有 `stop` 端点(复用 `agent-chat.js:1505`,不新增中止路由,但要改内部实现,见 | ||
| 106 | + 3.1.1); | ||
| 107 | +3. 根据 `stop` 返回的 `cleaned` 字段决定下一步(见 3.1.1),确认可以安全发送后,把这条新 | ||
| 108 | + 消息按**正常发送流程**投出去,带**同一个 convId**,接续同一个对话历史,并在发送成功后 | ||
| 109 | + 插入上面说的分隔标记。 | ||
| 110 | + | ||
| 111 | +这本质是"打断并立即顶替",但工程实现上做成一个**深度为 1 的排队**(先确认上一个 turn 真的 | ||
| 112 | +已经退出,再发新的),不是前端拿到点击事件就立刻并发发第二个请求——原因见风险①: | ||
| 113 | +服务端目前对"同一 convId 并发"没有闸门,真并发会导致两个循环同时写同一个 ACP 会话。 | ||
| 114 | + | ||
| 115 | +#### 3.1.1 `stop` 端点改造:后端确认清理完成,而不是前端猜时间 | ||
| 116 | + | ||
| 117 | +现状 `stop` 端点(`agent-chat.js:1505-1512`)调用 `closeSession` 后立刻同步返回,不代表 | ||
| 118 | +原来那个 `/chat` 请求的 handler 已经真正退出(风险②)。改造成: | ||
| 119 | + | ||
| 120 | +- `stop` 端点内部在调用 `closeSession` 之后,轮询等待 `agentChatLocks` 里对应 `agentId` | ||
| 121 | + 的锁被清掉(即原请求走到 `agent-chat.js:2880` 的 `finally`),**预算 3 秒**(`closeSession` | ||
| 122 | + 杀子进程本身是毫秒级,慢的是原请求 `await` 被异常唤醒到走完 `finally` 这段,3 秒是给日志 | ||
| 123 | + flush 这类边缘情况留的余量,正常路径应该用不到)。 | ||
| 124 | +- 3 秒内确认清理完成 → 返回 `{ success: true, cleaned: true }`,前端立刻发新消息,用户基本 | ||
| 125 | + 感觉不到延迟。 | ||
| 126 | +- 3 秒仍未确认(极端情况) → 返回 `{ success: true, cleaned: false }`(尽力而为,不算失败), | ||
| 127 | + 前端**不自动强发**(会撞回风险①的并发洞),而是在输入框附近给一条不遮挡输入的提示 | ||
| 128 | + 「上一条还在收尾…」+ 一个「强制发送」按钮,把"要不要冒这个小概率风险"的决定权交还给用户, | ||
| 129 | + 而不是系统默默赌一把。 | ||
| 130 | + | ||
| 131 | +### 3.2 需要新增/改动的点 | ||
| 132 | + | ||
| 133 | +| 位置 | 改动 | | ||
| 134 | +|------|------| | ||
| 135 | +| `AgentChat.vue:192` | textarea 的 `:disabled="chatState.sending"` 改成允许 sending 态下继续输入;`autoResize`/`handleKeydown` 不受影响 | | ||
| 136 | +| `AgentChat.vue:194` | 发送按钮增加"sending 态下点击 = 打断并发送"分支,UI 上给一个过渡态(如按钮文案/图标切换) | | ||
| 137 | +| `AgentChat.vue:handleSend`(`:908`) | 抽出"等待上一个 turn 收尾"的等待逻辑,供打断分支复用 | | ||
| 138 | +| `agent-chat.js:2552-2568`(`agentChatLocks`) | **补上同 convId 并发闸门**(风险①,见下)——无论是否做打断功能,这都是一个已经存在、只是暂时没被前端触发到的并发 bug,建议顺带修 | | ||
| 139 | +| `agent-chat.js:1505`(stop 端点) | 改成内部轮询确认清理完成再返回,加 `cleaned: true/false` 字段,见 3.1.1 | | ||
| 140 | + | ||
| 141 | +### 3.3 风险 / 需要在实现阶段验证的点 | ||
| 142 | + | ||
| 143 | +1. **同 convId 并发无闸门(现存 bug,非本次引入)**:`agent-chat.js:2554` 的判断是 | ||
| 144 | + `existingLock.convId !== convId` 才拒绝,同 convId 的第二个请求会直接覆盖锁往下跑。 | ||
| 145 | + 目前靠前端 `disabled` 属性挡住,从没被服务端真正堵过。一旦解锁输入框,这条路径变得 | ||
| 146 | + 可达,必须先补服务端闸门,否则用户手速快一点就能让两个循环同时喂同一个 ACP 会话(未定义 | ||
| 147 | + 行为,轻则消息乱序,重则 CLI 子进程状态损坏)。 | ||
| 148 | +2. **中止后原请求是否会及时退出,还未验证**:`closeSession` 只是 `protocol.disconnect()`, | ||
| 149 | + 原来那个还挂着 `await` 的 `POST /chat` 请求 handler 会不会因此立刻抛错走到 | ||
| 150 | + `finally`(`:2880`)释放锁、结束响应,取决于 `ClaudeCliProtocol` 底层对子进程异常退出的 | ||
| 151 | + 处理方式。**已拍板的应对方案见 3.1.1**(`stop` 端点内部轮询 + 3 秒预算 + `cleaned` 字段 | ||
| 152 | + + 前端超时兜底按钮);实现阶段仍需要用一次真实 Claude CLI 会话验证「中止后原请求平均/最坏 | ||
| 153 | + 多久会真正退出」,如果实测远超 3 秒(比如需要等底层网络超时),要回来调整这个预算数字, | ||
| 154 | + 而不是改设计本身。 | ||
| 155 | + | ||
| 156 | +--- | ||
| 157 | + | ||
| 158 | +## 四、方案三:串台加固(回归测试) | ||
| 159 | + | ||
| 160 | +`9b1c3f1` 的修复方式是在十几个 `handleEvent` 分支里散落地加 `if (isCurrent) ...`,容易在 | ||
| 161 | +未来新增分支时漏掉。建议补一个**前端组件级测试**(而不是走完整 E2E),思路: | ||
| 162 | + | ||
| 163 | +- 模拟两个 Agent 的 `chatStates`,对 Agent A 发起一次"慢"流(用假的 SSE 事件序列,中间插 | ||
| 164 | + 延时),在流还没结束时把 `agentId.value` 切到 Agent B; | ||
| 165 | +- 断言 Agent A 后续到达的事件(`thinking` / `agent_output` / `tool_call` 等每一种 SSE | ||
| 166 | + 事件类型都要覆盖到)只更新了 `chatStates.get('A')`,不影响当前显示(Agent B 的视图); | ||
| 167 | +- 反向断言:切回 Agent A 时,之前"憋住"没刷 UI 的内容应该已经正确落在 `chatStates.get('A')` | ||
| 168 | + 里,切回来就能看到完整历史,不丢消息。 | ||
| 169 | + | ||
| 170 | +这条测试的价值不是证明现在没 bug(`9b1c3f1` 已经手工验证过),而是**卡住以后的回归**—— | ||
| 171 | +后续如果给 `handleEvent` 加新的 SSE 事件类型,忘了包 `isCurrent()` 判断,测试会红。 | ||
| 172 | + | ||
| 173 | +--- | ||
| 174 | + | ||
| 175 | +## 五、不做的事(明确排除) | ||
| 176 | + | ||
| 177 | +- **不引入 WebSocket**。三个参考项目和 LemonBoss 现状都证明 SSE 足够,问题不在传输层。 | ||
| 178 | + 真要引入 WebSocket 的场景是"没有聊天窗口打开时也要收到 Agent 的后台推送"(比如心跳异常 | ||
| 179 | + 主动弹通知),这是一个不同的需求,不属于本文档范围,需要的话应该另开文档讨论。 | ||
| 180 | +- **不做"多条消息同时排队"的完整 FIFO**。deepseek-harness 那套是为多 Agent | ||
| 181 | + parent-child 场景设计的,LemonBoss 这里是单用户单会话打断,深度 1("打断当前的,接上 | ||
| 182 | + 新的")已经覆盖需求,做完整队列是过度设计。 | ||
| 183 | + | ||
| 184 | +--- | ||
| 185 | + | ||
| 186 | +## 六、实施顺序建议 | ||
| 187 | + | ||
| 188 | +三个方案彼此独立,互不阻塞,建议顺序(从风险低/收益快到风险高): | ||
| 189 | + | ||
| 190 | +1. **方案三**(串台回归测试)——纯新增测试,零风险,先把安全网铺好。 | ||
| 191 | +2. **方案一**(打字机平滑)——只碰渲染逻辑和一个转发回调,不改并发/状态机,风险最低。 | ||
| 192 | +3. **方案二**(打断-排队)——涉及并发闸门(风险①必须先修)和状态机改造,且风险②需要真实 | ||
| 193 | + CLI 验证,建议放最后,且实现前先用一次真实 Claude CLI 会话验证"中止后原请求多久会退出"。 | ||
| 194 | + | ||
| 195 | +## 七、已拍板的设计决策 | ||
| 196 | + | ||
| 197 | +以下两点原本是未决问题,已确认,写在这里避免以后重新讨论一遍: | ||
| 198 | + | ||
| 199 | +1. **超时策略**:由后端 `stop` 端点内部确认清理完成(轮询 `agentChatLocks`),预算 **3 秒**; | ||
| 200 | + 超时不代表失败,返回 `cleaned: false`,前端不自动强发,展示「上一条还在收尾…」+ 手动 | ||
| 201 | + 「强制发送」按钮,风险由用户显式承担,不做静默重试。详见 3.1.1。 | ||
| 202 | +2. **UI 区分**:Stop 按钮和"sending 态下的发送按钮"必须视觉上可区分(图标/配色 + 不同 | ||
| 203 | + tooltip),打断成功后在对话记录里插入一条分隔标记,便于事后回看。详见 3.1。 | ||
| 204 | + | ||
| 205 | +本文档目前没有遗留的未决问题;进入实现阶段后如果冒出新的(比如风险②实测的具体数字要调整 | ||
| 206 | +预算),回来更新这一节。 |
scripts/backfill-ceo-hire.js
0 → 100644
| 1 | +#!/usr/bin/env node | ||
| 2 | +/** | ||
| 3 | + * backfill-ceo-hire.js —— 存量公司补雇 ceo | ||
| 4 | + * | ||
| 5 | + * 依据: docs/todo/system-design/39-boss-agent-task-supervision.md §二 P1「缺口 B」 | ||
| 6 | + * | ||
| 7 | + * `ceo` 不在任何 org-graph-template 里,也没有自动招聘钩子——POST /api/company/create | ||
| 8 | + * 这次起改成新建公司时固定雇一次,但存量公司不会补跑那条创建流程,需要这个脚本单独补雇。 | ||
| 9 | + * | ||
| 10 | + * 幂等: hire() 本身按 company_agents 表判重(见 company-service.js:429),已经雇过的公司 | ||
| 11 | + * 会拿到 status='already_exists',跳过不算失败,可重复执行。 | ||
| 12 | + * | ||
| 13 | + * 用法: | ||
| 14 | + * node scripts/backfill-ceo-hire.js | ||
| 15 | + */ | ||
| 16 | + | ||
| 17 | +const fs = require('fs'); | ||
| 18 | +const path = require('path'); | ||
| 19 | + | ||
| 20 | +const DATA_DIR = path.resolve(__dirname, '../data/companies'); | ||
| 21 | + | ||
| 22 | +function main() { | ||
| 23 | + const { CompanyService } = require('../server/services/company-service'); | ||
| 24 | + const companyService = new CompanyService(); | ||
| 25 | + | ||
| 26 | + if (!fs.existsSync(DATA_DIR)) { | ||
| 27 | + console.log('[backfill-ceo-hire] data/companies/ 不存在,无需处理'); | ||
| 28 | + return; | ||
| 29 | + } | ||
| 30 | + | ||
| 31 | + const dirs = fs.readdirSync(DATA_DIR, { withFileTypes: true }).filter((d) => d.isDirectory()); | ||
| 32 | + let hired = 0; | ||
| 33 | + let skipped = 0; | ||
| 34 | + let failed = 0; | ||
| 35 | + | ||
| 36 | + for (const d of dirs) { | ||
| 37 | + const fp = path.join(DATA_DIR, d.name, 'company.json'); | ||
| 38 | + if (!fs.existsSync(fp)) continue; | ||
| 39 | + | ||
| 40 | + try { | ||
| 41 | + const result = companyService.hire(d.name, 'ceo', {}); | ||
| 42 | + if (result.status === 'already_exists') { | ||
| 43 | + skipped += 1; | ||
| 44 | + } else { | ||
| 45 | + console.log(`[backfill-ceo-hire] ${d.name} → ceo(${result.status})`); | ||
| 46 | + hired += 1; | ||
| 47 | + } | ||
| 48 | + } catch (e) { | ||
| 49 | + console.error(`[backfill-ceo-hire] ${d.name} 雇 ceo 失败: ${e.message}`); | ||
| 50 | + failed += 1; | ||
| 51 | + } | ||
| 52 | + } | ||
| 53 | + | ||
| 54 | + console.log(`[backfill-ceo-hire] 完成:新雇 ${hired} 家,跳过(已雇过)${skipped} 家,失败 ${failed} 家`); | ||
| 55 | + if (failed > 0) process.exitCode = 1; | ||
| 56 | +} | ||
| 57 | + | ||
| 58 | +main(); |
server/index.js
| @@ -173,6 +173,13 @@ async function start() { | @@ -173,6 +173,13 @@ async function start() { | ||
| 173 | } catch (err) { | 173 | } catch (err) { |
| 174 | console.error('[Startup] 僵尸 goal 扫描启动失败:', err.message); | 174 | console.error('[Startup] 僵尸 goal 扫描启动失败:', err.message); |
| 175 | } | 175 | } |
| 176 | + // 39 号文档 P3:ceo 低频兜底扫描(30 分钟一次),补事件驱动唤醒(settleRootRun 里 | ||
| 177 | + // 那条)漏掉的 blocked task。同 startZombieGoalSweeper 的形状,挂在同一处。 | ||
| 178 | + try { | ||
| 179 | + taskSettle.startCeoSweeper(); | ||
| 180 | + } catch (err) { | ||
| 181 | + console.error('[Startup] ceo 兜底扫描启动失败:', err.message); | ||
| 182 | + } | ||
| 176 | // 27-P3C 批 3:任务队列。进程重启会丢掉所有在途的 setImmediate 唤醒 | 183 | // 27-P3C 批 3:任务队列。进程重启会丢掉所有在途的 setImmediate 唤醒 |
| 177 | // (队列钩子挂在 graph-run-sse.transitionRun 上,那是进程内的), | 184 | // (队列钩子挂在 graph-run-sse.transitionRun 上,那是进程内的), |
| 178 | // 于是重启后队里的任务会一直躺着 —— 界面上和「正在排队」一模一样,正是本批要消灭的症状。 | 185 | // 于是重启后队里的任务会一直躺着 —— 界面上和「正在排队」一模一样,正是本批要消灭的症状。 |
server/routes/agent-chat.js
| @@ -1502,13 +1502,18 @@ router.delete('/:agentId/conversations/:convId', (req, res) => { | @@ -1502,13 +1502,18 @@ router.delete('/:agentId/conversations/:convId', (req, res) => { | ||
| 1502 | }); | 1502 | }); |
| 1503 | 1503 | ||
| 1504 | /** 停止当前正在生成的任务 */ | 1504 | /** 停止当前正在生成的任务 */ |
| 1505 | -router.post('/:agentId/chat/:convId/stop', (req, res) => { | 1505 | +// 40 号文档方案二(3.1.1):中止后不能立刻同步返回——原来那个 /chat 请求的 handler |
| 1506 | +// 是否已经走到 finally(释放 agentChatLocks、res.end())还不确定,"打断并发送"要用这个 | ||
| 1507 | +// 信号判断能不能安全发第二条。这里轮询等最多 3 秒,超时不算失败,只是 cleaned:false, | ||
| 1508 | +// 交给前端决定要不要"强制发送"。 | ||
| 1509 | +router.post('/:agentId/chat/:convId/stop', async (req, res) => { | ||
| 1506 | const { agentId, convId } = req.params; | 1510 | const { agentId, convId } = req.params; |
| 1507 | const key = `${agentId}:${convId}`; | 1511 | const key = `${agentId}:${convId}`; |
| 1508 | - if (closeSession(key, '用户手动中断')) { | ||
| 1509 | - return res.json({ success: true, message: 'Agent 已中断' }); | 1512 | + if (!closeSession(key, '用户手动中断')) { |
| 1513 | + return res.json({ success: false, message: '未找到运行中的 Agent 进程' }); | ||
| 1510 | } | 1514 | } |
| 1511 | - res.json({ success: false, message: '未找到运行中的 Agent 进程' }); | 1515 | + const cleaned = await waitForLockRelease(agentId); |
| 1516 | + res.json({ success: true, message: 'Agent 已中断', cleaned }); | ||
| 1512 | }); | 1517 | }); |
| 1513 | 1518 | ||
| 1514 | /** 读取 bootstrap file */ | 1519 | /** 读取 bootstrap file */ |
| @@ -2537,7 +2542,17 @@ router.get('/:agentId/uploads/:convId/:filename', (req, res) => { | @@ -2537,7 +2542,17 @@ router.get('/:agentId/uploads/:convId/:filename', (req, res) => { | ||
| 2537 | // 同一 convId 的多轮对话不受限制(ACP 进程复用) | 2542 | // 同一 convId 的多轮对话不受限制(ACP 进程复用) |
| 2538 | // 32 号文档方案 B:这把锁挪到了共享模块 agent-session-lock.js,company.js 的招聘装配 | 2543 | // 32 号文档方案 B:这把锁挪到了共享模块 agent-session-lock.js,company.js 的招聘装配 |
| 2539 | // 重试逻辑也要用同一把——两条路径各自维护一份的话,各自都以为自己没有并发。 | 2544 | // 重试逻辑也要用同一把——两条路径各自维护一份的话,各自都以为自己没有并发。 |
| 2540 | -const { agentChatLocks, AGENT_LOCK_TIMEOUT } = require('../src/core/agent-session-lock'); | 2545 | +const { agentChatLocks, AGENT_LOCK_TIMEOUT, waitForLockRelease } = require('../src/core/agent-session-lock'); |
| 2546 | + | ||
| 2547 | +// 40 号文档方案一:Claude CLI 一次 agent_message_chunk 可能带几十到几百字符,SSE 原样 | ||
| 2548 | +// 转发就是"一坨一坨蹦出来",没有打字机效果。这里按固定步长切成小片再各发一条 SSE 事件—— | ||
| 2549 | +// 只影响推给前端的粒度,`logMessageChunk` 落盘的仍是整段,不受影响。 | ||
| 2550 | +const TYPEWRITER_CHUNK_SIZE = 10; | ||
| 2551 | +function sliceForTypewriter(text, size = TYPEWRITER_CHUNK_SIZE) { | ||
| 2552 | + const pieces = []; | ||
| 2553 | + for (let i = 0; i < text.length; i += size) pieces.push(text.slice(i, i + size)); | ||
| 2554 | + return pieces; | ||
| 2555 | +} | ||
| 2541 | 2556 | ||
| 2542 | router.post('/:agentId/chat', async (req, res) => { | 2557 | router.post('/:agentId/chat', async (req, res) => { |
| 2543 | const { message, conversationId: reqConvId } = req.body; | 2558 | const { message, conversationId: reqConvId } = req.body; |
| @@ -2549,16 +2564,21 @@ router.post('/:agentId/chat', async (req, res) => { | @@ -2549,16 +2564,21 @@ router.post('/:agentId/chat', async (req, res) => { | ||
| 2549 | 2564 | ||
| 2550 | const convId = reqConvId || `conv-${Date.now().toString(36)}`; | 2565 | const convId = reqConvId || `conv-${Date.now().toString(36)}`; |
| 2551 | 2566 | ||
| 2552 | - // 🔒 并发互斥检查:同一 Agent 不同 convId 的并发请求被拒绝 | 2567 | + // 🔒 并发互斥检查:同一 Agent 的并发请求被拒绝——无论 convId 是否相同。 |
| 2568 | + // 40 号文档风险①:这里原来只挡"不同 convId",同一 convId 的第二个请求会直接 | ||
| 2569 | + // 覆盖锁往下跑,等于两个循环同时喂同一个 ACP 会话(未定义行为)。此前一直靠前端 | ||
| 2570 | + // textarea 的 disabled 属性挡住,从没被服务端真正堵过;"打断并发送"要解锁输入框, | ||
| 2571 | + // 这条路径就变得可达了,必须在这里把闸门补上。 | ||
| 2553 | const existingLock = agentChatLocks.get(agentId); | 2572 | const existingLock = agentChatLocks.get(agentId); |
| 2554 | - if (existingLock && existingLock.convId !== convId) { | 2573 | + if (existingLock) { |
| 2555 | const elapsed = Date.now() - existingLock.startedAt; | 2574 | const elapsed = Date.now() - existingLock.startedAt; |
| 2556 | if (elapsed < AGENT_LOCK_TIMEOUT) { | 2575 | if (elapsed < AGENT_LOCK_TIMEOUT) { |
| 2557 | console.log(`[AgentChat] 🔒 拒绝 ${agentId} 并发请求 — 已有活跃会话 ${existingLock.convId} (${(elapsed / 1000).toFixed(0)}s)`); | 2576 | console.log(`[AgentChat] 🔒 拒绝 ${agentId} 并发请求 — 已有活跃会话 ${existingLock.convId} (${(elapsed / 1000).toFixed(0)}s)`); |
| 2558 | return res.status(429).json({ | 2577 | return res.status(429).json({ |
| 2559 | error: `Agent ${agentId} 正在执行另一个会话 (${existingLock.convId}),请等待完成`, | 2578 | error: `Agent ${agentId} 正在执行另一个会话 (${existingLock.convId}),请等待完成`, |
| 2560 | activeConvId: existingLock.convId, | 2579 | activeConvId: existingLock.convId, |
| 2561 | - elapsedMs: elapsed | 2580 | + elapsedMs: elapsed, |
| 2581 | + sameConversation: existingLock.convId === convId, | ||
| 2562 | }); | 2582 | }); |
| 2563 | } | 2583 | } |
| 2564 | // 超时释放 | 2584 | // 超时释放 |
| @@ -2625,7 +2645,9 @@ router.post('/:agentId/chat', async (req, res) => { | @@ -2625,7 +2645,9 @@ router.post('/:agentId/chat', async (req, res) => { | ||
| 2625 | console.log(`[SSE-DEBUG] 📤 agent_output (${update.content.text.length} chars)`); | 2645 | console.log(`[SSE-DEBUG] 📤 agent_output (${update.content.text.length} chars)`); |
| 2626 | fullOutput += update.content.text; | 2646 | fullOutput += update.content.text; |
| 2627 | logMessageChunk(agentId, convId, update.content.text); | 2647 | logMessageChunk(agentId, convId, update.content.text); |
| 2628 | - sendSSE('agent_output', { agent: agentId, message: update.content.text }); | 2648 | + for (const piece of sliceForTypewriter(update.content.text)) { |
| 2649 | + sendSSE('agent_output', { agent: agentId, message: piece }); | ||
| 2650 | + } | ||
| 2629 | } else if (update?.sessionUpdate === 'agent_thought_chunk' && update.content?.text) { | 2651 | } else if (update?.sessionUpdate === 'agent_thought_chunk' && update.content?.text) { |
| 2630 | logMessageChunk(agentId, convId, update.content.text); | 2652 | logMessageChunk(agentId, convId, update.content.text); |
| 2631 | sendSSE('thinking', { message: update.content.text }); | 2653 | sendSSE('thinking', { message: update.content.text }); |
server/routes/company.js
| @@ -8,10 +8,14 @@ const express = require('express'); | @@ -8,10 +8,14 @@ const express = require('express'); | ||
| 8 | const path = require('path'); | 8 | const path = require('path'); |
| 9 | const fs = require('fs'); | 9 | const fs = require('fs'); |
| 10 | const router = express.Router(); | 10 | const router = express.Router(); |
| 11 | -const { requireAuth, INTERNAL_TOKEN, INTERNAL_TOKEN_HEADER } = require('../middleware/jwt-auth'); | 11 | +const { requireAuth, requireAuthOrInternal, INTERNAL_TOKEN, INTERNAL_TOKEN_HEADER } = require('../middleware/jwt-auth'); |
| 12 | 12 | ||
| 13 | // 38 号文档 Phase 1:全站要求登录。 | 13 | // 38 号文档 Phase 1:全站要求登录。 |
| 14 | -router.use(requireAuth); | 14 | +// 39 号文档:goals/tasks 系列端点要给 agent(如 ceo 的心跳监督循环)用 curl 直接调, |
| 15 | +// agent 子进程没有用户 cookie,改用 requireAuthOrInternal(放行带对 INTERNAL_TOKEN 的自环 | ||
| 16 | +// 调用,其余情况仍退回 requireAuth)——与 agent-chat.js 同一个模式,理由也相同: | ||
| 17 | +// 这个内部令牌只在本机进程树内有效,不落盘、不外泄,安全模型早已被 agent-chat.js 验证过。 | ||
| 18 | +router.use(requireAuthOrInternal); | ||
| 15 | 19 | ||
| 16 | const { CompanyService, listCompanyAgents, buildAgentDefinitionBrief, canAccessCompany } = require('../services/company-service'); | 20 | const { CompanyService, listCompanyAgents, buildAgentDefinitionBrief, canAccessCompany } = require('../services/company-service'); |
| 17 | const { ScheduleService } = require('../services/schedule-service'); | 21 | const { ScheduleService } = require('../services/schedule-service'); |
| @@ -32,6 +36,14 @@ const scheduleService = new ScheduleService(); | @@ -32,6 +36,14 @@ const scheduleService = new ScheduleService(); | ||
| 32 | // 404 而不是 403:不告诉调用方"这个 id 存在、只是不是你的",直接当不存在处理。 | 36 | // 404 而不是 403:不告诉调用方"这个 id 存在、只是不是你的",直接当不存在处理。 |
| 33 | router.param('companyId', (req, res, next, companyId) => { | 37 | router.param('companyId', (req, res, next, companyId) => { |
| 34 | const company = companyService.get(companyId); | 38 | const company = companyService.get(companyId); |
| 39 | + // 39 号文档:内部自环调用(agent 用 INTERNAL_TOKEN 发起,比如 ceo 心跳查看/操作 | ||
| 40 | + // goal/task 板)没有"当前登录用户"这个概念,不能对它做归属校验——与 | ||
| 41 | + // agent-chat.js 的 :agentId param 中间件是同一处理方式(req.user.internal 直接放行)。 | ||
| 42 | + if (req.user?.internal) { | ||
| 43 | + if (!company) return res.status(404).json({ error: `公司 ${companyId} 不存在` }); | ||
| 44 | + req.company = company; | ||
| 45 | + return next(); | ||
| 46 | + } | ||
| 35 | if (!canAccessCompany(company, req.user)) { | 47 | if (!canAccessCompany(company, req.user)) { |
| 36 | return res.status(404).json({ error: `公司 ${companyId} 不存在` }); | 48 | return res.status(404).json({ error: `公司 ${companyId} 不存在` }); |
| 37 | } | 49 | } |
| @@ -146,6 +158,15 @@ router.post('/create', async (req, res) => { | @@ -146,6 +158,15 @@ router.post('/create', async (req, res) => { | ||
| 146 | console.error('[Create] orggraph-planning 自动招聘失败(不影响公司创建):', e.message); | 158 | console.error('[Create] orggraph-planning 自动招聘失败(不影响公司创建):', e.message); |
| 147 | } | 159 | } |
| 148 | 160 | ||
| 161 | + // 39 号文档 P1:固定雇一个 ceo,负责监督 goal/task 板(不进任何 org graph, | ||
| 162 | + // 与 org-graph-template 选哪张无关,所以独立于上面那段的 bootstrap.status)。 | ||
| 163 | + // 同样遵守「任何失败都不能影响公司创建」的降级约定。 | ||
| 164 | + try { | ||
| 165 | + companyService.hire(id, 'ceo', {}); | ||
| 166 | + } catch (e) { | ||
| 167 | + console.error('[Create] ceo 自动招聘失败(不影响公司创建):', e.message); | ||
| 168 | + } | ||
| 169 | + | ||
| 149 | res.json({ success: true, company, message: '公司已创建,正在由 orggraph-planning 自动规划组织结构(异步进行中)。' }); | 170 | res.json({ success: true, company, message: '公司已创建,正在由 orggraph-planning 自动规划组织结构(异步进行中)。' }); |
| 150 | } catch (err) { | 171 | } catch (err) { |
| 151 | res.status(400).json({ error: err.message }); | 172 | res.status(400).json({ error: err.message }); |
| @@ -2146,6 +2167,22 @@ router.post('/:companyId/tasks', (req, res) => { | @@ -2146,6 +2167,22 @@ router.post('/:companyId/tasks', (req, res) => { | ||
| 2146 | throw err; | 2167 | throw err; |
| 2147 | } | 2168 | } |
| 2148 | 2169 | ||
| 2170 | + // 39 号文档地基 1:draft:true 时只落库,不入队、不下达。服务的是 agent(如 ceo) | ||
| 2171 | + // 自主提的新任务——task-planning 拆出来的 task 尚且要先落 draft 等人点「全部下达」 | ||
| 2172 | + // 才转 queued(P3C §四.9),agent 在线判断的风险不该比一次性拆分低。 | ||
| 2173 | + // 落库后走既有 `POST /:companyId/tasks/:taskId/enqueue` 正式下达,不新建端点。 | ||
| 2174 | + if (body.draft === true) { | ||
| 2175 | + return res.status(201).json({ | ||
| 2176 | + success: true, | ||
| 2177 | + status: 'draft', | ||
| 2178 | + taskId: taskRow.id, | ||
| 2179 | + goalId: goal.id, | ||
| 2180 | + companyId, | ||
| 2181 | + task: description, | ||
| 2182 | + hint: `草稿已创建,未入队。调用 POST /api/company/${companyId}/tasks/${taskRow.id}/enqueue 正式下达。`, | ||
| 2183 | + }); | ||
| 2184 | + } | ||
| 2185 | + | ||
| 2149 | const launched = enqueueTaskRun({ companyId, taskRow, budget: body.budget || DEFAULT_TASK_BUDGET }); | 2186 | const launched = enqueueTaskRun({ companyId, taskRow, budget: body.budget || DEFAULT_TASK_BUDGET }); |
| 2150 | 2187 | ||
| 2151 | markGoalDispatched(goal); | 2188 | markGoalDispatched(goal); |
server/services/devloop/path-resolver.js
| @@ -7,8 +7,10 @@ | @@ -7,8 +7,10 @@ | ||
| 7 | * | 7 | * |
| 8 | * 支持三种 mode(见 arch-project-root.html §②): | 8 | * 支持三种 mode(见 arch-project-root.html §②): |
| 9 | * - sandbox : 自动生成 root_path = `{dataDir}/companies/{cid}/projects/{pid}/` | 9 | * - sandbox : 自动生成 root_path = `{dataDir}/companies/{cid}/projects/{pid}/` |
| 10 | - * - linked-new : 用户指定 root_path(绝对路径),注册到 ~/.devloop/linked-projects.json | ||
| 11 | - * - linked-existing : v2,本文件预留接口但 register 阶段拒收 | 10 | + * - linked-new : 用户指定一个空/新建的 root_path(绝对路径),注册到 ~/.devloop/linked-projects.json |
| 11 | + * - linked-existing : 用户指定一个已存在的目录,接管为项目根,同样注册到 ~/.devloop/linked-projects.json | ||
| 12 | + * (已实现,校验见 validateLinkedTarget:路径存在且是目录、未挂载过 DevLoop 项目、 | ||
| 13 | + * 有 .git 则 working tree 必须干净) | ||
| 12 | * | 14 | * |
| 13 | * 详尽规格:docs/prd/newfunction/_spec/arch-project-root.md | 15 | * 详尽规格:docs/prd/newfunction/_spec/arch-project-root.md |
| 14 | */ | 16 | */ |
| @@ -209,31 +211,28 @@ function isSensitivePath(abs) { | @@ -209,31 +211,28 @@ function isSensitivePath(abs) { | ||
| 209 | } | 211 | } |
| 210 | 212 | ||
| 211 | /** | 213 | /** |
| 212 | - * 在文件系统上分配一个 root_path,但不写任何业务文件。 | ||
| 213 | - * 仅 mkdir + 校验路径合法。 | ||
| 214 | - * 业务文件(project.json / .gitignore / git init)由 LoopController.createProject 完成。 | 214 | + * `linked-new` / `linked-existing` 共用的校验逻辑,**不碰 registry / cache / pid**。 |
| 215 | + * 抽出来是因为 39 号文档的公司级 `target_dir` 机制也要用同一套校验(敏感路径黑名单、 | ||
| 216 | + * git 干净树……),但它是「一个公司固定一个目录,不进 DevLoop 的 pid 注册表」, | ||
| 217 | + * 语义和 `allocateProjectRoot` 的 linked-* 分支(一对多、按 pid 登记)不同,不能直接复用 | ||
| 218 | + * 那个函数——于是只抽这一半(纯校验 + 解析 root_path),registry 写入留在调用方各自决定。 | ||
| 215 | * | 219 | * |
| 216 | - * @returns { pid, root_path, mode } | 220 | + * @returns {string} 校验通过后的绝对 root_path |
| 221 | + * @throws 校验失败:错误 message/code 与原 `allocateProjectRoot` 内联版本逐字一致, | ||
| 222 | + * 不要在这里改措辞——外部可能已经在依赖具体的 error.code(如 DIRTY_WORKTREE)。 | ||
| 217 | */ | 223 | */ |
| 218 | -function allocateProjectRoot({ company_id, mode = 'sandbox', target_path }) { | ||
| 219 | - if (!company_id) throw new Error('company_id required'); | ||
| 220 | - | ||
| 221 | - const pid = generatePid(); | 224 | +function validateLinkedTarget(target_path, mode) { |
| 225 | + if (!target_path) throw new Error(`target_path required for ${mode}`); | ||
| 226 | + if (!path.isAbsolute(target_path)) { | ||
| 227 | + throw new Error('target_path must be absolute'); | ||
| 228 | + } | ||
| 229 | + if (isSensitivePath(target_path)) { | ||
| 230 | + const e = new Error(`sensitive_path: ${target_path} is in blacklist`); | ||
| 231 | + e.code = 'SENSITIVE_PATH'; | ||
| 232 | + throw e; | ||
| 233 | + } | ||
| 222 | 234 | ||
| 223 | - let root_path; | ||
| 224 | - if (mode === 'sandbox') { | ||
| 225 | - root_path = path.join(DATA_DIR, 'companies', company_id, 'projects', pid); | ||
| 226 | - fs.mkdirSync(root_path, { recursive: true }); | ||
| 227 | - } else if (mode === 'linked-new') { | ||
| 228 | - if (!target_path) throw new Error('target_path required for linked-new'); | ||
| 229 | - if (!path.isAbsolute(target_path)) { | ||
| 230 | - throw new Error('target_path must be absolute'); | ||
| 231 | - } | ||
| 232 | - if (isSensitivePath(target_path)) { | ||
| 233 | - const e = new Error(`sensitive_path: ${target_path} is in blacklist`); | ||
| 234 | - e.code = 'SENSITIVE_PATH'; | ||
| 235 | - throw e; | ||
| 236 | - } | 235 | + if (mode === 'linked-new') { |
| 237 | // 父目录必须存在 | 236 | // 父目录必须存在 |
| 238 | const parent = path.dirname(target_path); | 237 | const parent = path.dirname(target_path); |
| 239 | if (!fs.existsSync(parent)) { | 238 | if (!fs.existsSync(parent)) { |
| @@ -248,26 +247,10 @@ function allocateProjectRoot({ company_id, mode = 'sandbox', target_path }) { | @@ -248,26 +247,10 @@ function allocateProjectRoot({ company_id, mode = 'sandbox', target_path }) { | ||
| 248 | } else { | 247 | } else { |
| 249 | fs.mkdirSync(target_path, { recursive: false }); | 248 | fs.mkdirSync(target_path, { recursive: false }); |
| 250 | } | 249 | } |
| 251 | - root_path = path.resolve(target_path); | ||
| 252 | - // 注册到 linked-projects | ||
| 253 | - const reg = loadLinkedRegistry(); | ||
| 254 | - reg[pid] = { | ||
| 255 | - root_path, | ||
| 256 | - mode: 'linked-new', | ||
| 257 | - company_id, | ||
| 258 | - registered_at: new Date().toISOString(), | ||
| 259 | - }; | ||
| 260 | - saveLinkedRegistry(reg); | ||
| 261 | - } else if (mode === 'linked-existing') { | ||
| 262 | - if (!target_path) throw new Error('target_path required for linked-existing'); | ||
| 263 | - if (!path.isAbsolute(target_path)) { | ||
| 264 | - throw new Error('target_path must be absolute'); | ||
| 265 | - } | ||
| 266 | - if (isSensitivePath(target_path)) { | ||
| 267 | - const e = new Error(`sensitive_path: ${target_path} is in blacklist`); | ||
| 268 | - e.code = 'SENSITIVE_PATH'; | ||
| 269 | - throw e; | ||
| 270 | - } | 250 | + return path.resolve(target_path); |
| 251 | + } | ||
| 252 | + | ||
| 253 | + if (mode === 'linked-existing') { | ||
| 271 | if (!fs.existsSync(target_path)) { | 254 | if (!fs.existsSync(target_path)) { |
| 272 | throw new Error(`target_path does not exist: ${target_path} (use linked-new for empty/new dir)`); | 255 | throw new Error(`target_path does not exist: ${target_path} (use linked-new for empty/new dir)`); |
| 273 | } | 256 | } |
| @@ -279,7 +262,7 @@ function allocateProjectRoot({ company_id, mode = 'sandbox', target_path }) { | @@ -279,7 +262,7 @@ function allocateProjectRoot({ company_id, mode = 'sandbox', target_path }) { | ||
| 279 | if (fs.existsSync(path.join(target_path, 'project.json'))) { | 262 | if (fs.existsSync(path.join(target_path, 'project.json'))) { |
| 280 | throw new Error(`target_path already contains a DevLoop project.json: ${target_path}`); | 263 | throw new Error(`target_path already contains a DevLoop project.json: ${target_path}`); |
| 281 | } | 264 | } |
| 282 | - // 已有 .git → 必须 working tree 干净(避免后续 Agent 的 git add -A 把用户脏改动扫进 DevLoop commit) | 265 | + // 已有 .git → 必须 working tree 干净(避免后续 Agent 的 git add -A 把用户脏改动扫进自动 commit) |
| 283 | if (fs.existsSync(path.join(target_path, '.git'))) { | 266 | if (fs.existsSync(path.join(target_path, '.git'))) { |
| 284 | const { execFileSync } = require('child_process'); | 267 | const { execFileSync } = require('child_process'); |
| 285 | let porcelain; | 268 | let porcelain; |
| @@ -299,11 +282,35 @@ function allocateProjectRoot({ company_id, mode = 'sandbox', target_path }) { | @@ -299,11 +282,35 @@ function allocateProjectRoot({ company_id, mode = 'sandbox', target_path }) { | ||
| 299 | throw err; | 282 | throw err; |
| 300 | } | 283 | } |
| 301 | } | 284 | } |
| 302 | - root_path = path.resolve(target_path); | 285 | + return path.resolve(target_path); |
| 286 | + } | ||
| 287 | + | ||
| 288 | + throw new Error(`unknown mode: ${mode}`); | ||
| 289 | +} | ||
| 290 | + | ||
| 291 | +/** | ||
| 292 | + * 在文件系统上分配一个 root_path,但不写任何业务文件。 | ||
| 293 | + * 仅 mkdir + 校验路径合法。 | ||
| 294 | + * 业务文件(project.json / .gitignore / git init)由 LoopController.createProject 完成。 | ||
| 295 | + * | ||
| 296 | + * @returns { pid, root_path, mode } | ||
| 297 | + */ | ||
| 298 | +function allocateProjectRoot({ company_id, mode = 'sandbox', target_path }) { | ||
| 299 | + if (!company_id) throw new Error('company_id required'); | ||
| 300 | + | ||
| 301 | + const pid = generatePid(); | ||
| 302 | + | ||
| 303 | + let root_path; | ||
| 304 | + if (mode === 'sandbox') { | ||
| 305 | + root_path = path.join(DATA_DIR, 'companies', company_id, 'projects', pid); | ||
| 306 | + fs.mkdirSync(root_path, { recursive: true }); | ||
| 307 | + } else if (mode === 'linked-new' || mode === 'linked-existing') { | ||
| 308 | + root_path = validateLinkedTarget(target_path, mode); | ||
| 309 | + // 注册到 linked-projects | ||
| 303 | const reg = loadLinkedRegistry(); | 310 | const reg = loadLinkedRegistry(); |
| 304 | reg[pid] = { | 311 | reg[pid] = { |
| 305 | root_path, | 312 | root_path, |
| 306 | - mode: 'linked-existing', | 313 | + mode, |
| 307 | company_id, | 314 | company_id, |
| 308 | registered_at: new Date().toISOString(), | 315 | registered_at: new Date().toISOString(), |
| 309 | }; | 316 | }; |
| @@ -319,6 +326,25 @@ function allocateProjectRoot({ company_id, mode = 'sandbox', target_path }) { | @@ -319,6 +326,25 @@ function allocateProjectRoot({ company_id, mode = 'sandbox', target_path }) { | ||
| 319 | return { pid, root_path, mode }; | 326 | return { pid, root_path, mode }; |
| 320 | } | 327 | } |
| 321 | 328 | ||
| 329 | +/** | ||
| 330 | + * 39 号文档「公司级目标代码目录」专用:校验并解析一个**固定**目录, | ||
| 331 | + * 不生成 pid、不写 `~/.devloop/linked-projects.json`、不进 DevLoop 的项目列表。 | ||
| 332 | + * 语义是「这家公司所有 run 永远落这一个目录」,跟 DevLoop 「一对多、每个项目一个 pid」 | ||
| 333 | + * 的项目模型是两回事,故意不复用那套注册表——见 `validateLinkedTarget` 头部注释。 | ||
| 334 | + * | ||
| 335 | + * 每次调用都会重新校验(包括 git 干净树检查),不缓存结果:目标目录的状态可能在 | ||
| 336 | + * 两次调用之间被人手动改过(比如两次任务之间用户自己提交了一次),下次分配前应该 | ||
| 337 | + * 重新确认一次,而不是信任上一次的结论。 | ||
| 338 | + * | ||
| 339 | + * @returns {string} 校验通过后的绝对 root_path | ||
| 340 | + */ | ||
| 341 | +function resolveFixedTarget({ target_path, mode }) { | ||
| 342 | + if (mode !== 'linked-new' && mode !== 'linked-existing') { | ||
| 343 | + throw new Error(`unknown mode: ${mode} (must be linked-new | linked-existing)`); | ||
| 344 | + } | ||
| 345 | + return validateLinkedTarget(target_path, mode); | ||
| 346 | +} | ||
| 347 | + | ||
| 322 | function unregisterLinkedProject(pid) { | 348 | function unregisterLinkedProject(pid) { |
| 323 | const reg = loadLinkedRegistry(); | 349 | const reg = loadLinkedRegistry(); |
| 324 | if (reg[pid]) { | 350 | if (reg[pid]) { |
| @@ -427,6 +453,8 @@ module.exports = { | @@ -427,6 +453,8 @@ module.exports = { | ||
| 427 | resolveMemoryDir, | 453 | resolveMemoryDir, |
| 428 | resolveCompanyDir, | 454 | resolveCompanyDir, |
| 429 | allocateProjectRoot, | 455 | allocateProjectRoot, |
| 456 | + resolveFixedTarget, | ||
| 457 | + validateLinkedTarget, | ||
| 430 | unregisterLinkedProject, | 458 | unregisterLinkedProject, |
| 431 | listProjects, | 459 | listProjects, |
| 432 | patchProjectMeta, | 460 | patchProjectMeta, |
server/services/graph-engine.js
| @@ -59,6 +59,9 @@ const { | @@ -59,6 +59,9 @@ const { | ||
| 59 | recordFile, | 59 | recordFile, |
| 60 | // 27-P4 T4/T5:advanceBatch 批量拾取宽度的缺省值 | 60 | // 27-P4 T4/T5:advanceBatch 批量拾取宽度的缺省值 |
| 61 | DEFAULT_MAX_CONCURRENCY, | 61 | DEFAULT_MAX_CONCURRENCY, |
| 62 | + // 39 号文档「确定性通路」:回退熔断收敛时顺手开一条 draft task,见 maybeProposeReplanTask | ||
| 63 | + createTask, | ||
| 64 | + getGoal, | ||
| 62 | } = require('./work-graph-db'); | 65 | } = require('./work-graph-db'); |
| 63 | 66 | ||
| 64 | /** | 67 | /** |
| @@ -2125,6 +2128,9 @@ function handleBackwardEdge(runId, def, nodeRun, edge, out, remediationNote = nu | @@ -2125,6 +2128,9 @@ function handleBackwardEdge(runId, def, nodeRun, edge, out, remediationNote = nu | ||
| 2125 | verdict: 'force_terminated', cap: out.loop.cap, | 2128 | verdict: 'force_terminated', cap: out.loop.cap, |
| 2126 | }, { ref: edge.id }); | 2129 | }, { ref: edge.id }); |
| 2127 | blockNode(runId, nodeRun.id, remediationNote); | 2130 | blockNode(runId, nodeRun.id, remediationNote); |
| 2131 | + // 39 号文档「确定性通路」:熔断收敛之后顺手把这个卡点回写成 goal/task 板上一条 draft | ||
| 2132 | + // task,不等 ceo 心跳才发现。best-effort,绝不能反过来影响上面已经做完的熔断收敛。 | ||
| 2133 | + maybeProposeReplanTask(runId, rootRunId, nodeRun, def, remediationNote); | ||
| 2128 | return true; | 2134 | return true; |
| 2129 | } | 2135 | } |
| 2130 | 2136 | ||
| @@ -2155,6 +2161,52 @@ function handleBackwardEdge(runId, def, nodeRun, edge, out, remediationNote = nu | @@ -2155,6 +2161,52 @@ function handleBackwardEdge(runId, def, nodeRun, edge, out, remediationNote = nu | ||
| 2155 | } | 2161 | } |
| 2156 | 2162 | ||
| 2157 | /** | 2163 | /** |
| 2164 | + * 39 号文档「确定性通路」:run 树级/单边回退熔断(force_terminated)收敛时,顺手在同一个 | ||
| 2165 | + * goal 下开一条 draft task,把卡住的诊断信息交回 goal/task 板,而不是只留一个 blocked | ||
| 2166 | + * 节点等人翻控制台才发现。 | ||
| 2167 | + * | ||
| 2168 | + * 只在根 run 触发一次(`runId === rootRunId`):嵌套子图自己的回退熔断会先在子层 | ||
| 2169 | + * blockNode,结论再沿子 run 边界冒泡成父层(org)节点的失败——冒泡到根时同一次「卡住」 | ||
| 2170 | + * 只应该开一条 task,不是每层各开一条。 | ||
| 2171 | + * | ||
| 2172 | + * 落库时机是 `draft`,不自动 enqueue(与 §四.9 task-planning 同一条哲学:这是一次 | ||
| 2173 | + * 「猜」,不该自动花钱);`report_to` 继承原 goal 的 `report_to`(§四.2 已拍板)。 | ||
| 2174 | + * | ||
| 2175 | + * 纯 best-effort:任何一步查不到/写不进都吞掉,不能让「顺手开个 task」反过来打断 | ||
| 2176 | + * 熔断收敛本身——熔断收敛是安全关键路径,这个不是。 | ||
| 2177 | + */ | ||
| 2178 | +function maybeProposeReplanTask(runId, rootRunId, nodeRun, def, remediationNote) { | ||
| 2179 | + if (runId !== rootRunId) return; | ||
| 2180 | + try { | ||
| 2181 | + const db = getDb(); | ||
| 2182 | + const task = db.prepare(`SELECT * FROM tasks WHERE current_run_id = ?`).get(rootRunId); | ||
| 2183 | + if (!task) return; // 这条 run 树当前不是任何 task 的 current_run_id(例如已被重跑顶替),不开 | ||
| 2184 | + const goal = getGoal(task.goal_id); | ||
| 2185 | + if (!goal) return; | ||
| 2186 | + | ||
| 2187 | + const d = def.nodes.get(nodeRun.def_node_id) || {}; | ||
| 2188 | + const nodeName = d.name || nodeRun.def_node_id; | ||
| 2189 | + const reason = remediationNote || `节点 ${nodeName} 反复回退撞了熔断硬顶,未能收敛`; | ||
| 2190 | + | ||
| 2191 | + createTask({ | ||
| 2192 | + goalId: goal.id, | ||
| 2193 | + companyId: goal.company_id, | ||
| 2194 | + title: `[待重新规划] ${(task.title || task.description || goal.title || '').slice(0, 160)}`, | ||
| 2195 | + description: `任务 ${task.id}(${task.title || task.description || ''})执行时在节点 ` | ||
| 2196 | + + `${nodeName} 反复回退未能收敛,已停止自动重试。\n\n诊断:${reason}\n\n` | ||
| 2197 | + + `此任务由系统自动生成(草稿),需要人工或 ceo 审阅后决定:调整原任务描述重新下达,还是关闭。`, | ||
| 2198 | + executionMode: 'manual', | ||
| 2199 | + priority: goal.priority || 'P1', | ||
| 2200 | + reportTo: goal.report_to || null, | ||
| 2201 | + createdBy: 'system', | ||
| 2202 | + }); | ||
| 2203 | + } catch (err) { | ||
| 2204 | + // best-effort:吞掉。上一步的熔断收敛(blockNode)已经做完,这里出错不影响主路径。 | ||
| 2205 | + console.error('[graph-engine] maybeProposeReplanTask 失败(不影响熔断收敛):', err.message); | ||
| 2206 | + } | ||
| 2207 | +} | ||
| 2208 | + | ||
| 2209 | +/** | ||
| 2158 | * 解析跨层地址 → 「该退到哪个子 run 的哪一行」(**纯查询,不改任何状态**)。 | 2210 | * 解析跨层地址 → 「该退到哪个子 run 的哪一行」(**纯查询,不改任何状态**)。 |
| 2159 | * | 2211 | * |
| 2160 | * 输入的地址来自失败节点行的 `fail_node_ref`(三值透传的第四值,见 normalizeNodeRef 那段)。 | 2212 | * 输入的地址来自失败节点行的 `fail_node_ref`(三值透传的第四值,见 normalizeNodeRef 那段)。 |
| @@ -3449,11 +3501,42 @@ function readProjectRootRecord(run) { | @@ -3449,11 +3501,42 @@ function readProjectRootRecord(run) { | ||
| 3449 | } | 3501 | } |
| 3450 | 3502 | ||
| 3451 | /** | 3503 | /** |
| 3504 | + * 39 号文档「公司级目标代码目录」:读 `company.json.target_dir`/`target_mode`。 | ||
| 3505 | + * | ||
| 3506 | + * 走 `companyRootDir` 而不是 `company-service.get`——理由与 `work-graph-db.maxActiveRootRuns` | ||
| 3507 | + * 头部注释一致(那边的判据是这个仓库唯一持有者,这里只是同一份配置的第二个读者, | ||
| 3508 | + * 字段名必须逐字对齐,不能另起 camelCase):`companyRootDir` 认 GRAPH_RUNS_DIR 测试覆盖, | ||
| 3509 | + * `company-service.get` 不认,用后者会导致设了 GRAPH_RUNS_DIR 的单测读到真仓库的 | ||
| 3510 | + * `data/companies/`,撞上同名公司就读到真配置。 | ||
| 3511 | + * | ||
| 3512 | + * 读不到 / 格式不对一律当没配(回落 sandbox),不抛错——目标目录是可选加成, | ||
| 3513 | + * 不能因为它读取失败就让整个项目根分配跟着炸。 | ||
| 3514 | + */ | ||
| 3515 | +function readCompanyTargetConfig(companyId) { | ||
| 3516 | + if (!companyId) return null; | ||
| 3517 | + try { | ||
| 3518 | + const fp = path.join(companyRootDir(companyId), 'company.json'); | ||
| 3519 | + if (!fs.existsSync(fp)) return null; | ||
| 3520 | + const cfg = JSON.parse(fs.readFileSync(fp, 'utf-8')); | ||
| 3521 | + if (!cfg.target_dir || typeof cfg.target_dir !== 'string') return null; | ||
| 3522 | + const target_mode = cfg.target_mode === 'linked-new' ? 'linked-new' : 'linked-existing'; | ||
| 3523 | + return { target_dir: cfg.target_dir, target_mode }; | ||
| 3524 | + } catch { | ||
| 3525 | + return null; | ||
| 3526 | + } | ||
| 3527 | +} | ||
| 3528 | + | ||
| 3529 | +/** | ||
| 3452 | * 真正在盘上开一个目录。 | 3530 | * 真正在盘上开一个目录。 |
| 3453 | * | 3531 | * |
| 3454 | * GRAPH_RUNS_DIR 覆盖时项目根跟着走(`{RUNS_ROOT}/{rootRunId}/project`),理由与本文件 | 3532 | * GRAPH_RUNS_DIR 覆盖时项目根跟着走(`{RUNS_ROOT}/{rootRunId}/project`),理由与本文件 |
| 3455 | * 顶部 RUNS_ROOT 那段注释完全一样:run 的产物被重定向到临时目录,项目根却还落在仓库 | 3533 | * 顶部 RUNS_ROOT 那段注释完全一样:run 的产物被重定向到临时目录,项目根却还落在仓库 |
| 3456 | * `data/companies/` 的话,测试每跑一次就往仓库里堆一个空项目目录。两处必须同源。 | 3534 | * `data/companies/` 的话,测试每跑一次就往仓库里堆一个空项目目录。两处必须同源。 |
| 3535 | + * | ||
| 3536 | + * 配了 `company.json.target_dir` 时,该公司**所有** run 解析到同一个固定 root_path, | ||
| 3537 | + * 不再走 sandbox 那套「每 run 一个新 pid」——39 号文档 P5:公司级目标代码目录。 | ||
| 3538 | + * 校验失败(比如 git 树不干净)直接抛出,不静默降级回 sandbox:配错了必须看得见, | ||
| 3539 | + * 不能让 agent 写到一个跟用户预期不一样的空目录里却毫无察觉。 | ||
| 3457 | */ | 3540 | */ |
| 3458 | function allocateRunProjectRoot(run) { | 3541 | function allocateRunProjectRoot(run) { |
| 3459 | const runsRoot = runsRootOverride(); | 3542 | const runsRoot = runsRootOverride(); |
| @@ -3462,6 +3545,13 @@ function allocateRunProjectRoot(run) { | @@ -3462,6 +3545,13 @@ function allocateRunProjectRoot(run) { | ||
| 3462 | fs.mkdirSync(root_path, { recursive: true }); | 3545 | fs.mkdirSync(root_path, { recursive: true }); |
| 3463 | return { pid: `p-run-${run.root_run_id}`, root_path, mode: 'runs-dir-override' }; | 3546 | return { pid: `p-run-${run.root_run_id}`, root_path, mode: 'runs-dir-override' }; |
| 3464 | } | 3547 | } |
| 3548 | + const targetCfg = readCompanyTargetConfig(run.company_id); | ||
| 3549 | + if (targetCfg) { | ||
| 3550 | + const root_path = require('./devloop/path-resolver').resolveFixedTarget({ | ||
| 3551 | + target_path: targetCfg.target_dir, mode: targetCfg.target_mode, | ||
| 3552 | + }); | ||
| 3553 | + return { pid: 'company-target', root_path, mode: targetCfg.target_mode }; | ||
| 3554 | + } | ||
| 3465 | return require('./devloop/path-resolver').allocateProjectRoot({ | 3555 | return require('./devloop/path-resolver').allocateProjectRoot({ |
| 3466 | company_id: run.company_id, mode: 'sandbox', | 3556 | company_id: run.company_id, mode: 'sandbox', |
| 3467 | }); | 3557 | }); |
server/services/task-settle.js
| @@ -192,6 +192,12 @@ function settleRootRun(run, to) { | @@ -192,6 +192,12 @@ function settleRootRun(run, to) { | ||
| 192 | if (taskStatus === 'blocked') { | 192 | if (taskStatus === 'blocked') { |
| 193 | const why = describeRunBlock(run.id); | 193 | const why = describeRunBlock(run.id); |
| 194 | if (why) wdb.noteTaskBlockedReason(run.task_id, run.id, why); | 194 | if (why) wdb.noteTaskBlockedReason(run.task_id, run.id, why); |
| 195 | + | ||
| 196 | + // 39 号文档 P3:blocked **不是**终态(下面 settleGoalIfAllTasksDone 也不会碰它), | ||
| 197 | + // goal 因此不会收敛、`reportGoalOutcome` 那条唤醒 report_to 的路径也不会触发—— | ||
| 198 | + // 卡住的任务本来会一直静默躺着,直到有人自己点进控制台才发现(30 号文档点过名的 | ||
| 199 | + // 那个空白)。这里主动叫醒 ceo,不等它下一次心跳。best-effort,失败只打日志。 | ||
| 200 | + wakeCeo(run.company_id, why); | ||
| 195 | } | 201 | } |
| 196 | 202 | ||
| 197 | const task = wdb.getTask(run.task_id); | 203 | const task = wdb.getTask(run.task_id); |
| @@ -199,6 +205,39 @@ function settleRootRun(run, to) { | @@ -199,6 +205,39 @@ function settleRootRun(run, to) { | ||
| 199 | } | 205 | } |
| 200 | 206 | ||
| 201 | /** | 207 | /** |
| 208 | + * 39 号文档 P3:唤醒本公司的 ceo(事件驱动的主路径)。 | ||
| 209 | + * 与 company.js 的 triggerOrgGraphPlanning / 本文件 reportGoalOutcome 里唤醒 report_to | ||
| 210 | + * 同一个模式:公司限定地址(`{companyId}:ceo`,本体只有一份,公司态按公司分)+ | ||
| 211 | + * INTERNAL_TOKEN 自环调用。发射后不等结果——这条不是收敛链路的一部分,晚几秒钟被 | ||
| 212 | + * ceo 看到不影响正确性,不值得为它拖慢 settleRootRun 的返回。 | ||
| 213 | + */ | ||
| 214 | +function wakeCeo(companyId, reason) { | ||
| 215 | + if (!companyId) return; | ||
| 216 | + try { | ||
| 217 | + const PORT = require('../config').port || 3010; | ||
| 218 | + const { INTERNAL_TOKEN, INTERNAL_TOKEN_HEADER } = require('../middleware/jwt-auth'); | ||
| 219 | + const namespacedId = `${companyId}:ceo`; | ||
| 220 | + fetch(`http://127.0.0.1:${PORT}/api/agent/${encodeURIComponent(namespacedId)}/chat`, { | ||
| 221 | + method: 'POST', | ||
| 222 | + headers: { 'Content-Type': 'application/json', [INTERNAL_TOKEN_HEADER]: INTERNAL_TOKEN }, | ||
| 223 | + body: JSON.stringify({ | ||
| 224 | + message: `[SYSTEM] 有一条 task 刚转入 blocked 状态:${reason || '(无详情)'}\n` | ||
| 225 | + + `请查看 tools.md 里的读接口确认情况,判断是否需要提一条新任务或调整优先级。`, | ||
| 226 | + conversationId: `ceo-wake-${companyId}-${Date.now().toString(36)}`, | ||
| 227 | + isHeartbeat: true, | ||
| 228 | + }), | ||
| 229 | + }) | ||
| 230 | + .then((res) => { | ||
| 231 | + if (!res.ok) console.error(`[CeoWake] 唤醒 ${namespacedId} 失败: HTTP ${res.status}`); | ||
| 232 | + }) | ||
| 233 | + .catch((e) => console.error(`[CeoWake] 唤醒 ${namespacedId} 失败(网络): ${e.message}`)); | ||
| 234 | + } catch (e) { | ||
| 235 | + // best-effort:唤醒失败不能影响调用方(settleRootRun)已经做完的状态回写。 | ||
| 236 | + console.error('[CeoWake] 唤醒 ceo 异常(不影响状态回写):', e.message); | ||
| 237 | + } | ||
| 238 | +} | ||
| 239 | + | ||
| 240 | +/** | ||
| 202 | * goal 级收敛:这个 goal 的 task 是不是都到终态了?是则定判词并做**一次**对外汇报。 | 241 | * goal 级收敛:这个 goal 的 task 是不是都到终态了?是则定判词并做**一次**对外汇报。 |
| 203 | * | 242 | * |
| 204 | * 状态口径: | 243 | * 状态口径: |
| @@ -642,6 +681,50 @@ function stopZombieGoalSweeper() { | @@ -642,6 +681,50 @@ function stopZombieGoalSweeper() { | ||
| 642 | zombieSweepTimer = null; | 681 | zombieSweepTimer = null; |
| 643 | } | 682 | } |
| 644 | 683 | ||
| 684 | +/** | ||
| 685 | + * 39 号文档 P3:ceo 低频兜底扫描。`settleRootRun` 里的事件驱动唤醒(task 转 blocked 那一刻) | ||
| 686 | + * 是主路径,这里只补漏——进程重启、fetch 失败、或本来就没接住那次转换。量级放到 30 分钟, | ||
| 687 | + * 不是主决策路径,照抄 `task-scheduler.js` 头部注释里"兜底扫描周期"那个设计哲学。 | ||
| 688 | + * 按 company_id 分组只叫醒一次,不逐条 task 叫(否则一家公司卡 5 条 task 就是 5 次心跳)。 | ||
| 689 | + */ | ||
| 690 | +const CEO_SWEEP_INTERVAL_MS = Number(process.env.CEO_SWEEP_INTERVAL_MS) || 30 * 60 * 1000; | ||
| 691 | +let ceoSweepTimer = null; | ||
| 692 | + | ||
| 693 | +function sweepBlockedTasksForCeo() { | ||
| 694 | + try { | ||
| 695 | + const wdb = require('./work-graph-db'); | ||
| 696 | + const rows = wdb.getDb() | ||
| 697 | + .prepare(` | ||
| 698 | + SELECT company_id, COUNT(*) AS cnt, GROUP_CONCAT(id) AS ids | ||
| 699 | + FROM tasks | ||
| 700 | + WHERE archived_at IS NULL AND status = 'blocked' | ||
| 701 | + GROUP BY company_id | ||
| 702 | + `) | ||
| 703 | + .all(); | ||
| 704 | + for (const r of rows) { | ||
| 705 | + wakeCeo(r.company_id, `兜底扫描:本公司当前有 ${r.cnt} 条 blocked task(${r.ids}),` | ||
| 706 | + + `事件驱动那条唤醒可能漏了,请查看。`); | ||
| 707 | + } | ||
| 708 | + return rows; | ||
| 709 | + } catch (err) { | ||
| 710 | + console.error(`[CeoWake] sweepBlockedTasksForCeo 异常: ${err.message}`); | ||
| 711 | + return []; | ||
| 712 | + } | ||
| 713 | +} | ||
| 714 | + | ||
| 715 | +/** 启动周期性 ceo 兜底扫描(unref,不拖着进程不退出)。幂等:重复调用只返回已有的计时器 */ | ||
| 716 | +function startCeoSweeper(intervalMs = CEO_SWEEP_INTERVAL_MS) { | ||
| 717 | + if (ceoSweepTimer) return ceoSweepTimer; | ||
| 718 | + ceoSweepTimer = setInterval(sweepBlockedTasksForCeo, intervalMs); | ||
| 719 | + if (typeof ceoSweepTimer.unref === 'function') ceoSweepTimer.unref(); | ||
| 720 | + return ceoSweepTimer; | ||
| 721 | +} | ||
| 722 | + | ||
| 723 | +function stopCeoSweeper() { | ||
| 724 | + if (ceoSweepTimer) clearInterval(ceoSweepTimer); | ||
| 725 | + ceoSweepTimer = null; | ||
| 726 | +} | ||
| 727 | + | ||
| 645 | module.exports = { | 728 | module.exports = { |
| 646 | settleRootRun, | 729 | settleRootRun, |
| 647 | settleGoalIfAllTasksDone, | 730 | settleGoalIfAllTasksDone, |
| @@ -649,6 +732,10 @@ module.exports = { | @@ -649,6 +732,10 @@ module.exports = { | ||
| 649 | sweepStuckActiveGoals, | 732 | sweepStuckActiveGoals, |
| 650 | startZombieGoalSweeper, | 733 | startZombieGoalSweeper, |
| 651 | stopZombieGoalSweeper, | 734 | stopZombieGoalSweeper, |
| 735 | + wakeCeo, | ||
| 736 | + sweepBlockedTasksForCeo, | ||
| 737 | + startCeoSweeper, | ||
| 738 | + stopCeoSweeper, | ||
| 652 | RUN_TO_TASK_STATUS, | 739 | RUN_TO_TASK_STATUS, |
| 653 | // 占位值不再是常量而是「前缀 + 唯一 token」,所以对外给的是前缀和判定函数: | 740 | // 占位值不再是常量而是「前缀 + 唯一 token」,所以对外给的是前缀和判定函数: |
| 654 | // 任何地方想认出「这条还没有结论」,都必须走 isPendingVerdict,别再去比字符串相等。 | 741 | // 任何地方想认出「这条还没有结论」,都必须走 isPendingVerdict,别再去比字符串相等。 |
server/services/work-graph-db.js
| @@ -102,17 +102,34 @@ function warnMaxParallelOnce(companyId, raw, why) { | @@ -102,17 +102,34 @@ function warnMaxParallelOnce(companyId, raw, why) { | ||
| 102 | function maxActiveRootRuns(companyId) { | 102 | function maxActiveRootRuns(companyId) { |
| 103 | if (!companyId) return DEFAULT_MAX_ACTIVE_ROOT_RUNS; | 103 | if (!companyId) return DEFAULT_MAX_ACTIVE_ROOT_RUNS; |
| 104 | let raw; | 104 | let raw; |
| 105 | + let targetDir; | ||
| 105 | try { | 106 | try { |
| 106 | const { companyRootDir } = require('./company-paths'); | 107 | const { companyRootDir } = require('./company-paths'); |
| 107 | const fp = require('path').join(companyRootDir(companyId), 'company.json'); | 108 | const fp = require('path').join(companyRootDir(companyId), 'company.json'); |
| 108 | const fs = require('fs'); | 109 | const fs = require('fs'); |
| 109 | if (!fs.existsSync(fp)) return DEFAULT_MAX_ACTIVE_ROOT_RUNS; | 110 | if (!fs.existsSync(fp)) return DEFAULT_MAX_ACTIVE_ROOT_RUNS; |
| 110 | - raw = JSON.parse(fs.readFileSync(fp, 'utf-8')).max_parallel_tasks; | 111 | + const cfg = JSON.parse(fs.readFileSync(fp, 'utf-8')); |
| 112 | + raw = cfg.max_parallel_tasks; | ||
| 113 | + targetDir = cfg.target_dir; | ||
| 111 | } catch (err) { | 114 | } catch (err) { |
| 112 | // company.json 坏了不该让一次出队变成 500:回落缺省,但必须留声 | 115 | // company.json 坏了不该让一次出队变成 500:回落缺省,但必须留声 |
| 113 | warnMaxParallelOnce(companyId, null, `读取失败(${err.message}),按 ${DEFAULT_MAX_ACTIVE_ROOT_RUNS} 处理`); | 116 | warnMaxParallelOnce(companyId, null, `读取失败(${err.message}),按 ${DEFAULT_MAX_ACTIVE_ROOT_RUNS} 处理`); |
| 114 | return DEFAULT_MAX_ACTIVE_ROOT_RUNS; | 117 | return DEFAULT_MAX_ACTIVE_ROOT_RUNS; |
| 115 | } | 118 | } |
| 119 | + // 39 号文档 P5:配了公司级目标代码目录(多个 run 共享同一份真实工作区)时, | ||
| 120 | + // 强制把并行度钉死为 1,不管 max_parallel_tasks 配的是什么——`linked-existing`/ | ||
| 121 | + // `linked-new` 那道「进入时 working tree 干净」的校验只在分配那一刻查一次, | ||
| 122 | + // 不是运行期互斥锁,两个 coder agent 同时写同一个 git 仓库会真的冲突。 | ||
| 123 | + // ponytail: 天花板是「配了 target_dir 的公司同一时间只能有一个任务真正动代码」; | ||
| 124 | + // 真要多任务并发写同一仓库,需要 git worktree + 完成时合并那一层,是独立工作量。 | ||
| 125 | + if (typeof targetDir === 'string' && targetDir) { | ||
| 126 | + const key = `${companyId}:target_dir`; | ||
| 127 | + if (!warnedMaxParallel.has(key)) { | ||
| 128 | + warnedMaxParallel.add(key); | ||
| 129 | + console.warn(`[work-graph-db] company ${companyId} 配了 target_dir=${JSON.stringify(targetDir)},并行度强制钉为 1(忽略 max_parallel_tasks=${JSON.stringify(raw)})`); | ||
| 130 | + } | ||
| 131 | + return 1; | ||
| 132 | + } | ||
| 116 | if (raw === undefined || raw === null) return DEFAULT_MAX_ACTIVE_ROOT_RUNS; | 133 | if (raw === undefined || raw === null) return DEFAULT_MAX_ACTIVE_ROOT_RUNS; |
| 117 | if (!Number.isInteger(raw) || raw < 1) { | 134 | if (!Number.isInteger(raw) || raw < 1) { |
| 118 | warnMaxParallelOnce(companyId, raw, `不是 ≥1 的整数,按 ${DEFAULT_MAX_ACTIVE_ROOT_RUNS} 处理`); | 135 | warnMaxParallelOnce(companyId, raw, `不是 ≥1 的整数,按 ${DEFAULT_MAX_ACTIVE_ROOT_RUNS} 处理`); |
server/src/core/agent-session-lock.js
| @@ -14,4 +14,21 @@ | @@ -14,4 +14,21 @@ | ||
| 14 | const agentChatLocks = new Map(); // agentId → { convId, startedAt } | 14 | const agentChatLocks = new Map(); // agentId → { convId, startedAt } |
| 15 | const AGENT_LOCK_TIMEOUT = 30 * 60 * 1000; // 30 分钟安全阀,与原实现一致 | 15 | const AGENT_LOCK_TIMEOUT = 30 * 60 * 1000; // 30 分钟安全阀,与原实现一致 |
| 16 | 16 | ||
| 17 | -module.exports = { agentChatLocks, AGENT_LOCK_TIMEOUT }; | 17 | +/** |
| 18 | + * 40 号文档「打断-排队」用:中止一个 turn 之后,轮询等这把锁真正被清掉 | ||
| 19 | + * (即原请求的 handler 走到 finally),而不是前端凭感觉睡一个固定时长。 | ||
| 20 | + * 查不到锁本身也算"已清空"——用于 stop 端点在锁已经因为别的原因释放时仍能返回 cleaned:true。 | ||
| 21 | + */ | ||
| 22 | +function waitForLockRelease(agentId, budgetMs = 3000, pollMs = 100) { | ||
| 23 | + return new Promise((resolve) => { | ||
| 24 | + const deadline = Date.now() + budgetMs; | ||
| 25 | + const check = () => { | ||
| 26 | + if (!agentChatLocks.has(agentId)) return resolve(true); | ||
| 27 | + if (Date.now() >= deadline) return resolve(false); | ||
| 28 | + setTimeout(check, pollMs); | ||
| 29 | + }; | ||
| 30 | + check(); | ||
| 31 | + }); | ||
| 32 | +} | ||
| 33 | + | ||
| 34 | +module.exports = { agentChatLocks, AGENT_LOCK_TIMEOUT, waitForLockRelease }; |
server/tests/devloop/path-resolver.test.js
| @@ -162,6 +162,70 @@ describe('pathResolver', () => { | @@ -162,6 +162,70 @@ describe('pathResolver', () => { | ||
| 162 | }); | 162 | }); |
| 163 | }); | 163 | }); |
| 164 | 164 | ||
| 165 | + describe('resolveFixedTarget(39 号文档:公司级目标代码目录,不进 registry)', () => { | ||
| 166 | + it('linked-existing:校验通过时返回 root_path,不写 linked-projects registry', () => { | ||
| 167 | + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-fixed-le-')); | ||
| 168 | + fs.writeFileSync(path.join(dir, 'README.md'), '# user'); | ||
| 169 | + const before = fs.existsSync(pathResolver.LINKED_REGISTRY) | ||
| 170 | + ? fs.readFileSync(pathResolver.LINKED_REGISTRY, 'utf8') : null; | ||
| 171 | + const root = pathResolver.resolveFixedTarget({ target_path: dir, mode: 'linked-existing' }); | ||
| 172 | + expect(root).toBe(dir); | ||
| 173 | + const after = fs.existsSync(pathResolver.LINKED_REGISTRY) | ||
| 174 | + ? fs.readFileSync(pathResolver.LINKED_REGISTRY, 'utf8') : null; | ||
| 175 | + expect(after).toBe(before); // registry 文件内容没变——没有 pid 被登记进去 | ||
| 176 | + expect(pathResolver.findProjectByPath(dir)).toBe(null); // 也没有进内存 cache | ||
| 177 | + }); | ||
| 178 | + | ||
| 179 | + it('linked-existing:git 树不干净照样拒绝(复用同一套校验,报同一个 error.code)', () => { | ||
| 180 | + const { execFileSync } = require('child_process'); | ||
| 181 | + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-fixed-dirty-')); | ||
| 182 | + execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: dir }); | ||
| 183 | + execFileSync('git', ['config', 'user.email', 't@l'], { cwd: dir }); | ||
| 184 | + execFileSync('git', ['config', 'user.name', 't'], { cwd: dir }); | ||
| 185 | + fs.writeFileSync(path.join(dir, 'a.txt'), 'one'); | ||
| 186 | + execFileSync('git', ['add', '-A'], { cwd: dir }); | ||
| 187 | + execFileSync('git', ['commit', '-q', '-m', 'init'], { cwd: dir }); | ||
| 188 | + fs.writeFileSync(path.join(dir, 'b.txt'), 'untracked'); | ||
| 189 | + let caught = null; | ||
| 190 | + try { | ||
| 191 | + pathResolver.resolveFixedTarget({ target_path: dir, mode: 'linked-existing' }); | ||
| 192 | + } catch (e) { caught = e; } | ||
| 193 | + expect(caught).not.toBe(null); | ||
| 194 | + expect(caught.message).toMatch(/uncommitted changes/); | ||
| 195 | + expect(caught.code).toBe('DIRTY_WORKTREE'); | ||
| 196 | + }); | ||
| 197 | + | ||
| 198 | + it('linked-existing:路径不存在时报错,不静默创建/降级', () => { | ||
| 199 | + const fake = path.join(os.tmpdir(), 'devloop-fixed-le-nope'); | ||
| 200 | + fs.rmSync(fake, { recursive: true, force: true }); | ||
| 201 | + expect(() => | ||
| 202 | + pathResolver.resolveFixedTarget({ target_path: fake, mode: 'linked-existing' }) | ||
| 203 | + ).toThrow(/does not exist/); | ||
| 204 | + expect(fs.existsSync(fake)).toBe(false); // 没有被顺手 mkdir 出来 | ||
| 205 | + }); | ||
| 206 | + | ||
| 207 | + it('linked-new:空目录直接接受,同样不进 registry', () => { | ||
| 208 | + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-fixed-new-')); | ||
| 209 | + fs.rmSync(dir, { recursive: true, force: true }); // 留空壳测试「不存在则新建」 | ||
| 210 | + const root = pathResolver.resolveFixedTarget({ target_path: dir, mode: 'linked-new' }); | ||
| 211 | + expect(root).toBe(dir); | ||
| 212 | + expect(fs.existsSync(dir)).toBe(true); | ||
| 213 | + expect(pathResolver.findProjectByPath(dir)).toBe(null); | ||
| 214 | + }); | ||
| 215 | + | ||
| 216 | + it('拒绝未知 mode', () => { | ||
| 217 | + expect(() => | ||
| 218 | + pathResolver.resolveFixedTarget({ target_path: '/tmp/x', mode: 'sandbox' }) | ||
| 219 | + ).toThrow(/unknown mode/); | ||
| 220 | + }); | ||
| 221 | + | ||
| 222 | + it('敏感路径黑名单同样生效', () => { | ||
| 223 | + expect(() => | ||
| 224 | + pathResolver.resolveFixedTarget({ target_path: '/etc/whatever', mode: 'linked-existing' }) | ||
| 225 | + ).toThrow(/sensitive_path/); | ||
| 226 | + }); | ||
| 227 | + }); | ||
| 228 | + | ||
| 165 | describe('resolveProjectRoot + 派生路径', () => { | 229 | describe('resolveProjectRoot + 派生路径', () => { |
| 166 | it('throws on unknown pid', () => { | 230 | it('throws on unknown pid', () => { |
| 167 | expect(() => pathResolver.resolveProjectRoot('p-nonexistent')).toThrow(/Unknown project/); | 231 | expect(() => pathResolver.resolveProjectRoot('p-nonexistent')).toThrow(/Unknown project/); |
server/tests/unit/agent-chat-interrupt.test.js
0 → 100644
| 1 | +/** | ||
| 2 | + * 40 号文档方案二的两条后端回归: | ||
| 3 | + * | ||
| 4 | + * 1. 风险①:同一 agentId + 同一 convId 的并发请求过去完全没有闸门(`agentChatLocks` 的判断 | ||
| 5 | + * 只挡"不同 convId"),一直靠前端 textarea 的 disabled 属性挡住。"打断并发送"要解锁输入框, | ||
| 6 | + * 这条路径就变得可达——两个循环同时喂同一个 ACP 会话是未定义行为。 | ||
| 7 | + * 2. `stop` 端点现在要等锁真正释放(或 3 秒预算到期)才返回,`cleaned` 字段供前端判断 | ||
| 8 | + * 能不能安全发第二条。 | ||
| 9 | + */ | ||
| 10 | +process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; | ||
| 11 | +process.env.AGENT_SESSION_IDLE_MS = '60000'; | ||
| 12 | + | ||
| 13 | +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; | ||
| 14 | +import fs from 'fs'; | ||
| 15 | +import path from 'path'; | ||
| 16 | +import express from 'express'; | ||
| 17 | +import { createRequire } from 'node:module'; | ||
| 18 | + | ||
| 19 | +const require_ = createRequire(import.meta.url); | ||
| 20 | +const ROOT = path.resolve(__dirname, '../../..'); | ||
| 21 | +const AGENT = 'zz-chat-interrupt'; | ||
| 22 | +const BODY = path.join(ROOT, 'system', 'agents', AGENT); | ||
| 23 | + | ||
| 24 | +function clearRuntimeState() { | ||
| 25 | + for (const d of ['conversations', '_global', 'memory']) { | ||
| 26 | + fs.rmSync(path.join(BODY, d), { recursive: true, force: true }); | ||
| 27 | + } | ||
| 28 | +} | ||
| 29 | + | ||
| 30 | +describe('agent-chat · 打断-排队(40 号文档)', () => { | ||
| 31 | + let base, srv; | ||
| 32 | + | ||
| 33 | + beforeAll(async () => { | ||
| 34 | + clearRuntimeState(); | ||
| 35 | + fs.mkdirSync(BODY, { recursive: true }); | ||
| 36 | + fs.writeFileSync(path.join(BODY, 'config.json'), JSON.stringify({ id: AGENT, type: 'skill', name: AGENT, status: 'active' }, null, 2)); | ||
| 37 | + fs.writeFileSync(path.join(BODY, 'identity.md'), `# ${AGENT}\n`); | ||
| 38 | + fs.writeFileSync(path.join(BODY, 'agent.md'), '# 打断-排队单测用的常驻本体\n'); | ||
| 39 | + | ||
| 40 | + const app = express(); | ||
| 41 | + app.use(express.json()); | ||
| 42 | + app.use('/api/agent', require_('../../routes/agent-chat')); | ||
| 43 | + await new Promise((r) => { srv = app.listen(0, '127.0.0.1', () => { base = `http://127.0.0.1:${srv.address().port}`; r(); }); }); | ||
| 44 | + }); | ||
| 45 | + | ||
| 46 | + afterAll(async () => { | ||
| 47 | + if (srv) await new Promise((r) => srv.close(r)); | ||
| 48 | + clearRuntimeState(); | ||
| 49 | + }); | ||
| 50 | + | ||
| 51 | + afterEach(() => vi.restoreAllMocks()); | ||
| 52 | + | ||
| 53 | + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | ||
| 54 | + | ||
| 55 | + it('同一 convId 的第二个请求在第一个还没跑完时会被 429 拒绝,不会两个循环同时跑', async () => { | ||
| 56 | + const conv = 'race-same-conv'; | ||
| 57 | + const { ClaudeCliProtocol } = require_('@mas/acp-core'); | ||
| 58 | + const original = ClaudeCliProtocol.prototype.sendPrompt; | ||
| 59 | + // 拖住第一个请求,制造一个真实的并发窗口 | ||
| 60 | + vi.spyOn(ClaudeCliProtocol.prototype, 'sendPrompt').mockImplementation(function (...args) { | ||
| 61 | + return sleep(300).then(() => original.apply(this, args)); | ||
| 62 | + }); | ||
| 63 | + | ||
| 64 | + const first = fetch(`${base}/api/agent/${AGENT}/chat`, { | ||
| 65 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | ||
| 66 | + body: JSON.stringify({ message: 'first', conversationId: conv }), | ||
| 67 | + }); | ||
| 68 | + await sleep(50); // 确保 first 已经进了 handler、拿到了锁 | ||
| 69 | + const second = await fetch(`${base}/api/agent/${AGENT}/chat`, { | ||
| 70 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | ||
| 71 | + body: JSON.stringify({ message: 'second', conversationId: conv }), | ||
| 72 | + }); | ||
| 73 | + | ||
| 74 | + expect(second.status).toBe(429); | ||
| 75 | + const body = await second.json(); | ||
| 76 | + expect(body.sameConversation).toBe(true); | ||
| 77 | + | ||
| 78 | + const firstRes = await first; | ||
| 79 | + await firstRes.text(); // 读完流,等 finally 跑完 | ||
| 80 | + }); | ||
| 81 | + | ||
| 82 | + it('stop 之后:锁在预算内释放,返回 cleaned:true', async () => { | ||
| 83 | + const conv = 'stop-cleaned'; | ||
| 84 | + const chatDone = fetch(`${base}/api/agent/${AGENT}/chat`, { | ||
| 85 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | ||
| 86 | + body: JSON.stringify({ message: 'ping', conversationId: conv }), | ||
| 87 | + }).then(r => r.text()); | ||
| 88 | + | ||
| 89 | + await sleep(50); // 等它先跑起来、拿到锁 | ||
| 90 | + const stopRes = await fetch(`${base}/api/agent/${AGENT}/chat/${conv}/stop`, { method: 'POST' }); | ||
| 91 | + const stopBody = await stopRes.json(); | ||
| 92 | + expect(stopBody.success).toBe(true); | ||
| 93 | + expect(stopBody.cleaned).toBe(true); | ||
| 94 | + | ||
| 95 | + await chatDone; | ||
| 96 | + }); | ||
| 97 | + | ||
| 98 | + it('stop 之后立刻发同一个 convId 的新消息不会被 429 拒绝(cleaned 确认过锁已释放)', async () => { | ||
| 99 | + const conv = 'stop-then-resend'; | ||
| 100 | + const chatDone = fetch(`${base}/api/agent/${AGENT}/chat`, { | ||
| 101 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | ||
| 102 | + body: JSON.stringify({ message: 'ping', conversationId: conv }), | ||
| 103 | + }).then(r => r.text()); | ||
| 104 | + | ||
| 105 | + await sleep(50); | ||
| 106 | + const stopRes = await fetch(`${base}/api/agent/${AGENT}/chat/${conv}/stop`, { method: 'POST' }); | ||
| 107 | + const { cleaned } = await stopRes.json(); | ||
| 108 | + expect(cleaned).toBe(true); | ||
| 109 | + | ||
| 110 | + const resendRes = await fetch(`${base}/api/agent/${AGENT}/chat`, { | ||
| 111 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | ||
| 112 | + body: JSON.stringify({ message: 'resend', conversationId: conv }), | ||
| 113 | + }); | ||
| 114 | + expect(resendRes.status).toBe(200); | ||
| 115 | + await resendRes.text(); | ||
| 116 | + await chatDone; | ||
| 117 | + }); | ||
| 118 | +}); |
server/tests/unit/graph-engine-target-dir.test.js
0 → 100644
| 1 | +/** | ||
| 2 | + * Unit Tests — graph-engine 的公司级目标代码目录(39 号文档 P5) | ||
| 3 | + * | ||
| 4 | + * 验的是 `ensureProjectRoot` 在配了 `company.json.target_dir` 时的行为: | ||
| 5 | + * T1 没配 target_dir:走老的 sandbox 路径,行为不变(零回归) | ||
| 6 | + * T2 配了 target_dir:两个不同 root_run_id 的 run(同一公司)解析到同一个 root_path | ||
| 7 | + * T3 target_dir 校验失败(git 树不干净):ensureProjectRoot 优雅降级成 null + timeline 记录, | ||
| 8 | + * 不静默 fallback 回 sandbox | ||
| 9 | + * T4 GRAPH_RUNS_DIR 测试覆盖优先级高于 target_dir(不能让业务配置绕过测试隔离) | ||
| 10 | + * | ||
| 11 | + * `ensureProjectRoot` 本身不碰 SQLite(只读写文件系统),所以不需要像 | ||
| 12 | + * graph-boundary.test.js 那样做 GRAPH_DB_PATH 隔离;但会真的往 `data/companies/` 写 | ||
| 13 | + * 东西(company.json 走 companyRootDir,不认测试注入,除非设 GRAPH_RUNS_DIR——T1-T3 | ||
| 14 | + * 就是要验「不设 GRAPH_RUNS_DIR 时的生产行为」,所以只能用真路径 + 用后即删, | ||
| 15 | + * 手法同 path-resolver.test.js 的 sandbox 用例)。 | ||
| 16 | + */ | ||
| 17 | +const fs = require('fs'); | ||
| 18 | +const os = require('os'); | ||
| 19 | +const path = require('path'); | ||
| 20 | + | ||
| 21 | +const engine = require('../../services/graph-engine'); | ||
| 22 | +const { companyRootDir } = require('../../services/company-paths'); | ||
| 23 | + | ||
| 24 | +describe('graph-engine 公司级目标代码目录(target_dir)', () => { | ||
| 25 | + const cleanups = []; | ||
| 26 | + | ||
| 27 | + afterEach(() => { | ||
| 28 | + delete process.env.GRAPH_RUNS_DIR; | ||
| 29 | + while (cleanups.length) { | ||
| 30 | + const p = cleanups.pop(); | ||
| 31 | + try { fs.rmSync(p, { recursive: true, force: true }); } catch { /* best-effort */ } | ||
| 32 | + } | ||
| 33 | + }); | ||
| 34 | + | ||
| 35 | + function writeCompany(companyId, patch) { | ||
| 36 | + const dir = companyRootDir(companyId); | ||
| 37 | + fs.mkdirSync(dir, { recursive: true }); | ||
| 38 | + cleanups.push(dir); | ||
| 39 | + fs.writeFileSync(path.join(dir, 'company.json'), JSON.stringify({ id: companyId, name: companyId, ...patch })); | ||
| 40 | + return dir; | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + function fakeRun(companyId, rootRunId) { | ||
| 44 | + return { root_run_id: rootRunId, company_id: companyId }; | ||
| 45 | + } | ||
| 46 | + | ||
| 47 | + it('T1 没配 target_dir:走 sandbox,行为跟改动前一样', () => { | ||
| 48 | + const cid = 'tdcfg-t1'; | ||
| 49 | + writeCompany(cid, {}); | ||
| 50 | + const run = fakeRun(cid, 'r-t1'); | ||
| 51 | + const rec = engine.ensureProjectRoot({ run }); | ||
| 52 | + expect(rec).not.toBe(null); | ||
| 53 | + expect(rec.mode).toBe('sandbox'); | ||
| 54 | + expect(rec.root_path).toContain(path.join('data', 'companies', cid, 'projects')); | ||
| 55 | + expect(fs.existsSync(rec.root_path)).toBe(true); | ||
| 56 | + cleanups.push(path.join(companyRootDir(cid), '..', 'runs')); // 无害;下面按 run 目录精确清 | ||
| 57 | + cleanups.push(path.dirname(path.dirname(rec.root_path))); // projects/ 目录 | ||
| 58 | + cleanups.push(path.join(companyRootDir(cid), 'runs', run.root_run_id)); | ||
| 59 | + }); | ||
| 60 | + | ||
| 61 | + it('T2 配了 target_dir:同一公司两个不同 run 解析到同一个 root_path,且不产生 pid 目录', () => { | ||
| 62 | + const cid = 'tdcfg-t2'; | ||
| 63 | + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-engine-target-')); | ||
| 64 | + cleanups.push(target); | ||
| 65 | + fs.writeFileSync(path.join(target, 'README.md'), '# existing repo, no .git'); | ||
| 66 | + writeCompany(cid, { target_dir: target, target_mode: 'linked-existing' }); | ||
| 67 | + | ||
| 68 | + const runA = fakeRun(cid, 'r-t2-a'); | ||
| 69 | + const runB = fakeRun(cid, 'r-t2-b'); | ||
| 70 | + cleanups.push(path.join(companyRootDir(cid), 'runs', runA.root_run_id)); | ||
| 71 | + cleanups.push(path.join(companyRootDir(cid), 'runs', runB.root_run_id)); | ||
| 72 | + | ||
| 73 | + const recA = engine.ensureProjectRoot({ run: runA }); | ||
| 74 | + const recB = engine.ensureProjectRoot({ run: runB }); | ||
| 75 | + | ||
| 76 | + expect(recA).not.toBe(null); | ||
| 77 | + expect(recB).not.toBe(null); | ||
| 78 | + expect(recA.root_path).toBe(target); | ||
| 79 | + expect(recB.root_path).toBe(target); | ||
| 80 | + expect(recA.mode).toBe('linked-existing'); | ||
| 81 | + // 没有新的 sandbox pid 目录被造出来 | ||
| 82 | + const projectsDir = path.join(companyRootDir(cid), 'projects'); | ||
| 83 | + expect(fs.existsSync(projectsDir)).toBe(false); | ||
| 84 | + }); | ||
| 85 | + | ||
| 86 | + it('T2b target_mode 缺省时按 linked-existing 处理(目标目录本来就该已经存在)', () => { | ||
| 87 | + const cid = 'tdcfg-t2b'; | ||
| 88 | + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-engine-target-')); | ||
| 89 | + cleanups.push(target); | ||
| 90 | + writeCompany(cid, { target_dir: target }); // 不传 target_mode | ||
| 91 | + | ||
| 92 | + const run = fakeRun(cid, 'r-t2b'); | ||
| 93 | + cleanups.push(path.join(companyRootDir(cid), 'runs', run.root_run_id)); | ||
| 94 | + | ||
| 95 | + const rec = engine.ensureProjectRoot({ run }); | ||
| 96 | + expect(rec.root_path).toBe(target); | ||
| 97 | + expect(rec.mode).toBe('linked-existing'); | ||
| 98 | + }); | ||
| 99 | + | ||
| 100 | + it('T3 target_dir 校验失败(git 树不干净):不静默降级回 sandbox,返回 null', () => { | ||
| 101 | + const { execFileSync } = require('child_process'); | ||
| 102 | + const cid = 'tdcfg-t3'; | ||
| 103 | + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-engine-target-dirty-')); | ||
| 104 | + cleanups.push(target); | ||
| 105 | + execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: target }); | ||
| 106 | + execFileSync('git', ['config', 'user.email', 't@l'], { cwd: target }); | ||
| 107 | + execFileSync('git', ['config', 'user.name', 't'], { cwd: target }); | ||
| 108 | + fs.writeFileSync(path.join(target, 'a.txt'), 'one'); | ||
| 109 | + execFileSync('git', ['add', '-A'], { cwd: target }); | ||
| 110 | + execFileSync('git', ['commit', '-q', '-m', 'init'], { cwd: target }); | ||
| 111 | + fs.writeFileSync(path.join(target, 'b.txt'), 'untracked, dirty tree'); | ||
| 112 | + | ||
| 113 | + writeCompany(cid, { target_dir: target, target_mode: 'linked-existing' }); | ||
| 114 | + const run = fakeRun(cid, 'r-t3'); | ||
| 115 | + cleanups.push(path.join(companyRootDir(cid), 'runs', run.root_run_id)); | ||
| 116 | + | ||
| 117 | + const rec = engine.ensureProjectRoot({ run }); | ||
| 118 | + expect(rec).toBe(null); // 优雅失败,不是编一个假 sandbox 路径 | ||
| 119 | + | ||
| 120 | + const projectsDir = path.join(companyRootDir(cid), 'projects'); | ||
| 121 | + expect(fs.existsSync(projectsDir)).toBe(false); // 确认真的没有偷偷退化成 sandbox | ||
| 122 | + }); | ||
| 123 | + | ||
| 124 | + it('T4 GRAPH_RUNS_DIR 测试覆盖优先级高于 target_dir(测试隔离不能被业务配置绕过)', () => { | ||
| 125 | + const cid = 'tdcfg-t4'; | ||
| 126 | + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-engine-target-override-')); | ||
| 127 | + cleanups.push(target); | ||
| 128 | + writeCompany(cid, { target_dir: target, target_mode: 'linked-existing' }); | ||
| 129 | + | ||
| 130 | + const runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-engine-runs-override-')); | ||
| 131 | + cleanups.push(runsDir); | ||
| 132 | + process.env.GRAPH_RUNS_DIR = runsDir; | ||
| 133 | + | ||
| 134 | + const run = fakeRun(cid, 'r-t4'); | ||
| 135 | + const rec = engine.ensureProjectRoot({ run }); | ||
| 136 | + expect(rec.mode).toBe('runs-dir-override'); | ||
| 137 | + expect(rec.root_path).toBe(path.join(runsDir, run.root_run_id, 'project')); | ||
| 138 | + expect(rec.root_path).not.toBe(target); | ||
| 139 | + }); | ||
| 140 | +}); |
server/tests/unit/graph-engine.test.js
| @@ -752,6 +752,90 @@ describe('graph-engine', () => { | @@ -752,6 +752,90 @@ describe('graph-engine', () => { | ||
| 752 | }); | 752 | }); |
| 753 | 753 | ||
| 754 | // ────────────────────────────────────────────────────────────── | 754 | // ────────────────────────────────────────────────────────────── |
| 755 | + // 39 号文档 P4「确定性通路」:回退熔断收敛时顺手开一条 draft task | ||
| 756 | + // ────────────────────────────────────────────────────────────── | ||
| 757 | + describe('回退熔断 · 确定性通路自动开新 task(39 号文档 §二 P4)', () => { | ||
| 758 | + it('单边硬顶 force_terminated 时,会在同一个 goal 下新开一条 draft task,report_to 继承自 goal', async () => { | ||
| 759 | + const companyId = nextCompany(); | ||
| 760 | + const goal = wdb.createGoal({ | ||
| 761 | + companyId, | ||
| 762 | + title: '测试 goal', | ||
| 763 | + description: '测试 goal 的描述', | ||
| 764 | + priority: 'P2', | ||
| 765 | + createdBy: 'human', | ||
| 766 | + reportTo: 'some-upstream-agent', | ||
| 767 | + }); | ||
| 768 | + const task = wdb.createTask({ | ||
| 769 | + goalId: goal.id, | ||
| 770 | + companyId, | ||
| 771 | + title: '测试 task', | ||
| 772 | + description: '测试 task 的描述', | ||
| 773 | + executionMode: 'auto', | ||
| 774 | + priority: 'P2', | ||
| 775 | + createdBy: 'human', | ||
| 776 | + }); | ||
| 777 | + | ||
| 778 | + const { runId } = buildWorkRun({ | ||
| 779 | + companyId, | ||
| 780 | + nodes: ['impl', 'check'], | ||
| 781 | + edges: [ | ||
| 782 | + { from: 'impl', to: 'check' }, | ||
| 783 | + { from: 'check', to: 'impl', condition: 'fail', edgeKind: 'backward' }, | ||
| 784 | + ], | ||
| 785 | + mode: 'auto', | ||
| 786 | + }); | ||
| 787 | + // 让这条 run 看起来是「这条 task 当前的 run」——只有 runId === rootRunId 且 | ||
| 788 | + // current_run_id 指回它,maybeProposeReplanTask 才会触发(避免嵌套子图重复开)。 | ||
| 789 | + wdb.updateTask(task.id, { currentRunId: runId }); | ||
| 790 | + | ||
| 791 | + fakeExecutor({ | ||
| 792 | + impl: { status: 'success' }, | ||
| 793 | + check: { status: 'fail', remediationNote: '反复修不好' }, | ||
| 794 | + }); | ||
| 795 | + | ||
| 796 | + await engine.startRun(runId, { mode: 'auto' }).driver; | ||
| 797 | + | ||
| 798 | + // 熔断收敛本身不受影响(既有断言的等价物) | ||
| 799 | + expect(wdb.getRun(runId).run_status).toBe('blocked'); | ||
| 800 | + | ||
| 801 | + const tasksInGoal = wdb.listCompanyTasks(companyId, { goalId: goal.id, includeArchived: true }); | ||
| 802 | + expect(tasksInGoal).toHaveLength(2); | ||
| 803 | + const created = tasksInGoal.find((t) => t.id !== task.id); | ||
| 804 | + expect(created).toBeTruthy(); | ||
| 805 | + expect(created.status).toBe('draft'); | ||
| 806 | + expect(created.created_by).toBe('system'); | ||
| 807 | + // report_to 继承自 goal(39 号文档 §四.2 已拍板),不是继承自触发它的 task | ||
| 808 | + expect(created.report_to).toBe('some-upstream-agent'); | ||
| 809 | + expect(created.description).toContain('反复修不好'); | ||
| 810 | + }); | ||
| 811 | + | ||
| 812 | + it('嵌套子图自己的回退熔断(runId !== rootRunId)不会新开 task——只在根 run 触发一次', async () => { | ||
| 813 | + // buildWorkRun 建出的是顶层 run,rootRunId 本来就等于自己;这里改用 | ||
| 814 | + // 「run 不是任何 task 的 current_run_id」这条更好触发的早退路径验证同一条防重复保证: | ||
| 815 | + // 没有匹配的 task 行,maybeProposeReplanTask 必须安静地什么都不做,不能抛错。 | ||
| 816 | + const companyId = nextCompany(); | ||
| 817 | + const { runId } = buildWorkRun({ | ||
| 818 | + companyId, | ||
| 819 | + nodes: ['impl', 'check'], | ||
| 820 | + edges: [ | ||
| 821 | + { from: 'impl', to: 'check' }, | ||
| 822 | + { from: 'check', to: 'impl', condition: 'fail', edgeKind: 'backward' }, | ||
| 823 | + ], | ||
| 824 | + mode: 'auto', | ||
| 825 | + }); | ||
| 826 | + fakeExecutor({ | ||
| 827 | + impl: { status: 'success' }, | ||
| 828 | + check: { status: 'fail', remediationNote: '再修一遍' }, | ||
| 829 | + }); | ||
| 830 | + | ||
| 831 | + await expect(engine.startRun(runId, { mode: 'auto' }).driver).resolves.not.toThrow(); | ||
| 832 | + expect(wdb.getRun(runId).run_status).toBe('blocked'); | ||
| 833 | + // 没有任何 task 挂这条 run,不应该凭空生出一条 task 来(company 下 tasks 表应仍为空) | ||
| 834 | + expect(wdb.listCompanyTasks(companyId, {})).toHaveLength(0); | ||
| 835 | + }); | ||
| 836 | + }); | ||
| 837 | + | ||
| 838 | + // ────────────────────────────────────────────────────────────── | ||
| 755 | // §六 验收 9b:多边不架空熔断(R-16 发现的 3×N 漏洞) | 839 | // §六 验收 9b:多边不架空熔断(R-16 发现的 3×N 漏洞) |
| 756 | // ────────────────────────────────────────────────────────────── | 840 | // ────────────────────────────────────────────────────────────── |
| 757 | describe('回退熔断 · 多边不架空(§六 验收 9b / R-16)', () => { | 841 | describe('回退熔断 · 多边不架空(§六 验收 9b / R-16)', () => { |
server/tests/unit/route-auth-gate.test.js
| @@ -44,8 +44,8 @@ describe('route-auth-gate(38 号文档 Phase 1)', () => { | @@ -44,8 +44,8 @@ describe('route-auth-gate(38 号文档 Phase 1)', () => { | ||
| 44 | delete process.env.FORCE_REAL_AUTH; | 44 | delete process.env.FORCE_REAL_AUTH; |
| 45 | }); | 45 | }); |
| 46 | 46 | ||
| 47 | - it('company.js 挂了 requireAuth', () => { | ||
| 48 | - expectGated(require('../../routes/company.js'), requireAuth, 'company.js'); | 47 | + it('company.js 挂了 requireAuthOrInternal(39 号文档:goals/tasks 系列端点要给 agent 用 curl 直接调,比如 ceo 的心跳监督循环,不是普通 requireAuth)', () => { |
| 48 | + expectGated(require('../../routes/company.js'), requireAuthOrInternal, 'company.js'); | ||
| 49 | }); | 49 | }); |
| 50 | 50 | ||
| 51 | it('tasks.js 挂了 requireAuth', () => { | 51 | it('tasks.js 挂了 requireAuth', () => { |
server/tests/unit/task-blocked-ceo-wake.test.js
0 → 100644
| 1 | +/** | ||
| 2 | + * Unit Tests — task 转 blocked 时主动唤醒 ceo(39 号文档 §二 P3,事件驱动主路径) | ||
| 3 | + * | ||
| 4 | + * 背景:blocked 不是终态,`settleGoalIfAllTasksDone` 不会碰它,goal 因此不会收敛, | ||
| 5 | + * `reportGoalOutcome` 那条唤醒 report_to 的路径也不会触发——卡住的任务本来会一直静默 | ||
| 6 | + * 躺着,直到有人自己点进控制台才发现(30 号文档点过名的空白)。`settleRootRun` 现在 | ||
| 7 | + * 会在 blocked 回写之后顺手唤醒本公司的 ceo。 | ||
| 8 | + * | ||
| 9 | + * 只验唤醒这一跳的逻辑(URL / header / body 形状),不验 agent-chat 真的收到消息之后 | ||
| 10 | + * 会做什么——那是 ceo 自己 tools.md / heartbeat.md 的行为,不在引擎测试射程内。 | ||
| 11 | + * | ||
| 12 | + * 隔离手法与 task-blocked-reason.test.js 一致。 | ||
| 13 | + */ | ||
| 14 | +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; | ||
| 15 | +import fs from 'fs'; | ||
| 16 | + | ||
| 17 | +describe('task 转 blocked 唤醒 ceo(39 号文档 P3)', () => { | ||
| 18 | + let wdb; | ||
| 19 | + let settle; | ||
| 20 | + const TEST_DB_PATH = '/tmp/test-blocked-ceo-wake.sqlite'; | ||
| 21 | + const files = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]; | ||
| 22 | + | ||
| 23 | + beforeAll(() => { | ||
| 24 | + for (const f of files) { try { fs.unlinkSync(f); } catch { /* 没有就算了 */ } } | ||
| 25 | + process.env.GRAPH_DB_PATH = TEST_DB_PATH; | ||
| 26 | + for (const m of [ | ||
| 27 | + '../../services/graph-db', | ||
| 28 | + '../../services/org-graph-service', | ||
| 29 | + '../../services/work-graph-db', | ||
| 30 | + '../../services/graph-run-sse', | ||
| 31 | + '../../services/task-settle', | ||
| 32 | + ]) { | ||
| 33 | + delete require.cache[require.resolve(m)]; | ||
| 34 | + } | ||
| 35 | + wdb = require('../../services/work-graph-db'); | ||
| 36 | + settle = require('../../services/task-settle'); | ||
| 37 | + }); | ||
| 38 | + | ||
| 39 | + afterAll(() => { | ||
| 40 | + for (const f of files) { try { fs.unlinkSync(f); } catch { /* 同上 */ } } | ||
| 41 | + delete process.env.GRAPH_DB_PATH; | ||
| 42 | + }); | ||
| 43 | + | ||
| 44 | + beforeEach(() => { | ||
| 45 | + const db = wdb.getDb(); | ||
| 46 | + db.pragma('foreign_keys = OFF'); | ||
| 47 | + db.exec(` | ||
| 48 | + DELETE FROM graph_run_nodes; | ||
| 49 | + DELETE FROM graph_runs; | ||
| 50 | + DELETE FROM tasks; | ||
| 51 | + DELETE FROM goals; | ||
| 52 | + DELETE FROM work_graph_edges; | ||
| 53 | + DELETE FROM work_graph_nodes; | ||
| 54 | + DELETE FROM work_graphs; | ||
| 55 | + DELETE FROM system_agents; | ||
| 56 | + `); | ||
| 57 | + db.pragma('foreign_keys = ON'); | ||
| 58 | + }); | ||
| 59 | + | ||
| 60 | + let seq = 0; | ||
| 61 | + | ||
| 62 | + /** 与 task-blocked-reason.test.js 的 scene() 同构:造出「一条 root run 正卡在某节点」 */ | ||
| 63 | + function scene() { | ||
| 64 | + const companyId = `ceowake-co-${++seq}`; | ||
| 65 | + const now = new Date().toISOString(); | ||
| 66 | + const db = wdb.getDb(); | ||
| 67 | + db.prepare(` | ||
| 68 | + INSERT OR IGNORE INTO system_agents | ||
| 69 | + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at) | ||
| 70 | + VALUES ('sdd-planning', 'sdd-planning', 'public', NULL, 'system/agents/sdd-planning', 'active', ?, ?) | ||
| 71 | + `).run(now, now); | ||
| 72 | + | ||
| 73 | + const graph = wdb.createWorkGraph({ companyId, nodes: ['sdd-planning'], edges: [] }); | ||
| 74 | + const goal = wdb.createGoal({ companyId, title: '做一个官网', description: '测试用' }); | ||
| 75 | + const task = wdb.createTask({ | ||
| 76 | + goalId: goal.id, companyId, title: '做一个官网', description: '测试用', executionMode: 'manual', | ||
| 77 | + }); | ||
| 78 | + const run = wdb.createRun({ | ||
| 79 | + graphKind: 'work', graphId: graph.id, companyId, goalId: goal.id, taskId: task.id, | ||
| 80 | + executionMode: 'manual', runStatus: 'running', | ||
| 81 | + }); | ||
| 82 | + wdb.syncRunNodesToDef(run.id); | ||
| 83 | + wdb.updateTask(task.id, { status: 'running', currentRunId: run.id, startedAt: now }); | ||
| 84 | + | ||
| 85 | + const nodeRow = db.prepare(`SELECT id FROM graph_run_nodes WHERE run_id = ?`).get(run.id); | ||
| 86 | + db.prepare(`UPDATE graph_run_nodes SET status = 'blocked', fail_reason = 'boundary_escape', updated_at = ? WHERE id = ?`) | ||
| 87 | + .run(now, nodeRow.id); | ||
| 88 | + | ||
| 89 | + return { companyId, taskId: task.id, run: wdb.getRun(run.id) }; | ||
| 90 | + } | ||
| 91 | + | ||
| 92 | + it('blocked 回写时会 fetch 唤醒 `{companyId}:ceo`,带上正确的内部令牌头', async () => { | ||
| 93 | + const { companyId, run } = scene(); | ||
| 94 | + const { INTERNAL_TOKEN, INTERNAL_TOKEN_HEADER } = require('../../middleware/jwt-auth'); | ||
| 95 | + | ||
| 96 | + const calls = []; | ||
| 97 | + const originalFetch = global.fetch; | ||
| 98 | + global.fetch = vi.fn((url, opts) => { | ||
| 99 | + calls.push({ url, opts }); | ||
| 100 | + return Promise.resolve({ ok: true, status: 200 }); | ||
| 101 | + }); | ||
| 102 | + | ||
| 103 | + try { | ||
| 104 | + settle.settleRootRun(run, 'blocked'); | ||
| 105 | + // wakeCeo 是发射后不等结果,让微任务队列跑一轮再断言 | ||
| 106 | + await new Promise((r) => setImmediate(r)); | ||
| 107 | + } finally { | ||
| 108 | + global.fetch = originalFetch; | ||
| 109 | + } | ||
| 110 | + | ||
| 111 | + expect(calls).toHaveLength(1); | ||
| 112 | + const [{ url, opts }] = calls; | ||
| 113 | + expect(url).toContain(`/api/agent/${companyId}%3Aceo/chat`); | ||
| 114 | + expect(opts.method).toBe('POST'); | ||
| 115 | + expect(opts.headers[INTERNAL_TOKEN_HEADER]).toBe(INTERNAL_TOKEN); | ||
| 116 | + const body = JSON.parse(opts.body); | ||
| 117 | + expect(body.message).toContain('blocked'); | ||
| 118 | + expect(body.isHeartbeat).toBe(true); | ||
| 119 | + }); | ||
| 120 | + | ||
| 121 | + it('completed 回写不会唤醒 ceo(只有 blocked 才需要主动叫人)', async () => { | ||
| 122 | + const { run } = scene(); | ||
| 123 | + const calls = []; | ||
| 124 | + const originalFetch = global.fetch; | ||
| 125 | + global.fetch = vi.fn((url, opts) => { calls.push({ url, opts }); return Promise.resolve({ ok: true }); }); | ||
| 126 | + | ||
| 127 | + try { | ||
| 128 | + // 先把节点状态改成 success,让 settleTaskFromRun 认成 completed | ||
| 129 | + const db = wdb.getDb(); | ||
| 130 | + db.prepare(`UPDATE graph_run_nodes SET status = 'success', fail_reason = NULL WHERE run_id = ?`).run(run.id); | ||
| 131 | + settle.settleRootRun(run, 'completed'); | ||
| 132 | + await new Promise((r) => setImmediate(r)); | ||
| 133 | + } finally { | ||
| 134 | + global.fetch = originalFetch; | ||
| 135 | + } | ||
| 136 | + | ||
| 137 | + expect(calls).toHaveLength(0); | ||
| 138 | + }); | ||
| 139 | + | ||
| 140 | + it('fetch 唤醒失败(网络异常)不影响 settleRootRun 的返回值 —— best-effort,不能拖垮主路径', () => { | ||
| 141 | + const { run } = scene(); | ||
| 142 | + const originalFetch = global.fetch; | ||
| 143 | + global.fetch = vi.fn(() => Promise.reject(new Error('网络挂了'))); | ||
| 144 | + | ||
| 145 | + let result; | ||
| 146 | + try { | ||
| 147 | + result = settle.settleRootRun(run, 'blocked'); | ||
| 148 | + } finally { | ||
| 149 | + global.fetch = originalFetch; | ||
| 150 | + } | ||
| 151 | + | ||
| 152 | + expect(result).toEqual({ task: 'blocked', goal: null }); | ||
| 153 | + }); | ||
| 154 | + | ||
| 155 | + it('低频兜底扫描 sweepBlockedTasksForCeo:按公司分组只唤醒一次,不逐条 task 叫', async () => { | ||
| 156 | + const a = scene(); | ||
| 157 | + const b = scene(); | ||
| 158 | + // 同一家公司再卡一条 —— a.companyId 应该只被唤醒一次,不是两次 | ||
| 159 | + const db = wdb.getDb(); | ||
| 160 | + const now = new Date().toISOString(); | ||
| 161 | + const graph2 = wdb.createWorkGraph({ companyId: a.companyId, nodes: ['sdd-planning'], edges: [] }); | ||
| 162 | + const goal2 = wdb.createGoal({ companyId: a.companyId, title: '第二条', description: '测试用' }); | ||
| 163 | + const task2 = wdb.createTask({ | ||
| 164 | + goalId: goal2.id, companyId: a.companyId, title: '第二条', description: '测试用', executionMode: 'manual', | ||
| 165 | + }); | ||
| 166 | + wdb.updateTask(task2.id, { status: 'blocked' }); | ||
| 167 | + // scene() 已经把 a/b 的 task 结算成 blocked(settleRootRun 还没跑,这里直接跑一次让状态落到位) | ||
| 168 | + settle.settleRootRun(a.run, 'blocked'); | ||
| 169 | + settle.settleRootRun(b.run, 'blocked'); | ||
| 170 | + | ||
| 171 | + const calls = []; | ||
| 172 | + const originalFetch = global.fetch; | ||
| 173 | + global.fetch = vi.fn((url) => { calls.push(url); return Promise.resolve({ ok: true }); }); | ||
| 174 | + | ||
| 175 | + try { | ||
| 176 | + const rows = settle.sweepBlockedTasksForCeo(); | ||
| 177 | + await new Promise((r) => setImmediate(r)); | ||
| 178 | + const companiesSwept = rows.map((r) => r.company_id).sort(); | ||
| 179 | + expect(companiesSwept).toEqual([a.companyId, b.companyId].sort()); | ||
| 180 | + const aRow = rows.find((r) => r.company_id === a.companyId); | ||
| 181 | + expect(aRow.cnt).toBe(2); // a 公司这次有 2 条 blocked task,但下面只应该 fetch 一次 | ||
| 182 | + } finally { | ||
| 183 | + global.fetch = originalFetch; | ||
| 184 | + } | ||
| 185 | + | ||
| 186 | + const aCalls = calls.filter((u) => u.includes(`${a.companyId}%3Aceo`)); | ||
| 187 | + expect(aCalls).toHaveLength(1); | ||
| 188 | + }); | ||
| 189 | +}); |
server/tests/unit/task-entry-draft-mode.test.js
0 → 100644
| 1 | +/** | ||
| 2 | + * task-entry-draft-mode.test.js —— POST /:companyId/tasks 的 draft:true 分支(39 号文档地基 1) | ||
| 3 | + * | ||
| 4 | + * 现有 `POST /:companyId/tasks` 建完任务无条件立刻入队执行("下达即执行")。 | ||
| 5 | + * `draft:true` 时只应该落库,不应该 enqueue/start——服务的是 agent(如 ceo)自主提的新任务, | ||
| 6 | + * 不该像 task-planning 一样先经过 draft 就自动花预算。 | ||
| 7 | + * | ||
| 8 | + * 隔离手法与 graph-engine.test.js / company-goal-task-routes.test.js 一致:先设 | ||
| 9 | + * GRAPH_DB_PATH / GRAPH_RUNS_DIR 再 require,真实的 data/graph.sqlite、data/companies/ 全程不被碰。 | ||
| 10 | + */ | ||
| 11 | +const fs = require('fs'); | ||
| 12 | +const os = require('os'); | ||
| 13 | +const path = require('path'); | ||
| 14 | + | ||
| 15 | +const TEST_DB_PATH = '/tmp/test-task-entry-draft-mode.sqlite'; | ||
| 16 | +const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]; | ||
| 17 | +const MODULES = [ | ||
| 18 | + '../../services/graph-db', | ||
| 19 | + '../../services/work-graph-db', | ||
| 20 | + '../../services/org-graph-service', | ||
| 21 | + '../../services/graph-run-sse', | ||
| 22 | + '../../routes/company', | ||
| 23 | +]; | ||
| 24 | + | ||
| 25 | +const CID = 'draft-mode-test-co'; | ||
| 26 | + | ||
| 27 | +let wdb; | ||
| 28 | +let orgSvc; | ||
| 29 | +let server; | ||
| 30 | +let base; | ||
| 31 | +let origCompanyGet; | ||
| 32 | + | ||
| 33 | +async function call(method, url, body) { | ||
| 34 | + const r = await fetch(base + url, { | ||
| 35 | + method, | ||
| 36 | + headers: { 'Content-Type': 'application/json' }, | ||
| 37 | + body: body === undefined ? undefined : JSON.stringify(body), | ||
| 38 | + }); | ||
| 39 | + return [r.status, await r.json()]; | ||
| 40 | +} | ||
| 41 | + | ||
| 42 | +function ensureAgents(ids) { | ||
| 43 | + const db = wdb.getDb(); | ||
| 44 | + const now = new Date().toISOString(); | ||
| 45 | + const ins = db.prepare(` | ||
| 46 | + INSERT OR IGNORE INTO system_agents | ||
| 47 | + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at) | ||
| 48 | + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?) | ||
| 49 | + `); | ||
| 50 | + for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, now, now); | ||
| 51 | +} | ||
| 52 | + | ||
| 53 | +describe('POST /:companyId/tasks draft 模式(39 号文档地基 1)', () => { | ||
| 54 | + beforeAll(async () => { | ||
| 55 | + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 本来就没有 */ } } | ||
| 56 | + process.env.GRAPH_DB_PATH = TEST_DB_PATH; | ||
| 57 | + process.env.GRAPH_RUNS_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'draft-mode-runs-')); | ||
| 58 | + for (const m of MODULES) delete require.cache[require.resolve(m)]; | ||
| 59 | + | ||
| 60 | + wdb = require('../../services/work-graph-db'); | ||
| 61 | + orgSvc = require('../../services/org-graph-service'); | ||
| 62 | + | ||
| 63 | + const { CompanyService } = require('../../services/company-service'); | ||
| 64 | + origCompanyGet = CompanyService.prototype.get; | ||
| 65 | + CompanyService.prototype.get = (companyId) => | ||
| 66 | + (companyId === CID ? { id: CID, name: CID, status: 'running' } : null); | ||
| 67 | + | ||
| 68 | + // 一张最小 org graph:POST /tasks 建行前会先查「公司有没有 org graph」,没有直接 409, | ||
| 69 | + // 这一步不是本文件要测的东西,先把它铺好。 | ||
| 70 | + ensureAgents(['org-planner', 'org-coder', 'org-validator']); | ||
| 71 | + expect( | ||
| 72 | + orgSvc.writeGraph(CID, { | ||
| 73 | + nodes: ['org-planner', 'org-coder', 'org-validator'], | ||
| 74 | + edges: [ | ||
| 75 | + { from: 'org-planner', to: 'org-coder', condition: 'success' }, | ||
| 76 | + { from: 'org-coder', to: 'org-validator', condition: 'success' }, | ||
| 77 | + ], | ||
| 78 | + }).ok | ||
| 79 | + ).toBe(true); | ||
| 80 | + | ||
| 81 | + const express = require('express'); | ||
| 82 | + const app = express(); | ||
| 83 | + app.use(express.json()); | ||
| 84 | + app.use('/api/company', require('../../routes/company')); | ||
| 85 | + | ||
| 86 | + await new Promise((resolve) => { server = app.listen(0, resolve); }); | ||
| 87 | + base = `http://127.0.0.1:${server.address().port}/api/company/${CID}`; | ||
| 88 | + }); | ||
| 89 | + | ||
| 90 | + afterAll(() => { | ||
| 91 | + if (server) server.close(); | ||
| 92 | + if (origCompanyGet) require('../../services/company-service').CompanyService.prototype.get = origCompanyGet; | ||
| 93 | + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 已经删了 */ } } | ||
| 94 | + try { fs.rmSync(process.env.GRAPH_RUNS_DIR, { recursive: true, force: true }); } catch { /* ignore */ } | ||
| 95 | + delete process.env.GRAPH_DB_PATH; | ||
| 96 | + delete process.env.GRAPH_RUNS_DIR; | ||
| 97 | + for (const m of MODULES) delete require.cache[require.resolve(m)]; | ||
| 98 | + }); | ||
| 99 | + | ||
| 100 | + it('draft:true → 201 + status draft,不落 runId,task 不入队(current_run_id 仍为空)', async () => { | ||
| 101 | + const [status, body] = await call('POST', '/tasks', { | ||
| 102 | + task: '(草稿)先别执行的一条任务', | ||
| 103 | + draft: true, | ||
| 104 | + created_by: 'ceo', | ||
| 105 | + }); | ||
| 106 | + | ||
| 107 | + expect(status).toBe(201); | ||
| 108 | + expect(body.status).toBe('draft'); | ||
| 109 | + expect(body.runId).toBeUndefined(); | ||
| 110 | + expect(body.taskId).toBeTruthy(); | ||
| 111 | + expect(body.hint).toContain(`/tasks/${body.taskId}/enqueue`); | ||
| 112 | + | ||
| 113 | + const row = wdb.getTask(body.taskId); | ||
| 114 | + expect(row.status).toBe('draft'); | ||
| 115 | + expect(row.current_run_id).toBeFalsy(); | ||
| 116 | + expect(row.created_by).toBe('ceo'); | ||
| 117 | + }); | ||
| 118 | + | ||
| 119 | + it('draft:true 建出的任务,之后走既有 /enqueue 端点能正常下达(不是死路)', async () => { | ||
| 120 | + const [, created] = await call('POST', '/tasks', { task: '(草稿)待下达', draft: true }); | ||
| 121 | + const [status, body] = await call('POST', `/tasks/${created.taskId}/enqueue`); | ||
| 122 | + expect(status).toBe(201); | ||
| 123 | + expect(['queued', 'running'].includes(body.status)).toBe(true); | ||
| 124 | + | ||
| 125 | + const row = wdb.getTask(created.taskId); | ||
| 126 | + expect(['queued', 'running'].includes(row.status)).toBe(true); | ||
| 127 | + }); | ||
| 128 | + | ||
| 129 | + it('默认行为不变:不传 draft 时仍然立刻入队/起跑(存量调用方不受影响)', async () => { | ||
| 130 | + const [status, body] = await call('POST', '/tasks', { task: '正常下达一条任务' }); | ||
| 131 | + expect(status).toBe(201); | ||
| 132 | + expect(body.status).not.toBe('draft'); | ||
| 133 | + expect(body.runId).toBeTruthy(); | ||
| 134 | + }); | ||
| 135 | +}); |
server/tests/unit/task-slots-parallel.test.js
| @@ -587,6 +587,53 @@ describe('批 4 · 公司级执行位(max_parallel_tasks)', () => { | @@ -587,6 +587,53 @@ describe('批 4 · 公司级执行位(max_parallel_tasks)', () => { | ||
| 587 | }); | 587 | }); |
| 588 | 588 | ||
| 589 | // ──────────────────────────────────────────────────────────────── | 589 | // ──────────────────────────────────────────────────────────────── |
| 590 | + // ⑤bis 39 号文档 P5:配了 target_dir 强制并行度=1,不管 max_parallel_tasks 说什么 | ||
| 591 | + // ──────────────────────────────────────────────────────────────── | ||
| 592 | + describe('⑤bis 公司级目标代码目录:target_dir 存在时并行度钉死为 1', () => { | ||
| 593 | + /** 同一家公司同时配 target_dir 和一个 >1 的 max_parallel_tasks */ | ||
| 594 | + function setTargetDir(companyId, targetDir, maxParallel) { | ||
| 595 | + const { companyRootDir } = require('../../services/company-paths'); | ||
| 596 | + const dir = companyRootDir(companyId); | ||
| 597 | + fs.mkdirSync(dir, { recursive: true }); | ||
| 598 | + const cfg = { id: companyId, name: companyId, target_dir: targetDir }; | ||
| 599 | + if (maxParallel !== undefined) cfg.max_parallel_tasks = maxParallel; | ||
| 600 | + fs.writeFileSync(path.join(dir, 'company.json'), JSON.stringify(cfg)); | ||
| 601 | + return companyId; | ||
| 602 | + } | ||
| 603 | + | ||
| 604 | + it('target_dir 非空 + max_parallel_tasks=5 → 仍然返回 1', () => { | ||
| 605 | + const cid = setTargetDir(nextCompany(), '/tmp/some-shared-repo', 5); | ||
| 606 | + expect(wdb.maxActiveRootRuns(cid)).toBe(1); | ||
| 607 | + }); | ||
| 608 | + | ||
| 609 | + it('没配 target_dir 时 max_parallel_tasks 照常生效(回归:这条改动不该动到没配的公司)', () => { | ||
| 610 | + const cid = setMaxParallel(nextCompany(), 3); | ||
| 611 | + expect(wdb.maxActiveRootRuns(cid)).toBe(3); | ||
| 612 | + }); | ||
| 613 | + | ||
| 614 | + it('target_dir 留声一次(不静默),且是可读的一次性日志,不刷屏', () => { | ||
| 615 | + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); | ||
| 616 | + try { | ||
| 617 | + const cid = setTargetDir(nextCompany(), '/tmp/shared-repo-log-test', 9); | ||
| 618 | + expect(wdb.maxActiveRootRuns(cid)).toBe(1); | ||
| 619 | + expect(wdb.maxActiveRootRuns(cid)).toBe(1); | ||
| 620 | + expect(wdb.maxActiveRootRuns(cid)).toBe(1); | ||
| 621 | + expect(warn).toHaveBeenCalledTimes(1); | ||
| 622 | + expect(String(warn.mock.calls[0][0])).toContain('target_dir'); | ||
| 623 | + } finally { | ||
| 624 | + warn.mockRestore(); | ||
| 625 | + } | ||
| 626 | + }); | ||
| 627 | + | ||
| 628 | + it('运行期把 target_dir 清空后,恢复读 max_parallel_tasks(调用时读盘,不是缓存快照)', () => { | ||
| 629 | + const cid = setTargetDir(nextCompany(), '/tmp/shared-repo-toggle', 4); | ||
| 630 | + expect(wdb.maxActiveRootRuns(cid)).toBe(1); | ||
| 631 | + setMaxParallel(cid, 4); // 覆写掉 target_dir | ||
| 632 | + expect(wdb.maxActiveRootRuns(cid)).toBe(4); | ||
| 633 | + }); | ||
| 634 | + }); | ||
| 635 | + | ||
| 636 | + // ──────────────────────────────────────────────────────────────── | ||
| 590 | // ⑥ DROP 迁移:存量库(带旧索引)启动一次就没了 | 637 | // ⑥ DROP 迁移:存量库(带旧索引)启动一次就没了 |
| 591 | // ──────────────────────────────────────────────────────────────── | 638 | // ──────────────────────────────────────────────────────────────── |
| 592 | describe('⑥ 迁移:带 idx_graph_runs_one_active_root 的存量库', () => { | 639 | describe('⑥ 迁移:带 idx_graph_runs_one_active_root 的存量库', () => { |
system/agents/ceo/heartbeat.md
0 → 100644
| 1 | +# 心跳任务 | ||
| 2 | + | ||
| 3 | +你收到的心跳消息会提"扫描全局 OKR 目标状态"——那是通用触发文案,对你不适用。 | ||
| 4 | +你要看的是本公司的 **goal/task 板**(`tools.md` 里的读接口),不是 OKR。 | ||
| 5 | + | ||
| 6 | +## 每次心跳 | ||
| 7 | + | ||
| 8 | +- [ ] 查 `GET /tasks?status=blocked` 和 `?status=failed`,看有没有需要处理的 | ||
| 9 | +- [ ] 查 `GET /goals?status=active`,扫一眼有没有明显该调整优先级的 | ||
| 10 | +- [ ] 都正常就直接回复"心跳正常",不要为了"用一下工具"而用工具 | ||
| 11 | + | ||
| 12 | +## 发现问题时 | ||
| 13 | + | ||
| 14 | +- 具体判断依据见 `tools.md` 的"什么时候该提新任务、什么时候该改优先级"一节 | ||
| 15 | +- 新提的任务一律带 `draft:true`,判断确实清楚、值得马上跑再 `enqueue` | ||
| 16 | +- 不确定的情况,把观察写进正文回复,不要瞎猜着动手改数据 | ||
| 17 | + | ||
| 18 | +## 不用管的 | ||
| 19 | + | ||
| 20 | +- 正在排队 / 正在跑但没超时的 task——那是调度器的活,不用你盯 | ||
| 21 | +- 纯技术性的失败(比如基础设施抖动)——引擎自己有重试和熔断机制 |
system/agents/ceo/tools.md
0 → 100644
| 1 | +# CEO 工具箱 | ||
| 2 | + | ||
| 3 | +> 你的岗位是监督本公司的 goal/task 板(不是执行任务——`org-planner`/`org-coder`/`org-validator` | ||
| 4 | +> 那条流水线干活,你只看板、判断、必要时提一条新任务或调一下优先级)。 | ||
| 5 | +> | ||
| 6 | +> 大部分心跳触发下,你**只需要读**:看一眼有没有 `blocked`/`failed` 太久的 task,正常就 | ||
| 7 | +> 回复"心跳正常",什么都不用调用。只有真发现问题时才用下面的写工具。 | ||
| 8 | + | ||
| 9 | +--- | ||
| 10 | + | ||
| 11 | +## 认证:每次调用都要带这个头 | ||
| 12 | + | ||
| 13 | +```bash | ||
| 14 | +curl -s "${LEMON_API_BASE:-http://localhost:3010/api}/company/<companyId>/goals" \ | ||
| 15 | + -H "x-internal-token: $LEMON_INTERNAL_TOKEN" | ||
| 16 | +``` | ||
| 17 | + | ||
| 18 | +`LEMON_INTERNAL_TOKEN` 是你的进程环境变量里现成的(服务端 spawn 你的时候自动带上), | ||
| 19 | +不用去别处找、也不用自己生成。**忘了带这个头,所有请求都会 401**——先检查这个, | ||
| 20 | +不要一看到 401 就怀疑接口路径写错了。 | ||
| 21 | + | ||
| 22 | +`<companyId>` 换成你自己所在的公司 id(就是你运行时坐标里那个 cid)。 | ||
| 23 | + | ||
| 24 | +--- | ||
| 25 | + | ||
| 26 | +## 读:先看板子上有什么 | ||
| 27 | + | ||
| 28 | +### 看哪些 goal 在跑 | ||
| 29 | +```bash | ||
| 30 | +curl -s "${LEMON_API_BASE:-http://localhost:3010/api}/company/<companyId>/goals?status=active" \ | ||
| 31 | + -H "x-internal-token: $LEMON_INTERNAL_TOKEN" | ||
| 32 | +``` | ||
| 33 | + | ||
| 34 | +### 找卡住的 task(这是你心跳时主要要查的) | ||
| 35 | +```bash | ||
| 36 | +curl -s "${LEMON_API_BASE:-http://localhost:3010/api}/company/<companyId>/tasks?status=blocked" \ | ||
| 37 | + -H "x-internal-token: $LEMON_INTERNAL_TOKEN" | ||
| 38 | +curl -s "${LEMON_API_BASE:-http://localhost:3010/api}/company/<companyId>/tasks?status=failed" \ | ||
| 39 | + -H "x-internal-token: $LEMON_INTERNAL_TOKEN" | ||
| 40 | +``` | ||
| 41 | + | ||
| 42 | +### 看某个 goal 下全部 task + 每条 task 的当前 run 摘要 | ||
| 43 | +```bash | ||
| 44 | +curl -s "${LEMON_API_BASE:-http://localhost:3010/api}/company/<companyId>/goals/<goalId>" \ | ||
| 45 | + -H "x-internal-token: $LEMON_INTERNAL_TOKEN" | ||
| 46 | +``` | ||
| 47 | + | ||
| 48 | +--- | ||
| 49 | + | ||
| 50 | +## 写:只在真正判断需要时才用 | ||
| 51 | + | ||
| 52 | +### 改一条 task 的优先级 | ||
| 53 | +```bash | ||
| 54 | +curl -s -X PATCH "${LEMON_API_BASE:-http://localhost:3010/api}/company/<companyId>/tasks/<taskId>" \ | ||
| 55 | + -H "x-internal-token: $LEMON_INTERNAL_TOKEN" \ | ||
| 56 | + -H "Content-Type: application/json" \ | ||
| 57 | + -d '{"priority":"P0"}' | ||
| 58 | +``` | ||
| 59 | + | ||
| 60 | +### 提一条新任务(草稿,不会立刻执行) | ||
| 61 | +```bash | ||
| 62 | +curl -s -X POST "${LEMON_API_BASE:-http://localhost:3010/api}/company/<companyId>/tasks" \ | ||
| 63 | + -H "x-internal-token: $LEMON_INTERNAL_TOKEN" \ | ||
| 64 | + -H "Content-Type: application/json" \ | ||
| 65 | + -d '{"goal_id":"<goalId>","task":"<这条新任务要做什么,写清楚,别人要能看懂>","draft":true}' | ||
| 66 | +``` | ||
| 67 | +**必须带 `"draft":true`。** 不带的话这条任务会立刻入队执行——你的判断和 `task-planning` | ||
| 68 | +拆分一样,都是一次"猜",不该自己直接烧预算。落库后返回一个 `taskId`。 | ||
| 69 | + | ||
| 70 | +### 把你(或别人)提的草稿正式下达 | ||
| 71 | +```bash | ||
| 72 | +curl -s -X POST "${LEMON_API_BASE:-http://localhost:3010/api}/company/<companyId>/tasks/<taskId>/enqueue" \ | ||
| 73 | + -H "x-internal-token: $LEMON_INTERNAL_TOKEN" | ||
| 74 | +``` | ||
| 75 | +你被授权可以自主调这一步(不需要等人类点),但前提是你已经确认过这条草稿的描述是清楚、 | ||
| 76 | +必要的——`enqueue` 之后就会真的排队执行、真的烧预算。判断依据不够就先别 enqueue, | ||
| 77 | +留着草稿等人类看到也行。 | ||
| 78 | + | ||
| 79 | +--- | ||
| 80 | + | ||
| 81 | +## 什么时候该提新任务、什么时候该改优先级 | ||
| 82 | + | ||
| 83 | +- **一条 task 长时间 `blocked` 或 `failed`**(预算耗尽 / 依赖死锁 / 反复回退熔断):看它的 | ||
| 84 | + `blocked_reason` 或诊断信息,判断是「原任务描述本身有问题,需要重新拆」还是「暂时的技术 | ||
| 85 | + 问题,等修复」。前者提一条新任务说明清楚问题、把原 task 的教训写进 description; | ||
| 86 | + 后者不用你管,等基础设施问题解决会自动恢复。 | ||
| 87 | + (注意:某些"卡住已回退熔断"的情形系统会**自动**先开一条草稿任务——你去看板前可能已经 | ||
| 88 | + 有一条 `created_by: 'system'` 的草稿在等你审了,先看有没有再决定要不要自己再开一条。) | ||
| 89 | +- **同一 goal 下多个 task 优先级冲突**(业务变化导致原定顺序不再合理):改 `priority`, | ||
| 90 | + 不需要新建任务。 | ||
| 91 | +- **`task-planning` 降级成单任务**(goal 描述里能看出"自动拆分失败,已按单任务下达"的 | ||
| 92 | + 标注):这种情况不要自己再拆一次——把情况记下来,正文回复里说明,等人类看一眼原始 | ||
| 93 | + goal 描述是不是本身就有歧义。 | ||
| 94 | + | ||
| 95 | +--- | ||
| 96 | + | ||
| 97 | +## 你不该用的工具 | ||
| 98 | + | ||
| 99 | +| 工具 / 动作 | 为什么不该 | | ||
| 100 | +|---|---| | ||
| 101 | +| `POST /:companyId/org-graph/replan` | 改组织结构是 `orggraph-planning` 的事,不是你的判断范围 | | ||
| 102 | +| 任何招聘 / 解雇端点 | 公司缺角色是 HR 与 `orggraph-planning` 的活,你发现"缺人"应该提一条 task 说明缺口,不是自己动手改编制 | | ||
| 103 | +| 直接改 `graph_runs` / `graph_run_nodes`(没有这类端点,但如果你找到裸 SQL 或内部函数也一样不许碰) | 执行状态由引擎维护,你只通过 goals/tasks 这层 REST 接口操作 | | ||
| 104 | +| `POST /:companyId/tasks`(不带 `draft:true`) | 会立刻烧预算,见上面的强调 | |
system/agents/zz-chat-interrupt/agent.md
0 → 100644
| 1 | +# 打断-排队单测用的常驻本体 |
system/agents/zz-chat-interrupt/config.json
0 → 100644
system/agents/zz-chat-interrupt/identity.md
0 → 100644
| 1 | +# zz-chat-interrupt |
vitest.config.js
| @@ -4,7 +4,7 @@ export default defineConfig({ | @@ -4,7 +4,7 @@ export default defineConfig({ | ||
| 4 | test: { | 4 | test: { |
| 5 | globals: true, | 5 | globals: true, |
| 6 | environment: 'node', | 6 | environment: 'node', |
| 7 | - include: ['src/**/*.test.js', 'server/src/tests/**/*.test.js', 'server/tests/**/*.test.js', 'packages/**/*.test.js'], | 7 | + include: ['src/**/*.test.js', 'server/src/tests/**/*.test.js', 'server/tests/**/*.test.js', 'packages/**/*.test.js', 'web/src/**/*.test.js'], |
| 8 | coverage: { | 8 | coverage: { |
| 9 | provider: 'v8', | 9 | provider: 'v8', |
| 10 | reporter: ['text', 'html'], | 10 | reporter: ['text', 'html'], |
web/src/components/AppNav.vue
| @@ -61,8 +61,8 @@ | @@ -61,8 +61,8 @@ | ||
| 61 | <a :href="`/${currentCompany.id}/pipelines`" target="_blank" class="nav-link" :class="{ active: $route.params.tab === 'pipelines' || $route.params.tab === 'sop' }"> | 61 | <a :href="`/${currentCompany.id}/pipelines`" target="_blank" class="nav-link" :class="{ active: $route.params.tab === 'pipelines' || $route.params.tab === 'sop' }"> |
| 62 | ⚙️ SOP | 62 | ⚙️ SOP |
| 63 | </a> | 63 | </a> |
| 64 | - <a :href="`/${currentCompany.id}/goals`" target="_blank" class="nav-link" :class="{ active: $route.params.tab === 'goals' || $route.params.tab === 'okr' }"> | ||
| 65 | - 🎯 OKR | 64 | + <a :href="`/${currentCompany.id}/task-board`" target="_blank" class="nav-link" :class="{ active: $route.path.endsWith('/task-board') }"> |
| 65 | + 🗂️ 任务看板 | ||
| 66 | </a> | 66 | </a> |
| 67 | <a :href="`/${currentCompany.id}/files`" target="_blank" class="nav-link" :class="{ active: $route.params.tab === 'files' }"> | 67 | <a :href="`/${currentCompany.id}/files`" target="_blank" class="nav-link" :class="{ active: $route.params.tab === 'files' }"> |
| 68 | 📁 Files | 68 | 📁 Files |
web/src/composables/agentChatEvents.js
0 → 100644
| 1 | +/** | ||
| 2 | + * 从 AgentChat.vue 抽出来的两块纯逻辑: | ||
| 3 | + * | ||
| 4 | + * 1. `createAgentChatEventHandler` —— SSE 事件分发器。只认 requestAgentId 是否等于 | ||
| 5 | + * 调用时刻"当前选中的 agent",据此决定要不要触碰全局态(路由/对话列表/DOM 滚动); | ||
| 6 | + * 传入的 `st`(每个 agent 一份的状态)永远无条件更新,不受这个判断影响——这样切走 | ||
| 7 | + * 了不会打断/污染,切回来能看到完整的后台进度(9b1c3f1 修的就是这条不变式)。 | ||
| 8 | + * 抽成不依赖组件实例的纯函数,是为了能在不挂载整个 Vue 组件的前提下对它做回归测试 | ||
| 9 | + * (40 号文档方案三)。 | ||
| 10 | + * | ||
| 11 | + * 2. `createTypewriterQueue` / `flushTypewriter` —— 打字机渲染缓冲(40 号文档方案一)。 | ||
| 12 | + * 后端已经把大段文本切成小片再发 SSE,这里再加一层匀速吐出,抹平网络到达节奏不均匀 | ||
| 13 | + * (突发多个 SSE 事件挤在一起)的情况。 | ||
| 14 | + */ | ||
| 15 | + | ||
| 16 | +const TYPEWRITER_TICK_MS = 20; | ||
| 17 | +const TYPEWRITER_CHARS_PER_TICK = 2; | ||
| 18 | + | ||
| 19 | +/** 把新到的文本接到 st 的打字机队列尾部,并确保有一个定时器在匀速吐出 */ | ||
| 20 | +export function createTypewriterQueue({ tickMs = TYPEWRITER_TICK_MS, charsPerTick = TYPEWRITER_CHARS_PER_TICK } = {}) { | ||
| 21 | + return function pushChunk(st, text) { | ||
| 22 | + if (!text) return; | ||
| 23 | + st._typeQueue = (st._typeQueue || '') + text; | ||
| 24 | + if (st._typeTimer) return; // 已经在吐了,接上队尾即可 | ||
| 25 | + st._typeTimer = setInterval(() => { | ||
| 26 | + if (!st._typeQueue) { | ||
| 27 | + clearInterval(st._typeTimer); | ||
| 28 | + st._typeTimer = null; | ||
| 29 | + return; | ||
| 30 | + } | ||
| 31 | + st.streamContent += st._typeQueue.slice(0, charsPerTick); | ||
| 32 | + st._typeQueue = st._typeQueue.slice(charsPerTick); | ||
| 33 | + }, tickMs); | ||
| 34 | + }; | ||
| 35 | +} | ||
| 36 | + | ||
| 37 | +/** 立刻把队列里剩下的字符吐光,用在"这轮已经结束/被打断,不用再演匀速"的时刻 */ | ||
| 38 | +export function flushTypewriter(st) { | ||
| 39 | + if (st._typeTimer) { | ||
| 40 | + clearInterval(st._typeTimer); | ||
| 41 | + st._typeTimer = null; | ||
| 42 | + } | ||
| 43 | + if (st._typeQueue) { | ||
| 44 | + st.streamContent += st._typeQueue; | ||
| 45 | + st._typeQueue = ''; | ||
| 46 | + } | ||
| 47 | +} | ||
| 48 | + | ||
| 49 | +/** | ||
| 50 | + * @param {() => string} getCurrentAgentId 读取"用户当前选中的 agent id" | ||
| 51 | + * @param {(requestAgentId: string, data: object, st: object) => void} onNewConversation | ||
| 52 | + * 仅在 isCurrent 时调用:处理路由跳转 + 对话列表插入这类全局副作用 | ||
| 53 | + * @param {() => void} scrollToBottom | ||
| 54 | + * @param {() => void} scrollAcpLog | ||
| 55 | + * @param {() => void} scrollSubLogs | ||
| 56 | + * @param {(st: object, text: string) => void} [pushChunk] 打字机队列的入队函数,默认新建一个 | ||
| 57 | + */ | ||
| 58 | +export function createAgentChatEventHandler({ | ||
| 59 | + getCurrentAgentId, | ||
| 60 | + onNewConversation, | ||
| 61 | + scrollToBottom, | ||
| 62 | + scrollAcpLog, | ||
| 63 | + scrollSubLogs, | ||
| 64 | + pushChunk = createTypewriterQueue(), | ||
| 65 | +}) { | ||
| 66 | + return function handleEvent(st, requestAgentId, type, data) { | ||
| 67 | + const isCurrent = getCurrentAgentId() === requestAgentId; | ||
| 68 | + switch (type) { | ||
| 69 | + case 'session': | ||
| 70 | + if (data.conversationId && !st.activeConvId) { | ||
| 71 | + st.activeConvId = data.conversationId; | ||
| 72 | + if (isCurrent) onNewConversation(requestAgentId, data, st); | ||
| 73 | + } | ||
| 74 | + break; | ||
| 75 | + case 'thinking': | ||
| 76 | + if (data.message && !data.message.startsWith('🏗️')) { | ||
| 77 | + st.thinkingContent += data.message; | ||
| 78 | + if (isCurrent) scrollToBottom(); | ||
| 79 | + } else { | ||
| 80 | + st.thinkingText = data.message || '思考中...'; | ||
| 81 | + } | ||
| 82 | + break; | ||
| 83 | + case 'agent_output': | ||
| 84 | + st.streaming = true; | ||
| 85 | + pushChunk(st, data.message || ''); | ||
| 86 | + if (isCurrent) scrollToBottom(); | ||
| 87 | + break; | ||
| 88 | + case 'tool_call': | ||
| 89 | + st.thinkingText = data.message || `🔧 ${data.tool}`; | ||
| 90 | + st.acpLog.push({ type: 'tool', icon: '🔧', text: `${data.tool}`, ts: Date.now() }); | ||
| 91 | + if (isCurrent) scrollAcpLog(); | ||
| 92 | + break; | ||
| 93 | + case 'tool_input': | ||
| 94 | + st.acpLog.push({ type: 'input', icon: '📥', text: data.input?.slice(0, 300) || '', ts: Date.now() }); | ||
| 95 | + if (isCurrent) scrollAcpLog(); | ||
| 96 | + break; | ||
| 97 | + case 'tool_result': | ||
| 98 | + st.acpLog.push({ type: 'result', icon: '📤', text: data.result?.slice(0, 300) || '', ts: Date.now() }); | ||
| 99 | + if (isCurrent) scrollAcpLog(); | ||
| 100 | + break; | ||
| 101 | + case 'session_cost': | ||
| 102 | + st.acpLog.push({ type: 'cost', icon: '💰', text: `$${data.cost?.toFixed(4)} (${((data.duration || 0) / 1000).toFixed(1)}s)`, ts: Date.now() }); | ||
| 103 | + break; | ||
| 104 | + case 'acp_raw': | ||
| 105 | + if (data.method && !data.method.includes('content_block_delta') && !data.method.includes('message_start')) { | ||
| 106 | + st.acpLog.push({ type: 'raw', icon: '⚡', text: data.method, ts: Date.now() }); | ||
| 107 | + if (isCurrent) scrollAcpLog(); | ||
| 108 | + } | ||
| 109 | + break; | ||
| 110 | + case 'sub_agent': | ||
| 111 | + if (!st.subAgents[data.callId]) st.subAgents[data.callId] = { agent: data.agent, status: 'running', content: '', collapsed: false }; | ||
| 112 | + if (data.event === 'chunk' && data.chunk) { | ||
| 113 | + st.subAgents[data.callId].content += data.chunk; | ||
| 114 | + if (isCurrent) scrollSubLogs(); | ||
| 115 | + } else if (data.event === 'done') st.subAgents[data.callId].status = data.status; | ||
| 116 | + else if (data.event === 'start') st.subAgents[data.callId].status = 'running'; | ||
| 117 | + if (isCurrent) scrollToBottom(); | ||
| 118 | + break; | ||
| 119 | + case 'error': | ||
| 120 | + st.messages.push({ role: 'assistant', content: `❌ ${data.message}`, timestamp: new Date().toISOString() }); | ||
| 121 | + st.acpLog.push({ type: 'error', icon: '🚨', text: data.message, ts: Date.now() }); | ||
| 122 | + st.thinkingText = ''; | ||
| 123 | + st.thinkingContent = ''; | ||
| 124 | + if (isCurrent) { | ||
| 125 | + scrollToBottom(); | ||
| 126 | + scrollAcpLog(); | ||
| 127 | + } | ||
| 128 | + break; | ||
| 129 | + } | ||
| 130 | + }; | ||
| 131 | +} |
web/src/composables/agentChatEvents.test.js
0 → 100644
| 1 | +/** | ||
| 2 | + * 回归测试:9b1c3f1 修的"多聊天串台"不变式(40 号文档方案三)。 | ||
| 3 | + * | ||
| 4 | + * 不变式:handleEvent 收到的每一条事件都要无条件写进它自己的 st(每个 agent 一份), | ||
| 5 | + * 但只有在事件所属的 requestAgentId 仍等于"用户当前选中的 agent"时,才允许触碰 | ||
| 6 | + * 全局副作用(路由跳转 / 对话列表 / DOM 滚动)。这条测试不需要挂载整个 Vue 组件—— | ||
| 7 | + * handleEvent 已经被抽成不依赖组件实例的纯函数(agentChatEvents.js),直接喂事件、 | ||
| 8 | + * 断言 st 内容和副作用回调的调用次数即可。 | ||
| 9 | + */ | ||
| 10 | +import { describe, it, expect, vi } from 'vitest'; | ||
| 11 | +import { createAgentChatEventHandler, createTypewriterQueue, flushTypewriter } from './agentChatEvents.js'; | ||
| 12 | + | ||
| 13 | +function makeState() { | ||
| 14 | + return { | ||
| 15 | + messages: [], activeConvId: null, | ||
| 16 | + sending: false, streaming: false, streamContent: '', | ||
| 17 | + subAgents: {}, subLogList: [], acpLog: [], | ||
| 18 | + thinkingText: '', thinkingContent: '', showThinking: true, | ||
| 19 | + }; | ||
| 20 | +} | ||
| 21 | + | ||
| 22 | +describe('agentChatEvents · 串台加固', () => { | ||
| 23 | + it('切走之后,旧 agent 的事件继续更新它自己的 st,但不再触发全局副作用', () => { | ||
| 24 | + let current = 'agent-A'; | ||
| 25 | + const scrollToBottom = vi.fn(); | ||
| 26 | + const scrollAcpLog = vi.fn(); | ||
| 27 | + const onNewConversation = vi.fn(); | ||
| 28 | + const handleEvent = createAgentChatEventHandler({ | ||
| 29 | + getCurrentAgentId: () => current, | ||
| 30 | + onNewConversation, | ||
| 31 | + scrollToBottom, | ||
| 32 | + scrollAcpLog, | ||
| 33 | + scrollSubLogs: vi.fn(), | ||
| 34 | + pushChunk: (st, text) => { st.streamContent += text; }, // 跳过打字机定时器,测同步内容 | ||
| 35 | + }); | ||
| 36 | + | ||
| 37 | + const stA = makeState(); | ||
| 38 | + handleEvent(stA, 'agent-A', 'session', { conversationId: 'conv-A' }); | ||
| 39 | + handleEvent(stA, 'agent-A', 'agent_output', { message: '你好' }); | ||
| 40 | + expect(scrollToBottom).toHaveBeenCalledTimes(1); // 只有 agent_output 会调 scrollToBottom | ||
| 41 | + expect(onNewConversation).toHaveBeenCalledTimes(1); // session 走的是 onNewConversation | ||
| 42 | + | ||
| 43 | + // 用户切到另一个 agent | ||
| 44 | + current = 'agent-B'; | ||
| 45 | + scrollToBottom.mockClear(); | ||
| 46 | + scrollAcpLog.mockClear(); | ||
| 47 | + | ||
| 48 | + handleEvent(stA, 'agent-A', 'agent_output', { message: ',世界' }); | ||
| 49 | + handleEvent(stA, 'agent-A', 'tool_call', { tool: 'bash' }); | ||
| 50 | + | ||
| 51 | + // st 照常被写:内容没丢 | ||
| 52 | + expect(stA.streamContent).toBe('你好,世界'); | ||
| 53 | + expect(stA.acpLog).toHaveLength(1); | ||
| 54 | + // 但不再是当前 agent,全局副作用一次都不该触发 | ||
| 55 | + expect(scrollToBottom).not.toHaveBeenCalled(); | ||
| 56 | + expect(scrollAcpLog).not.toHaveBeenCalled(); | ||
| 57 | + | ||
| 58 | + // 切回来之后,新事件恢复触发副作用,且历史内容完好 | ||
| 59 | + current = 'agent-A'; | ||
| 60 | + handleEvent(stA, 'agent-A', 'tool_call', { tool: 'grep' }); | ||
| 61 | + expect(scrollAcpLog).toHaveBeenCalledTimes(1); | ||
| 62 | + expect(stA.acpLog).toHaveLength(2); | ||
| 63 | + }); | ||
| 64 | + | ||
| 65 | + it('两个 agent 同时在跑,事件不会互相串台到对方的 st 里', () => { | ||
| 66 | + let current = 'agent-A'; | ||
| 67 | + const handleEvent = createAgentChatEventHandler({ | ||
| 68 | + getCurrentAgentId: () => current, | ||
| 69 | + onNewConversation: vi.fn(), | ||
| 70 | + scrollToBottom: vi.fn(), | ||
| 71 | + scrollAcpLog: vi.fn(), | ||
| 72 | + scrollSubLogs: vi.fn(), | ||
| 73 | + pushChunk: (st, text) => { st.streamContent += text; }, | ||
| 74 | + }); | ||
| 75 | + | ||
| 76 | + const stA = makeState(); | ||
| 77 | + const stB = makeState(); | ||
| 78 | + // 交替喂 A、B 两条流的事件,模拟后台并发 | ||
| 79 | + handleEvent(stA, 'agent-A', 'agent_output', { message: 'A1' }); | ||
| 80 | + handleEvent(stB, 'agent-B', 'agent_output', { message: 'B1' }); | ||
| 81 | + current = 'agent-B'; // 用户切到 B | ||
| 82 | + handleEvent(stA, 'agent-A', 'agent_output', { message: 'A2' }); | ||
| 83 | + handleEvent(stB, 'agent-B', 'agent_output', { message: 'B2' }); | ||
| 84 | + | ||
| 85 | + expect(stA.streamContent).toBe('A1A2'); | ||
| 86 | + expect(stB.streamContent).toBe('B1B2'); | ||
| 87 | + }); | ||
| 88 | +}); | ||
| 89 | + | ||
| 90 | +describe('agentChatEvents · 打字机渲染缓冲', () => { | ||
| 91 | + it('文本按固定节奏匀速吐出,而不是一次性整段落地', () => { | ||
| 92 | + vi.useFakeTimers(); | ||
| 93 | + try { | ||
| 94 | + const st = makeState(); | ||
| 95 | + const pushChunk = createTypewriterQueue({ tickMs: 10, charsPerTick: 2 }); | ||
| 96 | + pushChunk(st, 'abcdef'); | ||
| 97 | + | ||
| 98 | + expect(st.streamContent).toBe(''); // 入队即刻不落地,等定时器吐 | ||
| 99 | + vi.advanceTimersByTime(10); | ||
| 100 | + expect(st.streamContent).toBe('ab'); | ||
| 101 | + vi.advanceTimersByTime(10); | ||
| 102 | + expect(st.streamContent).toBe('abcd'); | ||
| 103 | + vi.advanceTimersByTime(10); | ||
| 104 | + expect(st.streamContent).toBe('abcdef'); | ||
| 105 | + vi.advanceTimersByTime(10); // 队列空了,定时器该自己停 | ||
| 106 | + expect(st._typeTimer).toBeNull(); | ||
| 107 | + } finally { | ||
| 108 | + vi.useRealTimers(); | ||
| 109 | + } | ||
| 110 | + }); | ||
| 111 | + | ||
| 112 | + it('flushTypewriter 立刻吐光剩余队列(打断/结束时用)', () => { | ||
| 113 | + vi.useFakeTimers(); | ||
| 114 | + try { | ||
| 115 | + const st = makeState(); | ||
| 116 | + const pushChunk = createTypewriterQueue({ tickMs: 10, charsPerTick: 1 }); | ||
| 117 | + pushChunk(st, 'hello'); | ||
| 118 | + vi.advanceTimersByTime(10); // 只吐了 1 个字符 | ||
| 119 | + expect(st.streamContent).toBe('h'); | ||
| 120 | + | ||
| 121 | + flushTypewriter(st); | ||
| 122 | + expect(st.streamContent).toBe('hello'); | ||
| 123 | + expect(st._typeTimer).toBeNull(); | ||
| 124 | + expect(st._typeQueue).toBe(''); | ||
| 125 | + } finally { | ||
| 126 | + vi.useRealTimers(); | ||
| 127 | + } | ||
| 128 | + }); | ||
| 129 | +}); |
web/src/router/index.js
| @@ -36,7 +36,10 @@ const routes = [ | @@ -36,7 +36,10 @@ const routes = [ | ||
| 36 | component: () => import('@/views/CompanyDashboard.vue'), | 36 | component: () => import('@/views/CompanyDashboard.vue'), |
| 37 | }, | 37 | }, |
| 38 | { | 38 | { |
| 39 | - path: '/:id/:tab(overview|agents|pipelines|goals|sop|okr|team|files|messages|skills|jobs|workspace)', | 39 | + // 39 号文档 §二 P2:'goals'/'okr' 两个 tab 值已摘掉 —— 前者是 OKR 树的历史命名残留 |
| 40 | + // (真正的 goal/task 看板走下面 'task-board' 独立路由,不挤在这条 :tab 正则里), | ||
| 41 | + // 后者是随 /okr 一起废弃的孤儿页面。 | ||
| 42 | + path: '/:id/:tab(overview|agents|pipelines|sop|team|files|messages|skills|jobs|workspace)', | ||
| 40 | name: 'CompanyDetail', | 43 | name: 'CompanyDetail', |
| 41 | component: () => import('@/views/CompanyDashboard.vue'), | 44 | component: () => import('@/views/CompanyDashboard.vue'), |
| 42 | props: true | 45 | props: true |
| @@ -50,9 +53,11 @@ const routes = [ | @@ -50,9 +53,11 @@ const routes = [ | ||
| 50 | props: true, | 53 | props: true, |
| 51 | }, | 54 | }, |
| 52 | { | 55 | { |
| 53 | - path: '/okr', | ||
| 54 | - name: 'OkrDashboard', | ||
| 55 | - component: () => import('@/views/OkrDashboard.vue'), | 56 | + // 39 号文档 §二 P2:goal/task 两级看板独立页,同样不挤进 :tab 正则(信息密度高,值得一整页)。 |
| 57 | + path: '/:id/task-board', | ||
| 58 | + name: 'TaskBoard', | ||
| 59 | + component: () => import('@/views/TaskBoard.vue'), | ||
| 60 | + props: true, | ||
| 56 | }, | 61 | }, |
| 57 | // 新增独立页面 | 62 | // 新增独立页面 |
| 58 | { | 63 | { |
web/src/views/AgentChat.vue
| @@ -128,24 +128,27 @@ | @@ -128,24 +128,27 @@ | ||
| 128 | </div> | 128 | </div> |
| 129 | </div> | 129 | </div> |
| 130 | <div v-else ref="messageListRef" class="message-list"> | 130 | <div v-else ref="messageListRef" class="message-list"> |
| 131 | - <div v-for="(msg, idx) in chatState.messages" :key="idx" class="msg-row" :class="`msg-${msg.role}`"> | ||
| 132 | - <div class="msg-avatar" :class="`avatar-${msg.role}`">{{ msg.role === 'user' ? '👤' : (agentInfo.emoji || '🏗️') }}</div> | ||
| 133 | - <div class="msg-body"> | ||
| 134 | - <div class="msg-header"> | ||
| 135 | - <span class="msg-role">{{ msg.role === 'user' ? '你' : agentInfo.name }}</span> | ||
| 136 | - <span class="msg-time">{{ formatMsgTime(msg.timestamp) }}</span> | ||
| 137 | - </div> | ||
| 138 | - <div class="msg-content" v-html="renderMarkdown(msg.content)"></div> | ||
| 139 | - <!-- Attachment cards --> | ||
| 140 | - <div v-if="msg.attachments && msg.attachments.length" class="msg-attachments"> | ||
| 141 | - <div v-for="att in msg.attachments" :key="att.name" class="msg-attachment-card"> | ||
| 142 | - <span class="att-icon">{{ att.type === 'image' ? '🖼️' : '📄' }}</span> | ||
| 143 | - <span class="att-name">{{ att.name }}</span> | ||
| 144 | - <span class="att-size">{{ formatFileSize(att.size) }}</span> | 131 | + <template v-for="(msg, idx) in chatState.messages" :key="idx"> |
| 132 | + <div v-if="msg.role === 'divider'" class="msg-divider"><span>{{ msg.content }}</span></div> | ||
| 133 | + <div v-else class="msg-row" :class="`msg-${msg.role}`"> | ||
| 134 | + <div class="msg-avatar" :class="`avatar-${msg.role}`">{{ msg.role === 'user' ? '👤' : (agentInfo.emoji || '🏗️') }}</div> | ||
| 135 | + <div class="msg-body"> | ||
| 136 | + <div class="msg-header"> | ||
| 137 | + <span class="msg-role">{{ msg.role === 'user' ? '你' : agentInfo.name }}</span> | ||
| 138 | + <span class="msg-time">{{ formatMsgTime(msg.timestamp) }}</span> | ||
| 139 | + </div> | ||
| 140 | + <div class="msg-content" v-html="renderMarkdown(msg.content)"></div> | ||
| 141 | + <!-- Attachment cards --> | ||
| 142 | + <div v-if="msg.attachments && msg.attachments.length" class="msg-attachments"> | ||
| 143 | + <div v-for="att in msg.attachments" :key="att.name" class="msg-attachment-card"> | ||
| 144 | + <span class="att-icon">{{ att.type === 'image' ? '🖼️' : '📄' }}</span> | ||
| 145 | + <span class="att-name">{{ att.name }}</span> | ||
| 146 | + <span class="att-size">{{ formatFileSize(att.size) }}</span> | ||
| 147 | + </div> | ||
| 145 | </div> | 148 | </div> |
| 146 | </div> | 149 | </div> |
| 147 | </div> | 150 | </div> |
| 148 | - </div> | 151 | + </template> |
| 149 | <div v-if="chatState.streaming" class="msg-row msg-assistant streaming"> | 152 | <div v-if="chatState.streaming" class="msg-row msg-assistant streaming"> |
| 150 | <div class="msg-avatar avatar-assistant">{{ agentInfo.emoji || '🏗️' }}</div> | 153 | <div class="msg-avatar avatar-assistant">{{ agentInfo.emoji || '🏗️' }}</div> |
| 151 | <div class="msg-body"> | 154 | <div class="msg-body"> |
| @@ -186,11 +189,17 @@ | @@ -186,11 +189,17 @@ | ||
| 186 | <span v-for="f in uploadedFiles.slice(0, 3)" :key="f.name" class="uploaded-file-tag">{{ f.name }}</span> | 189 | <span v-for="f in uploadedFiles.slice(0, 3)" :key="f.name" class="uploaded-file-tag">{{ f.name }}</span> |
| 187 | <span v-if="uploadedFiles.length > 3" class="uploaded-file-tag more">+{{ uploadedFiles.length - 3 }}</span> | 190 | <span v-if="uploadedFiles.length > 3" class="uploaded-file-tag more">+{{ uploadedFiles.length - 3 }}</span> |
| 188 | </div> | 191 | </div> |
| 192 | + <!-- sending 态下 stop 迟迟没确认清理完成时的兜底提示(40 号文档方案二) --> | ||
| 193 | + <div v-if="chatState.pendingForceSend" class="force-send-bar"> | ||
| 194 | + <span>上一条还在收尾…</span> | ||
| 195 | + <button class="force-send-btn" @click="handleForceSend">强制发送</button> | ||
| 196 | + </div> | ||
| 189 | <div class="input-box" :class="{ focused: inputFocused }"> | 197 | <div class="input-box" :class="{ focused: inputFocused }"> |
| 190 | <button class="attach-btn" @click="triggerFileInput" title="上传文件">📎</button> | 198 | <button class="attach-btn" @click="triggerFileInput" title="上传文件">📎</button> |
| 191 | <input type="file" ref="fileInputRef" multiple style="display:none" @change="handleFileSelect" /> | 199 | <input type="file" ref="fileInputRef" multiple style="display:none" @change="handleFileSelect" /> |
| 192 | - <textarea ref="inputRef" v-model="inputText" :disabled="chatState.sending" :placeholder="chatState.sending ? '等待回复中...' : '跟 ' + agentInfo.name + ' 对话...'" class="msg-textarea" rows="1" @keydown="handleKeydown" @focus="inputFocused = true" @blur="inputFocused = false" @input="autoResize" @paste="handlePaste"></textarea> | ||
| 193 | - <button v-if="chatState.sending" class="send-btn stop-btn" @click="handleStop">⏹</button> | 200 | + <textarea ref="inputRef" v-model="inputText" :placeholder="chatState.sending ? '输入新消息可打断当前回复...' : '跟 ' + agentInfo.name + ' 对话...'" class="msg-textarea" rows="1" @keydown="handleKeydown" @focus="inputFocused = true" @blur="inputFocused = false" @input="autoResize" @paste="handlePaste"></textarea> |
| 201 | + <button v-if="chatState.sending" class="send-btn stop-btn" title="仅停止,不发送新消息" @click="handleStop">⏹</button> | ||
| 202 | + <button v-if="chatState.sending" class="send-btn interrupt-btn" :class="{ ready: inputText.trim() || pendingFiles.length }" :disabled="(!inputText.trim() && !pendingFiles.length) || chatState.interrupting" title="发送并打断当前回复" @click="handleInterruptAndSend">⤴</button> | ||
| 194 | <button v-else class="send-btn" :class="{ ready: (inputText.trim() || pendingFiles.length) && !chatState.sending }" :disabled="!inputText.trim() && !pendingFiles.length" @click="handleSend">➤</button> | 203 | <button v-else class="send-btn" :class="{ ready: (inputText.trim() || pendingFiles.length) && !chatState.sending }" :disabled="!inputText.trim() && !pendingFiles.length" @click="handleSend">➤</button> |
| 195 | </div> | 204 | </div> |
| 196 | <div class="input-hint">Shift+Enter 换行 · {{ chatState.messages.length }} 条消息<template v-if="pendingFiles.length"> · {{ pendingFiles.length }} 个待上传文件</template><template v-if="uploadedFiles.length"> · 📎 {{ uploadedFiles.length }} 个附件</template></div> | 205 | <div class="input-hint">Shift+Enter 换行 · {{ chatState.messages.length }} 条消息<template v-if="pendingFiles.length"> · {{ pendingFiles.length }} 个待上传文件</template><template v-if="uploadedFiles.length"> · 📎 {{ uploadedFiles.length }} 个附件</template></div> |
| @@ -360,6 +369,7 @@ import { useRoute, useRouter } from 'vue-router' | @@ -360,6 +369,7 @@ import { useRoute, useRouter } from 'vue-router' | ||
| 360 | import AppNav from '@/components/AppNav.vue' | 369 | import AppNav from '@/components/AppNav.vue' |
| 361 | import FileNode from '@/components/FileNode.vue' | 370 | import FileNode from '@/components/FileNode.vue' |
| 362 | import { marked } from 'marked' | 371 | import { marked } from 'marked' |
| 372 | +import { createAgentChatEventHandler, flushTypewriter } from '@/composables/agentChatEvents.js' | ||
| 363 | 373 | ||
| 364 | const route = useRoute() | 374 | const route = useRoute() |
| 365 | const router = useRouter() | 375 | const router = useRouter() |
| @@ -463,6 +473,7 @@ function ensureChatState(id) { | @@ -463,6 +473,7 @@ function ensureChatState(id) { | ||
| 463 | sending: false, streaming: false, streamContent: '', | 473 | sending: false, streaming: false, streamContent: '', |
| 464 | subAgents: {}, subLogList: [], acpLog: [], | 474 | subAgents: {}, subLogList: [], acpLog: [], |
| 465 | thinkingText: '🏗️ 启动中...', thinkingContent: '', showThinking: true, | 475 | thinkingText: '🏗️ 启动中...', thinkingContent: '', showThinking: true, |
| 476 | + interrupting: false, pendingForceSend: false, | ||
| 466 | }) | 477 | }) |
| 467 | } | 478 | } |
| 468 | return chatStates.get(id) | 479 | return chatStates.get(id) |
| @@ -958,6 +969,7 @@ async function handleSend() { | @@ -958,6 +969,7 @@ async function handleSend() { | ||
| 958 | } | 969 | } |
| 959 | } | 970 | } |
| 960 | } | 971 | } |
| 972 | + flushTypewriter(st) // 流已经读完,剩下排队的字符没必要再演匀速,直接吐光再拼进消息 | ||
| 961 | if (st.streamContent) { | 973 | if (st.streamContent) { |
| 962 | st.messages.push({ role: 'assistant', content: st.streamContent, timestamp: new Date().toISOString() }) | 974 | st.messages.push({ role: 'assistant', content: st.streamContent, timestamp: new Date().toISOString() }) |
| 963 | } | 975 | } |
| @@ -981,70 +993,86 @@ async function handleStop() { | @@ -981,70 +993,86 @@ async function handleStop() { | ||
| 981 | const st = chatState.value | 993 | const st = chatState.value |
| 982 | if (!st.activeConvId) return | 994 | if (!st.activeConvId) return |
| 983 | try { await fetch(`/api/agent/${agentId.value}/chat/${st.activeConvId}/stop`, { method: 'POST' }) } catch(e) { console.error('Stop error:', e) } | 995 | try { await fetch(`/api/agent/${agentId.value}/chat/${st.activeConvId}/stop`, { method: 'POST' }) } catch(e) { console.error('Stop error:', e) } |
| 996 | + flushTypewriter(st) | ||
| 984 | st.sending = false; st.streaming = false | 997 | st.sending = false; st.streaming = false |
| 985 | st.messages.push({ role: 'assistant', content: `*[已终止]*`, timestamp: new Date().toISOString() }) | 998 | st.messages.push({ role: 'assistant', content: `*[已终止]*`, timestamp: new Date().toISOString() }) |
| 986 | scrollToBottom() | 999 | scrollToBottom() |
| 987 | } | 1000 | } |
| 988 | 1001 | ||
| 1002 | +// 40 号文档方案二:执行中把已生成的部分内容 + 一条分隔标记落进历史,再当作一轮全新对话 | ||
| 1003 | +// 重置 sending 相关状态——不依赖"旧的 fetch 循环自己发现流已结束"这件事的时序, | ||
| 1004 | +// 保证接下来立刻调用 handleSend() 是安全的(旧循环之后自己走到 finally 时, | ||
| 1005 | +// st.streamContent 已经是空的,不会重复 push)。 | ||
| 1006 | +function finalizeInterruptedTurn(st) { | ||
| 1007 | + flushTypewriter(st) | ||
| 1008 | + if (st.streamContent) { | ||
| 1009 | + st.messages.push({ role: 'assistant', content: st.streamContent, timestamp: new Date().toISOString() }) | ||
| 1010 | + } | ||
| 1011 | + st.messages.push({ role: 'divider', content: '已打断上一条回复', timestamp: new Date().toISOString() }) | ||
| 1012 | + st.sending = false; st.streaming = false; st.streamContent = ''; st.thinkingContent = '' | ||
| 1013 | +} | ||
| 1014 | + | ||
| 1015 | +// sending 态下点发送 = "打断并发送":先中止当前 turn,服务端确认清理完成(cleaned)后 | ||
| 1016 | +// 再把这条新消息接上;服务端 3 秒还没确认的话不自动强发(避免撞上同 convId 并发的坑), | ||
| 1017 | +// 交给用户自己点"强制发送"。 | ||
| 1018 | +async function handleInterruptAndSend() { | ||
| 1019 | + const st = chatState.value | ||
| 1020 | + if (!inputText.value.trim() && pendingFiles.value.length === 0) return | ||
| 1021 | + if (!st.activeConvId) return handleSend() | ||
| 1022 | + | ||
| 1023 | + st.interrupting = true | ||
| 1024 | + let cleaned = false | ||
| 1025 | + try { | ||
| 1026 | + const r = await fetch(`/api/agent/${agentId.value}/chat/${st.activeConvId}/stop`, { method: 'POST' }) | ||
| 1027 | + const data = await r.json().catch(() => ({})) | ||
| 1028 | + cleaned = !!data.cleaned | ||
| 1029 | + } catch (e) { | ||
| 1030 | + console.error('Interrupt error:', e) | ||
| 1031 | + } | ||
| 1032 | + st.interrupting = false | ||
| 1033 | + | ||
| 1034 | + if (!cleaned) { st.pendingForceSend = true; return } | ||
| 1035 | + finalizeInterruptedTurn(st) | ||
| 1036 | + await handleSend() | ||
| 1037 | +} | ||
| 1038 | + | ||
| 1039 | +function handleForceSend() { | ||
| 1040 | + const st = chatState.value | ||
| 1041 | + st.pendingForceSend = false | ||
| 1042 | + finalizeInterruptedTurn(st) | ||
| 1043 | + handleSend() | ||
| 1044 | +} | ||
| 1045 | + | ||
| 989 | // st/requestAgentId 是 handleSend 传下来的快照;只有 requestAgentId 仍是当前选中 agent 时 | 1046 | // st/requestAgentId 是 handleSend 传下来的快照;只有 requestAgentId 仍是当前选中 agent 时 |
| 990 | // 才动 DOM(滚动)/URL/全局对话列表——避免把某个后台 agent 的进度"带"到当前正看的页面上。 | 1047 | // 才动 DOM(滚动)/URL/全局对话列表——避免把某个后台 agent 的进度"带"到当前正看的页面上。 |
| 991 | -function handleEvent(st, requestAgentId, type, data) { | ||
| 992 | - const isCurrent = agentId.value === requestAgentId | ||
| 993 | - switch (type) { | ||
| 994 | - case 'session': | ||
| 995 | - if (data.conversationId && !st.activeConvId) { | ||
| 996 | - st.activeConvId = data.conversationId | ||
| 997 | - if (isCurrent) { | ||
| 998 | - if (route.params.id) { | ||
| 999 | - router.replace({ params: { id: route.params.id, agentId: requestAgentId, convId: data.conversationId } }) | ||
| 1000 | - } else { | ||
| 1001 | - router.replace({ params: { agentId: requestAgentId, convId: data.conversationId } }) | ||
| 1002 | - } | ||
| 1003 | - const existsInList = conversations.value.some(c => (c.conversationId || c.id) === data.conversationId) | ||
| 1004 | - if (!existsInList) { | ||
| 1005 | - conversations.value.unshift({ conversationId: data.conversationId, title: st.messages.find(m => m.role === 'user')?.content?.slice(0, 50) || '新对话', updatedAt: new Date().toISOString(), messageCount: 1, status: 'running' }) | ||
| 1006 | - } | ||
| 1007 | - } | ||
| 1008 | - } | ||
| 1009 | - break | ||
| 1010 | - case 'thinking': | ||
| 1011 | - if (data.message && !data.message.startsWith('🏗️')) { st.thinkingContent += data.message; if (isCurrent) scrollToBottom() } | ||
| 1012 | - else { st.thinkingText = data.message || '思考中...' } | ||
| 1013 | - break | ||
| 1014 | - case 'agent_output': | ||
| 1015 | - st.streaming = true; st.streamContent += (data.message || ''); if (isCurrent) scrollToBottom(); break | ||
| 1016 | - case 'tool_call': | ||
| 1017 | - st.thinkingText = data.message || `🔧 ${data.tool}` | ||
| 1018 | - st.acpLog.push({ type: 'tool', icon: '🔧', text: `${data.tool}`, ts: Date.now() }); if (isCurrent) scrollAcpLog(); break | ||
| 1019 | - case 'tool_input': | ||
| 1020 | - st.acpLog.push({ type: 'input', icon: '📥', text: data.input?.slice(0, 300) || '', ts: Date.now() }); if (isCurrent) scrollAcpLog(); break | ||
| 1021 | - case 'tool_result': | ||
| 1022 | - st.acpLog.push({ type: 'result', icon: '📤', text: data.result?.slice(0, 300) || '', ts: Date.now() }); if (isCurrent) scrollAcpLog(); break | ||
| 1023 | - case 'session_cost': | ||
| 1024 | - st.acpLog.push({ type: 'cost', icon: '💰', text: `$${data.cost?.toFixed(4)} (${((data.duration||0)/1000).toFixed(1)}s)`, ts: Date.now() }); break | ||
| 1025 | - case 'acp_raw': | ||
| 1026 | - if (data.method && !data.method.includes('content_block_delta') && !data.method.includes('message_start')) { | ||
| 1027 | - st.acpLog.push({ type: 'raw', icon: '⚡', text: data.method, ts: Date.now() }); if (isCurrent) scrollAcpLog() | ||
| 1028 | - } | ||
| 1029 | - break | ||
| 1030 | - case 'sub_agent': | ||
| 1031 | - if (!st.subAgents[data.callId]) st.subAgents[data.callId] = { agent: data.agent, status: 'running', content: '', collapsed: false } | ||
| 1032 | - if (data.event === 'chunk' && data.chunk) { | ||
| 1033 | - st.subAgents[data.callId].content += data.chunk | ||
| 1034 | - if (isCurrent) setTimeout(() => { document.querySelectorAll('.sub-log-content').forEach(el => el.scrollTop = el.scrollHeight) }, 50) | ||
| 1035 | - } else if (data.event === 'done') st.subAgents[data.callId].status = data.status | ||
| 1036 | - else if (data.event === 'start') st.subAgents[data.callId].status = 'running' | ||
| 1037 | - if (isCurrent) scrollToBottom(); break | ||
| 1038 | - case 'error': | ||
| 1039 | - st.messages.push({ role: 'assistant', content: `❌ ${data.message}`, timestamp: new Date().toISOString() }) | ||
| 1040 | - st.acpLog.push({ type: 'error', icon: '🚨', text: data.message, ts: Date.now() }) | ||
| 1041 | - st.thinkingText = ''; st.thinkingContent = '' | ||
| 1042 | - if (isCurrent) { scrollToBottom(); scrollAcpLog() } | ||
| 1043 | - break | 1048 | +// 分发逻辑本体已抽到 composables/agentChatEvents.js(40 号文档方案三,便于单独做回归测试)。 |
| 1049 | +function scrollSubLogs() { | ||
| 1050 | + setTimeout(() => { document.querySelectorAll('.sub-log-content').forEach(el => el.scrollTop = el.scrollHeight) }, 50) | ||
| 1051 | +} | ||
| 1052 | +const handleEvent = createAgentChatEventHandler({ | ||
| 1053 | + getCurrentAgentId: () => agentId.value, | ||
| 1054 | + scrollToBottom, | ||
| 1055 | + scrollAcpLog, | ||
| 1056 | + scrollSubLogs, | ||
| 1057 | + onNewConversation(requestAgentId, data, st) { | ||
| 1058 | + if (route.params.id) { | ||
| 1059 | + router.replace({ params: { id: route.params.id, agentId: requestAgentId, convId: data.conversationId } }) | ||
| 1060 | + } else { | ||
| 1061 | + router.replace({ params: { agentId: requestAgentId, convId: data.conversationId } }) | ||
| 1062 | + } | ||
| 1063 | + const existsInList = conversations.value.some(c => (c.conversationId || c.id) === data.conversationId) | ||
| 1064 | + if (!existsInList) { | ||
| 1065 | + conversations.value.unshift({ conversationId: data.conversationId, title: st.messages.find(m => m.role === 'user')?.content?.slice(0, 50) || '新对话', updatedAt: new Date().toISOString(), messageCount: 1, status: 'running' }) | ||
| 1066 | + } | ||
| 1067 | + }, | ||
| 1068 | +}) | ||
| 1069 | + | ||
| 1070 | +function handleKeydown(e) { | ||
| 1071 | + if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { | ||
| 1072 | + e.preventDefault() | ||
| 1073 | + chatState.value.sending ? handleInterruptAndSend() : handleSend() | ||
| 1044 | } | 1074 | } |
| 1045 | } | 1075 | } |
| 1046 | - | ||
| 1047 | -function handleKeydown(e) { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); handleSend() } } | ||
| 1048 | function scrollAcpLog() { nextTick(() => { const el = document.querySelector('.acp-log-stream'); if (el) el.scrollTop = el.scrollHeight }) } | 1076 | function scrollAcpLog() { nextTick(() => { const el = document.querySelector('.acp-log-stream'); if (el) el.scrollTop = el.scrollHeight }) } |
| 1049 | function autoResize() { const el = inputRef.value; if (!el) return; el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight, 160) + 'px' } | 1077 | function autoResize() { const el = inputRef.value; if (!el) return; el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight, 160) + 'px' } |
| 1050 | function scrollToBottom() { nextTick(() => { if (messageListRef.value) messageListRef.value.scrollTop = messageListRef.value.scrollHeight }) } | 1078 | function scrollToBottom() { nextTick(() => { if (messageListRef.value) messageListRef.value.scrollTop = messageListRef.value.scrollHeight }) } |
| @@ -1305,6 +1333,14 @@ onMounted(async () => { | @@ -1305,6 +1333,14 @@ onMounted(async () => { | ||
| 1305 | .send-btn.ready { background: linear-gradient(135deg,var(--accent),var(--accent-light)); color: #fff; box-shadow: 0 3px 10px rgba(16,185,129,0.3); } | 1333 | .send-btn.ready { background: linear-gradient(135deg,var(--accent),var(--accent-light)); color: #fff; box-shadow: 0 3px 10px rgba(16,185,129,0.3); } |
| 1306 | .send-btn.ready:hover { transform: scale(1.05); } | 1334 | .send-btn.ready:hover { transform: scale(1.05); } |
| 1307 | .input-hint { margin-top: 2px; font-size: 9px; color: var(--text-faint); text-align: center; } | 1335 | .input-hint { margin-top: 2px; font-size: 9px; color: var(--text-faint); text-align: center; } |
| 1336 | +/* sending 态下:Stop(纯中止)与 interrupt-btn(打断并发送)视觉上要能一眼区分开 */ | ||
| 1337 | +.interrupt-btn.ready { background: linear-gradient(135deg,#f59e0b,#fbbf24); color: #fff; box-shadow: 0 3px 10px rgba(245,158,11,0.35); } | ||
| 1338 | +.interrupt-btn.ready:hover { transform: scale(1.05); } | ||
| 1339 | +.force-send-bar { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; padding: 6px 10px; font-size: 11px; color: var(--text-muted); background: var(--accent-bg); border: 1px solid var(--border-active); border-radius: 7px; } | ||
| 1340 | +.force-send-btn { border: none; border-radius: 6px; padding: 4px 10px; font-size: 11px; cursor: pointer; background: #f59e0b; color: #fff; } | ||
| 1341 | +.force-send-btn:hover { opacity: 0.9; } | ||
| 1342 | +.msg-divider { display: flex; align-items: center; gap: 8px; margin: 10px 0; font-size: 10px; color: var(--text-faint); } | ||
| 1343 | +.msg-divider::before, .msg-divider::after { content: ''; flex: 1; height: 1px; background: var(--border-primary); } | ||
| 1308 | 1344 | ||
| 1309 | /* ═══ 第4列: ACP / 预览 ═══ */ | 1345 | /* ═══ 第4列: ACP / 预览 ═══ */ |
| 1310 | .acp-panel { | 1346 | .acp-panel { |
web/src/views/CompanyDashboard.vue
| @@ -43,6 +43,11 @@ | @@ -43,6 +43,11 @@ | ||
| 43 | <button class="btn-templates" @click="goToGraphConsole" style="border: none; background: transparent;"> | 43 | <button class="btn-templates" @click="goToGraphConsole" style="border: none; background: transparent;"> |
| 44 | 🕸️ 执行控制台 | 44 | 🕸️ 执行控制台 |
| 45 | </button> | 45 | </button> |
| 46 | + <!-- 39 号文档 §二 P2:goal/task 看板独立页入口,同一处放法(不挤进 :tab 白名单, | ||
| 47 | + 按 goToGraphConsole 的先例给一个跳转按钮)。 --> | ||
| 48 | + <button class="btn-templates" @click="goToTaskBoard" style="border: none; background: transparent;"> | ||
| 49 | + 🗂️ 任务看板 | ||
| 50 | + </button> | ||
| 46 | <button class="btn-templates" @click="showTemplates = !showTemplates" style="border: none; background: transparent;"> | 51 | <button class="btn-templates" @click="showTemplates = !showTemplates" style="border: none; background: transparent;"> |
| 47 | 📦 模板库 ({{ templates.length }}) | 52 | 📦 模板库 ({{ templates.length }}) |
| 48 | </button> | 53 | </button> |
| @@ -355,103 +360,6 @@ | @@ -355,103 +360,6 @@ | ||
| 355 | </div> | 360 | </div> |
| 356 | </div> | 361 | </div> |
| 357 | 362 | ||
| 358 | - <!-- Tab: Goals (Full OKR Tree) --> | ||
| 359 | - <div v-if="activeTab === 'goals'" class="tab-content"> | ||
| 360 | - <div class="okr-layout"> | ||
| 361 | - <!-- Left: Goal List --> | ||
| 362 | - <div class="okr-sidebar"> | ||
| 363 | - <div class="okr-sidebar-header"> | ||
| 364 | - <span>🎯 OKR 目标</span> | ||
| 365 | - <span class="okr-count" v-if="goals.length">{{ goals.length }}</span> | ||
| 366 | - </div> | ||
| 367 | - <div class="okr-goal-list"> | ||
| 368 | - <div | ||
| 369 | - v-for="g in goals" :key="g.id" | ||
| 370 | - class="okr-goal-card" :class="{ active: selectedGoalId === g.id }" | ||
| 371 | - @click="selectGoalDetail(g.id)" | ||
| 372 | - > | ||
| 373 | - <div class="okr-goal-top"> | ||
| 374 | - <span class="okr-gid">{{ g.id }}</span> | ||
| 375 | - <span class="okr-gstatus" :class="g.status">{{ okrStatusLabel(g.status) }}</span> | ||
| 376 | - </div> | ||
| 377 | - <div class="okr-gtitle">{{ g.title }}</div> | ||
| 378 | - <div class="okr-gprogress"> | ||
| 379 | - <div class="okr-pbar"><div class="okr-pfill" :style="{ width: (g.progress * 100) + '%' }"></div></div> | ||
| 380 | - <span class="okr-ppct">{{ Math.round(g.progress * 100) }}%</span> | ||
| 381 | - </div> | ||
| 382 | - <div class="okr-gmeta"> | ||
| 383 | - <span>👤 {{ g.owner }}</span> | ||
| 384 | - <span>{{ g.doneCount || 0 }}/{{ g.totalCount || 0 }}</span> | ||
| 385 | - </div> | ||
| 386 | - </div> | ||
| 387 | - <div v-if="goals.length === 0" class="empty-mini">暂无目标</div> | ||
| 388 | - </div> | ||
| 389 | - </div> | ||
| 390 | - | ||
| 391 | - <!-- Right: Goal Detail + Node Tree --> | ||
| 392 | - <div class="okr-detail" v-if="selectedGoalDetail"> | ||
| 393 | - <div class="okr-hero"> | ||
| 394 | - <div class="okr-hero-row"> | ||
| 395 | - <span class="okr-hero-id">{{ selectedGoalDetail.id }}</span> | ||
| 396 | - <span class="okr-hero-status" :class="selectedGoalDetail.status">{{ okrStatusLabel(selectedGoalDetail.status) }}</span> | ||
| 397 | - <span class="okr-hero-owner">👤 {{ selectedGoalDetail.owner }}</span> | ||
| 398 | - </div> | ||
| 399 | - <h2 class="okr-hero-title">{{ selectedGoalDetail.title }}</h2> | ||
| 400 | - <p class="okr-hero-desc">{{ selectedGoalDetail.description || '暂无详细描述' }}</p> | ||
| 401 | - <div class="okr-hero-progress" v-if="selectedGoalProgress"> | ||
| 402 | - <div class="okr-hp-top"> | ||
| 403 | - <span>目标总体进度</span> | ||
| 404 | - <span class="okr-hp-pct">{{ Math.round(selectedGoalProgress.progress * 100) }}%</span> | ||
| 405 | - </div> | ||
| 406 | - <div class="okr-hp-bar"><div class="okr-hp-fill" :style="{ width: (selectedGoalProgress.progress * 100) + '%' }"></div></div> | ||
| 407 | - <div class="okr-hp-stats"> | ||
| 408 | - <span>总: {{ selectedGoalProgress.total }}</span> | ||
| 409 | - <span class="stat-d">✅ {{ selectedGoalProgress.done }}</span> | ||
| 410 | - <span class="stat-p">🔄 {{ selectedGoalProgress.inProgress }}</span> | ||
| 411 | - <span class="stat-w">⏳ {{ selectedGoalProgress.pending }}</span> | ||
| 412 | - </div> | ||
| 413 | - </div> | ||
| 414 | - </div> | ||
| 415 | - <div class="okr-tree-area"> | ||
| 416 | - <h3 class="okr-tree-title">OKR 拆解树</h3> | ||
| 417 | - <div class="okr-tree"> | ||
| 418 | - <div | ||
| 419 | - v-for="node in okrSortedNodes" :key="node.id" | ||
| 420 | - class="okr-tnode" | ||
| 421 | - > | ||
| 422 | - <div class="okr-tnode-line"> | ||
| 423 | - <div class="okr-tnode-indent" :style="{ width: (node.level - 2) * 24 + 'px' }"></div> | ||
| 424 | - <div class="okr-tnode-card" :class="[node.status, 'okr-lv-' + node.level]"> | ||
| 425 | - <div class="okr-tnode-hdr"> | ||
| 426 | - <span class="okr-nid">{{ node.id }}</span> | ||
| 427 | - <span class="okr-ntype">{{ okrLevelName(node.level) }}</span> | ||
| 428 | - <span class="okr-nstatus" :class="node.status">{{ okrStatusLabel(node.status) }}</span> | ||
| 429 | - </div> | ||
| 430 | - <div class="okr-ntitle">{{ node.title }}</div> | ||
| 431 | - <div class="okr-nfooter"> | ||
| 432 | - <span class="okr-nowner" v-if="node.owner">👤 {{ node.owner }}</span> | ||
| 433 | - <span class="okr-nprio" v-if="node.priority" :class="node.priority">{{ node.priority }}</span> | ||
| 434 | - <span class="okr-ntask" v-if="node.taskId">🔗 {{ node.taskId }}</span> | ||
| 435 | - </div> | ||
| 436 | - <div class="okr-nsummary" v-if="node.summary"><strong>摘要:</strong> {{ node.summary }}</div> | ||
| 437 | - <div class="okr-nsummary" v-if="node.output"><strong>产出:</strong> {{ node.output }}</div> | ||
| 438 | - </div> | ||
| 439 | - </div> | ||
| 440 | - </div> | ||
| 441 | - <div v-if="okrSortedNodes.length === 0" class="empty-mini">暂无 OKR 节点</div> | ||
| 442 | - </div> | ||
| 443 | - </div> | ||
| 444 | - </div> | ||
| 445 | - <div class="okr-detail okr-empty" v-else> | ||
| 446 | - <div class="empty-state-large"> | ||
| 447 | - <div style="font-size:48px; margin-bottom:16px; opacity:0.8">🎯</div> | ||
| 448 | - <h3>OKR 目标看板</h3> | ||
| 449 | - <p>从左侧选择一个目标查看拆解树与进度</p> | ||
| 450 | - </div> | ||
| 451 | - </div> | ||
| 452 | - </div> | ||
| 453 | - </div> | ||
| 454 | - | ||
| 455 | <!-- Tab: Files --> | 363 | <!-- Tab: Files --> |
| 456 | <div v-if="activeTab === 'files'" class="tab-content"> | 364 | <div v-if="activeTab === 'files'" class="tab-content"> |
| 457 | <div class="files-layout"> | 365 | <div class="files-layout"> |
| @@ -852,10 +760,6 @@ const companyAgents = ref([]) | @@ -852,10 +760,6 @@ const companyAgents = ref([]) | ||
| 852 | const pipelines = ref([]) | 760 | const pipelines = ref([]) |
| 853 | const pipelineRuns = ref([]) | 761 | const pipelineRuns = ref([]) |
| 854 | const selectedPipeline = ref(null) | 762 | const selectedPipeline = ref(null) |
| 855 | -const goals = ref([]) | ||
| 856 | -const selectedGoalId = ref(null) | ||
| 857 | -const selectedGoalDetail = ref(null) | ||
| 858 | -const selectedGoalProgress = ref(null) | ||
| 859 | const templates = ref([]) | 763 | const templates = ref([]) |
| 860 | const activeTab = ref('overview') | 764 | const activeTab = ref('overview') |
| 861 | const showTemplates = ref(false) | 765 | const showTemplates = ref(false) |
| @@ -1039,7 +943,6 @@ async function loadCompanyData(id, tab) { | @@ -1039,7 +943,6 @@ async function loadCompanyData(id, tab) { | ||
| 1039 | if (activeTab.value === 'overview') await loadCompanyStatus(id) | 943 | if (activeTab.value === 'overview') await loadCompanyStatus(id) |
| 1040 | if (activeTab.value === 'agents') { await loadCompanyAgents(id); await loadOrgGraph(id) } | 944 | if (activeTab.value === 'agents') { await loadCompanyAgents(id); await loadOrgGraph(id) } |
| 1041 | if (activeTab.value === 'pipelines') await loadPipelines(id) | 945 | if (activeTab.value === 'pipelines') await loadPipelines(id) |
| 1042 | - if (activeTab.value === 'goals') await loadGoals(id) | ||
| 1043 | if (activeTab.value === 'files') await loadCompanyFiles(id) | 946 | if (activeTab.value === 'files') await loadCompanyFiles(id) |
| 1044 | if (activeTab.value === 'messages') await loadMessages(id) | 947 | if (activeTab.value === 'messages') await loadMessages(id) |
| 1045 | if (activeTab.value === 'skills') await loadSkillsList(id) | 948 | if (activeTab.value === 'skills') await loadSkillsList(id) |
| @@ -1064,6 +967,11 @@ function goToGraphConsole() { | @@ -1064,6 +967,11 @@ function goToGraphConsole() { | ||
| 1064 | router.push(`/${selectedCompany.value.id}/graph-console`) | 967 | router.push(`/${selectedCompany.value.id}/graph-console`) |
| 1065 | } | 968 | } |
| 1066 | 969 | ||
| 970 | +function goToTaskBoard() { | ||
| 971 | + if (!selectedCompany.value) return | ||
| 972 | + router.push(`/${selectedCompany.value.id}/task-board`) | ||
| 973 | +} | ||
| 974 | + | ||
| 1067 | watch(() => route?.params, async (params) => { | 975 | watch(() => route?.params, async (params) => { |
| 1068 | if (!params) return | 976 | if (!params) return |
| 1069 | if (route.name === 'CompanyDetail' && params.id) { | 977 | if (route.name === 'CompanyDetail' && params.id) { |
| @@ -1216,54 +1124,6 @@ async function retryPipelineRun(runId) { | @@ -1216,54 +1124,6 @@ async function retryPipelineRun(runId) { | ||
| 1216 | } catch (e) { alert(`❌ 重试失败: ${e.message}`) } | 1124 | } catch (e) { alert(`❌ 重试失败: ${e.message}`) } |
| 1217 | } | 1125 | } |
| 1218 | 1126 | ||
| 1219 | -async function loadGoals(id) { | ||
| 1220 | - try { | ||
| 1221 | - const { data } = await axios.get(`/api/company/${id}/okr`) | ||
| 1222 | - goals.value = data | ||
| 1223 | - // Auto-select first goal | ||
| 1224 | - if (data.length > 0 && !selectedGoalId.value) { | ||
| 1225 | - selectGoalDetail(data[0].id) | ||
| 1226 | - } | ||
| 1227 | - } catch (e) { console.error(e) } | ||
| 1228 | -} | ||
| 1229 | - | ||
| 1230 | -async function selectGoalDetail(goalId) { | ||
| 1231 | - if (!selectedCompany.value) return | ||
| 1232 | - selectedGoalId.value = goalId | ||
| 1233 | - try { | ||
| 1234 | - const [goalRes, progRes] = await Promise.all([ | ||
| 1235 | - axios.get(`/api/company/${selectedCompany.value.id}/okr/${goalId}`), | ||
| 1236 | - axios.get(`/api/company/${selectedCompany.value.id}/okr/${goalId}/progress`) | ||
| 1237 | - ]) | ||
| 1238 | - selectedGoalDetail.value = goalRes.data | ||
| 1239 | - selectedGoalProgress.value = progRes.data | ||
| 1240 | - } catch (e) { console.error(e) } | ||
| 1241 | -} | ||
| 1242 | - | ||
| 1243 | -function okrStatusLabel(status) { | ||
| 1244 | - const map = { active: '运转中', in_progress: '进行中', pending: '待开始', done: '已完成', failed: '失败', blocked: '已阻塞' } | ||
| 1245 | - return map[status] || status | ||
| 1246 | -} | ||
| 1247 | - | ||
| 1248 | -function okrLevelName(level) { | ||
| 1249 | - const names = ['Goal', 'Objective', 'Key Result', 'Sub-Task', 'Micro-Task'] | ||
| 1250 | - return names[Math.min(level - 1, 4)] | ||
| 1251 | -} | ||
| 1252 | - | ||
| 1253 | -const okrSortedNodes = computed(() => { | ||
| 1254 | - if (!selectedGoalDetail.value?.nodes) return [] | ||
| 1255 | - const nodes = selectedGoalDetail.value.nodes | ||
| 1256 | - const keys = Object.keys(nodes).sort((a, b) => { | ||
| 1257 | - const partsA = a.replace('G-','').split('.').map(Number) | ||
| 1258 | - const partsB = b.replace('G-','').split('.').map(Number) | ||
| 1259 | - for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) { | ||
| 1260 | - if ((partsA[i] || 0) !== (partsB[i] || 0)) return (partsA[i] || 0) - (partsB[i] || 0) | ||
| 1261 | - } | ||
| 1262 | - return 0 | ||
| 1263 | - }) | ||
| 1264 | - return keys.map(id => ({ id, ...nodes[id], level: id.split('.').length })) | ||
| 1265 | -}) | ||
| 1266 | - | ||
| 1267 | async function loadCompanyStatus(id) { | 1127 | async function loadCompanyStatus(id) { |
| 1268 | try { | 1128 | try { |
| 1269 | const { data } = await axios.get(`/api/company/${id}/status`) | 1129 | const { data } = await axios.get(`/api/company/${id}/status`) |
| @@ -1848,84 +1708,6 @@ function closeDropdown() { | @@ -1848,84 +1708,6 @@ function closeDropdown() { | ||
| 1848 | .run-meta { display: flex; gap: 16px; font-size: 12px; color: var(--text-muted); } | 1708 | .run-meta { display: flex; gap: 16px; font-size: 12px; color: var(--text-muted); } |
| 1849 | .run-actions { margin-top: 8px; } | 1709 | .run-actions { margin-top: 8px; } |
| 1850 | 1710 | ||
| 1851 | -/* ═══ OKR Full Layout ═══ */ | ||
| 1852 | -.okr-layout { display: flex; height: calc(100vh - 140px); overflow: hidden; } | ||
| 1853 | -.okr-sidebar { width: 280px; min-width: 260px; border-right: 1px solid var(--border-primary); background: var(--bg-overlay); display: flex; flex-direction: column; } | ||
| 1854 | -.okr-sidebar-header { padding: 12px 16px; font-size: 13px; font-weight: 700; color: var(--text-secondary); border-bottom: 1px solid var(--border-primary); display: flex; align-items: center; justify-content: space-between; background: var(--bg-secondary); } | ||
| 1855 | -.okr-count { background: var(--bg-surface-active); color: var(--accent); padding: 2px 8px; border-radius: 12px; font-size: 11px; } | ||
| 1856 | -.okr-goal-list { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; } | ||
| 1857 | -.okr-goal-card { padding: 12px 14px; border-radius: 10px; cursor: pointer; background: var(--bg-surface); border: 1px solid var(--border-primary); transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); } | ||
| 1858 | -.okr-goal-card:hover { transform: translateY(-2px); border-color: var(--accent-light); box-shadow: 0 4px 12px rgba(16,185,129,0.08); } | ||
| 1859 | -.okr-goal-card.active { background: var(--accent-bg); border-color: var(--border-active); box-shadow: 0 0 0 1px var(--accent) inset; } | ||
| 1860 | -.okr-goal-top { display: flex; justify-content: space-between; margin-bottom: 6px; } | ||
| 1861 | -.okr-gid { font-size: 11px; font-family: 'JetBrains Mono', monospace; font-weight: 600; color: var(--text-tertiary); } | ||
| 1862 | -.okr-gstatus { font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 4px; } | ||
| 1863 | -.okr-gstatus.active { background: rgba(59,130,246,0.15); color: #60a5fa; } | ||
| 1864 | -.okr-gstatus.done { background: rgba(16,185,129,0.15); color: #34d399; } | ||
| 1865 | -.okr-gtitle { font-size: 13px; font-weight: 600; margin-bottom: 10px; color: var(--text-primary); line-height: 1.4; } | ||
| 1866 | -.okr-gprogress { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } | ||
| 1867 | -.okr-pbar { flex: 1; height: 6px; background: var(--bg-input-focus); border-radius: 3px; overflow: hidden; } | ||
| 1868 | -.okr-pfill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent-light)); border-radius: 3px; transition: width 0.5s ease; } | ||
| 1869 | -.okr-ppct { font-size: 11px; font-weight: 600; color: var(--text-secondary); width: 32px; text-align: right; } | ||
| 1870 | -.okr-gmeta { display: flex; justify-content: space-between; font-size: 10px; color: var(--text-faint); } | ||
| 1871 | - | ||
| 1872 | -/* OKR Detail Panel */ | ||
| 1873 | -.okr-detail { flex: 1; overflow-y: auto; background: var(--bg-primary); display: flex; flex-direction: column; } | ||
| 1874 | -.okr-detail.okr-empty { align-items: center; justify-content: center; background: var(--bg-overlay); } | ||
| 1875 | -.okr-detail.okr-empty h3 { font-size: 20px; font-weight: 600; margin-bottom: 8px; color: var(--text-secondary); } | ||
| 1876 | -.okr-detail.okr-empty p { color: var(--text-muted); font-size: 13px; } | ||
| 1877 | - | ||
| 1878 | -.okr-hero { padding: 30px 40px; border-bottom: 1px solid var(--border-primary); background: linear-gradient(180deg, var(--accent-bg) 0%, transparent 100%); } | ||
| 1879 | -.okr-hero-row { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; } | ||
| 1880 | -.okr-hero-id { font-size: 14px; font-family: 'JetBrains Mono', monospace; font-weight: 700; color: var(--accent-light); } | ||
| 1881 | -.okr-hero-status { font-size: 12px; font-weight: 600; padding: 3px 8px; border-radius: 6px; } | ||
| 1882 | -.okr-hero-status.active { background: rgba(59,130,246,0.2); color: #60a5fa; border: 1px solid rgba(59,130,246,0.3); } | ||
| 1883 | -.okr-hero-status.done { background: rgba(16,185,129,0.2); color: #34d399; border: 1px solid rgba(16,185,129,0.3); } | ||
| 1884 | -.okr-hero-owner { font-size: 12px; font-weight: 500; color: var(--text-secondary); margin-left: auto; background: var(--bg-surface); padding: 4px 10px; border-radius: 12px; border: 1px solid var(--border-primary); } | ||
| 1885 | -.okr-hero-title { font-size: 26px; font-weight: 800; margin: 0 0 10px; letter-spacing: -0.5px; } | ||
| 1886 | -.okr-hero-desc { font-size: 14px; color: var(--text-tertiary); line-height: 1.6; max-width: 800px; margin: 0; } | ||
| 1887 | - | ||
| 1888 | -.okr-hero-progress { margin-top: 24px; max-width: 600px; background: var(--bg-surface); padding: 16px 20px; border-radius: 12px; border: 1px solid var(--border-primary); } | ||
| 1889 | -.okr-hp-top { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 10px; font-size: 13px; font-weight: 600; color: var(--text-secondary); } | ||
| 1890 | -.okr-hp-pct { font-size: 24px; font-weight: 800; color: var(--accent-light); line-height: 1; } | ||
| 1891 | -.okr-hp-bar { height: 8px; background: var(--bg-input-focus); border-radius: 4px; overflow: hidden; margin-bottom: 12px; } | ||
| 1892 | -.okr-hp-fill { height: 100%; background: linear-gradient(90deg, #10b981, #34d399); border-radius: 4px; transition: width 0.8s cubic-bezier(0.16, 1, 0.3, 1); } | ||
| 1893 | -.okr-hp-stats { display: flex; gap: 16px; font-size: 11px; font-weight: 500; color: var(--text-tertiary); } | ||
| 1894 | -.okr-hp-stats .stat-d { color: #34d399; } | ||
| 1895 | -.okr-hp-stats .stat-p { color: #60a5fa; } | ||
| 1896 | -.okr-hp-stats .stat-w { color: var(--text-muted); } | ||
| 1897 | - | ||
| 1898 | -/* OKR Tree */ | ||
| 1899 | -.okr-tree-area { padding: 30px 40px; max-width: 1000px; } | ||
| 1900 | -.okr-tree-title { font-size: 18px; font-weight: 600; margin: 0 0 20px; color: var(--text-primary); } | ||
| 1901 | -.okr-tree { display: flex; flex-direction: column; gap: 6px; } | ||
| 1902 | -.okr-tnode-line { display: flex; width: 100%; } | ||
| 1903 | -.okr-tnode-indent { position: relative; } | ||
| 1904 | -.okr-tnode-indent::before { content: ''; position: absolute; top: -16px; bottom: 50%; left: -14px; width: 14px; border-left: 1.5px solid var(--border-active); border-bottom: 1.5px solid var(--border-active); border-bottom-left-radius: 6px; z-index: 1; opacity: 0.3; } | ||
| 1905 | -.okr-tnode-card { flex: 1; background: var(--bg-surface); border: 1px solid var(--border-primary); border-radius: 8px; padding: 12px 16px; transition: all 0.2s; } | ||
| 1906 | -.okr-tnode-card:hover { border-color: rgba(255,255,255,0.1); background: var(--bg-surface-hover); } | ||
| 1907 | -.okr-tnode-card.okr-lv-2 { background: rgba(59,130,246,0.03); border-color: rgba(59,130,246,0.1); } | ||
| 1908 | -.okr-tnode-card.okr-lv-3 { background: rgba(16,185,129,0.03); border-color: rgba(16,185,129,0.1); } | ||
| 1909 | -.okr-tnode-hdr { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } | ||
| 1910 | -.okr-nid { font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 700; color: var(--text-tertiary); } | ||
| 1911 | -.okr-ntype { font-size: 9px; font-weight: 600; padding: 2px 6px; border-radius: 10px; background: rgba(255,255,255,0.05); color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; } | ||
| 1912 | -.okr-nstatus { font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 4px; border: 1px solid transparent; } | ||
| 1913 | -.okr-nstatus.done { background: rgba(16,185,129,0.1); color: #34d399; border-color: rgba(16,185,129,0.2); } | ||
| 1914 | -.okr-nstatus.in_progress { background: rgba(59,130,246,0.1); color: #60a5fa; border-color: rgba(59,130,246,0.2); animation: okr-pulse 2s infinite; } | ||
| 1915 | -.okr-nstatus.pending { background: rgba(255,255,255,0.05); color: var(--text-muted); border-color: var(--border-primary); } | ||
| 1916 | -@keyframes okr-pulse { 0% { box-shadow: 0 0 0 0 rgba(96,165,250,0.2); } 70% { box-shadow: 0 0 0 4px rgba(96,165,250,0); } 100% { box-shadow: 0 0 0 0 rgba(96,165,250,0); } } | ||
| 1917 | -.okr-ntitle { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; line-height: 1.4; } | ||
| 1918 | -.okr-nfooter { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 4px; } | ||
| 1919 | -.okr-nowner { font-size: 11px; font-weight: 500; color: var(--accent-light); display: flex; align-items: center; gap: 4px; background: var(--accent-bg); padding: 2px 8px; border-radius: 10px; } | ||
| 1920 | -.okr-nprio { font-size: 10px; font-weight: 700; font-family: 'JetBrains Mono', monospace; } | ||
| 1921 | -.okr-nprio.P0 { color: #ef4444; } | ||
| 1922 | -.okr-nprio.P1 { color: #f59e0b; } | ||
| 1923 | -.okr-nprio.P2 { color: var(--text-muted); } | ||
| 1924 | -.okr-ntask { font-size: 10px; font-family: 'JetBrains Mono', monospace; color: var(--blue-text); background: rgba(59,130,246,0.1); padding: 2px 6px; border-radius: 4px; } | ||
| 1925 | -.okr-nsummary { margin-top: 8px; padding-top: 8px; border-top: 1px dashed var(--border-primary); font-size: 11px; color: var(--text-tertiary); line-height: 1.5; } | ||
| 1926 | -.okr-nsummary strong { color: var(--text-secondary); } | ||
| 1927 | - | ||
| 1928 | -.empty-state-large { text-align: center; color: var(--text-muted); } | ||
| 1929 | 1711 | ||
| 1930 | /* ═══ Modal ═══ */ | 1712 | /* ═══ Modal ═══ */ |
| 1931 | .modal-overlay { | 1713 | .modal-overlay { |
web/src/views/OkrDashboard.vue deleted
| 1 | -<template> | ||
| 2 | - <div class="okr-dashboard-page"> | ||
| 3 | - <AppNav /> | ||
| 4 | - <div class="okr-layout"> | ||
| 5 | - <!-- 第1列: Goal 列表 --> | ||
| 6 | - <aside class="goal-sidebar"> | ||
| 7 | - <div class="col-header"> | ||
| 8 | - <span>🎯 OKR 目标</span> | ||
| 9 | - <span class="count-badge" v-if="goals.length">{{ goals.length }}</span> | ||
| 10 | - </div> | ||
| 11 | - <div class="goal-list"> | ||
| 12 | - <div | ||
| 13 | - v-for="g in goals" :key="g.id" | ||
| 14 | - class="goal-card" :class="{ active: selectedGoalId === g.id }" | ||
| 15 | - @click="selectGoal(g.id)" | ||
| 16 | - > | ||
| 17 | - <div class="goal-card-header"> | ||
| 18 | - <span class="goal-id">{{ g.id }}</span> | ||
| 19 | - <span class="goal-status" :class="g.status">{{ getDisplayStatus(g.status) }}</span> | ||
| 20 | - </div> | ||
| 21 | - <div class="goal-title" :title="g.title">{{ g.title }}</div> | ||
| 22 | - <div class="goal-progress-wrap"> | ||
| 23 | - <div class="progress-bar"><div class="progress-fill" :style="{ width: (g.progress * 100) + '%' }"></div></div> | ||
| 24 | - <span class="progress-text">{{ Math.round(g.progress * 100) }}%</span> | ||
| 25 | - </div> | ||
| 26 | - <div class="goal-meta"> | ||
| 27 | - <span class="goal-owner">👤 {{ g.owner }}</span> | ||
| 28 | - <span class="goal-counts">节点: {{ g.doneCount || 0 }}/{{ g.totalCount || 0 }}</span> | ||
| 29 | - </div> | ||
| 30 | - </div> | ||
| 31 | - <div v-if="goals.length === 0" class="empty-state">当前没有目标</div> | ||
| 32 | - </div> | ||
| 33 | - </aside> | ||
| 34 | - | ||
| 35 | - <!-- 第2列: Goal 详情与节点树 --> | ||
| 36 | - <section class="goal-detail-panel" v-if="selectedGoal"> | ||
| 37 | - <div class="goal-hero"> | ||
| 38 | - <div class="hero-top"> | ||
| 39 | - <span class="hero-id">{{ selectedGoal.id }}</span> | ||
| 40 | - <span class="hero-status" :class="selectedGoal.status">{{ getDisplayStatus(selectedGoal.status) }}</span> | ||
| 41 | - <span class="hero-owner">👤 负责人: {{ selectedGoal.owner }}</span> | ||
| 42 | - </div> | ||
| 43 | - <h1 class="hero-title">{{ selectedGoal.title }}</h1> | ||
| 44 | - <p class="hero-desc">{{ selectedGoal.description || '暂无详细描述...' }}</p> | ||
| 45 | - <div class="hero-progress-large" v-if="selectedGoalProgress"> | ||
| 46 | - <div class="large-progress-top"> | ||
| 47 | - <span class="large-progress-label">目标总体进度</span> | ||
| 48 | - <span class="large-progress-value">{{ Math.round(selectedGoalProgress.progress * 100) }}%</span> | ||
| 49 | - </div> | ||
| 50 | - <div class="large-progress-bar"> | ||
| 51 | - <div class="large-progress-fill" :style="{ width: (selectedGoalProgress.progress * 100) + '%' }"></div> | ||
| 52 | - </div> | ||
| 53 | - <div class="large-progress-stats"> | ||
| 54 | - <span>总叶子节点: {{ selectedGoalProgress.total }}</span> | ||
| 55 | - <span class="stat-done">已完成: {{ selectedGoalProgress.done }}</span> | ||
| 56 | - <span class="stat-inprogress">进行中: {{ selectedGoalProgress.inProgress }}</span> | ||
| 57 | - <span class="stat-pending">待开始: {{ selectedGoalProgress.pending }}</span> | ||
| 58 | - </div> | ||
| 59 | - </div> | ||
| 60 | - </div> | ||
| 61 | - | ||
| 62 | - <div class="nodes-area"> | ||
| 63 | - <div class="nodes-header"> | ||
| 64 | - <h3>OKR 拆解树配置</h3> | ||
| 65 | - </div> | ||
| 66 | - <div class="tree-container"> | ||
| 67 | - <div | ||
| 68 | - v-for="node in sortedNodes" :key="node.id" | ||
| 69 | - class="tree-node" | ||
| 70 | - :class="'level-' + node.level" | ||
| 71 | - > | ||
| 72 | - <div class="node-line"> | ||
| 73 | - <div class="node-prefix" :style="{ width: (node.level - 2) * 24 + 'px' }"></div> | ||
| 74 | - <div class="node-card" :class="[node.status, 'level-style-' + node.level]"> | ||
| 75 | - <div class="node-header"> | ||
| 76 | - <span class="node-id">{{ node.id }}</span> | ||
| 77 | - <span class="node-type-badge">{{ getLevelName(node.level) }}</span> | ||
| 78 | - <span class="node-status" :class="node.status">{{ getDisplayStatus(node.status) }}</span> | ||
| 79 | - </div> | ||
| 80 | - <div class="node-title">{{ node.title }}</div> | ||
| 81 | - <div class="node-footer"> | ||
| 82 | - <span class="node-owner" v-if="node.owner">👤 {{ node.owner }}</span> | ||
| 83 | - <span class="node-priority" v-if="node.priority" :class="node.priority">{{ node.priority }}</span> | ||
| 84 | - <span class="node-task" v-if="node.taskId">🔗 {{ node.taskId }}</span> | ||
| 85 | - </div> | ||
| 86 | - <div class="node-summary" v-if="node.summary"> | ||
| 87 | - <strong>摘要:</strong> {{ node.summary }} | ||
| 88 | - </div> | ||
| 89 | - <div class="node-summary" v-if="node.output"> | ||
| 90 | - <strong>产出:</strong> {{ node.output }} | ||
| 91 | - </div> | ||
| 92 | - </div> | ||
| 93 | - </div> | ||
| 94 | - </div> | ||
| 95 | - </div> | ||
| 96 | - </div> | ||
| 97 | - </section> | ||
| 98 | - | ||
| 99 | - <section class="goal-detail-panel empty-panel" v-else> | ||
| 100 | - <div class="empty-state-large"> | ||
| 101 | - <div class="empty-icon">🎯</div> | ||
| 102 | - <h2>OKR 目标看板</h2> | ||
| 103 | - <p>请从左侧列表选择一个目标查看详细拆解树与进度</p> | ||
| 104 | - </div> | ||
| 105 | - </section> | ||
| 106 | - </div> | ||
| 107 | - </div> | ||
| 108 | -</template> | ||
| 109 | - | ||
| 110 | -<script setup> | ||
| 111 | -import { ref, computed, onMounted } from 'vue' | ||
| 112 | -import AppNav from '@/components/AppNav.vue' | ||
| 113 | - | ||
| 114 | -const goals = ref([]) | ||
| 115 | -const selectedGoalId = ref(null) | ||
| 116 | -const selectedGoal = ref(null) | ||
| 117 | -const selectedGoalProgress = ref(null) | ||
| 118 | - | ||
| 119 | -const statusMap = { | ||
| 120 | - 'active': '运转中', | ||
| 121 | - 'in_progress': '进行中', | ||
| 122 | - 'pending': '待开始', | ||
| 123 | - 'done': '已完成', | ||
| 124 | - 'failed': '失败', | ||
| 125 | - 'blocked': '已阻塞' | ||
| 126 | -} | ||
| 127 | - | ||
| 128 | -function getDisplayStatus(status) { | ||
| 129 | - return statusMap[status] || status | ||
| 130 | -} | ||
| 131 | - | ||
| 132 | -function getLevelName(level) { | ||
| 133 | - const names = ['Goal', 'Objective', 'Key Result', 'Sub-Task', 'Micro-Task']; | ||
| 134 | - return names[Math.min(level - 1, 4)]; | ||
| 135 | -} | ||
| 136 | - | ||
| 137 | -async function loadGoals() { | ||
| 138 | - try { | ||
| 139 | - const res = await fetch('/api/okr') | ||
| 140 | - if (res.ok) { | ||
| 141 | - goals.value = await res.json() | ||
| 142 | - // 默认选中第一个 | ||
| 143 | - if (goals.value.length > 0 && !selectedGoalId.value) { | ||
| 144 | - selectGoal(goals.value[0].id) | ||
| 145 | - } | ||
| 146 | - } | ||
| 147 | - } catch (err) { | ||
| 148 | - console.error('Failed to load goals:', err) | ||
| 149 | - } | ||
| 150 | -} | ||
| 151 | - | ||
| 152 | -async function selectGoal(id) { | ||
| 153 | - selectedGoalId.value = id | ||
| 154 | - try { | ||
| 155 | - const [goalRes, progRes] = await Promise.all([ | ||
| 156 | - fetch(`/api/okr/${id}`), | ||
| 157 | - fetch(`/api/okr/${id}/progress`) | ||
| 158 | - ]) | ||
| 159 | - if (goalRes.ok) selectedGoal.value = await goalRes.json() | ||
| 160 | - if (progRes.ok) selectedGoalProgress.value = await progRes.json() | ||
| 161 | - } catch (err) { | ||
| 162 | - console.error('Failed to load goal detail:', err) | ||
| 163 | - } | ||
| 164 | -} | ||
| 165 | - | ||
| 166 | -// 将扁平的nodes转为按树结构排序的列表,便于直接循环渲染并计算缩进 | ||
| 167 | -const sortedNodes = computed(() => { | ||
| 168 | - if (!selectedGoal.value || !selectedGoal.value.nodes) return [] | ||
| 169 | - const nodes = selectedGoal.value.nodes | ||
| 170 | - | ||
| 171 | - // 给定一个父节点,递归获取所有后代,按ID自然排序确保层级 | ||
| 172 | - const result = [] | ||
| 173 | - const keys = Object.keys(nodes).sort((a, b) => { | ||
| 174 | - // 采用自然排序法,如 G-1.2 排在 G-1.10 前面 | ||
| 175 | - const partsA = a.replace('G-','').split('.').map(Number) | ||
| 176 | - const partsB = b.replace('G-','').split('.').map(Number) | ||
| 177 | - for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) { | ||
| 178 | - const numA = partsA[i] || 0 | ||
| 179 | - const numB = partsB[i] || 0 | ||
| 180 | - if (numA !== numB) return numA - numB | ||
| 181 | - } | ||
| 182 | - return 0 | ||
| 183 | - }) | ||
| 184 | - | ||
| 185 | - for (const id of keys) { | ||
| 186 | - result.push({ | ||
| 187 | - id, | ||
| 188 | - ...nodes[id], | ||
| 189 | - level: id.split('.').length | ||
| 190 | - }) | ||
| 191 | - } | ||
| 192 | - return result | ||
| 193 | -}) | ||
| 194 | - | ||
| 195 | -onMounted(() => { | ||
| 196 | - loadGoals() | ||
| 197 | - // 简单轮询刷新进度 | ||
| 198 | - setInterval(() => { | ||
| 199 | - if (selectedGoalId.value) { | ||
| 200 | - selectGoal(selectedGoalId.value) | ||
| 201 | - } else { | ||
| 202 | - loadGoals() | ||
| 203 | - } | ||
| 204 | - }, 10000) | ||
| 205 | -}) | ||
| 206 | -</script> | ||
| 207 | - | ||
| 208 | -<style scoped> | ||
| 209 | -.okr-dashboard-page { | ||
| 210 | - height: 100vh; display: flex; flex-direction: column; | ||
| 211 | - background: var(--bg-primary); | ||
| 212 | - color: var(--text-primary); font-family: 'Inter', -apple-system, sans-serif; | ||
| 213 | -} | ||
| 214 | -.okr-layout { flex: 1; display: flex; overflow: hidden; } | ||
| 215 | - | ||
| 216 | -.col-header { | ||
| 217 | - padding: 12px 16px; font-size: 13px; font-weight: 700; color: var(--text-secondary); | ||
| 218 | - border-bottom: 1px solid var(--border-primary); | ||
| 219 | - display: flex; align-items: center; justify-content: space-between; | ||
| 220 | - background: var(--bg-secondary); | ||
| 221 | -} | ||
| 222 | -.count-badge { | ||
| 223 | - background: var(--bg-surface-active); color: var(--accent); | ||
| 224 | - padding: 2px 8px; border-radius: 12px; font-size: 11px; | ||
| 225 | -} | ||
| 226 | - | ||
| 227 | -/* ═══ Left Sidebar: Goals ═══ */ | ||
| 228 | -.goal-sidebar { | ||
| 229 | - width: 280px; min-width: 260px; border-right: 1px solid var(--border-primary); | ||
| 230 | - background: var(--bg-overlay); display: flex; flex-direction: column; | ||
| 231 | -} | ||
| 232 | -.goal-list { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; } | ||
| 233 | -.goal-card { | ||
| 234 | - padding: 12px 14px; border-radius: 10px; cursor: pointer; | ||
| 235 | - background: var(--bg-surface); border: 1px solid var(--border-primary); | ||
| 236 | - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); | ||
| 237 | -} | ||
| 238 | -.goal-card:hover { transform: translateY(-2px); border-color: var(--accent-light); box-shadow: 0 4px 12px rgba(16,185,129,0.08); } | ||
| 239 | -.goal-card.active { | ||
| 240 | - background: var(--accent-bg); border-color: var(--border-active); | ||
| 241 | - box-shadow: 0 0 0 1px var(--accent) inset; | ||
| 242 | -} | ||
| 243 | - | ||
| 244 | -.goal-card-header { display: flex; justify-content: space-between; margin-bottom: 6px; } | ||
| 245 | -.goal-id { font-size: 11px; font-family: 'JetBrains Mono', monospace; font-weight: 600; color: var(--text-tertiary); } | ||
| 246 | -.goal-status { font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 4px; } | ||
| 247 | -.goal-status.active { background: rgba(59,130,246,0.15); color: #60a5fa; } | ||
| 248 | -.goal-status.done { background: rgba(16,185,129,0.15); color: #34d399; } | ||
| 249 | - | ||
| 250 | -.goal-title { font-size: 13px; font-weight: 600; margin-bottom: 10px; color: var(--text-primary); line-height: 1.4; } | ||
| 251 | - | ||
| 252 | -.goal-progress-wrap { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } | ||
| 253 | -.progress-bar { flex: 1; height: 6px; background: var(--bg-input-focus); border-radius: 3px; overflow: hidden; } | ||
| 254 | -.progress-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent-light)); border-radius: 3px; transition: width 0.5s ease; } | ||
| 255 | -.progress-text { font-size: 11px; font-weight: 600; color: var(--text-secondary); width: 32px; text-align: right; } | ||
| 256 | - | ||
| 257 | -.goal-meta { display: flex; justify-content: space-between; font-size: 10px; color: var(--text-faint); } | ||
| 258 | - | ||
| 259 | -.empty-state { text-align: center; padding: 40px 20px; color: var(--text-faint); font-size: 12px; } | ||
| 260 | - | ||
| 261 | -/* ═══ Right Panel: Details ═══ */ | ||
| 262 | -.goal-detail-panel { | ||
| 263 | - flex: 1; overflow-y: auto; background: var(--bg-primary); | ||
| 264 | - display: flex; flex-direction: column; | ||
| 265 | -} | ||
| 266 | -.goal-detail-panel.empty-panel { align-items: center; justify-content: center; background: var(--bg-overlay); } | ||
| 267 | - | ||
| 268 | -.empty-state-large { text-align: center; color: var(--text-muted); } | ||
| 269 | -.empty-icon { font-size: 48px; margin-bottom: 16px; opacity: 0.8; } | ||
| 270 | -.empty-state-large h2 { font-size: 20px; font-weight: 600; margin-bottom: 8px; color: var(--text-secondary); } | ||
| 271 | - | ||
| 272 | -.goal-hero { | ||
| 273 | - padding: 30px 40px; border-bottom: 1px solid var(--border-primary); | ||
| 274 | - background: linear-gradient(180deg, var(--accent-bg) 0%, transparent 100%); | ||
| 275 | -} | ||
| 276 | -.hero-top { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; } | ||
| 277 | -.hero-id { font-size: 14px; font-family: 'JetBrains Mono', monospace; font-weight: 700; color: var(--accent-light); } | ||
| 278 | -.hero-status { font-size: 12px; font-weight: 600; padding: 3px 8px; border-radius: 6px; } | ||
| 279 | -.hero-status.active { background: rgba(59,130,246,0.2); color: #60a5fa; border: 1px solid rgba(59,130,246,0.3); } | ||
| 280 | -.hero-status.done { background: rgba(16,185,129,0.2); color: #34d399; border: 1px solid rgba(16,185,129,0.3); } | ||
| 281 | - | ||
| 282 | -.hero-owner { font-size: 12px; font-weight: 500; color: var(--text-secondary); margin-left: auto; background: var(--bg-surface); padding: 4px 10px; border-radius: 12px; border: 1px solid var(--border-primary); } | ||
| 283 | -.hero-title { font-size: 28px; font-weight: 800; margin: 0 0 12px; letter-spacing: -0.5px; } | ||
| 284 | -.hero-desc { font-size: 14px; color: var(--text-tertiary); line-height: 1.6; max-width: 800px; margin: 0; } | ||
| 285 | - | ||
| 286 | -.hero-progress-large { margin-top: 24px; max-width: 600px; background: var(--bg-surface); padding: 16px 20px; border-radius: 12px; border: 1px solid var(--border-primary); } | ||
| 287 | -.large-progress-top { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 10px; } | ||
| 288 | -.large-progress-label { font-size: 13px; font-weight: 600; color: var(--text-secondary); } | ||
| 289 | -.large-progress-value { font-size: 24px; font-weight: 800; color: var(--accent-light); line-height: 1; } | ||
| 290 | -.large-progress-bar { height: 8px; background: var(--bg-input-focus); border-radius: 4px; overflow: hidden; margin-bottom: 12px; } | ||
| 291 | -.large-progress-fill { height: 100%; background: linear-gradient(90deg, #10b981, #34d399); border-radius: 4px; transition: width 0.8s cubic-bezier(0.16, 1, 0.3, 1); } | ||
| 292 | -.large-progress-stats { display: flex; gap: 16px; font-size: 11px; font-weight: 500; color: var(--text-tertiary); } | ||
| 293 | -.stat-done { color: #34d399; } | ||
| 294 | -.stat-inprogress { color: #60a5fa; } | ||
| 295 | -.stat-pending { color: var(--text-muted); } | ||
| 296 | - | ||
| 297 | -/* ═══ Nodes Tree ═══ */ | ||
| 298 | -.nodes-area { padding: 30px 40px; max-width: 1000px; } | ||
| 299 | -.nodes-header h3 { font-size: 18px; font-weight: 600; margin: 0 0 20px; color: var(--text-primary); } | ||
| 300 | - | ||
| 301 | -.tree-container { display: flex; flex-direction: column; gap: 6px; } | ||
| 302 | -.tree-node { display: flex; flex-direction: column; } | ||
| 303 | -.node-line { display: flex; width: 100%; } | ||
| 304 | -.node-prefix { position: relative; } | ||
| 305 | -/* 连线占位 */ | ||
| 306 | -.node-prefix::before { | ||
| 307 | - content: ''; position: absolute; top: -16px; bottom: 50%; left: -14px; | ||
| 308 | - width: 14px; border-left: 1.5px solid var(--border-active); border-bottom: 1.5px solid var(--border-active); | ||
| 309 | - border-bottom-left-radius: 6px; z-index: 1; opacity: 0.3; | ||
| 310 | -} | ||
| 311 | - | ||
| 312 | -.node-card { | ||
| 313 | - flex: 1; background: var(--bg-surface); border: 1px solid var(--border-primary); | ||
| 314 | - border-radius: 8px; padding: 12px 16px; position: relative; | ||
| 315 | - transition: all 0.2s; | ||
| 316 | -} | ||
| 317 | -.node-card:hover { border-color: rgba(255,255,255,0.1); background: var(--bg-surface-hover); } | ||
| 318 | - | ||
| 319 | -/* Level Styles */ | ||
| 320 | -.node-card.level-style-2 { background: rgba(59,130,246,0.03); border-color: rgba(59,130,246,0.1); } | ||
| 321 | -.node-card.level-style-3 { background: rgba(16,185,129,0.03); border-color: rgba(16,185,129,0.1); } | ||
| 322 | -.node-card.level-style-4 { background: var(--bg-surface); } | ||
| 323 | - | ||
| 324 | -.node-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } | ||
| 325 | -.node-id { font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 700; color: var(--text-tertiary); } | ||
| 326 | -.node-type-badge { font-size: 9px; font-weight: 600; padding: 2px 6px; border-radius: 10px; background: rgba(255,255,255,0.05); color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; } | ||
| 327 | - | ||
| 328 | -.node-status { font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 4px; border: 1px solid transparent; } | ||
| 329 | -.node-status.done { background: rgba(16,185,129,0.1); color: #34d399; border-color: rgba(16,185,129,0.2); } | ||
| 330 | -.node-status.in_progress { background: rgba(59,130,246,0.1); color: #60a5fa; border-color: rgba(59,130,246,0.2); animation: pulse-light 2s infinite; } | ||
| 331 | -.node-status.pending { background: rgba(255,255,255,0.05); color: var(--text-muted); border-color: var(--border-primary); } | ||
| 332 | - | ||
| 333 | -@keyframes pulse-light { | ||
| 334 | - 0% { box-shadow: 0 0 0 0 rgba(96,165,250,0.2); } | ||
| 335 | - 70% { box-shadow: 0 0 0 4px rgba(96,165,250,0); } | ||
| 336 | - 100% { box-shadow: 0 0 0 0 rgba(96,165,250,0); } | ||
| 337 | -} | ||
| 338 | - | ||
| 339 | -.node-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; line-height: 1.4; } | ||
| 340 | - | ||
| 341 | -.node-footer { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 6px; } | ||
| 342 | -.node-owner { font-size: 11px; font-weight: 500; color: var(--accent-light); display: flex; align-items: center; gap: 4px; background: var(--accent-bg); padding: 2px 8px; border-radius: 10px; } | ||
| 343 | -.node-priority { font-size: 10px; font-weight: 700; font-family: 'JetBrains Mono', monospace; } | ||
| 344 | -.node-priority.P0 { color: #ef4444; } | ||
| 345 | -.node-priority.P1 { color: #f59e0b; } | ||
| 346 | -.node-priority.P2 { color: var(--text-muted); } | ||
| 347 | -.node-task { font-size: 10px; font-family: 'JetBrains Mono', monospace; color: var(--blue-text); background: rgba(59,130,246,0.1); padding: 2px 6px; border-radius: 4px; } | ||
| 348 | - | ||
| 349 | -.node-summary { margin-top: 8px; padding-top: 8px; border-top: 1px dashed var(--border-primary); font-size: 11px; color: var(--text-tertiary); line-height: 1.5; } | ||
| 350 | -.node-summary strong { color: var(--text-secondary); } | ||
| 351 | - | ||
| 352 | -</style> |
web/src/views/TaskBoard.vue
0 → 100644
| 1 | +<!-- | ||
| 2 | + 任务看板 —— goal/task 两级模型的独立页面(39 号文档 §二 P2)。 | ||
| 3 | + | ||
| 4 | + 在此之前 goal/task 只能在 GraphConsole 左栏的窄侧栏里看,这里把同一份组件 | ||
| 5 | + (GoalTaskPane + goal-task-model.js)铺满整页,数据层原样不动,不重写。 | ||
| 6 | + | ||
| 7 | + 点一条 task 不在本页画执行图(那是 GraphConsole 的活),而是跳过去 —— | ||
| 8 | + `/:id/graph-console?goal=...&task=...` 是 GraphConsole 已经支持的选中态复原路径 | ||
| 9 | + (见该文件 onMounted 里 `route.query.goal` / `route.query.task` 那两行),不新开一条协议。 | ||
| 10 | +--> | ||
| 11 | +<template> | ||
| 12 | + <div class="board"> | ||
| 13 | + <header class="top"> | ||
| 14 | + <div class="who"> | ||
| 15 | + <router-link :to="`/${companyId}/overview`" class="back">←</router-link> | ||
| 16 | + <b>{{ company?.name || companyId }}</b> | ||
| 17 | + <span class="sep">任务看板</span> | ||
| 18 | + </div> | ||
| 19 | + <router-link :to="`/${companyId}/graph-console`" class="to-console">🕸️ 执行控制台</router-link> | ||
| 20 | + </header> | ||
| 21 | + | ||
| 22 | + <div class="body"> | ||
| 23 | + <GoalTaskPane | ||
| 24 | + :company-id="companyId" | ||
| 25 | + :goals="goals" | ||
| 26 | + :loading="goalsLoading" | ||
| 27 | + :selected-task-id="selectedTaskId" | ||
| 28 | + :selected-goal-id="selectedGoalId" | ||
| 29 | + :slots="slots" | ||
| 30 | + @select="goToDetail" | ||
| 31 | + @refresh="loadGoals" | ||
| 32 | + /> | ||
| 33 | + </div> | ||
| 34 | + </div> | ||
| 35 | +</template> | ||
| 36 | + | ||
| 37 | +<script setup> | ||
| 38 | +import { ref, computed, onMounted, onBeforeUnmount } from 'vue'; | ||
| 39 | +import { useRoute, useRouter } from 'vue-router'; | ||
| 40 | +import GoalTaskPane from '@/components/graph-console/GoalTaskPane.vue'; | ||
| 41 | + | ||
| 42 | +const route = useRoute(); | ||
| 43 | +const router = useRouter(); | ||
| 44 | +const props = defineProps({ id: { type: String, default: '' } }); | ||
| 45 | +const companyId = computed(() => props.id || route.params.id); | ||
| 46 | + | ||
| 47 | +const company = ref(null); | ||
| 48 | +const goals = ref([]); | ||
| 49 | +const goalsLoading = ref(false); | ||
| 50 | +const slots = ref(null); | ||
| 51 | +const selectedGoalId = ref(''); | ||
| 52 | +const selectedTaskId = ref(''); | ||
| 53 | + | ||
| 54 | +let treePoll = null; | ||
| 55 | + | ||
| 56 | +onMounted(async () => { | ||
| 57 | + selectedGoalId.value = String(route.query.goal || ''); | ||
| 58 | + selectedTaskId.value = String(route.query.task || ''); | ||
| 59 | + await Promise.all([loadCompany(), loadGoals()]); | ||
| 60 | + // 与 GraphConsole 同款兜底:没有公司级 SSE 通道,靠轮询看到别处发生的状态变化 | ||
| 61 | + treePoll = setInterval(() => { loadGoals(); }, 20000); | ||
| 62 | +}); | ||
| 63 | +onBeforeUnmount(() => clearInterval(treePoll)); | ||
| 64 | + | ||
| 65 | +async function loadCompany() { | ||
| 66 | + try { | ||
| 67 | + const r = await fetch(`/api/company/${companyId.value}`); | ||
| 68 | + if (r.ok) company.value = await r.json(); | ||
| 69 | + } catch { /* 页头名字掉了不影响功能 */ } | ||
| 70 | +} | ||
| 71 | + | ||
| 72 | +async function loadGoals() { | ||
| 73 | + goalsLoading.value = true; | ||
| 74 | + try { | ||
| 75 | + const r = await fetch(`/api/company/${companyId.value}/goals?withTasks=1`); | ||
| 76 | + if (!r.ok) return; | ||
| 77 | + const j = await r.json(); | ||
| 78 | + goals.value = j.goals || []; | ||
| 79 | + slots.value = j.slots || null; | ||
| 80 | + } catch { /* 轮询失败保留上一份树 */ } finally { | ||
| 81 | + goalsLoading.value = false; | ||
| 82 | + } | ||
| 83 | +} | ||
| 84 | + | ||
| 85 | +/** 点一条 task:跳到执行控制台看它的图,选中态通过 query 带过去 */ | ||
| 86 | +function goToDetail({ goalId, taskId }) { | ||
| 87 | + selectedGoalId.value = goalId || ''; | ||
| 88 | + selectedTaskId.value = taskId || ''; | ||
| 89 | + if (!taskId) return; | ||
| 90 | + router.push({ path: `/${companyId.value}/graph-console`, query: { goal: goalId || '', task: taskId } }); | ||
| 91 | +} | ||
| 92 | +</script> | ||
| 93 | + | ||
| 94 | +<style scoped> | ||
| 95 | +.board { display: flex; flex-direction: column; height: 100vh; background: #fff; color: #1a1a1a; } | ||
| 96 | + | ||
| 97 | +.top { | ||
| 98 | + display: flex; align-items: center; justify-content: space-between; | ||
| 99 | + padding: 10px 16px; border-bottom: 1px solid #eee; flex: 0 0 auto; | ||
| 100 | +} | ||
| 101 | +.who { display: flex; align-items: center; gap: 8px; font-size: 13px; } | ||
| 102 | +.back { text-decoration: none; color: #666; font-size: 16px; } | ||
| 103 | +.sep { color: #999; } | ||
| 104 | +.to-console { text-decoration: none; color: #2b6cb0; font-size: 12px; font-weight: 600; } | ||
| 105 | +.to-console:hover { text-decoration: underline; } | ||
| 106 | + | ||
| 107 | +.body { flex: 1 1 auto; overflow-y: auto; padding: 16px 24px; max-width: 900px; width: 100%; margin: 0 auto; box-sizing: border-box; } | ||
| 108 | + | ||
| 109 | +@media (max-width: 700px) { | ||
| 110 | + .body { padding: 12px; } | ||
| 111 | +} | ||
| 112 | +</style> |