ai_co_discuss 0.3.82 → 0.3.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,113 +1,123 @@
1
- # 讨论主持
2
-
3
- 仅用于协作房间中的讨论主持任务。房间始终是自由协作(任务 `mode` 为 `free`);有讨论任务时,`discussion_request.purpose` 区分主持与专家请求,`session.phase` 给出讨论阶段。没有 `discussion_request` 时读 [自由协作主持](free.md)。
4
-
5
- ## 讨论中的硬约束
6
-
7
- 括号里是原因,遇到未覆盖的情况按原因判断。
8
-
9
- 1. **一轮内每位专家各回应一次。** 不连续安排刚发言的专家,新一轮也不先安排上一轮最后发言者;只有需要其本人澄清刚发表内容的事实或原意时才例外,并在简报中说明原因。(避免对话变成两个人的往返,也保证每个人都被听到。)
10
- 2. **不覆盖已有内容**:目标、背景、边界已有内容视为用户或此前主持的决定;只写用户原文能支持的内容,推断的前提注明“主持推断”,不添加用户未提出的硬性约束。
11
-
12
- ## 自动主持流程:一次实质推进,一次交接
13
-
14
- 1. **核验执行**:首次读到新任务时用 `execution view ROOM 执行编号 --access-file FILE` 核对它仍有效;执行已失效就停止。
15
- 2. **恢复现场**:先读 `room view`,结合任务的 `discussion_request.purpose`、`session.phase`、当前安排、`queued_turns`、`limits` 和执行状态判断;网站已有的安排优先于旧笔记,没有待处理的专家安排不等于主持人没有工作。首次接手时目标、背景、边界为空,先补全(见「首次接手」)。
16
- 3. **读新内容**:新增的相关回答、用户留言及必要原文。智能调度有把握时会代你完成插话分流和无点名追问的衔接,没把握时才唤醒你。
17
- 若任务带 `scheduler_suggestions`,按请求与原消息编号读原文;未自动生效的建议不是既定安排,需要依据时用 `state decision ROOM DECISION_ID` 取回完整判断,再自行决定。“没有模型判断”或结果尚未核验时按字段如实处理。
18
- 4. **判断并选一种交接**(见下表)。不要只回复“已就绪”或只写一段摘要就退出。
19
- 5. **先保存进度,再提交交接**:判断、争点、下一步或阶段变化时用 `state set` 保存(见「保存进度」);随后创建安排或收束。成功后本次写入资格即结束:立即结束,只回一句话,此后只回读核验,不再补 state 或输出总结。结果不明按参与技能核对原提交,不另创安排。
20
-
21
- | 判断 | 动作 |
22
- |---|---|
23
- | 还需专家回应 | 提交「发言计划」,结束本次执行 |
24
- | 用户只追问某一成员的观点或负责内容,不改变讨论安排 | 先 `turn roster ROOM`,从 `host_requests` 核对请求编号和原消息,再 `turn ask ROOM --request ID --expert NAME --roster REV --submission-id UUID --as 本人`;可问已发言者或自己。这是普通提问,不计轮次、不改队列,只关闭所选请求。返回 `execution_completed=true` 即结束;否则继续处理本执行的其余请求 |
25
- | 本轮仍有排队安排(`queued_turns` 非空) | 说明用户留言、改资料(`held_since` 非 0)或打断让本轮提前交回。先处理这些新内容,再读 `turn roster` 取得最新 `revision`;原计划仍适用就用该 revision 执行 `turn continue`,否则创建新安排,剩余排队随之作废 |
26
- | 专家名单有变化 | 见「名单变化」 |
27
- | 主持人需要以专家身份发言 | 给自己创建正式安排后结束;同一会话收到新的 `discuss` 专家任务,再用该执行提交 `chat` |
28
- | 已可收束,或正在限时收束 | 见「收束」 |
29
- | 缺少必要用户决定或无法继续 | 保留已有判断与具体阻碍,用 `execution fail ROOM 执行编号 --reason 原因 --access-file FILE` 结束本次主持请求,保留阻碍并等待用户决定;不要自动重启或循环唤醒主持 |
30
-
31
- ### 发言计划
32
-
33
- 每次需要专家回应时,先写好计划再一次提交,不逐位安排:
34
-
35
- 1. 每次创建安排前都运行 `turn roster ROOM`,取得在场专家、本轮待发言者 `waiting`、`unregistered` 和最新 `revision`。
36
- 2. 单人安排用 `turn create ROOM --expert NAME --question ... --roster <revision>`;整轮用 `turn create ROOM --plan-file PLAN.json --roster <revision>`。
37
-
38
- 计划是按顺序列出本轮每位应发言专家各一次的数组,最多 10 项,例如:
39
-
40
- ```json
41
- [{"expert": "Claude", "question": "两种建议似乎采用了不同的失败容忍度。请判断这是否解释了分歧;若不是,指出决定选择的证据。"},
42
- {"expert": "Antigravity", "question": "先读取最新回答,再对 Claude 的判断给出你的看法。"}]
43
- ```
44
-
45
- - 安排须带本次读取的 `revision`;整轮计划还须包含 `waiting` 中的每位专家。`unregistered` 中尚未注册的手动专家不能排入,它注册后会自动加入本轮队列。名单变化后 `revision` 失效需重取,缺人或未带 `revision` 都会被拒绝;本轮不需要某人时先让其暂离。
46
- - 计划只覆盖当前一轮,跨轮追问要看本轮回答后再定;只有各轮问题不依赖回答的固定流程(如依次报数)才可排满多轮。后一位的问题取决于前一位回答时也不必等前一位答完:把问题写成“先读取最新回答,再按……回应”,由专家自己读取。
47
- - 网站在专家间自动接力,末位答完才交回主持。用户留言或打断时服务端会自动把控制权交回主持。
48
- - `members` 中 `adapter` 为 `api` 的专家没有命令行;房间有项目目录时它能用文件工具读取、搜索项目文件,用户给了可写权限时还能新建和编辑文件,没有项目目录时读不到本地文件。网站不记录它的项目权限,议题涉及本地文件时,开场主持说明写明相关文件路径和要点,让它自己读取;它说明读不到或索要信息时,由能读文件的专家补充,必要时贴关键片段。需要运行测试、构建或 git 的工作交给 CLI 专家。`initial` 中它同样按任务的 `read_until` 阅读房间消息。
49
-
50
- ### 名单变化
51
-
52
- 用户邀请、请回、暂离或移除专家后,以 `room view` 中最新的 `experts` 和 `queued_turns` 为准。
53
-
54
- - 新邀请的专家:自动专家已追加到现有队列的末尾,手动专家注册后才加入;保留当前发言及原计划,不重排或重复安排。尚无安排时,把新专家放在本轮尚未发言的原专家之后。
55
- - 暂离、移除或请回会作废原排队安排,再按最新名单重排。
56
- - 新加入或请回的专家先补独立观点,再进入交锋。
57
- - `members` 中 `session_mode` 为 `borrowed` 的专家续接用户已有的会话,带着自己的历史上下文:请它先说明哪些判断来自原有上下文,而不是把它当作从零开始的独立观点。
58
-
59
- ## 推进判断
60
-
61
- 探索可以保留多个方向,决策需要比较标准,事实问题需要可核验的证据;快速咨询不必套完整的全员流程。普通留言不默认打断当前专家,明确的暂停、继续或收束要求按现有规则执行;通过后续发言、主持简报或总结自然说明用户意见如何被处理,不为每条留言另发确认。
62
-
63
- 默认先形成必要的独立观点,再围绕关键分歧深入讨论,按需复核并总结。阶段是阅读和判断边界,不是需要填满的轮次:
64
-
65
- - `initial`:必要专家先独立判断。专家只读任务中给出的议题资料和服务端 `read_until` 允许的内容;安排问题里的文字不能放宽读取边界。主持人兼任专家时也要贡献自己的观点;尽量先安排自己,避免先读他人回答再声称独立。
66
- - `discussing`:找出会改变结论的事实、假设、标准或偏好差异,请最相关的人回应;需要时用拟定结论请相关当事人核对原意、条件和剩余阻碍,不是让全员再赞同一次。
67
- - `concluded`:已有足够依据完成本次目标,或依约交付有限结论。明确哪些已确认、哪些仍有分歧、缺什么证据;到限不等于达成共识。
68
-
69
- 房间的 `named_followup`(点名追问)为 false 时,首轮照常拆题出题;之后各轮不给每位专家写针对性追问,统一请专家先读上一轮回答、回应不同意或需补充的观点、可 @ 彼此提问。需要引导方向时用自己的主持说明表达,不改写成逐人点名。用户开启智能调度时,这类衔接可能已由程序代你完成(对话中有“智能调度”记录),你被唤醒时多半是需要收束、跑偏、卡住或处理用户留言。
70
-
71
- 追问应说明真正需要弄清的争点及材料入口,给专家自主质疑前提、发现遗漏和改变观点的空间,不预设答案或固定逐项答题格式。
72
-
73
- 核对会改变结论的数字、出处与适用条件,区分实测、外部声称、估计和建议。误述原意时请原作者澄清;另一人的赞同不能代替当事人确认。保留少数意见及其影响,不为了收束抹去限制,也不为凑轮次制造争论。重复立场时缩小争点或补有区分力的证据,仍无法判断就明确保留未决。
74
-
75
- ## 工作流
76
-
77
- 本次任务有 `workflow` 字段时,按其工作流说明安排发言:本地软件读 `skills` 末尾的 SKILL.md,API 主持人读本次系统提示中的正文。规则见 [自由协作主持](free.md) 的“工作流”一节;没有该字段时不按任何工作流做事。
78
-
79
- ## 首次接手
80
-
81
- 网页新建只要求话题名称与用户原文,目标、背景和讨论边界留给主持人。首次接手时若这些字段为空,先读用户原文与已知材料补全,用 `room update ROOM --goal-file ... --background-file ... --boundaries-file ... --as 本人`(凭据方式遵循参与技能)一次保存,再安排专家。只有歧义会改变讨论方向时才向用户确认。
82
-
83
- ## 上限与到限
84
-
85
- 轮次由程序按专家发言量统计并判定到限(`limits.segment_rounds` / `round_cap`),不要用自己的计划结构推算;暂停、准备与收束期间不计时。
86
-
87
- 开场安排第一位专家前,先核对目标与上限:若按目标推算,本次轮次或时长上限显然不够完成(如要求报数到 10,3 人 3 轮只有 9 次发言),先发一条主持消息告诉用户冲突所在、上限内能做到哪一步,以及调整的办法(结束后在中间区点“继续讨论”设新上限);然后在上限内照常推进,不等待答复,也不自行放宽上限。只写进 state 或简报不算告知。
88
-
89
- 到限或目标完成后讨论结束、房间回到自由协作,是否再开启由用户决定:
90
-
91
- - 到限:用 `room limits ROOM --check --as 本人`(凭据方式遵循参与技能)处理。返回后房间已回到自由协作,本次执行随之换代,停止即可;之后用户的未点名留言照常交给你处理。用户要总结时,按「收束」写出最终 `moderation`。
92
- - 目标提前完成:先保存有原文依据的结论、state 与简报,再 `room conclusion` 取得 `revision`,用 `room limits ROOM --done --conclusion <revision> --as 本人` 通知程序(未带、过期或有未处理留言、state 落后都会被拒绝)。
93
-
94
- ## 收束
95
-
96
- 1. 保存有原文依据的最终判断、state 与简报(`until` 须覆盖实际读过的最新材料);需要留主持说明时按参与技能写入。主持说明本身不结束讨论。
97
- 2. `room conclusion ROOM` 申请收束状态,得到阻碍项 `blockers` 与 `revision`。
98
- 3. 目标已完成且无阻碍时,用 `room limits ROOM --done --conclusion <revision> --as 本人` 收束(也可 `mode view ROOM` 取 revision 后 `mode done ROOM --revision <revision>`,效果相同)。到限则用 `room limits ROOM --check --as 本人`。有未处理留言、state 落后或仍有排队安排会被拒绝;状态变化后 revision 失效需重取。成功后房间回到自由协作,本次执行结束,不再补 chat 回执;已结束的讨论只有用户能重新开启。
99
-
100
- 最终总结按议题组织结论、不同意见、重要条件与未验证影响,不强制固定栏目。用户要求独立报告时再调用仓库的 discuss-report,报告不表示建议已获采纳。
101
-
102
- ## 保存进度
103
-
104
- 当判断、争点、下一步或阶段发生变化时,用一次 `state set ROOM --body-file ... --until ... --brief-file ... --as 本人` 同时保存:
105
-
106
- - state:足够恢复判断的依据与原文编号、已处理内容、未决问题和下一步意图。
107
- - 简报:让用户迅速了解当前结论、关键分歧和为什么接下来这样安排;不复述执行日志。
108
-
109
- `--until` 只覆盖实际已读材料。下一安排尚未创建时写成意图,实际编号与状态由服务端安排记录提供;恢复时两者对照,不为补写同一个编号再做一轮状态维护。无变化不写,已在当前上下文的材料不反复全文读取。保存失败先核对,不能一边失去接续依据一边继续派发。
110
-
111
- ## 停止
112
-
113
- 等待回答或一次回复结束不是整场结束。用户叫停、已暂停/收束或时间用尽时停止新增安排。执行投递重试用尽只会让对应请求失败并交回主持判断;不要据此自动暂停整场讨论。自动任务由接入程序续租和停止,不建原生定时任务。恢复须有明确继续指令。向用户报告结论或暂停点,以及影响协作的真实障碍。
1
+ # 讨论主持
2
+
3
+ 仅用于协作房间中的讨论主持任务。房间始终是自由协作(任务 `mode` 为 `free`);有讨论任务时,`discussion_request.purpose` 区分主持与专家请求,`session.phase` 给出讨论阶段。没有 `discussion_request` 时读 [自由协作主持](free.md)。
4
+
5
+ ## 讨论中的硬约束
6
+
7
+ 括号里是原因,遇到未覆盖的情况按原因判断。
8
+
9
+ 1. **一轮内每位专家各回应一次。** 不连续安排刚发言的专家,新一轮也不先安排上一轮最后发言者;只有需要其本人澄清刚发表内容的事实或原意时才例外,并在简报中说明原因。(避免对话变成两个人的往返,也保证每个人都被听到。)
10
+ 2. **不覆盖已有内容**:目标、背景、边界已有内容视为用户或此前主持的决定;只写用户原文能支持的内容,推断的前提注明“主持推断”,不添加用户未提出的硬性约束。
11
+
12
+ ## 自动主持流程:一次实质推进,一次交接
13
+
14
+ 1. **核验执行**:首次读到新任务时用 `execution view ROOM 执行编号 --access-file FILE` 核对它仍有效;执行已失效就停止。
15
+ 2. **恢复现场**:先读 `room view`,结合任务的 `discussion_request.purpose`、`session.phase`、当前安排、`queued_turns`、`limits` 和执行状态判断;网站已有的安排优先于旧笔记,没有待处理的专家安排不等于主持人没有工作。首次接手时目标、背景、边界为空,先补全(见「首次接手」)。
16
+ 3. **读新内容**:新增的相关回答、用户留言及必要原文。智能调度有把握时会代你完成插话分流和无点名追问的衔接,没把握时才唤醒你。
17
+ 若任务带 `scheduler_suggestions`,按请求与原消息编号读原文;未自动生效的建议不是既定安排,需要依据时用 `state decision ROOM DECISION_ID` 取回完整判断,再自行决定。“没有模型判断”或结果尚未核验时按字段如实处理。
18
+ 4. **判断并选一种交接**(见下表)。不要只回复“已就绪”或只写一段摘要就退出。
19
+ 5. **先保存进度,再提交交接**:判断、争点、下一步或阶段变化时用 `state set` 保存(见「保存进度」);随后创建安排或收束。成功后本次写入资格即结束:立即结束,只回一句话,此后只回读核验,不再补 state 或输出总结。结果不明按参与技能核对原提交,不另创安排。
20
+
21
+ | 判断 | 动作 |
22
+ |---|---|
23
+ | 还需专家回应 | 提交「发言计划」,结束本次执行 |
24
+ | 用户只追问某一成员的观点或负责内容,不改变讨论安排 | 先 `turn roster ROOM`,从 `host_requests` 核对请求编号和原消息,再 `turn ask ROOM --request ID --expert NAME --roster REV --submission-id UUID --as 本人`;可问已发言者或自己。这是普通提问,不计轮次、不改队列,只关闭所选请求。返回 `execution_completed=true` 即结束;否则继续处理本执行的其余请求 |
25
+ | 本轮仍有排队安排(`queued_turns` 非空) | 说明用户留言、改资料(`held_since` 非 0)或打断让本轮提前交回。先处理这些新内容,再读 `turn roster` 取得最新 `revision`;原计划仍适用就用该 revision 执行 `turn continue`,否则创建新安排,剩余排队随之作废 |
26
+ | 专家名单有变化 | 见「名单变化」 |
27
+ | 主持人需要以专家身份发言 | 给自己创建正式安排后结束;同一会话收到新的 `discuss` 专家任务,再用该执行提交 `chat` |
28
+ | 已可收束,或正在限时收束 | 见「收束」 |
29
+ | 缺少必要用户决定或无法继续 | 保留已有判断与具体阻碍,用 `execution fail ROOM 执行编号 --reason 原因 --access-file FILE` 结束本次主持请求,保留阻碍并等待用户决定;不要自动重启或循环唤醒主持 |
30
+
31
+ ### 发言计划
32
+
33
+ 每次需要专家回应时,先写好计划再一次提交,不逐位安排:
34
+
35
+ 1. 每次创建安排前都运行 `turn roster ROOM`,取得在场专家、本轮待发言者 `waiting`、`unregistered` 和最新 `revision`。
36
+ 2. 单人安排用 `turn create ROOM --expert NAME --question ... --roster <revision>`;整轮用 `turn create ROOM --plan-file PLAN.json --roster <revision>`。
37
+
38
+ 计划是按顺序列出本轮每位应发言专家各一次的数组,最多 10 项,例如:
39
+
40
+ ```json
41
+ [{"expert": "Claude", "question": "两种建议似乎采用了不同的失败容忍度。请判断这是否解释了分歧;若不是,指出决定选择的证据。"},
42
+ {"expert": "Antigravity", "question": "先读取最新回答,再对 Claude 的判断给出你的看法。"}]
43
+ ```
44
+
45
+ - 安排须带本次读取的 `revision`;整轮计划还须包含 `waiting` 中的每位专家。`unregistered` 中尚未注册的手动专家不能排入,它注册后会自动加入本轮队列。名单变化后 `revision` 失效需重取,缺人或未带 `revision` 都会被拒绝;本轮不需要某人时先让其暂离。
46
+ - 计划只覆盖当前一轮,跨轮追问要看本轮回答后再定;只有各轮问题不依赖回答的固定流程(如依次报数)才可排满多轮。后一位的问题取决于前一位回答时也不必等前一位答完:把问题写成“先读取最新回答,再按……回应”,由专家自己读取。
47
+ - 网站在专家间自动接力,末位答完才交回主持。用户留言或打断时服务端会自动把控制权交回主持。
48
+ - `members` 中 `adapter` 为 `api` 的专家没有命令行;房间有项目目录时它能用文件工具读取、搜索项目文件,用户给了可写权限时还能新建和编辑文件,没有项目目录时读不到本地文件。网站不记录它的项目权限,议题涉及本地文件时,开场主持说明写明相关文件路径和要点,让它自己读取;它说明读不到或索要信息时,由能读文件的专家补充,必要时贴关键片段。需要运行测试、构建或 git 的工作交给 CLI 专家。`initial` 中它同样按任务的 `read_until` 阅读房间消息。
49
+
50
+ ### 名单变化
51
+
52
+ 用户邀请、请回、暂离或移除专家后,以 `room view` 中最新的 `experts` 和 `queued_turns` 为准。
53
+
54
+ - 新邀请的专家:自动专家已追加到现有队列的末尾,手动专家注册后才加入;保留当前发言及原计划,不重排或重复安排。尚无安排时,把新专家放在本轮尚未发言的原专家之后。
55
+ - 暂离、移除或请回会作废原排队安排,再按最新名单重排。
56
+ - 新加入或请回的专家先补独立观点,再进入交锋。
57
+ - `members` 中 `session_mode` 为 `borrowed` 的专家续接用户已有的会话,带着自己的历史上下文:请它先说明哪些判断来自原有上下文,而不是把它当作从零开始的独立观点。
58
+
59
+ ### 处理异常
60
+
61
+ 任务有 `host_workflows: true`(房主打开了“主持人完整权限”)时,讨论中出现的问题你自己处理,命令与边界见 [自由协作主持](free.md) 的“房间控制”:
62
+
63
+ - 专家失败、卡住或收到“已 X 分钟没有新输出”的通知:先用 `room events`、`execution view`、`host-control details` 读原因;它在跑长任务就等,偏了用 `steer` 插话,卡死用 `stop` 打断,偶发失败用 `retry`,模型不可用用 `model` 换,无法恢复就 `remove` 后 `invite` 换人(新专家按上文“名单变化”先补独立观点)。
64
+ - 调整本轮顺序用 `host-control order`;名单变化后原排队作废,按最新 `turn roster` 重排。
65
+ - 需要暂停、继续或结束整场:`host-control discussion --do pause|resume|end --revision REV`;目标提前完成仍按“收束”用 `room limits --done`。
66
+
67
+ 讨论的轮数、时长上限由用户设定,你不能调高;到限由程序结束。没有该字段时,这些操作请用户在网页上做。
68
+
69
+ ## 推进判断
70
+
71
+ 探索可以保留多个方向,决策需要比较标准,事实问题需要可核验的证据;快速咨询不必套完整的全员流程。普通留言不默认打断当前专家,明确的暂停、继续或收束要求按现有规则执行;通过后续发言、主持简报或总结自然说明用户意见如何被处理,不为每条留言另发确认。
72
+
73
+ 默认先形成必要的独立观点,再围绕关键分歧深入讨论,按需复核并总结。阶段是阅读和判断边界,不是需要填满的轮次:
74
+
75
+ - `initial`:必要专家先独立判断。专家只读任务中给出的议题资料和服务端 `read_until` 允许的内容;安排问题里的文字不能放宽读取边界。主持人兼任专家时也要贡献自己的观点;尽量先安排自己,避免先读他人回答再声称独立。
76
+ - `discussing`:找出会改变结论的事实、假设、标准或偏好差异,请最相关的人回应;需要时用拟定结论请相关当事人核对原意、条件和剩余阻碍,不是让全员再赞同一次。
77
+ - `concluded`:已有足够依据完成本次目标,或依约交付有限结论。明确哪些已确认、哪些仍有分歧、缺什么证据;到限不等于达成共识。
78
+
79
+ 房间的 `named_followup`(点名追问)为 false 时,首轮照常拆题出题;之后各轮不给每位专家写针对性追问,统一请专家先读上一轮回答、回应不同意或需补充的观点、可 @ 彼此提问。需要引导方向时用自己的主持说明表达,不改写成逐人点名。用户开启智能调度时,这类衔接可能已由程序代你完成(对话中有“智能调度”记录),你被唤醒时多半是需要收束、跑偏、卡住或处理用户留言。
80
+
81
+ 追问应说明真正需要弄清的争点及材料入口,给专家自主质疑前提、发现遗漏和改变观点的空间,不预设答案或固定逐项答题格式。
82
+
83
+ 核对会改变结论的数字、出处与适用条件,区分实测、外部声称、估计和建议。误述原意时请原作者澄清;另一人的赞同不能代替当事人确认。保留少数意见及其影响,不为了收束抹去限制,也不为凑轮次制造争论。重复立场时缩小争点或补有区分力的证据,仍无法判断就明确保留未决。
84
+
85
+ ## 工作流
86
+
87
+ 本次任务有 `workflow` 字段时,按其工作流说明安排发言:本地软件读 `skills` 末尾的 SKILL.md,API 主持人读本次系统提示中的正文。规则见 [自由协作主持](free.md) 的“工作流”一节;没有该字段时不按任何工作流做事。
88
+
89
+ ## 首次接手
90
+
91
+ 网页新建只要求话题名称与用户原文,目标、背景和讨论边界留给主持人。首次接手时若这些字段为空,先读用户原文与已知材料补全,用 `room update ROOM --goal-file ... --background-file ... --boundaries-file ... --as 本人`(凭据方式遵循参与技能)一次保存,再安排专家。只有歧义会改变讨论方向时才向用户确认。
92
+
93
+ ## 上限与到限
94
+
95
+ 轮次由程序按专家发言量统计并判定到限(`limits.segment_rounds` / `round_cap`),不要用自己的计划结构推算;暂停、准备与收束期间不计时。
96
+
97
+ 开场安排第一位专家前,先核对目标与上限:若按目标推算,本次轮次或时长上限显然不够完成(如要求报数到 10,3 人 3 轮只有 9 次发言),先发一条主持消息告诉用户冲突所在、上限内能做到哪一步,以及调整的办法(结束后在中间区点“继续讨论”设新上限);然后在上限内照常推进,不等待答复,也不自行放宽上限。只写进 state 或简报不算告知。
98
+
99
+ 到限或目标完成后讨论结束、房间回到自由协作,是否再开启由用户决定:
100
+
101
+ - 到限:用 `room limits ROOM --check --as 本人`(凭据方式遵循参与技能)处理。返回后房间已回到自由协作,本次执行随之换代,停止即可;之后用户的未点名留言照常交给你处理。用户要总结时,按「收束」写出最终 `moderation`。
102
+ - 目标提前完成:先保存有原文依据的结论、state 与简报,再 `room conclusion` 取得 `revision`,用 `room limits ROOM --done --conclusion <revision> --as 本人` 通知程序(未带、过期或有未处理留言、state 落后都会被拒绝)。
103
+
104
+ ## 收束
105
+
106
+ 1. 保存有原文依据的最终判断、state 与简报(`until` 须覆盖实际读过的最新材料);需要留主持说明时按参与技能写入。主持说明本身不结束讨论。
107
+ 2. `room conclusion ROOM` 申请收束状态,得到阻碍项 `blockers` 与 `revision`。
108
+ 3. 目标已完成且无阻碍时,用 `room limits ROOM --done --conclusion <revision> --as 本人` 收束(也可 `mode view ROOM` 取 revision 后 `mode done ROOM --revision <revision>`,效果相同)。到限则用 `room limits ROOM --check --as 本人`。有未处理留言、state 落后或仍有排队安排会被拒绝;状态变化后 revision 失效需重取。成功后房间回到自由协作,本次执行结束,不再补 chat 回执;已结束的讨论只有用户能重新开启。
109
+
110
+ 最终总结按议题组织结论、不同意见、重要条件与未验证影响,不强制固定栏目。用户要求独立报告时再调用仓库的 discuss-report,报告不表示建议已获采纳。
111
+
112
+ ## 保存进度
113
+
114
+ 当判断、争点、下一步或阶段发生变化时,用一次 `state set ROOM --body-file ... --until ... --brief-file ... --as 本人` 同时保存:
115
+
116
+ - state:足够恢复判断的依据与原文编号、已处理内容、未决问题和下一步意图。
117
+ - 简报:让用户迅速了解当前结论、关键分歧和为什么接下来这样安排;不复述执行日志。
118
+
119
+ `--until` 只覆盖实际已读材料。下一安排尚未创建时写成意图,实际编号与状态由服务端安排记录提供;恢复时两者对照,不为补写同一个编号再做一轮状态维护。无变化不写,已在当前上下文的材料不反复全文读取。保存失败先核对,不能一边失去接续依据一边继续派发。
120
+
121
+ ## 停止
122
+
123
+ 等待回答或一次回复结束不是整场结束。用户叫停、已暂停/收束或时间用尽时停止新增安排。执行投递重试用尽只会让对应请求失败并交回主持判断;不要据此自动暂停整场讨论。自动任务由接入程序续租和停止,不建原生定时任务。恢复须有明确继续指令。向用户报告结论或暂停点,以及影响协作的真实障碍。
@@ -30,6 +30,32 @@
30
30
  - 同一条用户消息最多推进若干轮(`room view` 的 `free.max_rounds`,0 为不限;本链已用与上限见 `free.chain_rounds` 的 `used` / `allowed`,`allowed` 为 null 即不限,用户可调):你派一次活算一轮,依次接力一圈算一轮,`--continue` 的续跑也算一轮,成员做完交回给你汇总不另计;程序发现问题的通知不计轮数,到上限后照样交给你。到上限时你的下一次派活会被暂存、任务链停下,由用户决定继续或发新消息;你不能自己加轮。派活前先想清楚需要几位、几轮,最后一轮留给汇总。(上限防止 AI 之间无人值守地来回转交。)
31
31
  - 已知整项任务所需轮数超过剩余轮数时(连写几集、多步实现加评审),在开始派活时带 `--need-rounds N --rounds-reason "…"`(N 为从这次派活起还需要的轮数,含最后汇总):剩余不够时任务链立即停下,用户看到理由并一键批准差额后才发出这次派活,之后不再中途停下;够用时照常派出。不要在回复里让用户改上限或回“继续”,也不要建议超出 `need-rounds` 上限(100)的数值。
32
32
 
33
+ ## 房间控制
34
+
35
+ 任务有 `host_workflows: true` 时,房主为本房间打开了“主持人完整权限”:房间里出了问题、或推进需要的控制,你自己处理,不请用户代劳。命令都是 `<cli> launch host-control 操作 … --access-file <本次任务的 access_file>`,成员用 `--member`(成员 ID 或显示名)。
36
+
37
+ - **先诊断**:成员失败、卡住或结果异常时,先读原因再动手:`<cli> room events ROOM` 看写入与状态变化(你看不到私聊和消息正文,原文用 message 命令读),`<cli> execution view 执行编号` 看服务端详情与失败原因,`host-control details --execution 编号` 看该次执行的本机过程(命令、输出、改动)。
38
+ - **再恢复**:
39
+ - 偏了方向或要补充:`steer --member 成员 --text 话` 向它进行中的执行插话;不需要打断时优先用它。
40
+ - 卡住或做错:`stop --member 成员` 打断(之后到的请求留到它下一次执行);`stop-all` 停止全部执行、请求与接力(讨论进行中时用 `discussion`)。
41
+ - 偶发失败(网络、超时、软件崩溃):`retry --execution 编号` 重试。
42
+ - 模型限流或不可用:`model --member 成员 --model M [--effort E]` 换模型;Codex 等软件的 App 卡住时 `adapter --member 成员 --adapter ID` 切到同一会话的 CLI(成员空闲时才能切)。
43
+ - 成员无法恢复:`remove` 后 `invite` 换一位。
44
+ - 撤回自己派出的活(接力或循环停不下来、方向错了、用户改了主意):`withdraw --message 派活消息编号`,接力或循环立即停下,未开始的请求取消,进行中的那一棒停止,不再交回你。
45
+ - 收到“执行 #N 已 X 分钟没有新输出”的系统通知时,先 `details` 判断它是在跑长任务还是卡住,再决定等待、插话、打断或重试。
46
+ - **调整成员**:`invite --name 平台 [--access read|write] [--model M] [--effort E]` 用本机已配置的软件邀请(返回新成员名,本次执行里就能点名);`away` / `remove` / `back --member 成员` 暂离、移除、请回;`access --member 成员 --access read|write` 调整项目权限;`label --member 成员 --label 显示名 [--description 描述]`。
47
+ - **推进**:
48
+ - 发言顺序:先 `order` 读取当前顺序和 `revision`,再 `order --order 甲,乙,丙 --revision REV`,列出全部尚未发言的成员。
49
+ - 讨论:先 `discussion` 读取状态和 `revision`,再 `discussion --do start --goal 目标 --revision REV`(上限用房间默认值)或 `--do pause|resume|end --revision REV`。
50
+ - Goal:任务需要在房间空闲时持续自动推进、且目标和完成标准明确时,先 `goal` 读取状态和 `revision`,再 `goal --do start --goal 目标 --revision REV`(预算用房主默认值,你不能改);`--do pause|resume|end` 控制。
51
+
52
+ - **兜底**:派出要跑较久的活(多集、多轮接力、循环评审)时,`checkin --minutes N`(5–120)让程序在房间还有进行中的工作时每 N 分钟给你一次“定时检查”,你据此确认没有卡住、跑偏或失败未处理;房间空闲、停止全部或换主持人后自动结束,`checkin --off` 提前关闭。定时检查要在你处理任务(有进行中的执行)时设置。另外,成员连续 10 分钟没有新输出时,程序会单独通知你。
53
+ - **主动发言**:不是房间派来的任务、而是你需要主动说明或派活时(如定时检查发现问题、用户在你自己的窗口里要你发言),用参与技能“主动发言”一节的 `message post … --proactive`,主持人的主动发言可以同时 `--to` 派活或 `--continue`。
54
+
55
+ `revision` 过期会被拒绝,重新读取后再提交。用户要求停下、换人或调整顺序时直接执行;你自己发现问题时先诊断再处理。每次控制都在回答里说明做了什么、为什么。以下仍由用户决定,需要时在回答里提出:对你自己操作或更换主持人,批准超出上限的轮数,项目权限高于可写或放行被拒绝的工具,改项目目录,撤回、回退用户的消息,私聊。你自己的执行失败由用户处理。
56
+
57
+ 没有 `host_workflows` 字段,或你是 API 主持人(没有命令行)时,这些操作请用户在网页上做,或请用户在电脑上“成员与设置 → 协作设置”打开“主持人完整权限”。事件记录和执行详情不需要该开关,主持人都能读。
58
+
33
59
  用户请成员分工改代码(定计划、实现、评审、整理文档)时,先读 [开发协作](dev-flow.md)。
34
60
 
35
61
  ## 工作流
@@ -40,4 +66,4 @@
40
66
 
41
67
  用户请你把本房间的做法存成工作流(或要你按某种分工新建一套流程)时,按 [workflow-creator](../../workflow-creator/SKILL.md) 技能做。任务有 `host_workflows: true` 时,房主已允许你自行保存并启用,不必等用户导入。
42
68
 
43
- 用户请你启用工作流库里已有的工作流(如“启用工作流「连载」(`novel-serial`)”)时:任务有 `host_workflows: true` 就运行 `<cli> launch workflow-adopt --id <工作流 ID> --access-file <本次任务的 access_file>`,程序在本房间启用它、邀请缺少的卡片成员(离开的请回),返回 `members`(卡片 ID → 成员名)和 `skill_path`;本次执行要接着按它做时读 `skill_path`,下一次执行起任务里有 `workflow` 字段,按上文使用。用户只说了启用、没给具体任务时,回答说明已启用和带来了谁即可。没有该字段,或你是 API 主持人(没有命令行)时,不能启用:在回答里请用户在电脑上“成员与设置 → 协作设置”打开“允许主持人自建工作流”后再让你启用。
69
+ 用户请你启用工作流库里已有的工作流(如“启用工作流「连载」(`novel-serial`)”)时:任务有 `host_workflows: true` 就运行 `<cli> launch workflow-adopt --id <工作流 ID> --access-file <本次任务的 access_file>`,程序在本房间启用它、邀请缺少的卡片成员(离开的请回),返回 `members`(卡片 ID → 成员名)和 `skill_path`;本次执行要接着按它做时读 `skill_path`,下一次执行起任务里有 `workflow` 字段,按上文使用。用户只说了启用、没给具体任务时,回答说明已启用和带来了谁即可。没有该字段,或你是 API 主持人(没有命令行)时,不能启用:在回答里请用户在电脑上“成员与设置 → 协作设置”打开“主持人完整权限”后再让你启用。
@@ -88,7 +88,7 @@ project_access: write
88
88
  | 你在哪里 | 怎么保存 |
89
89
  |---|---|
90
90
  | 房间主持人,任务里有 `host_workflows: true` | 房主已允许你自建:把草稿写成一个文件(下方“草稿格式”,只用一段 `1/1`),放在任务的 `draft_directory` 下,运行 `<cli> launch workflow-adopt --draft <文件> --access-file <本次任务的 access_file>`。程序校验、保存、在本房间启用并邀请缺少的卡片成员(离开的请回),返回 `members`(卡片 ID → 成员名),本次执行里就能点名派活。只能更新本房间绑定的工作流;卡片权限最高 write。 |
91
- | 房间主持人,任务里没有该字段 | 你不能保存。告诉用户:在“成员与设置 → 协作设置”打开“允许主持人自建工作流”后,你就能自己保存并启用;用户打开后再按上一行做。 |
91
+ | 房间主持人,任务里没有该字段 | 你不能保存。告诉用户:在“成员与设置 → 协作设置”打开“主持人完整权限”后,你就能自己保存并启用;用户打开后再按上一行做。 |
92
92
  | 不在房间里,用户直接让你建 | 先在一个临时文件夹里按“格式”写好,运行 `codiscuss launch workflow-check --dir <文件夹>` 校验;用户确认后运行 `codiscuss launch workflow-save --dir <文件夹>` 保存进工作流库。同 ID 会覆盖原有工作流,覆盖前先告诉用户。 |
93
93
 
94
94
  不要直接写入本机服务目录的 `workflows/`:那样会跳过校验和写入锁,格式错的文件会让启用它的房间读不到工作流。命令失败时按报错修正,不要绕过。
@@ -126,6 +126,10 @@ UNLIMITED_ROUNDS = 0
126
126
  # What the program notices for the host (a failure, a possible conflict, its own "continue when idle") hangs on a visible
127
127
  # message from this author. Only the host's own continue costs a round of its chain; an event is free, once per event.
128
128
  NOTICE_AUTHOR = "系统"
129
+ # A member's execution with no new output for this long is reported to the host once per quiet spell; nothing is stopped.
130
+ STALL_SECONDS = 600
131
+ # Bounds of the host's timed check-in interval, in minutes.
132
+ CHECKIN_MINUTES = (5, 120)
129
133
  MAX_CONTINUE_REASON = 300
130
134
  # A host that left gives up the role; the first active runner member (else manual) then takes it.
131
135
  RELEASE_HOST = "UPDATE room_members SET role='member' WHERE room_id=? AND role='host' AND status<>'active'"
@@ -676,7 +680,7 @@ def execution_timeline(connection, room_id, after=0):
676
680
 
677
681
 
678
682
  def room_event_log(connection, room_id, token, after=0, before=0):
679
- """Read the owner's append-only room event log written by the schema triggers.
683
+ """Read the room's append-only event log written by the schema triggers; the owner or the present host.
680
684
 
681
685
  Args:
682
686
  after: return events with a larger id (live follow), oldest first.
@@ -688,7 +692,7 @@ def room_event_log(connection, room_id, token, after=0, before=0):
688
692
  A write request (`api.*`) is stored after it finishes, so its id follows the changes it caused; its `at` is
689
693
  when it arrived, and ordering by `at` puts the input before its effects.
690
694
  """
691
- require_owner(connection, room_id, token)
695
+ host = require_owner_or_host(connection, room_id, token)
692
696
  after, before = max(0, int(after)), max(0, int(before))
693
697
  if after:
694
698
  rows = connection.execute("SELECT id,at,kind,data FROM room_events WHERE room_id=? AND id>? ORDER BY id LIMIT ?",
@@ -701,10 +705,28 @@ def room_event_log(connection, room_id, token, after=0, before=0):
701
705
  "SELECT 1 FROM room_events WHERE room_id=? AND id<? LIMIT 1", (room_id, first_id)).fetchone() is not None
702
706
  members = {str(row["id"]): row["name"] for row in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (room_id,))}
703
707
  events = sorted(({**dict(row), "data": json.loads(row["data"])} for row in rows), key=lambda event: (event["at"], event["id"]))
708
+ if host:
709
+ events = [host_event(event) for event in events if not host_hidden_event(event)]
704
710
  return {"events": events, "members": members, "more": more, "first_id": first_id,
705
711
  "last_id": max((row["id"] for row in rows), default=after)}
706
712
 
707
713
 
714
+ def host_hidden_event(event):
715
+ """Private chats stay the owner's: the host's read of the event log leaves out their writes."""
716
+ path = str(event["data"].get("path", "")) if event["kind"] == "api.write" else ""
717
+ return "/chat" in path
718
+
719
+
720
+ def host_event(event):
721
+ """The host's copy of an event without message text, so withdrawn messages stay unreadable; it reads text through message commands."""
722
+ data = dict(event["data"])
723
+ if event["kind"] == "message.posted":
724
+ data.pop("text", None)
725
+ if event["kind"] == "api.write" and "/messages" in str(data.get("path", "")):
726
+ data.pop("payload", None)
727
+ return {**event, "data": data}
728
+
729
+
708
730
  def recent_executions(connection, room_id):
709
731
  """Return bounded public reports, including requests no longer in the recent request list."""
710
732
  rows = connection.execute(f"""SELECT e.*,m.name AS member,
@@ -835,7 +857,9 @@ def free_view(connection, room_id):
835
857
  asks = {row["chain_id"]: {"asked_rounds": row["asked_rounds"], "asked_reason": row["asked_reason"]} for row in connection.execute(
836
858
  "SELECT chain_id,asked_rounds,asked_reason FROM free_chain_holds WHERE room_id=? AND asked_rounds>0", (room_id,))}
837
859
  chains = [{**chain, **asks.get(chain["chain_id"], {})} for chain in chains]
838
- return {"members": members, "requests": requests[::-1], "executions": recent_executions(connection, room_id),
860
+ checkin = connection.execute("SELECT interval_seconds,due_at FROM free_host_checkins WHERE room_id=?", (room_id,)).fetchone()
861
+ return {"host_checkin": {"minutes": checkin["interval_seconds"] // 60, "due_at": checkin["due_at"]} if checkin else None,
862
+ "members": members, "requests": requests[::-1], "executions": recent_executions(connection, room_id),
839
863
  "conflicts": possible_conflicts(connection, room_id, names), "halted_chains": chains, "max_rounds": max_rounds,
840
864
  "chain_rounds": progress, "recommendations": recent_recommendations(connection, room_id, now)[::-1],
841
865
  "queued_messages": queue_view(connection, room_id)}
@@ -884,6 +908,18 @@ def register_member(connection, room_id, token, now):
884
908
  return {"member": member["name"], "registered_at": stamp, "session_client": reported[0] if reported else ""}
885
909
 
886
910
 
911
+ def require_owner_or_host(connection, room_id, token):
912
+ """Allow the owner, or the room's present host reading to diagnose and steer the room; returns the host row or None."""
913
+ try:
914
+ require_owner(connection, room_id, token)
915
+ return None
916
+ except PermissionError:
917
+ member = require_member(connection, room_id, token)
918
+ if member["role"] != "host":
919
+ raise PermissionError("只有房主或当前主持人可以读取") from None
920
+ return member
921
+
922
+
887
923
  def authorize_free_write(connection, room_id, token, operation, payload):
888
924
  """Decide who is writing: the owner, or one member posting its own answer.
889
925
 
@@ -1776,6 +1812,26 @@ def expire_executions(connection, room_id):
1776
1812
  settle_batches(connection, room_id)
1777
1813
 
1778
1814
 
1815
+ def notice_stalled_executions(connection, room_id):
1816
+ """Tell the host about members whose software has shown no new output for STALL_SECONDS.
1817
+
1818
+ Executions have no time limit and the runner keeps renewing a hung one, so nothing else would notice. The host
1819
+ decides whether to wait, steer, interrupt or replace; its own executions are left out, since it cannot read a notice
1820
+ while it runs. A later quiet spell after new output is reported again.
1821
+ """
1822
+ now = int(time.time())
1823
+ rows = connection.execute("""SELECT e.id,e.activity_at,e.created_at,m.name,
1824
+ (SELECT MAX(chain_id) FROM member_requests WHERE execution_id=e.id) AS chain_id
1825
+ FROM member_executions e JOIN room_members m ON m.id=e.member_id
1826
+ WHERE e.room_id=? AND e.status IN ('dispatched','started') AND m.role<>'host' AND MAX(e.activity_at,e.created_at)<=?""",
1827
+ (room_id, now - STALL_SECONDS)).fetchall()
1828
+ for row in rows:
1829
+ quiet = max(row["activity_at"], row["created_at"])
1830
+ text = (f"{row['name']} 的执行 #{row['id']} 已 {(now - quiet) // 60} 分钟没有新输出,可能卡住。"
1831
+ f"先读这次执行的详情判断,再决定等待、插话、打断、重试或换人。")
1832
+ wake_host(connection, room_id, row["chain_id"], text, f"stall:{row['id']}:{quiet}")
1833
+
1834
+
1779
1835
  def record_cancel_reasons(connection, where, params, reason):
1780
1836
  """Remember why requests are about to be cancelled; call before the status change selects them."""
1781
1837
  connection.execute(f"INSERT OR REPLACE INTO request_cancel_reasons (request_id,reason) SELECT id,? FROM member_requests WHERE {where}", (reason, *params))
@@ -1853,18 +1909,93 @@ def stop_room_work(connection, room_id, reason, cancel_reason):
1853
1909
  finish_execution(connection, row["id"], "cancelled", reason, "cancelled", cancel_reason)
1854
1910
  record_cancel_reasons(connection, "room_id=? AND status='pending'", (room_id,), cancel_reason)
1855
1911
  connection.execute("UPDATE member_requests SET status='cancelled' WHERE room_id=? AND status='pending'", (room_id,))
1856
- # Stopping everything also stops a goal, or the host's own "continue", from waking the host again.
1912
+ # Stopping everything also stops a goal, the host's own "continue" or its timed check-in from waking the host again.
1857
1913
  connection.execute("DELETE FROM free_host_continues WHERE room_id=?", (room_id,))
1914
+ connection.execute("DELETE FROM free_host_checkins WHERE room_id=?", (room_id,))
1858
1915
  connection.execute("UPDATE room_goals SET status='paused',paused_at=? WHERE room_id=? AND status='active'", (int(time.time()), room_id))
1859
1916
  settle_batches(connection, room_id)
1860
1917
 
1861
1918
 
1919
+ def withdraw_dispatch(connection, room_id, token, message_id):
1920
+ """Take back one of the present host's dispatches: its relay or loop stops, waiting steps are cancelled, running ones
1921
+ stopped, and nothing hands back. The runner calls this with the owner credential for a host granted full control.
1922
+ """
1923
+ require_owner(connection, room_id, token)
1924
+ begin_write(connection)
1925
+ message = connection.execute("SELECT author FROM messages WHERE room_id=? AND id=?", (room_id, message_id)).fetchone()
1926
+ if not message:
1927
+ raise LookupError("消息不存在")
1928
+ host = connection.execute("SELECT name FROM room_members WHERE room_id=? AND role='host' AND status='active'", (room_id,)).fetchone()
1929
+ if not host or message["author"] != host["name"]:
1930
+ raise ValueError("只能撤回当前主持人自己的派活消息;用户的消息由用户撤回")
1931
+ sequences = "room_id=? AND message_id=? AND status IN ('running','checkpoint')"
1932
+ batches = [message_id] + [row[0] for row in connection.execute(f"SELECT batch_id FROM free_sequences WHERE {sequences}", (room_id, message_id))]
1933
+ connection.execute(f"UPDATE free_sequences SET status='stopped' WHERE {sequences}", (room_id, message_id))
1934
+ marks = ",".join("?" * len(batches))
1935
+ live = connection.execute(f"""SELECT DISTINCT e.member_id FROM member_requests r JOIN member_executions e ON e.id=r.execution_id
1936
+ WHERE r.room_id=? AND r.batch_id IN ({marks}) AND e.status IN ('dispatched','started')""", (room_id, *batches)).fetchall()
1937
+ for row in live:
1938
+ stop_member_work(connection, row[0], "主持人撤回派活", "user_stop", keep_pending=True)
1939
+ waiting = f"room_id=? AND batch_id IN ({marks}) AND status='pending'"
1940
+ record_cancel_reasons(connection, waiting, (room_id, *batches), "user_stop")
1941
+ connection.execute(f"UPDATE member_requests SET status='cancelled' WHERE {waiting}", (room_id, *batches))
1942
+ connection.execute(f"UPDATE free_batches SET settled=1 WHERE room_id=? AND batch_id IN ({marks})", (room_id, *batches))
1943
+ return free_view(connection, room_id)
1944
+
1945
+
1946
+ def set_host_checkin(connection, room_id, token, payload):
1947
+ """The host (through the runner, owner credential) asks to be checked in on every `minutes` while work is in flight,
1948
+ or turns it off. Bound to the chain of the host's running execution, so it must be set while the host works.
1949
+ """
1950
+ require_owner(connection, room_id, token)
1951
+ begin_write(connection)
1952
+ if payload.get("off") is True:
1953
+ connection.execute("DELETE FROM free_host_checkins WHERE room_id=?", (room_id,))
1954
+ return {"checkin": None}
1955
+ minutes = payload.get("minutes")
1956
+ low, high = CHECKIN_MINUTES
1957
+ if type(minutes) is not int or not low <= minutes <= high:
1958
+ raise ValueError(f"minutes 须为 {low}–{high} 的整数")
1959
+ row = connection.execute("""SELECT m.id AS host_id,(SELECT MAX(r.chain_id) FROM member_requests r JOIN member_executions e ON e.id=r.execution_id
1960
+ WHERE e.member_id=m.id AND e.status IN ('dispatched','started')) AS chain_id
1961
+ FROM room_members m WHERE m.room_id=? AND m.role='host' AND m.status='active'""", (room_id,)).fetchone()
1962
+ if not row or not row["chain_id"]:
1963
+ raise FileExistsError("主持人没有进行中的执行;请在处理任务时设置定时检查")
1964
+ due_at = int(time.time()) + minutes * 60
1965
+ connection.execute("""INSERT INTO free_host_checkins (room_id,host_id,chain_id,interval_seconds,due_at) VALUES (?,?,?,?,?)
1966
+ ON CONFLICT(room_id) DO UPDATE SET host_id=excluded.host_id,chain_id=excluded.chain_id,
1967
+ interval_seconds=excluded.interval_seconds,due_at=excluded.due_at""", (room_id, row["host_id"], row["chain_id"], minutes * 60, due_at))
1968
+ return {"checkin": {"minutes": minutes, "due_at": due_at}}
1969
+
1970
+
1971
+ def fire_host_checkin(connection, room_id):
1972
+ """Hand the host its timed check-in when due and work is still in flight; an idle room or a changed host ends it."""
1973
+ row = connection.execute("SELECT * FROM free_host_checkins WHERE room_id=?", (room_id,)).fetchone()
1974
+ now = int(time.time())
1975
+ if not row or row["due_at"] > now:
1976
+ return
1977
+ host = connection.execute("SELECT id FROM room_members WHERE room_id=? AND role='host' AND status='active'", (room_id,)).fetchone()
1978
+ busy = connection.execute("""SELECT 1 FROM member_requests WHERE room_id=? AND status IN ('pending','delivering') AND member_id<>?
1979
+ UNION SELECT 1 FROM free_sequences WHERE room_id=? AND status='running'""", (room_id, row["host_id"], room_id)).fetchone()
1980
+ if not host or host["id"] != row["host_id"] or not busy:
1981
+ connection.execute("DELETE FROM free_host_checkins WHERE room_id=?", (room_id,))
1982
+ return
1983
+ connection.execute("UPDATE free_host_checkins SET due_at=? WHERE room_id=?", (now + row["interval_seconds"], room_id))
1984
+ host_working = connection.execute("SELECT 1 FROM member_executions WHERE member_id=? AND status IN ('dispatched','started')",
1985
+ (row["host_id"],)).fetchone()
1986
+ if not host_working:
1987
+ wake_host(connection, room_id, row["chain_id"], "定时检查:房间里还有进行中的工作。读一下 room view 与各成员的执行进展,"
1988
+ "确认没有卡住、跑偏或失败未处理;一切正常就简短说明并结束本次执行。", f"checkin:{row['due_at']}")
1989
+
1990
+
1862
1991
  def inbox(connection, room_id, token):
1863
1992
  """List what the runner should deliver or reconcile; owner credential only."""
1864
1993
  require_owner(connection, room_id, token)
1865
1994
  begin_write(connection)
1866
1995
  enforce_session_limits(connection, room_id)
1867
1996
  expire_executions(connection, room_id)
1997
+ notice_stalled_executions(connection, room_id)
1998
+ fire_host_checkin(connection, room_id)
1868
1999
  release_recommendations(connection, room_id)
1869
2000
  archived = connection.execute("SELECT archived FROM room_directory WHERE room_id=?", (room_id,)).fetchone()[0]
1870
2001
  members, observers = [], []
@@ -2159,7 +2290,7 @@ def execution_detail(connection, row):
2159
2290
 
2160
2291
 
2161
2292
  def view_execution(connection, room_id, execution_id, token):
2162
- """Let the owner or the executing member read one execution."""
2293
+ """Let the owner, the present host (to diagnose a member's failure) or the executing member read one execution."""
2163
2294
  row = connection.execute("SELECT * FROM member_executions WHERE room_id=? AND id=?", (room_id, execution_id)).fetchone()
2164
2295
  if not row:
2165
2296
  raise LookupError("执行不存在")
@@ -2167,7 +2298,7 @@ def view_execution(connection, room_id, execution_id, token):
2167
2298
  require_owner(connection, room_id, token)
2168
2299
  except PermissionError:
2169
2300
  member = require_member(connection, room_id, token)
2170
- if member["id"] != row["member_id"]:
2301
+ if member["id"] != row["member_id"] and member["role"] != "host":
2171
2302
  raise
2172
2303
  return execution_detail(connection, row)
2173
2304
 
@@ -722,6 +722,15 @@ CREATE TABLE IF NOT EXISTS free_host_wakes (
722
722
  );
723
723
  -- The host's latest answer asked to continue once the room is idle; a later request for the host or a new owner
724
724
  -- message supersedes it (`after_request` is the newest request when it was asked).
725
+ -- The host's timed check-in: while the room still has work in flight, the host gets a notice every interval to look
726
+ -- for stuck or drifting work; it ends by itself once the room is idle, on stop-all, or when the host changes.
727
+ CREATE TABLE IF NOT EXISTS free_host_checkins (
728
+ room_id TEXT PRIMARY KEY REFERENCES rooms(id) ON DELETE CASCADE,
729
+ host_id INTEGER NOT NULL,
730
+ chain_id INTEGER NOT NULL,
731
+ interval_seconds INTEGER NOT NULL,
732
+ due_at INTEGER NOT NULL
733
+ );
725
734
  CREATE TABLE IF NOT EXISTS free_host_continues (
726
735
  room_id TEXT PRIMARY KEY REFERENCES rooms(id) ON DELETE CASCADE,
727
736
  host_id INTEGER NOT NULL,
package/backend/server.py CHANGED
@@ -33,7 +33,7 @@ from .free_room import (add_member, after_free_message, after_proactive, authori
33
33
  from .free_room import convert_suggestion, message_suggestions, save_suggestions, sync_participants
34
34
  from .free_room import adopt_recommendation, recommend_member, register_member, require_member
35
35
  from .private_chat import chat_action, member_chat
36
- from .free_room import describe_members, execution_notes, label_members, member_descriptions, member_display_names, member_labels, reorder_speakers, resolve_member_fields, set_chain_rounds, speaking_order, stop_room, stop_room_work
36
+ from .free_room import describe_members, execution_notes, label_members, set_host_checkin, withdraw_dispatch, member_descriptions, member_display_names, member_labels, reorder_speakers, resolve_member_fields, set_chain_rounds, speaking_order, stop_room, stop_room_work
37
37
  from .free_room import (ADAPTER_VALUE, ANCHOR_SQL, continue_chain, execution_timeline, group_route_plan, retract_message, rewind_plan, rewind_room,
38
38
  room_event_log, withdrawals)
39
39
  from .free_room import review_capability, review_roster, checked_review_plan, save_review_plan, check_review_retry
@@ -1939,6 +1939,11 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1939
1939
  with closing(connect_db(self.server.database)) as connection:
1940
1940
  self.admin_as_owner(connection, payload.get("room_id", ""))
1941
1941
  payload["token"] = self.headers.get("X-Discussion-Token", "")
1942
+ # A fork from a browser without the source room's credential acts as its owner, as the commands above do.
1943
+ if command == "room-fork" and not payload.get("source_token") and not self.bridge_origin:
1944
+ with closing(connect_db(self.server.database)) as connection:
1945
+ self.admin_as_owner(connection, payload.get("source_room_id", ""))
1946
+ payload["source_token"] = self.headers.get("X-Discussion-Token", "")
1942
1947
  if command == "machine-link":
1943
1948
  payload = {"site": site}
1944
1949
  if command == "room-config" and "participants" in payload:
@@ -2303,7 +2308,7 @@ class DiscussionHandler(BaseHTTPRequestHandler):
2303
2308
  result = queue_action(connection, room_id, token, self.read_payload(), post_queued, check_queued, interrupt_for_message)
2304
2309
  connection.commit()
2305
2310
  return self.respond(result)
2306
- if parts[3:4] in (["inbox"], ["executions"], ["members"], ["chats"], ["suggestions"], ["recommendations"], ["stop"]):
2311
+ if parts[3:4] in (["inbox"], ["executions"], ["members"], ["chats"], ["suggestions"], ["recommendations"], ["stop"], ["dispatches"], ["host-checkin"]):
2307
2312
  return self.handle_free_api(connection, parts, method)
2308
2313
  if parts[3:4] == ["attachments"]:
2309
2314
  return self.handle_attachments(connection, parts, params, method)
@@ -2512,6 +2517,10 @@ class DiscussionHandler(BaseHTTPRequestHandler):
2512
2517
  result = member_capability(connection, room_id, int(parts[4]), token, self.read_payload())
2513
2518
  elif parts[3:] == ["stop"] and method == "POST":
2514
2519
  result = stop_room(connection, room_id, token)
2520
+ elif len(parts) == 6 and parts[3] == "dispatches" and parts[4].isdigit() and parts[5] == "withdraw" and method == "POST":
2521
+ result = withdraw_dispatch(connection, room_id, token, int(parts[4]))
2522
+ elif parts[3:] == ["host-checkin"] and method == "POST":
2523
+ result = set_host_checkin(connection, room_id, token, self.read_payload())
2515
2524
  else:
2516
2525
  raise LookupError("地址不存在")
2517
2526
  drain_room_queue(connection, room_id)