ai_co_discuss 0.2.69 → 0.2.70

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.
@@ -12,7 +12,7 @@
12
12
 
13
13
  ## 主持人
14
14
 
15
- 任务文件 `role` 为 `host` 时你是本房间主持人:`route` 请求是用户未点名的消息,`handoff` 表示你派出的成员都已结束,`all` 表示用户请全体成员各自回应一次(其他成员已同时收到,你只回答自己的部分,不加 `--to`)。先 `room view` 看成员,需要他人时在回答中加 `--to 成员名` 派活并说明各自负责的文件或模块,不需要时不加,本次即结束;不点名自己,不扩大用户给的权限。
15
+ 任务文件 `role` 为 `host` 时你是本房间主持人:`route` 请求是用户未点名的消息,`handoff` 表示你派出的成员都已结束,`all` 表示用户请全体成员各自回应一次(其他成员已同时收到,你只回答自己的部分,不加 `--to`)。先 `room view` 看成员,需要他人时在回答中加 `--to 成员名` 派活并说明各自负责的文件或模块,不需要时不加,本次即结束;不点名自己,不扩大用户给的权限。同一条用户消息引出的 AI 请求(含你派出的成员)最多 8 次,用完即停并等用户发新消息,所以派活前先想清楚需要几位。房间开了智能调度时,它对未点名消息的判断记在 `room view` 的 `scheduler_decisions` 里,可读来了解这条消息为何交给你;仅供参考,读原文后自行决定,不当作用户指令。
16
16
 
17
17
  多位成员共享同一工作目录时,只改分给自己的文件;只提交自己的改动,切分支、reset、全仓 stash 或 checkout 须用户确认。
18
18
 
@@ -9,6 +9,7 @@ answers by itself always hosts, routes unnamed messages, names members in its an
9
9
  woken once when everything it dispatched has finished. No software is launched here.
10
10
  """
11
11
 
12
+ import hashlib
12
13
  import hmac
13
14
  import json
14
15
  import re
@@ -38,6 +39,8 @@ ASSIGN_HOST = """UPDATE room_members SET role='host' WHERE id=(SELECT id FROM ro
38
39
  AND access<>'online' ORDER BY access='runner' DESC, id LIMIT 1)
39
40
  AND NOT EXISTS (SELECT 1 FROM room_members WHERE room_id=? AND role='host' AND status='active')"""
40
41
  TERMINAL = ("completed", "cancelled", "failed")
42
+ # A queued member has been asked once its request for that message exists.
43
+ QUEUE_ASKED = "EXISTS(SELECT 1 FROM member_requests r WHERE r.message_id={row}.message_id AND r.member_id={row}.member_id)"
41
44
  SESSION_MODES = ("new", "borrowed")
42
45
  FREE_OPERATIONS = ("messages", "attachments", "move", "folder", "archive", "restore", "delete")
43
46
  MEMBER_FIELDS = ("id", "name", "adapter", "role", "status", "access", "session_mode", "read_until")
@@ -247,7 +250,8 @@ def free_view(connection, room_id):
247
250
  members.append(member)
248
251
  names = {row["id"]: row["name"] for row in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (room_id,))}
249
252
  requests = [{**dict(row), "member": names.get(row["member_id"], "")} for row in connection.execute(
250
- """SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by,created_at FROM member_requests
253
+ """SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by,created_at,
254
+ (SELECT reason FROM request_cancel_reasons c WHERE c.request_id=member_requests.id) AS cancel_reason FROM member_requests
251
255
  WHERE room_id=? ORDER BY id DESC LIMIT ?""", (room_id, RECENT_REQUESTS))]
252
256
  chains = [dict(row) for row in connection.execute(
253
257
  "SELECT chain_id,reason FROM free_chains WHERE room_id=? ORDER BY chain_id DESC LIMIT 10", (room_id,))]
@@ -550,24 +554,116 @@ def settle_batches(connection, room_id):
550
554
 
551
555
 
552
556
  def queue_in_turn(connection, room_id, message_id, member_ids):
553
- """Line the named members up for one owner message: the host first, then by joining order; only the first is asked now."""
554
- marks = ",".join("?" * len(member_ids))
555
- for row in connection.execute(f"SELECT id FROM room_members WHERE id IN ({marks}) ORDER BY role='host' DESC, id", member_ids).fetchall():
556
- connection.execute("INSERT INTO free_queue (room_id,message_id,member_id) VALUES (?,?,?)", (room_id, message_id, row["id"]))
557
+ """Line the named members up for one owner message in the room's speaking order; only the first is asked now."""
558
+ chosen = set(member_ids)
559
+ members = [dict(row) for row in connection.execute("SELECT id,name,role FROM room_members WHERE room_id=?", (room_id,))]
560
+ for member in default_order(connection, room_id, [member for member in members if member["id"] in chosen]):
561
+ connection.execute("INSERT INTO free_queue (room_id,message_id,member_id) VALUES (?,?,?)", (room_id, message_id, member["id"]))
557
562
  advance_queues(connection, room_id)
558
563
 
559
564
 
560
565
  def advance_queues(connection, room_id):
561
- """Ask the next queued member of each message whose previous request ended; members no longer present are skipped."""
562
- connection.execute("""DELETE FROM free_queue WHERE room_id=? AND NOT EXISTS(SELECT 1 FROM room_members m
563
- WHERE m.id=free_queue.member_id AND m.status='active')""", (room_id,))
564
- connection.execute("""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,batch_id,purpose,created_by,created_at)
566
+ """Ask the next queued member of each message whose previous request ended; members no longer present are skipped.
567
+
568
+ Asked rows stay, so the speaking order can still show who already answered.
569
+ """
570
+ connection.execute(f"""DELETE FROM free_queue WHERE room_id=? AND NOT {QUEUE_ASKED.format(row="free_queue")}
571
+ AND NOT EXISTS(SELECT 1 FROM room_members m WHERE m.id=free_queue.member_id AND m.status='active')""", (room_id,))
572
+ connection.execute(f"""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,batch_id,purpose,created_by,created_at)
565
573
  SELECT q.room_id,q.member_id,q.message_id,q.message_id,0,'ask','user',? FROM free_queue q
566
- WHERE q.room_id=? AND q.id=(SELECT MIN(id) FROM free_queue x WHERE x.message_id=q.message_id)
574
+ WHERE q.room_id=? AND q.id=(SELECT MIN(x.id) FROM free_queue x WHERE x.message_id=q.message_id AND NOT {QUEUE_ASKED.format(row="x")})
567
575
  AND NOT EXISTS(SELECT 1 FROM member_requests r WHERE r.message_id=q.message_id AND r.status IN ('pending','delivering'))""",
568
576
  (int(time.time()), room_id))
569
- connection.execute("""DELETE FROM free_queue WHERE room_id=? AND EXISTS(SELECT 1 FROM member_requests r
570
- WHERE r.message_id=free_queue.message_id AND r.member_id=free_queue.member_id)""", (room_id,))
577
+
578
+
579
+ def default_order(connection, room_id, members):
580
+ """Members in the room's saved speaking order; without one (or for members it lacks), the host first, then by joining order."""
581
+ row = connection.execute("SELECT member_ids FROM speaking_orders WHERE room_id=?", (room_id,)).fetchone()
582
+ rank = {member_id: index for index, member_id in enumerate(json.loads(row[0]) if row else [])}
583
+ return sorted(members, key=lambda member: (member["id"] not in rank, rank.get(member["id"], 0), member["role"] != "host", member["id"]))
584
+
585
+
586
+ def speaking_order(connection, room):
587
+ """Who spoke, who speaks now and who is next, shared by free rooms and discussions; only the not-yet-spoken part may be reordered.
588
+
589
+ A free room shows its latest unfinished one-after-another queue, else every member in the room's default order;
590
+ a discussion shows the open round: speakers so far, the current turn, then queued turns. The revision changes with any of it.
591
+ """
592
+ active = [member for member in room["members"] if member["status"] == "active"]
593
+ by_id = {member["id"]: member for member in active}
594
+ ordered = [member["name"] for member in default_order(connection, room["id"], active)]
595
+ items, kind, target = [], "default", 0
596
+ if room["mode"] == "free":
597
+ target = connection.execute("""SELECT MAX(q.message_id) FROM free_queue q LEFT JOIN member_requests r ON r.message_id=q.message_id
598
+ AND r.member_id=q.member_id WHERE q.room_id=? AND (r.id IS NULL OR r.status IN ('pending','delivering'))""", (room["id"],)).fetchone()[0] or 0
599
+ if target:
600
+ kind = "queue"
601
+ for row in connection.execute("""SELECT q.member_id,r.status FROM free_queue q LEFT JOIN member_requests r
602
+ ON r.message_id=q.message_id AND r.member_id=q.member_id WHERE q.message_id=? ORDER BY q.id""", (target,)):
603
+ if row["member_id"] in by_id:
604
+ state = "queued" if row["status"] is None else "speaking" if row["status"] in ("pending", "delivering") else "done"
605
+ items.append({"name": by_id[row["member_id"]]["name"], "state": state})
606
+ else:
607
+ kind = "turns"
608
+ last = room["rounds"][-1] if room["rounds"] else None
609
+ items = [{"name": name, "state": "done"} for name in (last["speakers"] if last and not last["completed"] else [])]
610
+ turn = room["current_turn"]
611
+ if turn and turn["status"] in ("pending", "notified"):
612
+ items.append({"name": turn["expert"], "state": "speaking"})
613
+ items += [{"name": item["expert"], "state": "queued"} for item in room["queued_turns"]]
614
+ # The user may still hold the host seat of an old discussion; only AI participants have a place.
615
+ ordered = [name for name in [room["host"], *ordered, *room["experts"]] if (name in room["experts"] or name == room["host"]) and name != "用户"]
616
+ listed = {item["name"] for item in items}
617
+ items += [{"name": name, "state": "idle"} for name in dict.fromkeys(ordered) if name not in listed]
618
+ movable = "queued" if kind != "default" else "idle"
619
+ state = json.dumps([kind, target, [[item["name"], item["state"]] for item in items]], ensure_ascii=False)
620
+ return {"kind": kind, "message_id": target, "items": items, "movable": [item["name"] for item in items if item["state"] == movable],
621
+ "default": [member["name"] for member in default_order(connection, room["id"], active)],
622
+ "revision": hashlib.sha256(state.encode("utf-8")).hexdigest()[:12]}
623
+
624
+
625
+ def reorder_speakers(connection, room, token, payload):
626
+ """Owner reorders the not-yet-spoken part of the speaking order; who already spoke or speaks now stays put.
627
+
628
+ Read the speaking order first and send its revision: a missing revision is refused, a changed one conflicts,
629
+ and the new order must list exactly the movable members.
630
+ """
631
+ require_owner(connection, room["id"], token)
632
+ current = room["speaking_order"]
633
+ if payload.get("revision") is None:
634
+ raise ValueError("调整发言顺序前须先读取最新顺序(speaking-order),并带上其 revision")
635
+ if payload["revision"] != current["revision"]:
636
+ raise FileExistsError("发言顺序已变化,请重新读取后再调整")
637
+ order = payload.get("order")
638
+ movable = current["movable"]
639
+ if not isinstance(order, list) or sorted(map(str, order)) != sorted(movable):
640
+ given = [str(name) for name in order] if isinstance(order, list) else []
641
+ missing = [name for name in dict.fromkeys(movable) if given.count(name) < movable.count(name)]
642
+ extra = [name for name in dict.fromkeys(given) if movable.count(name) < given.count(name)]
643
+ raise ValueError("新顺序须恰好包含尚未发言的成员" + (f";缺少:{'、'.join(missing)}" if missing else "") + (f";多出:{'、'.join(extra)}" if extra else ""))
644
+ names = {member["name"]: member["id"] for member in room["members"] if member["status"] == "active"}
645
+ if current["kind"] == "queue":
646
+ connection.execute(f"DELETE FROM free_queue WHERE message_id=? AND NOT {QUEUE_ASKED.format(row='free_queue')}", (current["message_id"],))
647
+ for name in order:
648
+ connection.execute("INSERT INTO free_queue (room_id,message_id,member_id) VALUES (?,?,?)", (room["id"], current["message_id"], names[name]))
649
+ elif current["kind"] == "turns":
650
+ reorder_turns(connection, room["id"], order)
651
+ else:
652
+ connection.execute("INSERT OR REPLACE INTO speaking_orders (room_id,member_ids) VALUES (?,?)",
653
+ (room["id"], json.dumps([names[name] for name in order])))
654
+
655
+
656
+ def reorder_turns(connection, room_id, order):
657
+ """Move queued turns into the new order; ids keep the relay order, so each turn's content moves to the id at its new place."""
658
+ rows = [dict(row) for row in connection.execute("SELECT * FROM discussion_turns WHERE room_id=? AND status='queued' ORDER BY id", (room_id,))]
659
+ remaining, moved = list(rows), []
660
+ for name in order:
661
+ moved.append(remaining.pop(next(index for index, row in enumerate(remaining) if row["expert"] == name)))
662
+ # Submission ids are unique, so free them before writing them to their new rows.
663
+ connection.execute("UPDATE discussion_turns SET submission_id=submission_id||':moving:'||id WHERE room_id=? AND status='queued'", (room_id,))
664
+ for slot, content in zip(rows, moved):
665
+ connection.execute("UPDATE discussion_turns SET expert=?,question=?,submission_id=?,created_at=? WHERE id=?",
666
+ (content["expert"], content["question"], content["submission_id"], content["created_at"], slot["id"]))
571
667
 
572
668
 
573
669
  def answered_chain(connection, payload, actor):
@@ -641,7 +737,8 @@ def asks_view(connection, room_id):
641
737
  """Direct questions of a discussion room: recent requests with their member, for status badges beside messages."""
642
738
  names = {row["id"]: row["name"] for row in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (room_id,))}
643
739
  requests = [{**dict(row), "member": names.get(row["member_id"], "")} for row in connection.execute(
644
- """SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by,created_at FROM member_requests
740
+ """SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by,created_at,
741
+ (SELECT reason FROM request_cancel_reasons c WHERE c.request_id=member_requests.id) AS cancel_reason FROM member_requests
645
742
  WHERE room_id=? ORDER BY id DESC LIMIT ?""", (room_id, RECENT_REQUESTS))]
646
743
  return {"requests": requests[::-1], "executions": recent_executions(connection, room_id), "execution_minutes": execution_minutes(connection, room_id)}
647
744
 
@@ -659,22 +756,56 @@ def expire_executions(connection, room_id):
659
756
  settle_batches(connection, room_id)
660
757
 
661
758
 
662
- def finish_execution(connection, execution_id, status, result, request_status):
759
+ def record_cancel_reasons(connection, where, params, reason):
760
+ """Remember why requests are about to be cancelled; call before the status change selects them."""
761
+ connection.execute(f"INSERT OR REPLACE INTO request_cancel_reasons (request_id,reason) SELECT id,? FROM member_requests WHERE {where}", (reason, *params))
762
+
763
+
764
+ def finish_execution(connection, execution_id, status, result, request_status, cancel_reason=""):
663
765
  """End one execution and move its delivered requests in the same transaction."""
766
+ if request_status == "cancelled" and cancel_reason:
767
+ record_cancel_reasons(connection, "execution_id=? AND status='delivering'", (execution_id,), cancel_reason)
664
768
  connection.execute("UPDATE member_executions SET status=?,finished_at=?,result=? WHERE id=? AND status IN ('dispatched','started')",
665
769
  (status, int(time.time()), result[:500], execution_id))
666
770
  connection.execute("UPDATE member_requests SET status=?,execution_id=CASE WHEN ?='pending' THEN NULL ELSE execution_id END WHERE execution_id=? AND status='delivering'",
667
771
  (request_status, request_status, execution_id))
668
772
 
669
773
 
670
- def stop_member_work(connection, member_id, reason):
774
+ def open_route_messages(connection, member_id, pending_only=False):
775
+ """Messages whose host request is still open, in order; they need a new host if this one leaves."""
776
+ states = "'pending'" if pending_only else "'pending','delivering'"
777
+ return [row[0] for row in connection.execute(
778
+ f"SELECT message_id FROM member_requests WHERE member_id=? AND purpose='route' AND status IN ({states}) ORDER BY message_id", (member_id,))]
779
+
780
+
781
+ def hand_over_routes(connection, room_id, message_ids, departed_id):
782
+ """Ask the current host to route messages left behind by the previous host."""
783
+ host = connection.execute("SELECT id FROM room_members WHERE room_id=? AND role='host' AND status='active' AND id<>?",
784
+ (room_id, departed_id or 0)).fetchone()
785
+ for message_id in message_ids if host else []:
786
+ connection.execute("""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,purpose,created_by,created_at)
787
+ VALUES (?,?,?,?,'route','user',?)""", (room_id, host["id"], message_id, message_id, int(time.time())))
788
+
789
+
790
+ def stop_member_work(connection, member_id, reason, cancel_reason):
671
791
  """Void a member's live execution and every open request, so nothing restarts by itself."""
672
792
  for row in connection.execute("SELECT id FROM member_executions WHERE member_id=? AND status IN ('dispatched','started')",
673
793
  (member_id,)).fetchall():
674
- finish_execution(connection, row["id"], "cancelled", reason, "cancelled")
794
+ finish_execution(connection, row["id"], "cancelled", reason, "cancelled", cancel_reason)
795
+ record_cancel_reasons(connection, "member_id=? AND status='pending'", (member_id,), cancel_reason)
675
796
  connection.execute("UPDATE member_requests SET status='cancelled' WHERE member_id=? AND status='pending'", (member_id,))
676
797
 
677
798
 
799
+ def stop_room_work(connection, room_id, reason, cancel_reason):
800
+ """Void every live execution and open request of a room, e.g. when it is archived."""
801
+ for row in connection.execute("SELECT id FROM member_executions WHERE room_id=? AND status IN ('dispatched','started')",
802
+ (room_id,)).fetchall():
803
+ finish_execution(connection, row["id"], "cancelled", reason, "cancelled", cancel_reason)
804
+ record_cancel_reasons(connection, "room_id=? AND status='pending'", (room_id,), cancel_reason)
805
+ connection.execute("UPDATE member_requests SET status='cancelled' WHERE room_id=? AND status='pending'", (room_id,))
806
+ settle_batches(connection, room_id)
807
+
808
+
678
809
  def inbox(connection, room_id, token):
679
810
  """List what the runner should deliver or reconcile; owner credential only."""
680
811
  require_owner(connection, room_id, token)
@@ -742,6 +873,7 @@ def route_wide(connection, room_id, message_id, action):
742
873
  if route["status"] != "pending":
743
874
  raise FileExistsError("主持人已开始处理这条消息,不再改派")
744
875
  if action == "none":
876
+ record_cancel_reasons(connection, "id=?", (route["id"],), "no_reply")
745
877
  connection.execute("UPDATE member_requests SET status='cancelled' WHERE id=?", (route["id"],))
746
878
  else:
747
879
  # The host keeps its row (one request per message and member) and now answers its own share, without dispatching.
@@ -777,8 +909,9 @@ def adopt_recommendation(connection, room_id, token, message_id, payload):
777
909
  mode = payload["mode"]
778
910
  if mode == "interrupt" and route["status"] == "delivering":
779
911
  # The host's execution may carry other messages too; stopping it voids that whole execution.
780
- finish_execution(connection, route["execution_id"], "cancelled", "智能调度改派,改由其他成员回答", "cancelled")
912
+ finish_execution(connection, route["execution_id"], "cancelled", "智能调度改派,改由其他成员回答", "cancelled", "rerouted")
781
913
  elif mode == "interrupt" and route["status"] == "pending":
914
+ record_cancel_reasons(connection, "id=?", (route["id"],), "rerouted")
782
915
  connection.execute("UPDATE member_requests SET status='cancelled' WHERE id=?", (route["id"],))
783
916
  connection.execute("UPDATE member_recommendations SET status='waiting',mode=? WHERE message_id=?", (mode, message_id))
784
917
  release_recommendations(connection, room_id)
@@ -903,7 +1036,7 @@ def owned_member(connection, room_id, member_id, token):
903
1036
  def interrupt_member(connection, room_id, member_id, token):
904
1037
  """Owner stop: void the live execution and every open request."""
905
1038
  owned_member(connection, room_id, member_id, token)
906
- stop_member_work(connection, member_id, "用户打断")
1039
+ stop_member_work(connection, member_id, "用户打断", "user_stop")
907
1040
  settle_batches(connection, room_id)
908
1041
  return free_view(connection, room_id)
909
1042
 
@@ -1057,8 +1190,12 @@ def member_action(connection, room_id, member_id, token, payload):
1057
1190
  """
1058
1191
  member = owned_member(connection, room_id, member_id, token)
1059
1192
  action = payload.get("action")
1193
+ routed = []
1060
1194
  if action in ("away", "remove"):
1061
- stop_member_work(connection, member_id, "成员已离开" if action == "away" else "成员已移除")
1195
+ # Unnamed messages still waiting on a departing host move to its successor instead of being dropped.
1196
+ routed = open_route_messages(connection, member_id)
1197
+ stop_member_work(connection, member_id, "成员已离开" if action == "away" else "成员已移除",
1198
+ "member_away" if action == "away" else "member_removed")
1062
1199
  connection.execute("UPDATE room_members SET status=? WHERE id=?", ("away" if action == "away" else "removed", member_id))
1063
1200
  elif action == "active":
1064
1201
  hashed = token_hash(payload.get("token"))
@@ -1077,10 +1214,17 @@ def member_action(connection, room_id, member_id, token, payload):
1077
1214
  if role == "member" and member["role"] == "host":
1078
1215
  raise ValueError("自由房间须由一位 AI 主持;请指定另一位成员担任主持人")
1079
1216
  if role == "host":
1217
+ # Unnamed messages the old host has not started move to the new host, which carries the host rules.
1218
+ old_host = connection.execute("SELECT id FROM room_members WHERE room_id=? AND role='host' AND id<>?", (room_id, member_id)).fetchone()
1219
+ if old_host:
1220
+ routed = open_route_messages(connection, old_host["id"], pending_only=True)
1221
+ record_cancel_reasons(connection, "member_id=? AND purpose='route' AND status='pending'", (old_host["id"],), "rerouted")
1222
+ connection.execute("UPDATE member_requests SET status='cancelled' WHERE member_id=? AND purpose='route' AND status='pending'", (old_host["id"],))
1080
1223
  connection.execute("UPDATE room_members SET role='member' WHERE room_id=? AND role='host'", (room_id,))
1081
1224
  connection.execute("UPDATE room_members SET role=? WHERE id=?", (role, member_id))
1082
1225
  else:
1083
1226
  raise ValueError("action 须为 away、active、remove、role 或 adopt")
1084
1227
  settle_batches(connection, room_id)
1085
1228
  sync_participants(connection, room_id)
1229
+ hand_over_routes(connection, room_id, routed, member_id if action in ("away", "remove") else None)
1086
1230
  return free_view(connection, room_id)
@@ -602,6 +602,11 @@ CREATE TABLE IF NOT EXISTS scheduler_routing (
602
602
  created_at INTEGER NOT NULL,
603
603
  PRIMARY KEY (room_id, message_id)
604
604
  );
605
+ -- Why a request was cancelled; a side table so older databases need no column change. Requests cancelled before it existed have no row.
606
+ CREATE TABLE IF NOT EXISTS request_cancel_reasons (
607
+ request_id INTEGER PRIMARY KEY REFERENCES member_requests(id) ON DELETE CASCADE,
608
+ reason TEXT NOT NULL CHECK(reason IN ('user_stop','member_away','member_removed','rerouted','no_reply','archived'))
609
+ );
605
610
  -- What produced a decision (model, threshold, prompt and package versions), so ratings stay comparable across versions.
606
611
  CREATE TABLE IF NOT EXISTS scheduler_decision_context (
607
612
  decision_id INTEGER PRIMARY KEY REFERENCES scheduler_decisions(id) ON DELETE CASCADE,
@@ -635,13 +640,19 @@ CREATE TABLE IF NOT EXISTS free_batches (
635
640
  settled INTEGER NOT NULL DEFAULT 0,
636
641
  PRIMARY KEY (room_id,batch_id)
637
642
  );
638
- -- Members the user named to answer one after another: each gets its request once the one before it ends (host first, then by joining order).
643
+ -- Members the user named to answer one after another, in the room's speaking order: each gets its request once the one before
644
+ -- it ends. Asked rows stay so the speaking order can show who already answered; the owner may reorder rows not yet asked.
639
645
  CREATE TABLE IF NOT EXISTS free_queue (
640
646
  id INTEGER PRIMARY KEY AUTOINCREMENT,
641
647
  room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
642
648
  message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
643
649
  member_id INTEGER NOT NULL REFERENCES room_members(id) ON DELETE CASCADE
644
650
  );
651
+ -- The owner's default speaking order (JSON list of member ids); members it lacks follow, the host first, then by joining order.
652
+ CREATE TABLE IF NOT EXISTS speaking_orders (
653
+ room_id TEXT PRIMARY KEY REFERENCES rooms(id) ON DELETE CASCADE,
654
+ member_ids TEXT NOT NULL
655
+ );
645
656
  -- Chains whose automatic progress stopped (budget reached or every dispatched member failed).
646
657
  CREATE TABLE IF NOT EXISTS free_chains (
647
658
  room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
package/backend/server.py CHANGED
@@ -26,6 +26,7 @@ from .free_room import (add_member, after_free_message, authorize_free_write, ch
26
26
  discussion_members, room_mode, save_room_access, sync_discussion_members, view_execution)
27
27
  from .free_room import add_discussion_member, convert_suggestion, discussion_member_action, message_suggestions, save_suggestions, sync_participants
28
28
  from .free_room import active_members, adopt_recommendation, ask_members, asks_view, checked_asks, recommend_member, register_member, route_held_messages
29
+ from .free_room import reorder_speakers, speaking_order, stop_room_work
29
30
  from .identity import MAX_ROOM_AGENTS, PARTICIPANTS, is_ai, is_participant
30
31
  from .room_import import import_room, imported_attachment
31
32
  from .runtime import (authorize_write, begin_write, configure_runtime, event_action,
@@ -252,6 +253,7 @@ def require_room(connection, room_id):
252
253
  # Free rooms have no host hand-off: user messages go straight to the named members.
253
254
  room["held_since"] = 0
254
255
  room["free"] = free_view(connection, room_id)
256
+ room["speaking_order"] = speaking_order(connection, room)
255
257
  return room
256
258
 
257
259
 
@@ -621,6 +623,9 @@ def manage_room(connection, room_id, action, payload):
621
623
  elif action in ("archive", "restore"):
622
624
  if action == "archive" and room["phase"] != "concluded":
623
625
  save_topic_settings(connection, room_id, {**room, "phase": "paused"})
626
+ if action == "archive" and room["mode"] == "free":
627
+ # An archived room is read-only: nothing it still owes may be delivered or answered later.
628
+ stop_room_work(connection, room_id, "房间已归档", "archived")
624
629
  connection.execute("UPDATE room_directory SET archived=? WHERE room_id=?", (int(action == "archive"), room_id))
625
630
  elif action == "delete":
626
631
  if payload.get("confirm_title") != room["title"]:
@@ -634,7 +639,7 @@ def manage_room(connection, room_id, action, payload):
634
639
 
635
640
  def delete_room_records(connection, room_id):
636
641
  """Remove a room's dependent records in the caller's transaction."""
637
- for table in ("member_recommendations", "free_queue", "member_requests", "member_executions", "member_sessions", "member_registrations", "room_members", "room_modes", "free_batches", "free_chains", "room_links",
642
+ for table in ("member_recommendations", "free_queue", "speaking_orders", "member_requests", "member_executions", "member_sessions", "member_registrations", "room_members", "room_modes", "free_batches", "free_chains", "room_links",
638
643
  "discussion_options", "round_budgets", "scheduler_decisions", "scheduler_routing", "discussion_sessions"):
639
644
  if table == "scheduler_decisions":
640
645
  for child in ("scheduler_feedback", "scheduler_decision_context"):
@@ -1144,8 +1149,10 @@ def roster_view(room):
1144
1149
  last = room["rounds"][-1] if room["rounds"] else None
1145
1150
  spoken = set(last["speakers"]) if last and not last["completed"] else set()
1146
1151
  pending_registration = unregistered_experts(room)
1147
- experts = [{"name": name, "mode": mode, "host": name == room["host"], "spoken": name in spoken,
1148
- "registered": name not in pending_registration} for name, mode in room["experts"].items()]
1152
+ # Experts follow the room's speaking order, so a round plan can take the user's preferred order from the roster.
1153
+ rank = {item["name"]: index for index, item in enumerate(room["speaking_order"]["items"])}
1154
+ experts = [{"name": name, "mode": room["experts"][name], "host": name == room["host"], "spoken": name in spoken,
1155
+ "registered": name not in pending_registration} for name in sorted(room["experts"], key=lambda name: rank.get(name, len(rank)))]
1149
1156
  state = json.dumps([sorted(room["experts"]), sorted(spoken), sorted(pending_registration)], ensure_ascii=False)
1150
1157
  return {"experts": experts, "unregistered": pending_registration,
1151
1158
  "waiting": [item["name"] for item in experts if not item["spoken"] and not item["host"] and item["registered"]],
@@ -1943,6 +1950,15 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1943
1950
  result = register_member(connection, room_id, token, utc_now())
1944
1951
  connection.commit()
1945
1952
  return self.respond(result)
1953
+ if parts[3:] == ["speaking-order"]:
1954
+ if method == "GET":
1955
+ return self.respond(require_room(connection, room_id)["speaking_order"])
1956
+ payload = self.read_payload()
1957
+ begin_write(connection)
1958
+ reorder_speakers(connection, require_room(connection, room_id), token, payload)
1959
+ result = require_room(connection, room_id)
1960
+ connection.commit()
1961
+ return self.respond(result)
1946
1962
  free = room_mode(connection, room_id) == "free"
1947
1963
  # A discussion keeps the local service's inbox and per-member executions for the user's direct questions.
1948
1964
  if parts[3:4] in (["inbox"], ["executions"]) or (free and parts[3:4] in (["members"], ["suggestions"], ["recommendations"])):