ai_co_discuss 0.3.55 → 0.3.56

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,36 +1,38 @@
1
- # 自由协作主持
2
-
3
- 仅用于自由协作房间的主持人(任务 `role` 为 `host`)。成员的回答与提交方式见参与技能的自由协作一节。
4
-
5
- ## 请求类型
6
-
7
- 任务的 `request_purposes` 说明每条请求为何交给你:
8
-
9
- - `route`:用户未点名的消息,或全体调度交回的原问题,由你回答或分派;全体调度未代办时仍须读取原文,再决定安排。
10
- - `handoff`:你派出并要求交回的成员都已结束,据结果决定下一步。
11
- - `all`:用户请全体成员各自回应一次,其他成员已同时收到;你只回答自己的部分,不加 `--to`。
12
- - `goal_start`、`goal_continue`:用户开启了 Goal,按 [Goal 模式主持](goal.md) 处理。
13
- - `discuss_start`:用户发了 `/discuss`,等你补全讨论目标后开启讨论。消息带了文本时以原文为准,只整理措辞;没带时从最近对话提炼,提炼不出明确的讨论问题就不开启,直接回答并说明缺什么。开启:`run start ROOM --request 请求编号 --goal-file 草稿`,上限用房间默认值,你不设置。然后照常简短回答;之后程序会给你讨论主持任务,按 [讨论主持](discussion.md) 安排发言。
14
-
15
- 先 `room view` 看成员与状态,再决定由谁处理。
16
-
17
- 任务若有 `scheduler_suggestions`,按其中的 `request_id`、`message_id` 先读原问题;`applied:false` 表示未自动生效,`actual_action:unverified` 表示动作结果尚未核验。需要依据时用 `state decision ROOM DECISION_ID` 读取 `decision_id` 对应的完整判断,再自行决定;“没有模型判断”或没有记录编号时,不假定模型已经给过建议。
18
-
19
- ## 派活
20
-
21
- - 需要他人时在回答中加 `--to 显示名`(可重复,不能点名自己),写明各自负责的文件或模块;不需要他人时不加,本次即结束。
22
- - 只点名一位成员时,它的回答直接面向用户;还要按结果安排下一步(如再交评审)时加 `--handoff`,它做完你会再被唤醒。派评审、验证这类结论要回到流程里的工作时默认加:不加则结论只停在用户面前,房间不会自动继续。点名多位时,全部结束后总会回到你汇总。
23
- - 需要预先指定实现后的只读评审时,先读 `review roster ROOM`,再在派实现的 `message post` 中带 `--next-review NAME --review-scope 范围 --review-roster REVISION`;评审者不能是本批实现者。授权与实现任务同时保存,自动要求该批交回主持人。候选目前限程序启动的 Codex CLI;没有合格成员时读取结果给出原因。
24
- - 收到 `review_handoffs` 时,先用 `review ready ROOM --batch ID` 读取完整委派、交付和报告,确认可交既定评审后用 `review advance ROOM --batch ID --revision REV --submission-id UUID --as 本人` 推进。只处理该批交接,其他请求继续完成;状态变化须重读,不能替换评审者或扩大权限。能力失效、额度不足、交付不完整等原因保留在读取结果和任务中。实际投递前本机复核只读权限与能力版本,失败撤销该次评审并交回主持人。
25
- - 收到 `handoff` 后交给其他成员或汇总给用户;成员的回答已完整时只用一两句说明完成,不复述。
26
- - 同一条用户消息最多推进若干轮(`room view` 的 `free.max_rounds`,本链已用见 `free.chain_rounds`,用户可调):你派一次活算一轮,成员做完交回给你汇总不另计。到上限时你的下一次派活会被暂存、任务链停下,由用户决定继续或发新消息;你不能自己加轮。派活前先想清楚需要几位、几轮,最后一轮留给汇总。(上限防止 AI 之间无人值守地来回转交。)
27
-
28
- 用户请成员分工改代码(定计划、实现、评审、整理文档)时,先读 [开发协作](dev-flow.md)。
29
-
30
- ## 工作流
31
-
32
- 本次任务有 `workflow` 字段时,本房间启用了用户的工作流。它像技能一样按需使用:`workflow.description` 是它的用途,请求与用途相符或用户要求按它做时才用,其他请求照常处理。要用时,本会话还没读过、或 `workflow.revision` 与上次读到的不同,才读 `workflow.skill_path`(API 主持人读系统提示中的工作流正文);否则沿用已读内容。按它组织工作时,`workflow.members` 列出每张卡片现在由哪位成员担任、是否在场、项目权限;只点名在场的成员,缺某个角色时在回答里说明缺谁,不另找成员代替。工作流只带来做法和成员,不扩大你的权限。
33
-
34
- 本次任务(本地任务文件或 API 的 `task`)没有 `workflow` 字段时,不按任何工作流做事,即使会话里读过。
35
-
36
- 用户请你把本房间的做法存成工作流(或要你按某种分工新建一套流程)时,按 [workflow-creator](../../workflow-creator/SKILL.md) 技能做。任务有 `host_workflows: true` 时,房主已允许你自行保存并启用,不必等用户导入。
1
+ # 自由协作主持
2
+
3
+ 仅用于自由协作房间的主持人(任务 `role` 为 `host`)。成员的回答与提交方式见参与技能的自由协作一节。
4
+
5
+ ## 请求类型
6
+
7
+ 任务的 `request_purposes` 说明每条请求为何交给你:
8
+
9
+ - `route`:用户未点名的消息,或全体调度交回的原问题,由你回答或分派;全体调度未代办时仍须读取原文,再决定安排。
10
+ - `handoff`:你派出并要求交回的成员都已结束,据结果决定下一步。
11
+ - `all`:用户请全体成员各自回应一次,其他成员已同时收到;你只回答自己的部分,不加 `--to`。
12
+ - `goal_start`、`goal_continue`:用户开启了 Goal,按 [Goal 模式主持](goal.md) 处理。
13
+ - `discuss_start`:用户发了 `/discuss`,等你补全讨论目标后开启讨论。消息带了文本时以原文为准,只整理措辞;没带时从最近对话提炼,提炼不出明确的讨论问题就不开启,直接回答并说明缺什么。开启:`run start ROOM --request 请求编号 --goal-file 草稿`,上限用房间默认值,你不设置。然后照常简短回答;之后程序会给你讨论主持任务,按 [讨论主持](discussion.md) 安排发言。
14
+
15
+ 先 `room view` 看成员与状态,再决定由谁处理。
16
+
17
+ 任务若有 `scheduler_suggestions`,按其中的 `request_id`、`message_id` 先读原问题;`applied:false` 表示未自动生效,`actual_action:unverified` 表示动作结果尚未核验。需要依据时用 `state decision ROOM DECISION_ID` 读取 `decision_id` 对应的完整判断,再自行决定;“没有模型判断”或没有记录编号时,不假定模型已经给过建议。
18
+
19
+ ## 派活
20
+
21
+ - 需要他人时在回答中加 `--to 显示名`(可重复,不能点名自己),写明各自负责的文件或模块;不需要他人时不加,本次即结束。
22
+ - 只点名一位成员时,它的回答直接面向用户;还要按结果安排下一步(如再交评审)时加 `--handoff`,它做完你会再被唤醒。派评审、验证这类结论要回到流程里的工作时默认加:不加则结论只停在用户面前,房间不会自动继续。点名多位时,全部结束后总会回到你汇总。
23
+ - 需要预先指定实现后的只读评审时,先读 `review roster ROOM`,再在派实现的 `message post` 中带 `--next-review NAME --review-scope 范围 --review-roster REVISION`;评审者不能是本批实现者。授权与实现任务同时保存,自动要求该批交回主持人。候选目前限程序启动的 Codex CLI;没有合格成员时读取结果给出原因。
24
+ - 收到 `review_handoffs` 时,先用 `review ready ROOM --batch ID` 读取完整委派、交付和报告,确认可交既定评审后用 `review advance ROOM --batch ID --revision REV --submission-id UUID --as 本人` 推进。只处理该批交接,其他请求继续完成;状态变化须重读,不能替换评审者或扩大权限。能力失效、额度不足、交付不完整等原因保留在读取结果和任务中。实际投递前本机复核只读权限与能力版本,失败撤销该次评审并交回主持人。
25
+ - 收到 `handoff` 后交给其他成员或汇总给用户;成员的回答已完整时只用一两句说明完成,不复述。
26
+ - 几位成员要按固定先后接力、后一位依赖前一位的结果(写完再审、实现后评审)时,派活加 `--sequential`,`--to` 按接力顺序点名:程序在每位答完后直接交给下一位,最后一位答完交回你,中间不唤醒你。每一棒算一轮,派活前确认剩余轮数够走完。派活正文要把每一棒做什么、按什么标准交给下一棒写清楚,后面的成员靠它和前一棒的回答工作。
27
+ - 要“改完再审、审到通过为止”时再加 `--loop`:每走完一圈,交回你的 `handoff` 带 `sequence.phase: checkpoint`。本圈最后一位明确要求返工时,用 `sequence continue ROOM --request 该交接请求编号 --as 本人` 开始下一圈(程序直接接着走,你本次即结束);认可、只剩可不采纳的建议、需要你或用户裁定时,不继续,直接验收或说明。开启智能调度时,它会先替你判断检查点,只有明确返工才自动开始下一圈,其余仍交给你;任务的 `scheduler_suggestions` 记着它的判断。`sequence view ROOM ID` 可读本圈各棒原文。轮数用尽时循环停下,由用户决定是否继续。
28
+ - 同一条用户消息最多推进若干轮(`room view` 的 `free.max_rounds`,本链已用见 `free.chain_rounds`,用户可调):你派一次活算一轮,成员做完交回给你汇总不另计。到上限时你的下一次派活会被暂存、任务链停下,由用户决定继续或发新消息;你不能自己加轮。派活前先想清楚需要几位、几轮,最后一轮留给汇总。(上限防止 AI 之间无人值守地来回转交。)
29
+
30
+ 用户请成员分工改代码(定计划、实现、评审、整理文档)时,先读 [开发协作](dev-flow.md)。
31
+
32
+ ## 工作流
33
+
34
+ 本次任务有 `workflow` 字段时,本房间启用了用户的工作流。它像技能一样按需使用:`workflow.description` 是它的用途,请求与用途相符或用户要求按它做时才用,其他请求照常处理。要用时,本会话还没读过、或 `workflow.revision` 与上次读到的不同,才读 `workflow.skill_path`(API 主持人读系统提示中的工作流正文);否则沿用已读内容。按它组织工作时,`workflow.members` 列出每张卡片现在由哪位成员担任、是否在场、项目权限;只点名在场的成员,缺某个角色时在回答里说明缺谁,不另找成员代替。工作流只带来做法和成员,不扩大你的权限。
35
+
36
+ 本次任务(本地任务文件或 API 的 `task`)没有 `workflow` 字段时,不按任何工作流做事,即使会话里读过。
37
+
38
+ 用户请你把本房间的做法存成工作流(或要你按某种分工新建一套流程)时,按 [workflow-creator](../../workflow-creator/SKILL.md) 技能做。任务有 `host_workflows: true` 时,房主已允许你自行保存并启用,不必等用户导入。
@@ -10,6 +10,8 @@
10
10
 
11
11
  任务需要多步操作时,在关键转折处(读完材料、改定方案、验证结果)用一两句中文写出进展,不另调命令:本机接入程序会把这些中途说明显示在网页的处理中卡片,让用户知道你在做什么。只写进展与判断,不复述工具输出,不写读技能、执行编号、提交命令之类的操作步骤(用户看不懂也用不上),不代替最终回答;几步就能答完的简单请求不写。
12
12
 
13
+ 任务的请求带 `sequence` 时,你是主持人安排的依次接力中的一棒:`sequence.message_id` 是主持人的安排原文,`request_messages` 是上一棒的回答;按安排完成你这一棒,答完程序直接交给 `sequence.next`(没有则交回主持人),不要等别人或自己点名下一位。
14
+
13
15
  ## 共享工作目录
14
16
 
15
17
  多位成员共享同一工作目录时,只改分给自己的文件;只提交自己的改动,切分支、reset、全仓 stash 或 checkout 须用户确认。(目录不加锁,越界改动会覆盖别人的工作。)
@@ -38,6 +38,7 @@ description: AI CoDiscuss 工作流的创建与修改技能。用户要把房间
38
38
  - **只写验证过的做法**:用户直接给了分工、还没跑过的,按用户的分工写,跑过一轮后再用同一 ID 修订。
39
39
  - **权限按职责最小**:只有需要改文件的角色给 `write`;同一份文件同一时刻只让一个角色改。
40
40
  - 主持人不设卡片;主持人的职责写在 SKILL.md 里。
41
+ - **固定先后的接力写成依次派活**:几个角色按固定顺序接力、后一位依赖前一位的结果时(写完再审、实现后评审),在 SKILL.md 写明主持人用一次依次派活点名这几位(按接力顺序),需要“改到通过为止”时写成循环,由主持人在每圈结束时决定是否再来一圈;不要写成主持人每一棒都亲自转交。原因:程序会在每棒答完后直接交给下一位,主持人只在开头和验收时出场。
41
42
 
42
43
  ## 格式
43
44
 
@@ -371,13 +371,13 @@ def advance_review(connection, room_id, token, payload):
371
371
  return {"batch_id": batch_id, "review_batch_id": review_batch_id, "execution_completed": completed, "duplicate": False}
372
372
 
373
373
 
374
- def finish_review_handoff(connection, handoff):
374
+ def finish_review_handoff(connection, handoff, result="review_advanced"):
375
375
  """Finish the host execution only when its other requests have all been handled."""
376
376
  execution_id = handoff["execution_id"]
377
377
  if not execution_id or connection.execute("SELECT 1 FROM member_requests WHERE execution_id=? AND status='delivering'", (execution_id,)).fetchone():
378
378
  return False
379
- connection.execute("UPDATE member_executions SET status='completed',finished_at=?,result='review_advanced' WHERE id=?",
380
- (int(time.time()), execution_id))
379
+ connection.execute("UPDATE member_executions SET status='completed',finished_at=?,result=? WHERE id=?",
380
+ (int(time.time()), result, execution_id))
381
381
  return True
382
382
 
383
383
 
@@ -1136,7 +1136,12 @@ def settle_batches(connection, room_id):
1136
1136
  continue
1137
1137
  connection.execute("UPDATE free_batches SET settled=1 WHERE room_id=? AND batch_id=?", (room_id, batch["batch_id"]))
1138
1138
  host = connection.execute("SELECT * FROM room_members WHERE id=? AND status='active' AND role='host'", (batch["host_id"],)).fetchone()
1139
- review = connection.execute("SELECT 1 FROM review_plans WHERE room_id=? AND (batch_id=? OR review_batch_id=?)", (room_id, batch["batch_id"], batch["batch_id"])).fetchone()
1139
+ sequence = connection.execute("SELECT * FROM free_sequences WHERE room_id=? AND batch_id=? AND status='running'",
1140
+ (room_id, batch["batch_id"])).fetchone()
1141
+ if sequence:
1142
+ advance_sequence(connection, room_id, sequence, host, "completed" in statuses)
1143
+ continue
1144
+ review =connection.execute("SELECT 1 FROM review_plans WHERE room_id=? AND (batch_id=? OR review_batch_id=?)", (room_id, batch["batch_id"], batch["batch_id"])).fetchone()
1140
1145
  if "completed" not in statuses:
1141
1146
  halt_chain(connection, room_id, batch["chain_id"], "all_failed")
1142
1147
  if review and host:
@@ -1174,6 +1179,157 @@ def advance_queues(connection, room_id):
1174
1179
  (int(time.time()), room_id))
1175
1180
 
1176
1181
 
1182
+ def checked_sequence(actor, payload):
1183
+ """Validate a host's one-after-another dispatch (optionally a loop) before its message is saved."""
1184
+ sequential, loop = payload.get("sequential", False), payload.get("loop", False)
1185
+ if not isinstance(actor, dict) or (sequential is False and loop is False):
1186
+ return
1187
+ if sequential is not True or type(loop) is not bool:
1188
+ raise ValueError("sequential 须为 true;loop 须为布尔值,且只与 sequential 一起使用")
1189
+ if actor["role"] != "host" or payload.get("kind") == "moderation":
1190
+ raise PermissionError("只有主持人派活时可以安排依次接力")
1191
+ names = payload.get("to") or []
1192
+ if len(names) < 2 or len(set(names)) != len(names):
1193
+ raise ValueError("依次接力须用 to 按顺序点名至少两位不同成员,每位只出现一次")
1194
+ if payload.get("next_review"):
1195
+ raise ValueError("依次接力不能同时声明预授权评审;评审者直接排进顺序即可")
1196
+
1197
+
1198
+ def start_sequence(connection, room_id, host, message_id, names, chain_id, loop):
1199
+ """Start a host's one-after-another dispatch with its first member; the rest follow as each answer arrives."""
1200
+ member_ids = named_members(active_members(connection, room_id), names, exclude=host["name"])
1201
+ if not chain_id:
1202
+ return
1203
+ connection.execute("""INSERT INTO free_sequences (room_id,message_id,chain_id,host_id,member_ids,loop,batch_id,steps)
1204
+ VALUES (?,?,?,?,?,?,?,?)""", (room_id, message_id, chain_id, host["id"], json.dumps(member_ids), int(loop), message_id,
1205
+ json.dumps([[1, member_ids[0], message_id]])))
1206
+ dispatch(connection, room_id, host, message_id, names[:1], chain_id)
1207
+
1208
+
1209
+ def sequence_step(connection, room_id, sequence, host, position, pass_number, message_id):
1210
+ """Ask the member at `position`, hung on `message_id`; one step is one batch, so it costs one round or waits at the limit."""
1211
+ member_id = json.loads(sequence["member_ids"])[position]
1212
+ name = connection.execute("SELECT name FROM room_members WHERE id=?", (member_id,)).fetchone()[0]
1213
+ steps = json.loads(sequence["steps"]) + [[pass_number, member_id, message_id]]
1214
+ connection.execute("UPDATE free_sequences SET status='running',pass=?,position=?,batch_id=?,steps=? WHERE id=?",
1215
+ (pass_number, position, message_id, json.dumps(steps), sequence["id"]))
1216
+ dispatch(connection, room_id, host, message_id, [name], sequence["chain_id"])
1217
+
1218
+
1219
+ def next_present(connection, room_id, sequence, start):
1220
+ """The first position from `start` whose member is still here and can be asked; members who left are skipped."""
1221
+ present = {row["id"] for row in active_members(connection, room_id).values()}
1222
+ return next((index for index, member_id in enumerate(json.loads(sequence["member_ids"])) if index >= start and member_id in present), None)
1223
+
1224
+
1225
+ def advance_sequence(connection, room_id, sequence, host, completed):
1226
+ """Pass a finished step on to the next member; after the last one hand back to the host, as a checkpoint for a loop.
1227
+
1228
+ A step nobody completed (failed, stopped, the member left) ends the sequence and halts the chain like any failed batch.
1229
+ """
1230
+ if not completed or not host or host["id"] != sequence["host_id"]:
1231
+ if not completed:
1232
+ halt_chain(connection, room_id, sequence["chain_id"], "all_failed")
1233
+ connection.execute("UPDATE free_sequences SET status='stopped' WHERE id=?", (sequence["id"],))
1234
+ return
1235
+ answer_id = connection.execute("""SELECT answer_id FROM member_requests WHERE room_id=? AND batch_id=? AND purpose='ask'
1236
+ AND status='completed' ORDER BY id DESC""", (room_id, sequence["batch_id"])).fetchone()[0]
1237
+ position = next_present(connection, room_id, sequence, sequence["position"] + 1)
1238
+ if position is not None:
1239
+ sequence_step(connection, room_id, sequence, host, position, sequence["pass"], answer_id)
1240
+ return
1241
+ insert_request(connection, room_id, host["id"], sequence["batch_id"], sequence["chain_id"], "handoff", "host")
1242
+ request_id = connection.execute("SELECT last_insert_rowid()").fetchone()[0]
1243
+ connection.execute("UPDATE free_sequences SET status=?,answer_id=?,handoff_request=? WHERE id=?",
1244
+ ("checkpoint" if sequence["loop"] else "done", answer_id, request_id, sequence["id"]))
1245
+
1246
+
1247
+ def sequence_view(connection, room_id, sequence_id, token):
1248
+ """The loop's plan and this pass's answers in full, for the host or the owner deciding at a checkpoint; it dispatches nothing."""
1249
+ try:
1250
+ require_owner(connection, room_id, token)
1251
+ except PermissionError:
1252
+ if require_member(connection, room_id, token)["role"] != "host":
1253
+ raise PermissionError("只有房主或当前主持人可读取依次接力")
1254
+ row = connection.execute("SELECT * FROM free_sequences WHERE room_id=? AND id=?", (room_id, sequence_id)).fetchone()
1255
+ if not row:
1256
+ raise LookupError("依次接力不存在")
1257
+ names = {item["id"]: item["name"] for item in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (room_id,))}
1258
+ origin = connection.execute("SELECT id,author,text FROM messages WHERE id=?", (row["message_id"],)).fetchone()
1259
+ answers = []
1260
+ for pass_number, member_id, batch_id in json.loads(row["steps"]):
1261
+ if pass_number != row["pass"]:
1262
+ continue
1263
+ answer = connection.execute("""SELECT r.status,m.id,m.text FROM member_requests r LEFT JOIN messages m ON m.id=r.answer_id
1264
+ WHERE r.room_id=? AND r.batch_id=? AND r.member_id=? AND r.purpose='ask'""", (room_id, batch_id, member_id)).fetchone()
1265
+ answers.append({"member": names.get(member_id), "status": answer["status"] if answer else "waiting",
1266
+ "message_id": answer["id"] if answer else None, "text": answer["text"] if answer else None})
1267
+ handoff = connection.execute("SELECT status FROM member_requests WHERE id=?", (row["handoff_request"],)).fetchone()
1268
+ return {"id": row["id"], "message_id": row["message_id"], "order": [names.get(item) for item in json.loads(row["member_ids"])],
1269
+ "loop": bool(row["loop"]), "pass": row["pass"], "position": row["position"], "status": row["status"],
1270
+ "chain_id": row["chain_id"], "remaining_rounds": rounds_left(connection, room_id, row["chain_id"]),
1271
+ "handoff_request": row["handoff_request"] or None, "handoff_status": handoff[0] if handoff else None,
1272
+ "origin": dict(origin) if origin else None, "answers": answers}
1273
+
1274
+
1275
+ def continue_sequence(connection, room_id, token, payload):
1276
+ """Run a loop's next pass from its checkpoint, closing only that hand-back; bound to the one checkpoint request.
1277
+
1278
+ The host calls it (smart scheduling with the host's credential when it judges the loop should go on); the first
1279
+ member present starts the pass on the last answer, and the round limit still holds it back like any dispatch.
1280
+ """
1281
+ begin_write(connection)
1282
+ host = require_member(connection, room_id, token, payload.get("actor"))
1283
+ if host["role"] != "host":
1284
+ raise PermissionError("只有当前主持人可以继续循环")
1285
+ request_id = payload.get("request_id")
1286
+ if type(request_id) is not int:
1287
+ raise ValueError("须提供检查点交接请求 request_id")
1288
+ sequence = connection.execute("SELECT * FROM free_sequences WHERE room_id=? AND handoff_request=? AND loop=1", (room_id, request_id)).fetchone()
1289
+ if not sequence:
1290
+ raise LookupError("该请求不是循环接力的检查点")
1291
+ request = connection.execute("SELECT * FROM member_requests WHERE id=?", (request_id,)).fetchone()
1292
+ if sequence["status"] == "running" and request["status"] == "completed":
1293
+ execution = connection.execute("SELECT status FROM member_executions WHERE id=?", (request["execution_id"],)).fetchone()
1294
+ return {"sequence_id": sequence["id"], "pass": sequence["pass"], "duplicate": True,
1295
+ "execution_completed": bool(execution and execution[0] == "completed")}
1296
+ if sequence["status"] != "checkpoint" or request["status"] not in ("pending", "delivering") or request["member_id"] != host["id"]:
1297
+ raise FileExistsError("这个检查点已处理、循环已结束,或主持人已更换")
1298
+ position = next_present(connection, room_id, sequence, 0)
1299
+ if position is None:
1300
+ raise FileExistsError("循环中的成员都已离开,无法继续")
1301
+ connection.execute("UPDATE member_requests SET status='completed' WHERE id=?", (request_id,))
1302
+ completed = finish_review_handoff(connection, request, "sequence_continued")
1303
+ sequence_step(connection, room_id, sequence, host, position, sequence["pass"] + 1, sequence["answer_id"])
1304
+ return {"sequence_id": sequence["id"], "pass": sequence["pass"] + 1, "duplicate": False, "execution_completed": completed}
1305
+
1306
+
1307
+ def sequence_request_context(connection, request):
1308
+ """Where a request sits in a host's one-after-another dispatch: a step, the hand-back after the last one, or a loop's checkpoint."""
1309
+ row = connection.execute("SELECT room_id,member_id,batch_id,purpose FROM member_requests WHERE id=?", (request["id"],)).fetchone()
1310
+ if not row:
1311
+ return {}
1312
+ if row["purpose"] == "handoff":
1313
+ sequence = connection.execute("SELECT * FROM free_sequences WHERE handoff_request=?", (request["id"],)).fetchone()
1314
+ elif row["purpose"] == "ask" and row["batch_id"]:
1315
+ sequence = connection.execute("SELECT * FROM free_sequences WHERE room_id=? AND batch_id=?", (row["room_id"], row["batch_id"])).fetchone()
1316
+ else:
1317
+ return {}
1318
+ if not sequence:
1319
+ return {}
1320
+ names = {item["id"]: item["name"] for item in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (row["room_id"],))}
1321
+ member_ids = json.loads(sequence["member_ids"])
1322
+ order = [names.get(item) for item in member_ids]
1323
+ phase = "step" if row["purpose"] == "ask" else "checkpoint" if sequence["loop"] else "end"
1324
+ if phase == "step" and row["member_id"] not in member_ids:
1325
+ return {}
1326
+ position = member_ids.index(row["member_id"]) if phase == "step" else len(order) - 1
1327
+ following = order[position + 1] if phase == "step" and position + 1 < len(order) else None
1328
+ return {"sequence": {"id": sequence["id"], "message_id": sequence["message_id"], "order": order, "loop": bool(sequence["loop"]),
1329
+ "pass": sequence["pass"], "position": position, "phase": phase, "next": following,
1330
+ "answer_id": sequence["answer_id"] or None}}
1331
+
1332
+
1177
1333
  def default_order(connection, room_id, members):
1178
1334
  """Members in the room's saved speaking order; without one (or for members it lacks), the host first, then by joining order."""
1179
1335
  row = connection.execute("SELECT member_ids FROM speaking_orders WHERE room_id=?", (room_id,)).fetchone()
@@ -1199,7 +1355,15 @@ def speaking_order(connection, room):
1199
1355
  kind = "turns"
1200
1356
  target = session["id"]
1201
1357
  items, later, session_plan_state = discussion.session_speaking_order(connection, session)
1202
- else:
1358
+ sequence = None if session else open_sequence(connection, room["id"])
1359
+ if sequence:
1360
+ # A host's one-after-another dispatch shows like a queue: this pass's members, done up to the one asked now.
1361
+ kind, target = "queue", sequence["message_id"]
1362
+ for index, member_id in enumerate(json.loads(sequence["member_ids"])):
1363
+ if member_id in by_id:
1364
+ state = "done" if sequence["status"] == "checkpoint" or index < sequence["position"] else "speaking" if index == sequence["position"] else "queued"
1365
+ items.append({"name": by_id[member_id]["name"], "state": state})
1366
+ elif not session:
1203
1367
  target = connection.execute("""SELECT MAX(message_id) FROM (
1204
1368
  SELECT MAX(message_id) AS message_id FROM free_queue_waiting WHERE room_id=?
1205
1369
  UNION ALL SELECT MAX(r.message_id) FROM member_requests r WHERE r.room_id=? AND r.status IN ('pending','delivering')
@@ -1215,12 +1379,21 @@ def speaking_order(connection, room):
1215
1379
  listed = {item["name"] for item in items}
1216
1380
  items += [{"name": name, "state": "idle"} for name in dict.fromkeys(ordered) if name not in listed]
1217
1381
  movable = "queued" if kind != "default" else "idle"
1218
- state = json.dumps([kind, target, [[item["name"], item["state"]] for item in items], later, session_plan_state], ensure_ascii=False)
1382
+ relay = {"id": sequence["id"], "loop": bool(sequence["loop"]), "pass": sequence["pass"]} if sequence else None
1383
+ state = json.dumps([kind, target, [[item["name"], item["state"]] for item in items], later, session_plan_state, relay], ensure_ascii=False)
1219
1384
  return {"kind": kind, "message_id": target, "items": items, "later": later, "movable": [item["name"] for item in items if item["state"] == movable],
1220
1385
  "default": [member["name"] for member in default_order(connection, room["id"], active)],
1386
+ **({"sequence": relay} if relay else {}),
1221
1387
  "revision": hashlib.sha256(state.encode("utf-8")).hexdigest()[:12]}
1222
1388
 
1223
1389
 
1390
+ def open_sequence(connection, room_id):
1391
+ """The room's latest one-after-another dispatch still running, or waiting at its checkpoint for the host."""
1392
+ return connection.execute("""SELECT s.* FROM free_sequences s WHERE s.room_id=? AND (s.status='running'
1393
+ OR s.status='checkpoint' AND EXISTS(SELECT 1 FROM member_requests r WHERE r.id=s.handoff_request AND r.status IN ('pending','delivering')))
1394
+ ORDER BY s.id DESC""", (room_id,)).fetchone()
1395
+
1396
+
1224
1397
  def reorder_speakers(connection, room, token, payload):
1225
1398
  """Owner reorders the not-yet-spoken part of the speaking order; who already spoke or speaks now stays put.
1226
1399
 
@@ -1241,7 +1414,15 @@ def reorder_speakers(connection, room, token, payload):
1241
1414
  extra = [name for name in dict.fromkeys(given) if movable.count(name) < given.count(name)]
1242
1415
  raise ValueError("新顺序须恰好包含尚未发言的成员" + (f";缺少:{'、'.join(missing)}" if missing else "") + (f";多出:{'、'.join(extra)}" if extra else ""))
1243
1416
  names = {member["name"]: member["id"] for member in room["members"] if member["status"] == "active"}
1244
- if current["kind"] == "queue":
1417
+ if current.get("sequence"):
1418
+ # Later passes of a loop keep the new order too; members who left stay in place at the end.
1419
+ sequence = connection.execute("SELECT * FROM free_sequences WHERE id=?", (current["sequence"]["id"],)).fetchone()
1420
+ member_ids = json.loads(sequence["member_ids"])
1421
+ kept = member_ids[:sequence["position"] + 1]
1422
+ moved = [names[name] for name in order]
1423
+ member_ids = kept + moved + [item for item in member_ids if item not in kept and item not in moved]
1424
+ connection.execute("UPDATE free_sequences SET member_ids=? WHERE id=?", (json.dumps(member_ids), sequence["id"]))
1425
+ elif current["kind"] == "queue":
1245
1426
  connection.execute(f"DELETE FROM free_queue WHERE message_id=? AND NOT {QUEUE_ASKED.format(row='free_queue')}", (current["message_id"],))
1246
1427
  for name in order:
1247
1428
  connection.execute("INSERT INTO free_queue (room_id,message_id,member_id) VALUES (?,?,?)", (room["id"], current["message_id"], names[name]))
@@ -1289,7 +1470,9 @@ def after_free_message(connection, room_id, message_id, actor, payload):
1289
1470
  connection.execute("UPDATE member_requests SET status='completed',answer_id=? WHERE execution_id=? AND status='delivering'",
1290
1471
  (message_id, execution_id))
1291
1472
  connection.execute("UPDATE room_members SET read_until=MAX(read_until,?) WHERE id=?", (message_id, actor["id"]))
1292
- if actor["role"] == "host" and payload.get("to"):
1473
+ if actor["role"] == "host" and payload.get("to") and payload.get("sequential") is True:
1474
+ start_sequence(connection, room_id, actor, message_id, payload["to"], chain_id, payload.get("loop") is True)
1475
+ elif actor["role"] == "host" and payload.get("to"):
1293
1476
  dispatch(connection, room_id, actor, message_id, payload["to"], chain_id, payload.get("handoff") is True or bool(payload.get("next_review")))
1294
1477
  settle_batches(connection, room_id)
1295
1478
 
@@ -1409,7 +1592,7 @@ def inbox(connection, room_id, token):
1409
1592
  # The runner compares this with its adapter's effect and reports a change.
1410
1593
  reported = connection.execute("SELECT rewind FROM member_capabilities WHERE member_id=?", (row["id"],)).fetchone()
1411
1594
  member_view["rewind"] = reported[0] if reported else ""
1412
- pending = [{**request, **review_request_context(connection, request)} for request in pending]
1595
+ pending = [{**request, **review_request_context(connection, request), **sequence_request_context(connection, request)} for request in pending]
1413
1596
  (members if row["access"] == "runner" else observers).append({**member_view, "pending": pending})
1414
1597
  executions = [{**public_execution(row), "member": row["name"], "retry": execution_retry(connection, row["id"])} for row in connection.execute(
1415
1598
  """SELECT member_executions.*,room_members.name FROM member_executions JOIN room_members ON room_members.id=member_id
@@ -1670,7 +1853,7 @@ def execution_detail(connection, row):
1670
1853
  FROM member_requests r LEFT JOIN session_requests sr ON sr.member_request_id=r.id
1671
1854
  LEFT JOIN task_message_sources t ON t.session_request_id=sr.id
1672
1855
  WHERE r.execution_id=? ORDER BY r.id""", (row["id"],))]
1673
- requests = [{**request, **review_request_context(connection, request)} for request in requests]
1856
+ requests = [{**request, **review_request_context(connection, request), **sequence_request_context(connection, request)} for request in requests]
1674
1857
  return {**public_execution(row), "member": member["name"], "read_until": member["read_until"], "requests": requests,
1675
1858
  "rewound_executions": member_rewinds(connection, member["id"]), "retry": execution_retry(connection, row["id"])}
1676
1859
 
@@ -2006,6 +2189,8 @@ def rewind_room(connection, room_id, token, payload):
2006
2189
  connection.execute("DELETE FROM free_queue WHERE room_id=? AND message_id>=?", (room_id, message_id))
2007
2190
  # Batches of taken-back dispatches never hand back, and a dispatch the round limit held from them is never sent.
2008
2191
  connection.execute("UPDATE free_batches SET settled=1 WHERE room_id=? AND batch_id>=?", (room_id, message_id))
2192
+ connection.execute("""UPDATE free_sequences SET status='stopped' WHERE room_id=? AND status IN ('running','checkpoint')
2193
+ AND (batch_id>=? OR message_id>=?)""", (room_id, message_id, message_id))
2009
2194
  connection.execute("UPDATE free_chain_holds SET message_id=0,host_id=0,names='[]',handoff=0 WHERE room_id=? AND message_id>=?", (room_id, message_id))
2010
2195
  connection.execute("""INSERT INTO message_withdrawals (message_id,kind,created_at) SELECT id,'rewound',? FROM messages m
2011
2196
  WHERE m.room_id=? AND m.id>=? AND NOT EXISTS(SELECT 1 FROM message_withdrawals w WHERE w.message_id=m.id)""", (now, room_id, message_id))
@@ -542,6 +542,27 @@ CREATE TABLE IF NOT EXISTS free_batches (
542
542
  settled INTEGER NOT NULL DEFAULT 0,
543
543
  PRIMARY KEY (room_id,batch_id)
544
544
  );
545
+ -- A host's one-after-another dispatch: each step is a one-member batch hung on the previous answer and costs one chain round.
546
+ -- After the last member it hands back to the host; a loop's hand-back is a checkpoint from which the host (or smart
547
+ -- scheduling for it) may run the next pass. `steps` lists [pass, member_id, batch_id] for every step started.
548
+ CREATE TABLE IF NOT EXISTS free_sequences (
549
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
550
+ room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
551
+ message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
552
+ chain_id INTEGER NOT NULL,
553
+ host_id INTEGER NOT NULL,
554
+ member_ids TEXT NOT NULL,
555
+ loop INTEGER NOT NULL DEFAULT 0,
556
+ pass INTEGER NOT NULL DEFAULT 1,
557
+ position INTEGER NOT NULL DEFAULT 0,
558
+ batch_id INTEGER NOT NULL,
559
+ answer_id INTEGER NOT NULL DEFAULT 0,
560
+ handoff_request INTEGER NOT NULL DEFAULT 0,
561
+ steps TEXT NOT NULL DEFAULT '[]',
562
+ status TEXT NOT NULL DEFAULT 'running' CHECK(status IN ('running','checkpoint','done','stopped'))
563
+ );
564
+ CREATE INDEX IF NOT EXISTS free_sequences_batch ON free_sequences(room_id,batch_id);
565
+ CREATE INDEX IF NOT EXISTS free_sequences_handoff ON free_sequences(handoff_request);
545
566
  -- Members the user named to answer one after another, in the room's speaking order: each gets its request once the one before
546
567
  -- it ends. Asked rows stay so the speaking order can show who already answered; the owner may reorder rows not yet asked.
547
568
  CREATE TABLE IF NOT EXISTS free_queue (
package/backend/server.py CHANGED
@@ -37,6 +37,7 @@ from .free_room import (ADAPTER_VALUE, ANCHOR_SQL, continue_chain, execution_tim
37
37
  room_event_log, withdrawals)
38
38
  from .free_room import review_capability, review_roster, checked_review_plan, save_review_plan, check_review_retry
39
39
  from .free_room import review_ready, advance_review, late_answer
40
+ from .free_room import checked_sequence, continue_sequence, sequence_view
40
41
  from .identity import MAX_ROOM_AGENTS, PARTICIPANTS, is_ai, is_participant
41
42
  from .message_queue import drain as drain_queue, enqueue, queue_action, queue_view, should_queue
42
43
  from .room_import import import_room, imported_attachment
@@ -1123,8 +1124,8 @@ def check_message_references(connection, room_id, message):
1123
1124
  def append_free_message(connection, room, payload, actor):
1124
1125
  """Append a free-room message: the owner's creates requests, a member's completes its execution."""
1125
1126
  if payload.get("kind") == "moderation":
1126
- if any(payload.get(field) for field in ("next_review", "review_scope", "review_roster")):
1127
- raise ValueError("主持说明不能声明评审授权;请在派实现任务时提交")
1127
+ if any(payload.get(field) for field in ("next_review", "review_scope", "review_roster", "sequential", "loop")):
1128
+ raise ValueError("主持说明不能声明评审授权或依次接力;请在派活时提交")
1128
1129
  return append_free_moderation(connection, room, payload, actor)
1129
1130
  if payload.get("kind") not in (None, "chat"):
1130
1131
  raise ValueError("自由协作与新讨论使用 kind=chat,并以 execution_id 或 request_ids 关联请求")
@@ -1159,6 +1160,7 @@ def append_free_message(connection, room, payload, actor):
1159
1160
  if actor != "用户":
1160
1161
  check_answer(connection, room_id, actor, payload)
1161
1162
  review_plan = checked_review_plan(connection, room_id, actor, payload)
1163
+ checked_sequence(actor, payload)
1162
1164
  cursor = connection.execute("INSERT INTO messages (room_id,author,text,reply_to,source,submission_id,created_at) VALUES (?,?,?,?,?,?,?)",
1163
1165
  (room_id, author, text, reply_to, expected["source"], submission_id, utc_now()))
1164
1166
  connection.execute("INSERT INTO message_details (message_id,kind,submitted_by) VALUES (?,?,?)",
@@ -1559,7 +1561,7 @@ def protocol_text(room):
1559
1561
  "用户管理和讨论开关使用房主凭据;本地成员使用本人的 --access-file,不借用其他身份。凭据私下交付,不公开 token。API 参与者遵循相同请求与执行约束,source=api;本地 CLI 使用 source=cli。",
1560
1562
  "runner 成员等待本机接入程序交付任务,用 execution view 核对本次执行和请求,再以 message post --as 本人 --kind chat --execution 本次编号 --access-file FILE 回答。执行失效后保留草稿,不能去掉编号改作普通发言;认领、续租与后续投递由接入程序处理。",
1561
1563
  "manual 本地成员首次用 member register 注册,会话 ID 由 CLI 自动取得;回答用 --request 请求编号,不编造执行编号。讨论不使用原生定时;原会话不能托管时由用户触发。",
1562
- "自由协作中的 host 可用 --to 点名成员,route 表示未点名的用户请求,handoff 表示派出任务已结束;链上 AI 派活受房间预算限制。普通成员的 --to 或正文 @ 只保存回应建议,不直接唤醒;不点名自己、不扩大授权。",
1564
+ "自由协作中的 host 可用 --to 点名成员,route 表示未点名的用户请求,handoff 表示派出任务已结束;链上 AI 派活受房间预算限制。--to 多位并加 --sequential 时按点名顺序依次接力,每棒一轮,最后交回主持人;再加 --loop 则交回为检查点,用 sequence continue --request 交接请求编号 开始下一圈,不继续即直接验收。普通成员的 --to 或正文 @ 只保存回应建议,不直接唤醒;不点名自己、不扩大授权。",
1563
1565
  "讨论中的用户 @ 直接提问独立排队,不计讨论轮次、不与讨论发言合并。initial 阶段先独立判断;服务端按讨论请求的 read_until 限制读取。直接提问和主持任务不继承该阅读限制。",
1564
1566
  "主持安排前先 turn roster 读取在场专家、waiting、unregistered 和 revision,再 turn create --roster revision;单项和多项都要带凭据,多项计划须覆盖本轮 waiting。继续排队同样先读 roster,再 turn continue --roster revision。状态或新消息变化后旧凭据被拒绝,重新读取再判断;每个回答保存后由程序推进下一项。",
1565
1567
  "局部追问先 turn roster 读取 host_requests 的请求与原消息编号,再 turn ask --request ID --expert NAME --roster revision --submission-id UUID;普通提问不计轮次、不改讨论队列,可问已发言者和主持人,只结束指定请求。",
@@ -2266,6 +2268,12 @@ class DiscussionHandler(BaseHTTPRequestHandler):
2266
2268
  result = advance_review(connection, room_id, token, self.read_payload())
2267
2269
  connection.commit()
2268
2270
  return self.respond(result)
2271
+ if len(parts) == 5 and parts[3] == "sequences" and parts[4].isdigit() and method == "GET":
2272
+ return self.respond(sequence_view(connection, room_id, int(parts[4]), token))
2273
+ if parts[3:] == ["sequences", "continue"] and method == "POST":
2274
+ result = continue_sequence(connection, room_id, token, self.read_payload())
2275
+ connection.commit()
2276
+ return self.respond(result)
2269
2277
  if parts[3:] == ["queue"] and method == "POST":
2270
2278
  result = queue_action(connection, room_id, token, self.read_payload(), post_queued, check_queued)
2271
2279
  connection.commit()