ai_co_discuss 0.3.46 → 0.3.49

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.
Files changed (43) hide show
  1. package/.agents/skills/discuss-host/SKILL.md +1 -0
  2. package/.agents/skills/discuss-host/references/discussion.md +113 -113
  3. package/.agents/skills/discuss-host/references/free.md +36 -34
  4. package/.agents/skills/discuss-host/references/goal.md +35 -0
  5. package/.agents/skills/discuss-participant/SKILL.md +1 -0
  6. package/.agents/skills/workflow-creator/SKILL.md +2 -2
  7. package/README.md +3 -1
  8. package/backend/admin.py +104 -35
  9. package/backend/free_room.py +2170 -2136
  10. package/backend/goal_room.py +358 -0
  11. package/backend/mode_room.py +105 -0
  12. package/backend/schema.sql +1593 -1510
  13. package/backend/server.py +2636 -2555
  14. package/backend/session_room.py +1180 -1148
  15. package/docs/THIRD_PARTY_NOTICES.md +1293 -1264
  16. package/docs/deployment.md +5 -1
  17. package/docs/en/workflow.md +15 -1
  18. package/docs/runner.md +28 -7
  19. package/docs/workflow.md +15 -1
  20. package/package.json +3 -2
  21. package/runner/adapters/api.mjs +333 -313
  22. package/runner/adapters/claude.mjs +12 -16
  23. package/runner/admin_account.mjs +12 -19
  24. package/runner/device_auth.mjs +369 -0
  25. package/runner/discuss.py +1208 -1125
  26. package/runner/discussion_launch.mjs +107 -12
  27. package/runner/discussion_service.mjs +25 -1
  28. package/runner/expert_prompts.mjs +132 -132
  29. package/runner/free_runner.mjs +982 -910
  30. package/runner/pinyin_table.mjs +406 -0
  31. package/runner/project_files.mjs +52 -3
  32. package/runner/relay_channel.mjs +51 -7
  33. package/runner/remote_launch.mjs +221 -235
  34. package/runner/remote_policy.mjs +156 -0
  35. package/runner/scheduler.mjs +459 -429
  36. package/runner/stt.mjs +2 -2
  37. package/runner/tts.mjs +74 -113
  38. package/runner/tts_providers.mjs +205 -0
  39. package/runner/webauthn.mjs +155 -0
  40. package/runner/workflows.mjs +371 -348
  41. package/web/app.js +355 -51
  42. package/web/modern.css +1 -1
  43. package/web/modern.js +5764 -3816
@@ -1,2136 +1,2170 @@
1
- """Free collaboration rooms: member credentials, durable requests and per-member executions.
2
-
3
- A free room has no floor, turns or rounds. The user (holding the room control credential)
4
- names members; each named member gets one durable request per message. The local runner
5
- delivers a runner member's open requests as one execution, and the member's answer completes
6
- them. A manual member finds its own requests and answers by request id. The user is the client, never the host: one member that
7
- answers by itself always hosts, routes unnamed messages, names members in its answers, and is
8
- woken once when everything it dispatched has finished. No software is launched here.
9
- """
10
-
11
- import hashlib
12
- import hmac
13
- import json
14
- import re
15
- import time
16
- import uuid
17
-
18
- from .identity import MAX_ROOM_AGENTS, default_label, is_ai
19
- from .message_queue import pause as pause_queue, queue_view
20
- from .runtime import (LEASE_SECONDS, MAX_EXECUTION_SEGMENTS, begin_write, enforce_session_limits,
21
- require_owner, save_segments, token_hash)
22
-
23
- # Adapter and client values are named by the runner's adapter layer; the site only checks their shape (AGENTS.md red line 9).
24
- ADAPTER_VALUE = re.compile(r"[a-z][a-z0-9_]{1,31}")
25
- MAX_REVIEW_SCOPE = 2000
26
- MAX_REVIEW_INPUT = 60000
27
- REVIEW_STATE_SQL = """SELECT json_object(
28
- 'room',json_array(id,title,goal,(SELECT archived FROM room_directory WHERE room_id=rooms.id)),
29
- 'latest',(SELECT COALESCE(MAX(id),0) FROM messages WHERE room_id=rooms.id),
30
- 'members',(SELECT json_group_array(json_array(id,name,role,status,access,session_mode,adapter,token_hash)) FROM (SELECT * FROM room_members WHERE room_id=rooms.id ORDER BY id)),
31
- 'caps',(SELECT json_group_array(json_array(c.member_id,c.member_revision,c.config_version,c.generation)) FROM member_review_capabilities c JOIN room_members m ON m.id=c.member_id WHERE m.room_id=rooms.id),
32
- 'requests',(SELECT json_group_array(json_array(id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose)) FROM (SELECT * FROM member_requests WHERE room_id=rooms.id ORDER BY id)),
33
- 'executions',(SELECT json_group_array(json_array(id,status,result,changes)) FROM (SELECT * FROM member_executions WHERE room_id=rooms.id ORDER BY id)),
34
- 'batches',(SELECT json_group_array(json_array(batch_id,settled,host_id,chain_id)) FROM free_batches WHERE room_id=rooms.id),
35
- 'plans',(SELECT json_group_array(json_array(batch_id,status,reviewer_id,config_version,member_revision,scope,review_batch_id)) FROM review_plans WHERE room_id=rooms.id),
36
- 'limit',COALESCE((SELECT max_rounds FROM free_round_limits WHERE room_id=rooms.id),3),
37
- 'holds',(SELECT json_group_array(json_array(chain_id,extra_rounds,message_id)) FROM free_chain_holds WHERE room_id=rooms.id),
38
- 'stopped',(SELECT json_group_array(json_array(chain_id,reason)) FROM free_chains WHERE room_id=rooms.id),
39
- 'withdrawn',(SELECT json_group_array(message_id) FROM message_withdrawals WHERE message_id IN (SELECT id FROM messages WHERE room_id=rooms.id))
40
- ) AS state FROM rooms WHERE id=?"""
41
- RECENT_REQUESTS = 100
42
- ADOPT_REASON_LIMIT = 500
43
- RECENT_EXECUTIONS = 100
44
- # The timeline page reads this many recent executions of each kind; with `after` (?3) only those running or ended since.
45
- # Parameters: ?1 room, ?2 limit, ?3 after (0 reads all). Shared verbatim with the Worker.
46
- TIMELINE_EXECUTIONS = 100
47
- # The event log page reads at most this many room events per call, newest first when paging back.
48
- ROOM_EVENT_PAGE = 500
49
- # The message a request's card sits under, for member_requests aliased `r`. A handoff request belongs to the dispatch
50
- # message (`message_id`, which batch logic relies on) but happens because the batch's last answer arrived, so its card
51
- # and answer sit under that answer; later messages (the user asking something meanwhile) then stay above it.
52
- ANCHOR_SQL = """CASE WHEN r.purpose='handoff' THEN COALESCE((SELECT MAX(a.answer_id) FROM member_requests a
53
- WHERE a.room_id=r.room_id AND a.batch_id=r.message_id AND a.purpose<>'handoff'), r.message_id) ELSE r.message_id END"""
54
- # Rounds used and allowed in the room's recent chains, for "第 2/3 轮"; parameters: the room's limit, the room.
55
- CHAIN_PROGRESS_SQL = """SELECT b.chain_id,COUNT(*) AS used,? + COALESCE((SELECT h.extra_rounds FROM free_chain_holds h
56
- WHERE h.room_id=b.room_id AND h.chain_id=b.chain_id),0) AS allowed FROM free_batches b WHERE b.room_id=?
57
- GROUP BY b.chain_id ORDER BY b.chain_id DESC LIMIT 10"""
58
- TIMELINE_STATES = {"completed": "done", "failed": "failed", "cancelled": "cancelled"}
59
- TIMELINE_FREE_IDS = "SELECT id FROM member_executions WHERE room_id=?1 AND (?3=0 OR finished_at=0 OR finished_at>=?3) ORDER BY id DESC LIMIT ?2"
60
- # Besides times: the task an execution took on (its request message), its result (the answer's summary, else the
61
- # answer's opening), its latest progress note, how many files it changed (NULL when unobserved) and its last output time.
62
- TIMELINE_SQL = f"""SELECT x.*,(SELECT COALESCE(NULLIF(s.text,''),substr(a.text,1,200)) FROM messages a
63
- LEFT JOIN message_summaries s ON s.message_id=a.id WHERE a.id=x.answer_id) AS result FROM (
64
- SELECT 'free' AS source,e.id,m.name AS member,m.role,e.status,
65
- COALESCE((SELECT MIN(r.created_at) FROM member_requests r WHERE r.execution_id=e.id),e.created_at) AS queued_at,
66
- e.created_at AS dispatched_at,0 AS started_at,e.finished_at,
67
- CASE WHEN e.status='failed' THEN e.result ELSE '' END AS reason,
68
- (SELECT parent.execution_id FROM member_requests r JOIN member_requests parent ON parent.answer_id=r.message_id
69
- WHERE r.execution_id=e.id AND parent.execution_id IS NOT NULL ORDER BY r.id LIMIT 1) AS parent_id,
70
- e.activity_at,(SELECT MIN(r.message_id) FROM member_requests r WHERE r.execution_id=e.id) AS task_id,
71
- (SELECT substr(q.text,1,160) FROM messages q WHERE q.id=(SELECT MIN(r.message_id) FROM member_requests r WHERE r.execution_id=e.id)) AS task,
72
- (SELECT MAX(r.answer_id) FROM member_requests r WHERE r.execution_id=e.id) AS answer_id,
73
- (SELECT n.text FROM execution_notes n WHERE n.execution_id=e.id ORDER BY n.seq DESC LIMIT 1) AS last_note,
74
- CASE WHEN e.changes='' THEN NULL ELSE json_array_length(e.changes) END AS changes
75
- FROM member_executions e JOIN room_members m ON m.id=e.member_id WHERE e.id IN ({TIMELINE_FREE_IDS})) x"""
76
- TIMELINE_SEGMENTS_SQL = f"""SELECT source,execution_id,phase,tool,start_ms,end_ms FROM execution_segments WHERE room_id=?1
77
- AND source='free' AND execution_id IN ({TIMELINE_FREE_IDS}) ORDER BY source,execution_id,seq"""
78
- # Only the times of progress notes: the texts are read when the reader hovers one.
79
- TIMELINE_NOTES_SQL = f"""SELECT execution_id,created_at FROM execution_notes
80
- WHERE room_id=?1 AND execution_id IN ({TIMELINE_FREE_IDS}) ORDER BY execution_id,seq"""
81
- RECENT_CHANGES = 50
82
- MAX_CHANGED_FILES = 200
83
- # What a member's software last showed it doing, as reported by the runner with each renewal.
84
- ACTIVITY_PHASES = ("", "thinking", "command", "tool", "editing", "writing")
85
- # Progress notes per execution, their length, and how many one renewal may carry.
86
- MAX_EXECUTION_NOTES = 50
87
- MAX_NOTE_LENGTH = 500
88
- MAX_NOTES_PER_RENEW = 10
89
- # A member description is a short note for the scheduler and the host, keyed by the member's name.
90
- MAX_MEMBER_DESCRIPTION = 500
91
- # A display label replaces the member's name on screen only; the name stays the member's identity everywhere else.
92
- MAX_MEMBER_LABEL = 32
93
- MAX_MEMBER_NAME = 80
94
- MAX_PATH_LENGTH = 500
95
- # Refused operations one execution may report, and the length of each field.
96
- MAX_DENIALS = 20
97
- MAX_DENIAL_FIELD = 300
98
- # A manual member's delivery is retried at most this often before the execution fails.
99
- MAX_RETRIES = 3
100
- MAX_RETRY_REASON = 500
101
- ACTIVE = ("dispatched", "started")
102
- ACCESS_MODES = ("runner", "manual")
103
- SCHEDULER_PROVIDERS = ("jev", "decisions")
104
- RECOMMEND_SECONDS = 60
105
- # A click sent just before the hint disappears still counts.
106
- RECOMMEND_GRACE_SECONDS = 5
107
- # Rounds a host may run from one owner message by default (a round: one host dispatch, its members' answers and the
108
- # hand-back); each owner message starts a chain. Counted in rounds, so a room with more members gets no fewer of them.
109
- CHAIN_ROUNDS = 3
110
- # The most rounds an owner may allow per chain, or add at once when continuing a stopped chain.
111
- MAX_CHAIN_ROUNDS = 20
112
- # A host that left gives up the role; the first active runner member (else manual) then takes it.
113
- RELEASE_HOST = "UPDATE room_members SET role='member' WHERE room_id=? AND role='host' AND status<>'active'"
114
- ASSIGN_HOST = """UPDATE room_members SET role='host' WHERE id=(SELECT id FROM room_members WHERE room_id=? AND status='active'
115
- ORDER BY access='runner' DESC, id LIMIT 1)
116
- AND NOT EXISTS (SELECT 1 FROM room_members WHERE room_id=? AND role='host' AND status='active')"""
117
- TERMINAL = ("completed", "cancelled", "failed")
118
- # A queued member has been asked once its request for that message exists.
119
- QUEUE_ASKED = "EXISTS(SELECT 1 FROM member_requests r WHERE r.message_id={row}.message_id AND r.member_id={row}.member_id)"
120
- SESSION_MODES = ("new", "borrowed")
121
- FREE_OPERATIONS = ("messages", "attachments", "move", "folder", "rename", "archive", "restore", "delete")
122
- MEMBER_FIELDS = ("id", "name", "adapter", "role", "status", "access", "session_mode", "read_until")
123
- EXECUTION_FIELDS = ("id", "member_id", "status", "session_id", "lease_until", "created_at", "finished_at", "result",
124
- "activity", "activity_at")
125
- # What the runner found when a voided execution's software was to stop; '' until it reports.
126
- STOP_RESULTS = ("stopped", "unstoppable", "unconfirmed")
127
- # How long a member voided where the runner could not stop it gets no new delivery unless its late answer shows the turn ended.
128
- HOLD_SECONDS = 600
129
- # What a rewind does to a runner member's session, as its adapter reports.
130
- REWIND_EFFECTS = ("cut", "fresh", "kept")
131
- # The reported capabilities and stop result read alongside an execution aliased `e`.
132
- CAPABILITY_COLUMNS = """(SELECT stoppable FROM execution_capabilities c WHERE c.execution_id=e.id) AS stoppable,
133
- (SELECT progress FROM execution_capabilities c WHERE c.execution_id=e.id) AS progress,
134
- (SELECT stop_result FROM execution_capabilities c WHERE c.execution_id=e.id) AS stop_result"""
135
-
136
-
137
- def reported_capabilities(row):
138
- """`{stoppable, progress}` as the runner reported them for one execution, or None before it did (or an older runner)."""
139
- return None if row["stoppable"] is None else {"stoppable": bool(row["stoppable"]), "progress": bool(row["progress"])}
140
- # Columns added after the first free-room tables; Python databases created by that build gain them here.
141
- LATER_COLUMNS = {"room_members": {"access": "TEXT NOT NULL DEFAULT 'runner'", "session_mode": "TEXT NOT NULL DEFAULT 'new'"},
142
- "member_executions": {"changes": "TEXT NOT NULL DEFAULT ''", "cwd": "TEXT NOT NULL DEFAULT ''",
143
- "activity": "TEXT NOT NULL DEFAULT ''", "activity_at": "INTEGER NOT NULL DEFAULT 0"},
144
- "member_requests": {"chain_id": "INTEGER NOT NULL DEFAULT 0", "batch_id": "INTEGER NOT NULL DEFAULT 0",
145
- "purpose": "TEXT NOT NULL DEFAULT 'ask'", "created_by": "TEXT NOT NULL DEFAULT 'user'"}}
146
-
147
-
148
- def migrate_free_tables(connection):
149
- """Add columns that early free-room databases lack; repeated runs change nothing."""
150
- for table, columns in LATER_COLUMNS.items():
151
- present = {row[1] for row in connection.execute(f"PRAGMA table_info({table})")}
152
- for name, definition in columns.items():
153
- if name not in present:
154
- connection.execute(f"ALTER TABLE {table} ADD COLUMN {name} {definition}")
155
- migrate_stop_results(connection)
156
-
157
-
158
- def migrate_stop_results(connection):
159
- """Keep development capability records while allowing the new unconfirmed stop result."""
160
- row = connection.execute("SELECT sql FROM sqlite_master WHERE name='execution_capabilities'").fetchone()
161
- if not row or "'unconfirmed'" in row[0]:
162
- return
163
- definition = row[0].replace("execution_capabilities", "execution_capabilities_next", 1)
164
- connection.execute(definition.replace("'unstoppable'", "'unstoppable','unconfirmed'"))
165
- connection.execute("INSERT INTO execution_capabilities_next SELECT * FROM execution_capabilities")
166
- connection.execute("DROP TABLE execution_capabilities")
167
- connection.execute("ALTER TABLE execution_capabilities_next RENAME TO execution_capabilities")
168
-
169
-
170
- def review_member_revision(member, generation=0):
171
- """Bind an attestation to the exact member identity and access configuration without exposing credentials."""
172
- state = ":".join(str(member[key]) for key in ("id", "status", "adapter", "access", "session_mode", "token_hash")) + f":{generation}"
173
- return hashlib.sha256(state.encode("utf-8")).hexdigest()
174
-
175
-
176
- def review_capability(connection, room_id, member_id, token, payload=None):
177
- """Read or replace one owner's local read-only attestation, guarded by the member revision."""
178
- require_owner(connection, room_id, token)
179
- if payload is not None:
180
- begin_write(connection)
181
- member = connection.execute("SELECT * FROM room_members WHERE room_id=? AND id=?", (room_id, member_id)).fetchone()
182
- if not member:
183
- raise LookupError("成员不存在")
184
- previous = connection.execute("SELECT generation FROM member_review_capabilities WHERE member_id=?", (member_id,)).fetchone()
185
- revision = review_member_revision(member, previous[0] if previous else 0)
186
- if payload is not None:
187
- if not payload.get("revision"):
188
- raise ValueError("请先读取成员 review-capability,再带 revision 提交能力事实")
189
- if payload["revision"] != revision:
190
- raise FileExistsError("成员状态已变化,请重新读取能力状态")
191
- if type(payload.get("read_only")) is not bool:
192
- raise ValueError("read_only 须为布尔值")
193
- if payload["read_only"]:
194
- version = payload.get("config_version", "")
195
- if not isinstance(version, str) or not re.fullmatch(r"[a-f0-9]{64}", version):
196
- raise ValueError("config_version 须为本机配置的 SHA-256 摘要")
197
- # Whether the software can enforce a read-only project is the runner's adapter to judge; it attests only then.
198
- if member["status"] != "active" or member["access"] != "runner" or member["session_mode"] != "new":
199
- raise FileExistsError("仅支持程序启动、可核验只读权限的本地 CLI 成员")
200
- connection.execute("""INSERT INTO member_review_capabilities(member_id,member_revision,config_version,adapter,recorded_at) VALUES (?,?,?,?,?)
201
- ON CONFLICT(member_id) DO UPDATE SET member_revision=excluded.member_revision,
202
- config_version=excluded.config_version,adapter=excluded.adapter,recorded_at=excluded.recorded_at""",
203
- (member_id, revision, version, member["adapter"], int(time.time())))
204
- else:
205
- connection.execute("UPDATE member_review_capabilities SET config_version='',member_revision='' WHERE member_id=?", (member_id,))
206
- capability = connection.execute("SELECT config_version,adapter FROM member_review_capabilities WHERE member_id=? AND member_revision=?",
207
- (member_id, revision)).fetchone()
208
- return {"member_id": member_id, "member": member["name"], "revision": revision,
209
- "capability": dict(capability) if capability else None}
210
-
211
-
212
- def review_roster(connection, room_id, token):
213
- """Return eligible local reviewers to the owner or current host; never publish member credentials."""
214
- try:
215
- require_owner(connection, room_id, token)
216
- except PermissionError:
217
- member = require_member(connection, room_id, token)
218
- if member["role"] != "host":
219
- raise PermissionError("只有房主或当前主持人可读取评审候选")
220
- rows = connection.execute("""SELECT m.*,c.config_version,c.member_revision,c.generation FROM room_members m
221
- JOIN member_review_capabilities c ON c.member_id=m.id WHERE m.room_id=? AND m.status='active'
222
- AND m.access='runner' AND m.session_mode='new' ORDER BY m.id""", (room_id,)).fetchall()
223
- members = [{"id": row["id"], "name": row["name"], "adapter": row["adapter"], "config_version": row["config_version"]}
224
- for row in rows if row["member_revision"] == review_member_revision(row, row["generation"])]
225
- state = connection.execute(REVIEW_STATE_SQL, (room_id,)).fetchone()["state"]
226
- return {"members": members, "revision": hashlib.sha256(state.encode("utf-8")).hexdigest(),
227
- "reason": "" if members else "没有可核验只读的评审成员"}
228
-
229
-
230
- def checked_review_plan(connection, room_id, actor, payload):
231
- """Validate one preauthorized review before the host message and implementation dispatch commit."""
232
- if not payload.get("next_review"):
233
- if payload.get("review_scope") or payload.get("review_roster"):
234
- raise ValueError("review_scope / review_roster 须与 next_review 一起提交")
235
- return None
236
- if not isinstance(actor, dict) or actor["role"] != "host" or not payload.get("to") or payload.get("kind") == "moderation":
237
- raise PermissionError("只有主持人派实现任务时可以声明下一步评审")
238
- if not isinstance(payload["next_review"], str):
239
- raise ValueError("next_review 须为成员名称")
240
- if not payload.get("review_roster"):
241
- raise ValueError("请先读取 review roster,再带 review_roster 声明评审")
242
- state = connection.execute(REVIEW_STATE_SQL, (room_id,)).fetchone()["state"]
243
- if payload["review_roster"] != hashlib.sha256(state.encode("utf-8")).hexdigest():
244
- raise FileExistsError("评审候选或任务状态已变化,请重新读取 review roster")
245
- scope = payload.get("review_scope", "")
246
- if not isinstance(scope, str) or not 1 <= len(scope.strip()) <= MAX_REVIEW_SCOPE:
247
- raise ValueError(f"评审范围须为 1–{MAX_REVIEW_SCOPE} 字")
248
- if payload["next_review"] in payload["to"]:
249
- raise ValueError("评审者不能是该批次的实现者")
250
- reviewer = connection.execute("""SELECT m.*,c.config_version,c.member_revision,c.generation FROM room_members m
251
- JOIN member_review_capabilities c ON c.member_id=m.id WHERE m.room_id=? AND m.name=?""", (room_id, payload["next_review"])).fetchone()
252
- if not reviewer or reviewer["status"] != "active" or reviewer["member_revision"] != review_member_revision(reviewer, reviewer["generation"]) or not reviewer["config_version"]:
253
- raise FileExistsError("没有可核验只读的评审成员:指定成员未上报有效能力或已失效")
254
- chain_id = answered_chain(connection, payload, actor)
255
- if not chain_id or rounds_left(connection, room_id, chain_id) < 1:
256
- raise FileExistsError("任务链剩余额度不足,不能保存实现批次及评审授权")
257
- return {"chain_id": chain_id, "host_id": actor["id"], "reviewer_id": reviewer["id"], "reviewer_name": reviewer["name"],
258
- "config_version": reviewer["config_version"], "member_revision": reviewer["member_revision"], "scope": scope.strip(),
259
- "implementers": json.dumps(sorted(set(payload["to"])), ensure_ascii=False)}
260
-
261
-
262
- def save_review_plan(connection, room_id, batch_id, plan):
263
- """Persist the next step only inside the implementation message transaction."""
264
- if plan:
265
- fields = ("chain_id", "host_id", "reviewer_id", "reviewer_name", "config_version", "member_revision", "scope", "implementers")
266
- connection.execute(f"INSERT INTO review_plans(room_id,batch_id,{','.join(fields)}) VALUES ({','.join('?' for _ in range(10))})",
267
- (room_id, batch_id, *(plan[key] for key in fields)))
268
-
269
-
270
- def check_review_retry(connection, room_id, message_id, payload):
271
- """A repeated host message cannot silently change or remove its preauthorized review."""
272
- plan = connection.execute("SELECT * FROM review_plans WHERE room_id=? AND batch_id=?", (room_id, message_id)).fetchone()
273
- if not plan and not payload.get("next_review"):
274
- return
275
- if not plan or (plan["reviewer_name"], plan["scope"], json.loads(plan["implementers"])) != (
276
- payload.get("next_review"), str(payload.get("review_scope", "")).strip(), sorted(set(payload.get("to", [])))):
277
- raise FileExistsError("这个提交编号已绑定不同的评审授权,请为新内容使用新编号")
278
-
279
-
280
- def review_ready(connection, room_id, batch_id, token):
281
- """Read original delivery evidence and the exact reasons a preauthorized step cannot advance."""
282
- roster = review_roster(connection, room_id, token)
283
- plan = connection.execute("SELECT * FROM review_plans WHERE room_id=? AND batch_id=?", (room_id, batch_id)).fetchone()
284
- if not plan:
285
- raise LookupError("该批次没有预授权评审")
286
- plan = dict(plan)
287
- batch = connection.execute("SELECT * FROM free_batches WHERE room_id=? AND batch_id=?", (room_id, batch_id)).fetchone()
288
- requests = [dict(row) for row in connection.execute("""SELECT r.id,r.member_id,m.name AS member,r.status,r.answer_id,
289
- a.text AS answer,e.id AS execution_id,e.status AS execution_status,e.result,e.changes
290
- FROM member_requests r JOIN room_members m ON m.id=r.member_id LEFT JOIN messages a ON a.id=r.answer_id
291
- LEFT JOIN member_executions e ON e.id=r.execution_id WHERE r.room_id=? AND r.batch_id=? ORDER BY r.id""", (room_id, batch_id))]
292
- handoff = connection.execute("SELECT * FROM member_requests WHERE room_id=? AND message_id=? AND member_id=? AND purpose='handoff'",
293
- (room_id, batch_id, plan["host_id"])).fetchone()
294
- host = connection.execute("SELECT id FROM room_members WHERE room_id=? AND role='host' AND status='active'", (room_id,)).fetchone()
295
- candidate = next((member for member in roster["members"] if member["id"] == plan["reviewer_id"]), None)
296
- capability = connection.execute("SELECT member_revision FROM member_review_capabilities WHERE member_id=?", (plan["reviewer_id"],)).fetchone()
297
- remaining = rounds_left(connection, room_id, plan["chain_id"])
298
- reason = review_block_reason(plan, requests, {
299
- "batch": bool(batch and batch["settled"]), "handoff": bool(handoff and handoff["status"] in ("pending", "delivering")),
300
- "host": bool(host and host[0] == plan["host_id"]), "remaining": remaining,
301
- "capability": bool(candidate and candidate["config_version"] == plan["config_version"] and capability[0] == plan["member_revision"]),
302
- "archived": connection.execute("SELECT archived FROM room_directory WHERE room_id=?", (room_id,)).fetchone()[0]})
303
- original = connection.execute("SELECT id,author,text FROM messages WHERE room_id=? AND id=?", (room_id, batch_id)).fetchone()
304
- if not original:
305
- reason = reason or "缺少原委派,不能自动交评审"
306
- elif len(json.dumps([dict(original), requests], ensure_ascii=False, separators=(",", ":"))) > MAX_REVIEW_INPUT:
307
- reason = reason or "交付原文与执行报告超过 60000 字符,请主持人处理"
308
- return {"plan": plan, "original": dict(original) if original else None, "deliveries": requests, "handoff": dict(handoff) if handoff else None,
309
- "remaining_rounds": remaining, "revision": roster["revision"], "ready": not reason, "reason": reason}
310
-
311
-
312
- def review_block_reason(plan, requests, facts):
313
- """Choose a concrete host-facing explanation without interpreting delivery prose."""
314
- if plan["status"] != "pending":
315
- return "评审授权已经消费或仅为导入历史"
316
- if facts["archived"] or not facts["host"]:
317
- return "房间已存档或主持人已改变,原评审授权不再有效"
318
- if not facts["batch"] or not requests or any(item["status"] != "completed" or not item["answer_id"] or item["answer"] is None for item in requests):
319
- return "实现批次尚未全部成功交付,或缺少交付原文"
320
- if any(item["member_id"] == plan["reviewer_id"] for item in requests):
321
- return "评审者不能是该批次的实现者"
322
- if not facts["handoff"]:
323
- return "该批次交回主持人的请求已处理或取消"
324
- if not facts["capability"]:
325
- return "没有可核验只读的评审成员:既定评审者离开或能力配置已变化"
326
- if facts["remaining"] < 1:
327
- return "任务链剩余额度不足,需用户续额或主持人另行处理"
328
- return ""
329
-
330
-
331
- def advance_review(connection, room_id, token, payload):
332
- """Consume one exact review authorization and close only its handoff in one transaction."""
333
- begin_write(connection)
334
- host = require_member(connection, room_id, token, payload.get("actor"))
335
- if host["role"] != "host":
336
- raise PermissionError("只有当前主持人可以推进预授权评审")
337
- batch_id, submission = payload.get("batch_id"), payload.get("submission_id", "")
338
- if type(batch_id) is not int or not isinstance(submission, str) or not re.fullmatch(r"[A-Za-z0-9_-]{8,100}", submission):
339
- raise ValueError("须提供 batch_id 和有效 submission_id")
340
- saved = connection.execute("SELECT batch_id,review_batch_id FROM review_plans WHERE room_id=? AND advance_submission=?", (room_id, submission)).fetchone()
341
- if saved:
342
- if saved["batch_id"] != batch_id:
343
- raise FileExistsError("提交编号已用于其他评审批次")
344
- execution = connection.execute("""SELECT e.status FROM member_requests r JOIN member_executions e ON e.id=r.execution_id
345
- JOIN review_plans p ON p.room_id=r.room_id AND p.batch_id=r.message_id AND p.host_id=r.member_id
346
- WHERE r.room_id=? AND p.batch_id=? AND r.purpose='handoff'""", (room_id, batch_id)).fetchone()
347
- return {"batch_id": batch_id, "review_batch_id": saved["review_batch_id"], "duplicate": True,
348
- "execution_completed": bool(execution and execution[0] == "completed")}
349
- if not payload.get("revision"):
350
- raise ValueError("请先读取 review ready,再携带 revision 推进")
351
- state = review_ready(connection, room_id, batch_id, token)
352
- if payload["revision"] != state["revision"]:
353
- raise FileExistsError("评审状态已变化,请重新读取 review ready")
354
- if not state["ready"]:
355
- raise FileExistsError(state["reason"])
356
- plan, handoff = state["plan"], state["handoff"]
357
- if plan["host_id"] != host["id"]:
358
- raise PermissionError("本主持人没有该批次的评审授权")
359
- answer_ids = sorted({item["answer_id"] for item in state["deliveries"]})
360
- question = f"请只读评审实现批次 #{batch_id}。范围:{plan['scope']}。先读原委派 #{batch_id},再读交付原文 " + "、".join(f"#{number}" for number in answer_ids) + "。不得修改项目;评审结束交回主持人。"
361
- cursor = connection.execute("INSERT INTO messages(room_id,author,text,source,submission_id,created_at) VALUES(?, '系统任务',?,'system',?,?)",
362
- (room_id, question, f"review-task-{batch_id}", time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())))
363
- review_batch_id = cursor.lastrowid
364
- connection.execute("UPDATE review_plans SET status='advanced',review_batch_id=?,advance_submission=? WHERE room_id=? AND batch_id=?",
365
- (review_batch_id, submission, room_id, batch_id))
366
- connection.execute("INSERT INTO free_batches(room_id,batch_id,chain_id,host_id) VALUES(?,?,?,?)", (room_id, review_batch_id, plan["chain_id"], host["id"]))
367
- connection.execute("INSERT INTO free_handoffs(room_id,batch_id) VALUES(?,?)", (room_id, review_batch_id))
368
- insert_request(connection, room_id, plan["reviewer_id"], review_batch_id, plan["chain_id"], "ask", "host", batch_id=review_batch_id)
369
- connection.execute("UPDATE member_requests SET status='completed' WHERE id=?", (handoff["id"],))
370
- completed = finish_review_handoff(connection, handoff)
371
- return {"batch_id": batch_id, "review_batch_id": review_batch_id, "execution_completed": completed, "duplicate": False}
372
-
373
-
374
- def finish_review_handoff(connection, handoff):
375
- """Finish the host execution only when its other requests have all been handled."""
376
- execution_id = handoff["execution_id"]
377
- if not execution_id or connection.execute("SELECT 1 FROM member_requests WHERE execution_id=? AND status='delivering'", (execution_id,)).fetchone():
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))
381
- return True
382
-
383
-
384
- def member_value(member):
385
- """Validate one member and its client-generated credential."""
386
- if not isinstance(member, dict) or not is_ai(member.get("name")):
387
- raise ValueError("成员须为有效 AI 身份")
388
- if not token_hash(member.get("token")):
389
- raise ValueError("每位成员须提供 32–256 字符的成员凭证")
390
- adapter = member.get("adapter", "")
391
- if not isinstance(adapter, str) or len(adapter) > 40:
392
- raise ValueError("成员适配器名称无效")
393
- access, session_mode = member.get("access", "runner"), member.get("session_mode", "new")
394
- if access not in ACCESS_MODES or session_mode not in SESSION_MODES:
395
- raise ValueError("成员接入方式须为 runner/manual,会话方式须为 new/borrowed")
396
- return {"name": member["name"], "adapter": adapter, "token": member["token"],
397
- "access": access, "session_mode": session_mode}
398
-
399
-
400
- def review_request_context(connection, request):
401
- """Expose only the request's review provenance and local capability binding."""
402
- plan = connection.execute("SELECT * FROM review_plans WHERE batch_id=? OR review_batch_id=?",
403
- (request["message_id"], request["message_id"])).fetchone()
404
- if not plan:
405
- return {}
406
- phase = "handoff" if request["message_id"] == plan["batch_id"] else "review" if request["purpose"] == "ask" else "result"
407
- if phase == "handoff" and request["purpose"] != "handoff":
408
- return {}
409
- review = {key: plan[key] for key in ("batch_id", "reviewer_id", "reviewer_name", "config_version", "member_revision", "scope")}
410
- review["phase"] = phase
411
- result = {"review": review}
412
- if phase != "handoff":
413
- result.update(source_question=connection.execute("SELECT text FROM messages WHERE id=?", (request["message_id"],)).fetchone()[0],
414
- source_message_id=plan["batch_id"], after_message_id=plan["batch_id"], cause_kind="review")
415
- if phase == "result":
416
- failed = connection.execute("""SELECT e.result FROM member_requests r JOIN member_executions e ON e.id=r.execution_id
417
- WHERE r.batch_id=? AND r.status IN ('cancelled','failed') ORDER BY r.id""", (plan["review_batch_id"],)).fetchall()
418
- review["reason"] = ";".join(row[0] for row in failed)
419
- return result
420
-
421
-
422
- def member_values(members):
423
- """Validate independent member credentials for a room."""
424
- if not isinstance(members, list) or not len(members) <= MAX_ROOM_AGENTS:
425
- raise ValueError("房间成员须为列表,且不超过成员上限")
426
- result = [member_value(member) for member in members]
427
- if len({member["name"] for member in result}) != len(result):
428
- raise ValueError("成员名称不能重复")
429
- if len({member["token"] for member in result}) != len(result):
430
- raise ValueError("每位成员须使用独立凭证")
431
- return result
432
-
433
-
434
- def insert_member(connection, room_id, member, now):
435
- """Store one member with only its credential hash."""
436
- connection.execute("""INSERT INTO room_members (room_id,name,adapter,token_hash,access,session_mode,created_at)
437
- VALUES (?,?,?,?,?,?,?)""", (room_id, member["name"], member["adapter"], token_hash(member["token"]),
438
- member["access"], member["session_mode"], now))
439
-
440
-
441
- def save_room_access(connection, room_id, control_token, members, settings):
442
- """Persist the mode, the owner's control hash and the members in the creating transaction."""
443
- control = token_hash(control_token)
444
- if not control:
445
- raise ValueError("建房须提供 32–256 字符的房间控制凭据")
446
- if any(token_hash(member["token"]) == control for member in members):
447
- raise ValueError("成员凭证不能与房主控制凭据相同")
448
- connection.execute("INSERT INTO room_modes (room_id,mode,control_hash) VALUES (?,'free',?)", (room_id, control))
449
- for member in members:
450
- insert_member(connection, room_id, member, settings["created_at"])
451
- connection.execute("UPDATE room_members SET role='host' WHERE room_id=? AND name=?", (room_id, settings["host"]))
452
-
453
-
454
- def sync_participants(connection, room_id):
455
- """Keep one active AI host, then mirror member capabilities and the host into shared room metadata."""
456
- connection.execute(RELEASE_HOST, (room_id,))
457
- connection.execute(ASSIGN_HOST, (room_id, room_id))
458
- members = connection.execute("""SELECT name,role,status FROM room_members
459
- WHERE room_id=? AND status<>'removed' ORDER BY id""", (room_id,)).fetchall()
460
- experts = {row["name"]: "cli" for row in members}
461
- host = next((row["name"] for row in members if row["role"] == "host" and row["status"] == "active"), "用户")
462
- connection.execute("UPDATE topic_settings SET experts=?,host=? WHERE room_id=?",
463
- (json.dumps(experts, ensure_ascii=False), host, room_id))
464
-
465
-
466
- def public_execution(row):
467
- """Return execution state without any credential material."""
468
- return {key: row[key] for key in EXECUTION_FIELDS} if row else None
469
-
470
-
471
- def held_until(connection, member_id):
472
- """Until when a member's next delivery waits, or 0: its latest execution was voided where the runner reported it cannot
473
- stop the software (a window or App), which may still be on that turn and would take a new task mid-turn. Its late
474
- answer ends the wait."""
475
- row = connection.execute("""SELECT e.finished_at FROM member_executions e JOIN execution_capabilities c ON c.execution_id=e.id
476
- WHERE e.id=(SELECT MAX(id) FROM member_executions WHERE member_id=?) AND e.status='cancelled'
477
- AND c.stop_result<>'stopped' AND (c.stoppable=0 OR c.stop_result IN ('unstoppable','unconfirmed'))
478
- AND NOT EXISTS(SELECT 1 FROM late_answers l WHERE l.execution_id=e.id)""", (member_id,)).fetchone()
479
- until = row["finished_at"] + HOLD_SECONDS if row else 0
480
- return until if until > int(time.time()) else 0
481
-
482
-
483
- def member_capability(connection, room_id, member_id, token, payload):
484
- """Record what a rewind does to one runner member's session, as the runner's adapter for it reports.
485
-
486
- Bound to one known member and replaced whole, so it takes no revision. Returns `{member_id, rewind}`.
487
- """
488
- require_owner(connection, room_id, token)
489
- begin_write(connection)
490
- if not connection.execute("SELECT 1 FROM room_members WHERE room_id=? AND id=?", (room_id, member_id)).fetchone():
491
- raise LookupError("成员不存在")
492
- if payload.get("rewind") not in REWIND_EFFECTS:
493
- raise ValueError(f"rewind 须为 {'、'.join(REWIND_EFFECTS)}")
494
- connection.execute("""INSERT INTO member_capabilities (member_id,room_id,rewind) VALUES (?,?,?)
495
- ON CONFLICT(member_id) DO UPDATE SET rewind=excluded.rewind""", (member_id, room_id, payload["rewind"]))
496
- return {"member_id": member_id, "rewind": payload["rewind"]}
497
-
498
-
499
- def execution_retry(connection, execution_id):
500
- """What the runner reported about a waiting retry of one execution, or None."""
501
- row = connection.execute("SELECT attempt,retry_at,reason,stopped FROM execution_retries WHERE execution_id=?", (execution_id,)).fetchone()
502
- return dict(row) if row else None
503
-
504
-
505
- def member_descriptions(connection, room_id):
506
- """The owner's descriptions of members by name; everyone who can read the room may read them."""
507
- return {row["name"]: row["description"] for row in connection.execute(
508
- "SELECT name,description FROM member_descriptions WHERE room_id=? ORDER BY name", (room_id,))}
509
-
510
-
511
- def describe_members(connection, room_id, token, payload):
512
- """Owner sets members' descriptions by name; an empty text removes that member's description."""
513
- require_owner(connection, room_id, token)
514
- descriptions = payload.get("descriptions")
515
- if (not isinstance(descriptions, dict) or not descriptions or len(descriptions) > MAX_ROOM_AGENTS
516
- or any(not isinstance(name, str) or not 0 < len(name) <= MAX_MEMBER_NAME or not isinstance(text, str)
517
- or len(text) > MAX_MEMBER_DESCRIPTION for name, text in descriptions.items())):
518
- raise ValueError(f"descriptions 须为 {{成员名: 描述}},最多 {MAX_ROOM_AGENTS} 位,每段最多 {MAX_MEMBER_DESCRIPTION} 字")
519
- begin_write(connection)
520
- now = int(time.time())
521
- for name, text in descriptions.items():
522
- if text.strip():
523
- connection.execute("""INSERT INTO member_descriptions (room_id,name,description,updated_at) VALUES (?,?,?,?)
524
- ON CONFLICT(room_id,name) DO UPDATE SET description=excluded.description,updated_at=excluded.updated_at""",
525
- (room_id, name, text.strip(), now))
526
- else:
527
- connection.execute("DELETE FROM member_descriptions WHERE room_id=? AND name=?", (room_id, name))
528
- return {"descriptions": member_descriptions(connection, room_id)}
529
-
530
-
531
- def member_labels(connection, room_id):
532
- """The owner's display labels of members by name; screens show them and the page turns a user's @label into the member name, never credentials."""
533
- return {row["name"]: row["label"] for row in connection.execute(
534
- "SELECT name,label FROM member_labels WHERE room_id=? ORDER BY name", (room_id,))}
535
-
536
-
537
- def member_display_names(connection, room_id):
538
- """What each present member is called in text: the owner's label, else its platform nickname, else its ID."""
539
- labels = member_labels(connection, room_id)
540
- return {row["name"]: labels.get(row["name"]) or default_label(row["name"]) for row in connection.execute(
541
- "SELECT name FROM room_members WHERE room_id=? AND status<>'removed' ORDER BY id", (room_id,))}
542
-
543
-
544
- def member_handles(connection, room_id):
545
- """Every way to name a present member, mapped to its ID: the ID, and its display name unless another member shares it."""
546
- shown = member_display_names(connection, room_id)
547
- handles = {name: name for name in shown}
548
- taken = list(shown.values())
549
- for name, label in shown.items():
550
- if label not in handles and taken.count(label) == 1:
551
- handles[label] = name
552
- return handles
553
-
554
-
555
- def resolve_member_fields(connection, room_id, payload):
556
- """Turn display names in a write's member fields into IDs, so members are named as they are seen; IDs still work."""
557
- handles = member_handles(connection, room_id)
558
- resolve = lambda value: handles.get(value.strip(), value) if isinstance(value, str) else value
559
- if isinstance(payload.get("to"), list):
560
- payload["to"] = [resolve(item) for item in payload["to"]]
561
- for key in ("next_review", "expert"):
562
- if key in payload:
563
- payload[key] = resolve(payload[key])
564
- if isinstance(payload.get("turns"), list):
565
- payload["turns"] = [{**item, "expert": resolve(item.get("expert"))} if isinstance(item, dict) else item for item in payload["turns"]]
566
- return payload
567
-
568
-
569
- def label_members(connection, room_id, token, payload):
570
- """Owner sets members' display labels by name; an empty text removes that member's label."""
571
- require_owner(connection, room_id, token)
572
- labels = payload.get("labels")
573
- if (not isinstance(labels, dict) or not labels or len(labels) > MAX_ROOM_AGENTS
574
- or any(not isinstance(name, str) or not 0 < len(name) <= MAX_MEMBER_NAME or not isinstance(text, str)
575
- or len(text.strip()) > MAX_MEMBER_LABEL for name, text in labels.items())):
576
- raise ValueError(f"labels 须为 {{成员名: 显示名}},最多 {MAX_ROOM_AGENTS} 位,每个最多 {MAX_MEMBER_LABEL} 字")
577
- begin_write(connection)
578
- now = int(time.time())
579
- for name, text in labels.items():
580
- if text.strip():
581
- connection.execute("""INSERT INTO member_labels (room_id,name,label,updated_at) VALUES (?,?,?,?)
582
- ON CONFLICT(room_id,name) DO UPDATE SET label=excluded.label,updated_at=excluded.updated_at""",
583
- (room_id, name, text.strip(), now))
584
- else:
585
- connection.execute("DELETE FROM member_labels WHERE room_id=? AND name=?", (room_id, name))
586
- return {"labels": member_labels(connection, room_id)}
587
-
588
- def note_count(connection, execution_id):
589
- """How many progress notes an execution has; the texts are read separately so room views stay small."""
590
- return connection.execute("SELECT COUNT(*) FROM execution_notes WHERE execution_id=?", (execution_id,)).fetchone()[0]
591
-
592
-
593
- def execution_notes(connection, room_id, execution_id, token=""):
594
- """Read one execution's progress notes in order, with the same access as the room view."""
595
- execution = connection.execute("SELECT member_id FROM member_executions WHERE room_id=? AND id=?", (room_id, execution_id)).fetchone()
596
- if not execution:
597
- raise LookupError("执行不存在")
598
- boundary = discussion.session_message_access(connection, room_id, {}, token)
599
- if boundary is not None:
600
- try:
601
- member = require_member(connection, room_id, token)
602
- except PermissionError:
603
- return {"notes": []}
604
- if member["id"] != execution["member_id"]:
605
- return {"notes": []}
606
- return {"notes": [dict(row) for row in connection.execute(
607
- "SELECT seq,text,created_at FROM execution_notes WHERE execution_id=? ORDER BY seq", (execution_id,))]}
608
-
609
-
610
- def execution_timeline(connection, room_id, after=0):
611
- """Read the room's recent executions with their phase segments and note times, for the timeline page.
612
-
613
- Times are epoch seconds and segments are millisecond offsets from `dispatched_at`. An execution's parent is the
614
- execution that wrote the message it answers. `state` is running, done, failed
615
- or cancelled, so the page never interprets execution statuses itself. A positive `after` reads only executions
616
- still running or ended since then, so a page following live work rereads little.
617
- """
618
- if not connection.execute("SELECT 1 FROM rooms WHERE id=?", (room_id,)).fetchone():
619
- raise LookupError("房间不存在")
620
- params = (room_id, TIMELINE_EXECUTIONS, max(0, int(after)))
621
- rows = [dict(row) for row in connection.execute(TIMELINE_SQL, params)]
622
- segments, notes = {}, {}
623
- for row in connection.execute(TIMELINE_SEGMENTS_SQL, params):
624
- segments.setdefault((row["source"], row["execution_id"]), []).append(
625
- {key: row[key] for key in ("phase", "tool", "start_ms", "end_ms")})
626
- for row in connection.execute(TIMELINE_NOTES_SQL, params):
627
- notes.setdefault(row["execution_id"], []).append(row["created_at"])
628
- for row in rows:
629
- row["state"] = TIMELINE_STATES.get(row["status"], "running")
630
- row["segments"] = segments.get((row["source"], row["id"]), [])
631
- row["notes"] = notes.get(row["id"], [])
632
- return {"now": int(time.time()), "after": params[2], "executions": rows, "execution_limit": TIMELINE_EXECUTIONS,
633
- "segment_limit": MAX_EXECUTION_SEGMENTS}
634
-
635
-
636
- def room_event_log(connection, room_id, token, after=0, before=0):
637
- """Read the owner's append-only room event log written by the schema triggers.
638
-
639
- Args:
640
- after: return events with a larger id (live follow), oldest first.
641
- before: with no `after`, return the page of events just below this id (0 = the newest page), oldest first.
642
-
643
- Returns:
644
- `events` (id, at in epoch ms, kind, data) ordered by `at` then id, `members` id → name for resolving
645
- `member_id`/`host_id`, `first_id`/`last_id` as paging cursors, and `more` when older events remain.
646
- A write request (`api.*`) is stored after it finishes, so its id follows the changes it caused; its `at` is
647
- when it arrived, and ordering by `at` puts the input before its effects.
648
- """
649
- require_owner(connection, room_id, token)
650
- after, before = max(0, int(after)), max(0, int(before))
651
- if after:
652
- rows = connection.execute("SELECT id,at,kind,data FROM room_events WHERE room_id=? AND id>? ORDER BY id LIMIT ?",
653
- (room_id, after, ROOM_EVENT_PAGE)).fetchall()
654
- else:
655
- rows = connection.execute("""SELECT id,at,kind,data FROM room_events WHERE room_id=? AND (?=0 OR id<?)
656
- ORDER BY id DESC LIMIT ?""", (room_id, before, before, ROOM_EVENT_PAGE)).fetchall()
657
- first_id = min((row["id"] for row in rows), default=0)
658
- more = bool(rows) and not after and connection.execute(
659
- "SELECT 1 FROM room_events WHERE room_id=? AND id<? LIMIT 1", (room_id, first_id)).fetchone() is not None
660
- members = {str(row["id"]): row["name"] for row in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (room_id,))}
661
- events = sorted(({**dict(row), "data": json.loads(row["data"])} for row in rows), key=lambda event: (event["at"], event["id"]))
662
- return {"events": events, "members": members, "more": more, "first_id": first_id,
663
- "last_id": max((row["id"] for row in rows), default=after)}
664
-
665
-
666
- def recent_executions(connection, room_id):
667
- """Return bounded public reports, including requests no longer in the recent request list."""
668
- rows = connection.execute(f"""SELECT e.*,m.name AS member,
669
- (SELECT json_group_array(id) FROM member_requests WHERE execution_id=e.id) AS request_ids,
670
- (SELECT MAX(answer_id) FROM member_requests WHERE execution_id=e.id) AS answer_id,
671
- (SELECT MIN(message_id) FROM member_requests WHERE execution_id=e.id) AS request_message_id,
672
- (SELECT MIN({ANCHOR_SQL}) FROM member_requests r WHERE r.execution_id=e.id) AS anchor_message_id,
673
- (SELECT COUNT(*) FROM execution_notes WHERE execution_id=e.id) AS note_count,
674
- (SELECT denials FROM execution_denials WHERE execution_id=e.id) AS denials,{CAPABILITY_COLUMNS},
675
- (SELECT json_object('created_at',created_at) FROM late_answers WHERE execution_id=e.id) AS late_answer
676
- FROM member_executions e JOIN room_members m ON m.id=e.member_id WHERE e.room_id=? ORDER BY e.id DESC LIMIT ?""", (room_id, RECENT_EXECUTIONS))
677
- executions = []
678
- for row in rows:
679
- requests = []
680
- for request in connection.execute("SELECT id,message_id,purpose,status,answer_id FROM member_requests WHERE execution_id=? ORDER BY id", (row["id"],)):
681
- requests.append({**dict(request), **session_request_source(connection, request["id"], request["message_id"])})
682
- executions.append({**public_execution(row), "member": row["member"], "cwd": row["cwd"], "changes": json.loads(row["changes"] or "[]"),
683
- "changes_recorded": bool(row["changes"]), "request_ids": json.loads(row["request_ids"]),
684
- "answer_id": row["answer_id"], "request_message_id": row["request_message_id"],
685
- "anchor_message_id": row["anchor_message_id"], "requests": requests,
686
- "notes": row["note_count"], "denials": json.loads(row["denials"] or "[]"),
687
- "capabilities": reported_capabilities(row), "stop_result": row["stop_result"] or "",
688
- "late_answer": json.loads(row["late_answer"]) if row["late_answer"] else None,
689
- "retry": execution_retry(connection, row["id"])})
690
- return executions
691
-
692
-
693
- def session_request_source(connection, member_request_id, message_id):
694
- """Expose session provenance and a visible transcript anchor for a member request."""
695
- origin = discussion.session_request_origin(connection, member_request_id)
696
- if origin:
697
- return origin
698
- request = connection.execute("SELECT message_id,purpose FROM member_requests WHERE id=?", (member_request_id,)).fetchone()
699
- return {"session_request_id": None, "discussion_session_id": None, "round": None, "source_question": "",
700
- "source_message_id": None, "cause_kind": None, "cause_request_id": None, "after_message_id": message_id,
701
- **review_request_context(connection, request)}
702
-
703
-
704
- def possible_conflicts(connection, room_id, names):
705
- """Report files changed by executions of different members whose time windows overlapped.
706
-
707
- This is an observation, not attribution: it cannot tell who wrote which part or catch
708
- writes outside the scanned working directory.
709
- """
710
- now = int(time.time())
711
- rows = [dict(row) for row in connection.execute(
712
- """SELECT id,member_id,created_at,finished_at,changes,cwd FROM member_executions
713
- WHERE room_id=? AND changes<>'' ORDER BY id DESC LIMIT ?""", (room_id, RECENT_CHANGES))]
714
- for row in rows:
715
- row["files"] = set(json.loads(row["changes"]))
716
- row["end"] = row["finished_at"] or now
717
- conflicts = []
718
- for index, first in enumerate(rows):
719
- for second in rows[index + 1:]:
720
- if first["member_id"] == second["member_id"] or first["cwd"] != second["cwd"]:
721
- continue
722
- if first["created_at"] > second["end"] or second["created_at"] > first["end"]:
723
- continue
724
- files = sorted(first["files"] & second["files"])
725
- if files:
726
- conflicts.append({"members": [names.get(second["member_id"], ""), names.get(first["member_id"], "")],
727
- "executions": [second["id"], first["id"]], "files": files})
728
- return conflicts
729
-
730
-
731
- # An invited manual member that has not registered counts as absent: it receives no request and is never a target.
732
- REGISTERED_MEMBER = """(room_members.access<>'manual' OR EXISTS(SELECT 1 FROM member_registrations r
733
- WHERE r.member_id=room_members.id AND r.token_hash=room_members.token_hash))"""
734
-
735
- # One snapshot for group routing; the Worker uses the same SQL inside its atomic write assertion.
736
- GROUP_STATE_SQL = f"""SELECT json_object(
737
- 'latest_id',(SELECT COALESCE(MAX(id),0) FROM messages WHERE room_id=rooms.id),
738
- 'title',rooms.title,'goal',rooms.goal,
739
- 'archived',(SELECT archived FROM room_directory WHERE room_id=rooms.id),
740
- 'order',COALESCE((SELECT member_ids FROM speaking_orders WHERE room_id=rooms.id),'[]'),
741
- 'members',(SELECT json_group_array(json_object('id',id,'name',name,'role',role,'status',status,'access',access,
742
- 'registered',{REGISTERED_MEMBER},'description',COALESCE((SELECT description FROM member_descriptions d
743
- WHERE d.room_id=room_members.room_id AND d.name=room_members.name),'')))
744
- FROM (SELECT * FROM room_members WHERE room_id=rooms.id ORDER BY id) AS room_members)) AS state
745
- FROM rooms WHERE id=?"""
746
-
747
-
748
- def free_view(connection, room_id):
749
- """Summarize members, their live execution, recent requests and possible file conflicts."""
750
- now = int(time.time())
751
- members = []
752
- for row in connection.execute("SELECT * FROM room_members WHERE room_id=? AND status<>'removed' ORDER BY id", (room_id,)):
753
- member = {key: row[key] for key in MEMBER_FIELDS}
754
- member["registered_at"] = registered_at(connection, row)
755
- reported = connection.execute("SELECT session_id,client FROM member_sessions WHERE member_id=?", (row["id"],)).fetchone()
756
- member["reported_session"], member["reported_client"] = (reported["session_id"], reported["client"]) if reported else ("", "")
757
- failure = connection.execute("SELECT reason FROM member_adopt_failures WHERE member_id=? AND session_id=?",
758
- (row["id"], member["reported_session"])).fetchone() if reported else None
759
- member["adopt_error"] = failure["reason"] if failure else ""
760
- latest = connection.execute(f"SELECT e.*,{CAPABILITY_COLUMNS} FROM member_executions e WHERE member_id=? ORDER BY id DESC LIMIT 1",
761
- (row["id"],)).fetchone()
762
- member["execution"] = public_execution(latest)
763
- if latest:
764
- member["execution"].update(capabilities=reported_capabilities(latest), stop_result=latest["stop_result"] or "")
765
- if latest and latest["status"] in ACTIVE:
766
- member["execution"]["stale"] = latest["lease_until"] <= now
767
- member["execution"]["notes"] = note_count(connection, latest["id"])
768
- member["execution"]["retry"] = execution_retry(connection, latest["id"])
769
- member["pending"] = connection.execute("SELECT COUNT(*) FROM member_requests WHERE member_id=? AND status='pending'",
770
- (row["id"],)).fetchone()[0]
771
- member["held_until"] = held_until(connection, row["id"])
772
- members.append(member)
773
- names = {row["id"]: row["name"] for row in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (room_id,))}
774
- requests = [{**dict(row), "member": names.get(row["member_id"], ""),
775
- **session_request_source(connection, row["id"], row["message_id"])} for row in connection.execute(
776
- """SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by,created_at,
777
- (SELECT reason FROM request_cancel_reasons c WHERE c.request_id=member_requests.id) AS cancel_reason FROM member_requests
778
- WHERE room_id=? ORDER BY id DESC LIMIT ?""", (room_id, RECENT_REQUESTS))]
779
- chains = [dict(row) for row in connection.execute(
780
- "SELECT chain_id,reason FROM free_chains WHERE room_id=? ORDER BY chain_id DESC LIMIT 10", (room_id,))]
781
- max_rounds = chain_rounds(connection, room_id)
782
- progress = [dict(row) for row in connection.execute(CHAIN_PROGRESS_SQL, (max_rounds, room_id))]
783
- return {"members": members, "requests": requests[::-1], "executions": recent_executions(connection, room_id),
784
- "conflicts": possible_conflicts(connection, room_id, names), "halted_chains": chains, "max_rounds": max_rounds,
785
- "chain_rounds": progress, "recommendations": recent_recommendations(connection, room_id, now)[::-1],
786
- "queued_messages": queue_view(connection, room_id)}
787
-
788
-
789
- def require_member(connection, room_id, token, name=None):
790
- """Resolve an active member strictly from its own credential."""
791
- hashed = token_hash(token)
792
- row = connection.execute("SELECT * FROM room_members WHERE room_id=? AND token_hash=? AND status='active'",
793
- (room_id, hashed)).fetchone() if hashed else None
794
- if not row or (name is not None and row["name"] != name):
795
- raise PermissionError("成员凭证无效、已停用或与发言身份不符")
796
- return dict(row)
797
-
798
-
799
- def registered_at(connection, member):
800
- """Return when the member registered with its current credential, or '' while it has not."""
801
- row = connection.execute("SELECT registered_at FROM member_registrations WHERE member_id=? AND token_hash=?",
802
- (member["id"], member["token_hash"])).fetchone()
803
- return row[0] if row else ""
804
-
805
-
806
- def register_member(connection, room_id, token, now):
807
- """A manual member announces itself with its own credential; repeating it keeps the first time.
808
-
809
- The session ID is not part of the request: the CLI attaches it to every call and the room records it separately.
810
- """
811
- begin_write(connection)
812
- member = require_member(connection, room_id, token)
813
- if member["access"] != "manual":
814
- raise ValueError("只有手动接入的成员需要注册")
815
- previous = registered_at(connection, member)
816
- stamp = previous or now
817
- connection.execute("""INSERT INTO member_registrations (member_id,room_id,token_hash,registered_at) VALUES (?,?,?,?)
818
- ON CONFLICT(member_id) DO UPDATE SET token_hash=excluded.token_hash,registered_at=excluded.registered_at""",
819
- (member["id"], room_id, member["token_hash"], stamp))
820
- if not previous:
821
- queue_invited_expert(connection, room_id, member["name"], now)
822
- reported = connection.execute("SELECT client FROM member_sessions WHERE member_id=?", (member["id"],)).fetchone()
823
- return {"member": member["name"], "registered_at": stamp, "session_client": reported[0] if reported else ""}
824
-
825
-
826
- def authorize_free_write(connection, room_id, token, operation, payload):
827
- """Decide who is writing: the owner, or one member posting its own answer.
828
-
829
- Returns:
830
- "用户" for the owner, or the member row for a member credential.
831
- """
832
- begin_write(connection)
833
- if operation not in FREE_OPERATIONS:
834
- raise ValueError("自由房间不使用发言权、安排、阶段或轮次")
835
- try:
836
- require_owner(connection, room_id, token)
837
- return "用户"
838
- except PermissionError:
839
- if operation not in ("messages", "attachments"):
840
- raise
841
- author = payload.get("author")
842
- try:
843
- member = require_member(connection, room_id, token)
844
- except PermissionError:
845
- if author in (None, "用户"):
846
- raise PermissionError("此浏览器没有本房间的房主凭据:请用创建房间的浏览器,或登录管理员后再发言") from None
847
- raise
848
- if author == member["name"] and payload.get("submitted_by", author) == author:
849
- return member
850
- raise PermissionError("成员只能以本人身份发言")
851
-
852
-
853
- def active_members(connection, room_id, registered_only=True):
854
- """Map active member names to their rows; unregistered manual members are left out unless a discussion start needs them pending."""
855
- return {row["name"]: row for row in connection.execute(
856
- f"SELECT * FROM room_members WHERE room_id=? AND status='active' AND ({REGISTERED_MEMBER} OR ?=0)", (room_id, int(registered_only)))}
857
-
858
-
859
- def named_members(active, names, exclude=None):
860
- """Validate a structured recipient list against the active members."""
861
- if not isinstance(names, list) or any(name not in active or name == exclude for name in names):
862
- raise ValueError("点名对象须为本房间在场成员" + (",且不能点名自己" if exclude else ""))
863
- return [active[name]["id"] for name in dict.fromkeys(names)]
864
-
865
-
866
- def recipients(connection, room_id, payload):
867
- """Resolve an owner message's recipients as (member id, purpose) pairs.
868
-
869
- A single active member is named by default. With several, an unnamed message goes to the
870
- AI host for routing; a room without one leaves it a plain note.
871
- Naming everyone with order auto leaves the host a group request: the runner has everyone answer
872
- at once or one after another; a host the runner does not wake has them all answer at once.
873
- """
874
- # Rooms created while the user could host get their AI host on the next message.
875
- sync_participants(connection, room_id)
876
- active = active_members(connection, room_id)
877
- if payload.get("to") is not None:
878
- named = named_members(active, payload["to"])
879
- host = next((row for row in active.values() if row["role"] == "host"), None)
880
- if payload.get("order") == "auto" and len(named) > 1 and host and host["access"] == "runner":
881
- return [(host["id"], "group")]
882
- return [(member_id, "ask") for member_id in named]
883
- if len(active) == 1:
884
- return [(next(iter(active.values()))["id"], "ask")]
885
- host = next((row for row in active.values() if row["role"] == "host"), None)
886
- return [(host["id"], "route")] if host else []
887
-
888
-
889
- def check_answer(connection, room_id, member, payload):
890
- """A runner member completes a live execution; a manual member may close its own open requests.
891
-
892
- Only the AI host dispatches work; other members' targets remain suggestions.
893
- """
894
- if payload.get("to") is not None:
895
- named_members(active_members(connection, room_id), payload["to"], exclude=member["name"])
896
- if payload.get("handoff") not in (None, False) and (payload["handoff"] is not True or member["role"] != "host" or not payload.get("to")):
897
- raise ValueError("handoff 只能由主持人与 to 一起使用,值为 true")
898
- if member["access"] == "manual":
899
- request_ids = payload.get("request_ids", [])
900
- if payload.get("execution_id") is not None or not isinstance(request_ids, list) or any(type(item) is not int for item in request_ids):
901
- raise ValueError("手动接入成员用 request_ids 关联请求,不使用执行编号")
902
- purposes = []
903
- requests = []
904
- for request_id in request_ids:
905
- row = connection.execute("SELECT * FROM member_requests WHERE room_id=? AND id=?", (room_id, request_id)).fetchone()
906
- if not row or row["member_id"] != member["id"] or row["status"] != "pending":
907
- raise FileExistsError(f"请求 {request_id} 不属于本成员或已处理")
908
- purposes.append(row["purpose"])
909
- requests.append(row)
910
- discussion.validate_session_request_answer(connection, room_id, requests)
911
- check_dispatch(member, payload, purposes)
912
- return None
913
- execution_id = payload.get("execution_id")
914
- if type(execution_id) is not int:
915
- raise ValueError("成员回答须关联本次执行编号 execution_id")
916
- row = connection.execute("SELECT * FROM member_executions WHERE room_id=? AND id=?", (room_id, execution_id)).fetchone()
917
- if not row or row["member_id"] != member["id"]:
918
- raise PermissionError("执行编号不属于本成员")
919
- if row["status"] not in ACTIVE or row["lease_until"] <= int(time.time()):
920
- if row["status"] == "cancelled":
921
- # Kept apart from the transcript: the page shows it folded, and it marks the end of a turn the runner could not stop.
922
- connection.execute("INSERT OR IGNORE INTO late_answers (execution_id,room_id,text,created_at) VALUES (?,?,?,?)",
923
- (execution_id, room_id, str(payload.get("text") or ""), int(time.time())))
924
- connection.commit()
925
- raise FileExistsError("本次执行已结束、被打断或已过期;回答不能改作普通发言,请保留草稿")
926
- requests = [dict(item) for item in connection.execute(
927
- "SELECT id,purpose FROM member_requests WHERE execution_id=? AND status='delivering'", (execution_id,))]
928
- discussion.validate_session_request_answer(connection, room_id, requests)
929
- check_dispatch(member, payload, [item["purpose"] for item in requests])
930
- return row
931
-
932
-
933
- def check_dispatch(member, payload, purposes):
934
- """A host answering only its share of an everyone request answers as a member, so it may not dispatch others."""
935
- if member["role"] == "host" and payload.get("to") and purposes and all(purpose == "all" for purpose in purposes):
936
- raise ValueError("这次是请全体成员各自回应,其他成员已收到请求;主持人只回答自己的部分,不能用 --to 派活")
937
-
938
-
939
- def insert_request(connection, room_id, member_id, message_id, chain_id, purpose, created_by, batch_id=0):
940
- """Queue one request; every request belongs to the chain its owner message started."""
941
- connection.execute("""INSERT INTO member_requests (room_id,member_id,message_id,chain_id,batch_id,purpose,created_by,created_at)
942
- VALUES (?,?,?,?,?,?,?,?)""", (room_id, member_id, message_id, chain_id, batch_id, purpose, created_by, int(time.time())))
943
-
944
-
945
- def mention_prose(text):
946
- """Remove Markdown code and quote blocks before interpreting response suggestions."""
947
- lines, fence, quoted = [], "", False
948
- for line in text.splitlines():
949
- marker = re.match(r"^ {0,3}(`{3,}|~{3,})", line)
950
- if marker:
951
- value = marker[1]
952
- if not fence:
953
- fence = value
954
- elif value[0] == fence[0] and len(value) >= len(fence):
955
- fence = ""
956
- continue
957
- if fence or re.match(r"^( {4}|\t)", line):
958
- continue
959
- if re.match(r"^ {0,3}>", line):
960
- quoted = True
961
- continue
962
- if not line.strip():
963
- quoted = False
964
- if not quoted:
965
- lines.append(line)
966
- return re.sub(r"(`+)[\s\S]*?\1(?!`)", "", "\n".join(lines))
967
-
968
-
969
- def save_suggestions(connection, room_id, message_id, member, payload):
970
- """Save ordinary members' explicit targets and prose mentions without waking anyone."""
971
- if member == "用户" or member["role"] == "host":
972
- return
973
- active = active_members(connection, room_id)
974
- targets = set(named_members(active, payload["to"] if payload.get("to") is not None else [], exclude=member["name"]))
975
- prose = mention_prose(str(payload.get("text", "")))
976
- for handle, name in member_handles(connection, room_id).items():
977
- if name in active and name != member["name"] and re.search(
978
- r"(^|[\s((,,。;;::])@" + re.escape(handle) + r"(?![A-Za-z0-9_\-\u4e00-\u9fff])", prose):
979
- targets.add(active[name]["id"])
980
- for target_id in targets:
981
- connection.execute("INSERT INTO member_suggestions (message_id,member_id) VALUES (?,?)", (message_id, target_id))
982
-
983
-
984
- def message_suggestions(connection, message_id):
985
- """Expose suggested members and the durable request, including terminal conversions."""
986
- return [dict(row) for row in connection.execute("""SELECT s.member_id,m.name AS member,m.status,
987
- r.id AS request_id,r.status AS request_status FROM member_suggestions s
988
- JOIN room_members m ON m.id=s.member_id
989
- LEFT JOIN member_requests r ON r.message_id=s.message_id AND r.member_id=s.member_id
990
- WHERE s.message_id=? ORDER BY s.member_id""", (message_id,))]
991
-
992
-
993
- def convert_suggestion(connection, room_id, token, payload):
994
- """Let the owner turn a saved suggestion into one request and a fresh user chain."""
995
- begin_write(connection)
996
- require_owner(connection, room_id, token)
997
- message_id, member_id = payload.get("message_id"), payload.get("member_id")
998
- if type(message_id) is not int or type(member_id) is not int:
999
- raise ValueError("须提供原消息编号 message_id 与成员编号 member_id")
1000
- target = connection.execute("""SELECT m.name,m.status FROM member_suggestions s
1001
- JOIN messages source ON source.id=s.message_id JOIN room_members m ON m.id=s.member_id
1002
- WHERE source.room_id=? AND m.room_id=? AND s.message_id=? AND s.member_id=?""",
1003
- (room_id, room_id, message_id, member_id)).fetchone()
1004
- if not target:
1005
- raise LookupError("这条消息没有该成员的回应建议")
1006
- if target["status"] != "active":
1007
- raise FileExistsError("该成员已离开,不能转为请求")
1008
- if connection.execute("SELECT 1 FROM room_directory WHERE room_id=? AND archived=1", (room_id,)).fetchone():
1009
- raise ValueError("房间已存档;恢复后才能继续协作")
1010
- previous = connection.execute("SELECT * FROM member_requests WHERE message_id=? AND member_id=?", (message_id, member_id)).fetchone()
1011
- if previous:
1012
- return {"request": dict(previous), "duplicate": True}
1013
- confirmation = connection.execute("""INSERT INTO messages (room_id,author,text,reply_to,source,submission_id,created_at)
1014
- VALUES (?,'用户',?,?,'user',?,strftime('%Y-%m-%dT%H:%M:%fZ','now'))""",
1015
- (room_id, f"请 @{target['name']} 回应 #{message_id} 的建议。", message_id, uuid.uuid4().hex))
1016
- connection.execute("INSERT INTO message_details (message_id,kind,submitted_by) VALUES (?,'chat','用户')", (confirmation.lastrowid,))
1017
- insert_request(connection, room_id, member_id, message_id, confirmation.lastrowid, "ask", "user")
1018
- request = connection.execute("SELECT * FROM member_requests WHERE message_id=? AND member_id=?", (message_id, member_id)).fetchone()
1019
- return {"request": dict(request), "duplicate": False}
1020
-
1021
-
1022
- def halt_chain(connection, room_id, chain_id, reason):
1023
- """Stop automatic progress on a chain; the user's next message starts a new one."""
1024
- connection.execute("INSERT OR IGNORE INTO free_chains (room_id,chain_id,reason) VALUES (?,?,?)", (room_id, chain_id, reason))
1025
-
1026
-
1027
- def rounds_left(connection, room_id, chain_id):
1028
- """Rounds a chain may still start: the room's limit plus what the owner added, less the dispatches made; none once halted."""
1029
- if connection.execute("SELECT 1 FROM free_chains WHERE room_id=? AND chain_id=?", (room_id, chain_id)).fetchone():
1030
- return 0
1031
- used = connection.execute("SELECT COUNT(*) FROM free_batches WHERE room_id=? AND chain_id=?", (room_id, chain_id)).fetchone()[0]
1032
- extra = connection.execute("SELECT extra_rounds FROM free_chain_holds WHERE room_id=? AND chain_id=?", (room_id, chain_id)).fetchone()
1033
- return chain_rounds(connection, room_id) + (extra[0] if extra else 0) - used
1034
-
1035
-
1036
- def chain_rounds(connection, room_id):
1037
- """Rounds a host may run from one owner message in this room."""
1038
- row = connection.execute("SELECT max_rounds FROM free_round_limits WHERE room_id=?", (room_id,)).fetchone()
1039
- return row[0] if row else CHAIN_ROUNDS
1040
-
1041
-
1042
- def set_chain_rounds(connection, room_id, token, payload):
1043
- """Owner sets the room's round limit; it applies to chains still running and to later ones."""
1044
- require_owner(connection, room_id, token)
1045
- value = payload.get("max_rounds")
1046
- if type(value) is not int or not 1 <= value <= MAX_CHAIN_ROUNDS:
1047
- raise ValueError(f"max_rounds 须为 1–{MAX_CHAIN_ROUNDS} 的整数")
1048
- begin_write(connection)
1049
- connection.execute("""INSERT INTO free_round_limits (room_id,max_rounds) VALUES (?,?)
1050
- ON CONFLICT(room_id) DO UPDATE SET max_rounds=excluded.max_rounds""", (room_id, value))
1051
- return {"max_rounds": value}
1052
-
1053
-
1054
- def continue_chain(connection, room_id, token, chain_id, payload):
1055
- """Owner lets a chain the round limit stopped run on: adds rounds and sends the dispatch the limit held back.
1056
-
1057
- Bound to one known chain, so it takes no revision; a chain that is not stopped by its limit is refused.
1058
- """
1059
- require_owner(connection, room_id, token)
1060
- rounds = payload.get("rounds")
1061
- if type(rounds) is not int or not 1 <= rounds <= MAX_CHAIN_ROUNDS:
1062
- raise ValueError(f"rounds 须为 1–{MAX_CHAIN_ROUNDS} 的整数")
1063
- begin_write(connection)
1064
- halted = connection.execute("SELECT reason FROM free_chains WHERE room_id=? AND chain_id=?", (room_id, chain_id)).fetchone()
1065
- if not halted or halted[0] != "budget":
1066
- raise FileExistsError("这条任务链没有因轮数上限停下,无需继续")
1067
- host = connection.execute("SELECT * FROM room_members WHERE room_id=? AND role='host' AND status='active'", (room_id,)).fetchone()
1068
- if not host:
1069
- raise ValueError("房间没有在场的主持人,无法继续")
1070
- connection.execute("""INSERT INTO free_chain_holds (room_id,chain_id,extra_rounds) VALUES (?,?,?)
1071
- ON CONFLICT(room_id,chain_id) DO UPDATE SET extra_rounds=extra_rounds+excluded.extra_rounds""", (room_id, chain_id, rounds))
1072
- connection.execute("DELETE FROM free_chains WHERE room_id=? AND chain_id=?", (room_id, chain_id))
1073
- held = connection.execute("SELECT message_id,names,handoff FROM free_chain_holds WHERE room_id=? AND chain_id=?", (room_id, chain_id)).fetchone()
1074
- connection.execute("UPDATE free_chain_holds SET message_id=0,host_id=0,names='[]',handoff=0 WHERE room_id=? AND chain_id=?", (room_id, chain_id))
1075
- if held["message_id"]:
1076
- dispatch(connection, room_id, host, held["message_id"], json.loads(held["names"]), chain_id, bool(held["handoff"]))
1077
- return free_view(connection, room_id)
1078
-
1079
-
1080
- def hold_dispatch(connection, room_id, host, message_id, names, chain_id, handoff):
1081
- """Stop a chain at its round limit, keeping the dispatch it held back so the owner can send it by continuing."""
1082
- halt_chain(connection, room_id, chain_id, "budget")
1083
- connection.execute("""INSERT INTO free_chain_holds (room_id,chain_id,message_id,host_id,names,handoff) VALUES (?,?,?,?,?,?)
1084
- ON CONFLICT(room_id,chain_id) DO UPDATE SET message_id=excluded.message_id,host_id=excluded.host_id,names=excluded.names,
1085
- handoff=excluded.handoff""", (room_id, chain_id, message_id, host["id"], json.dumps(list(names), ensure_ascii=False), int(handoff)))
1086
-
1087
-
1088
- def dispatch(connection, room_id, host, message_id, names, chain_id, handoff=False):
1089
- """Queue the host's named members as one batch, one round; several members, or one the host asked back, hand back when it ends."""
1090
- member_ids = named_members(active_members(connection, room_id), names, exclude=host["name"])
1091
- if not member_ids or not chain_id:
1092
- return
1093
- if rounds_left(connection, room_id, chain_id) < 1:
1094
- hold_dispatch(connection, room_id, host, message_id, names, chain_id, handoff)
1095
- return
1096
- for member_id in member_ids:
1097
- insert_request(connection, room_id, member_id, message_id, chain_id, "ask", "host", batch_id=message_id)
1098
- connection.execute("INSERT INTO free_batches (room_id,batch_id,chain_id,host_id) VALUES (?,?,?,?)",
1099
- (room_id, message_id, chain_id, host["id"]))
1100
- if handoff:
1101
- connection.execute("INSERT INTO free_handoffs (room_id,batch_id) VALUES (?,?)", (room_id, message_id))
1102
-
1103
-
1104
- def settle_batches(connection, room_id):
1105
- """Hand each finished batch back to its host once; an all-failed batch halts its chain instead."""
1106
- for batch in connection.execute("SELECT * FROM free_batches WHERE room_id=? AND settled=0", (room_id,)).fetchall():
1107
- statuses = [row[0] for row in connection.execute("SELECT status FROM member_requests WHERE room_id=? AND batch_id=?",
1108
- (room_id, batch["batch_id"]))]
1109
- if any(status not in TERMINAL for status in statuses):
1110
- continue
1111
- connection.execute("UPDATE free_batches SET settled=1 WHERE room_id=? AND batch_id=?", (room_id, batch["batch_id"]))
1112
- host = connection.execute("SELECT * FROM room_members WHERE id=? AND status='active' AND role='host'", (batch["host_id"],)).fetchone()
1113
- 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()
1114
- if "completed" not in statuses:
1115
- halt_chain(connection, room_id, batch["chain_id"], "all_failed")
1116
- if review and host:
1117
- insert_request(connection, room_id, host["id"], batch["batch_id"], batch["chain_id"], "handoff", "host")
1118
- elif not host or len(statuses) < 2 and not connection.execute(
1119
- "SELECT 1 FROM free_handoffs WHERE room_id=? AND batch_id=?", (room_id, batch["batch_id"])).fetchone():
1120
- # One member's answer reaches the user directly unless the host asked for it back, e.g. to send it to a reviewer.
1121
- continue
1122
- else:
1123
- # The hand-back closes the round it belongs to, so it is never held back: the last round still gets its summary.
1124
- insert_request(connection, room_id, host["id"], batch["batch_id"], batch["chain_id"], "handoff", "host")
1125
- advance_queues(connection, room_id)
1126
-
1127
-
1128
- def queue_in_turn(connection, room_id, message_id, member_ids):
1129
- """Line the named members up for one owner message in the room's speaking order; only the first is asked now."""
1130
- chosen = set(member_ids)
1131
- members = [dict(row) for row in connection.execute("SELECT id,name,role FROM room_members WHERE room_id=?", (room_id,))]
1132
- for member in default_order(connection, room_id, [member for member in members if member["id"] in chosen]):
1133
- connection.execute("INSERT INTO free_queue (room_id,message_id,member_id) VALUES (?,?,?)", (room_id, message_id, member["id"]))
1134
- advance_queues(connection, room_id)
1135
-
1136
-
1137
- def advance_queues(connection, room_id):
1138
- """Ask the next queued member of each message whose previous request ended; members no longer present are skipped.
1139
-
1140
- Asked rows stay, so the speaking order can still show who already answered.
1141
- """
1142
- connection.execute("""DELETE FROM free_queue WHERE id IN (SELECT q.id FROM free_queue_waiting q WHERE q.room_id=?
1143
- AND NOT EXISTS(SELECT 1 FROM room_members m WHERE m.id=q.member_id AND m.status='active'))""", (room_id,))
1144
- connection.execute("""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,batch_id,purpose,created_by,created_at)
1145
- SELECT q.room_id,q.member_id,q.message_id,q.message_id,0,'ask','user',? FROM free_queue_waiting q
1146
- WHERE q.id IN (SELECT MIN(id) FROM free_queue_waiting WHERE room_id=? GROUP BY message_id)
1147
- AND NOT EXISTS(SELECT 1 FROM member_requests r WHERE r.message_id=q.message_id AND r.status IN ('pending','delivering'))""",
1148
- (int(time.time()), room_id))
1149
-
1150
-
1151
- def default_order(connection, room_id, members):
1152
- """Members in the room's saved speaking order; without one (or for members it lacks), the host first, then by joining order."""
1153
- row = connection.execute("SELECT member_ids FROM speaking_orders WHERE room_id=?", (room_id,)).fetchone()
1154
- rank = {member_id: index for index, member_id in enumerate(json.loads(row[0]) if row else [])}
1155
- return sorted(members, key=lambda member: (member["id"] not in rank, rank.get(member["id"], 0), member["role"] != "host", member["id"]))
1156
-
1157
-
1158
- def speaking_order(connection, room):
1159
- """Who spoke, who speaks now and who is next; only the not-yet-spoken part may be reordered.
1160
-
1161
- Without an open discussion the room shows its latest unfinished one-after-another queue, else every member in the room's
1162
- default order; an open discussion shows its open round: speakers so far, the current speaker, then this round's queued
1163
- speakers; a plan reaching into later rounds lists them apart in `later` (one name list per round), read-only. The
1164
- revision changes with any of it.
1165
- """
1166
- active = [member for member in room["members"] if member["status"] == "active"]
1167
- by_id = {member["id"]: member for member in active}
1168
- ordered = [member["name"] for member in default_order(connection, room["id"], active)]
1169
- items, kind, target, later = [], "default", 0, []
1170
- session_plan_state = []
1171
- session = discussion.current_session(connection, room["id"], active_only=True)
1172
- if session:
1173
- kind = "turns"
1174
- target = session["id"]
1175
- items, later, session_plan_state = discussion.session_speaking_order(connection, session)
1176
- else:
1177
- target = connection.execute("""SELECT MAX(message_id) FROM (
1178
- SELECT MAX(message_id) AS message_id FROM free_queue_waiting WHERE room_id=?
1179
- UNION ALL SELECT MAX(r.message_id) FROM member_requests r WHERE r.room_id=? AND r.status IN ('pending','delivering')
1180
- AND EXISTS(SELECT 1 FROM free_queue q WHERE q.message_id=r.message_id AND q.member_id=r.member_id))""",
1181
- (room["id"], room["id"])).fetchone()[0] or 0
1182
- if target:
1183
- kind = "queue"
1184
- for row in connection.execute("""SELECT q.member_id,r.status FROM free_queue q LEFT JOIN member_requests r
1185
- ON r.message_id=q.message_id AND r.member_id=q.member_id WHERE q.message_id=? ORDER BY q.id""", (target,)):
1186
- if row["member_id"] in by_id:
1187
- state = "queued" if row["status"] is None else "speaking" if row["status"] in ("pending", "delivering") else "done"
1188
- items.append({"name": by_id[row["member_id"]]["name"], "state": state})
1189
- listed = {item["name"] for item in items}
1190
- items += [{"name": name, "state": "idle"} for name in dict.fromkeys(ordered) if name not in listed]
1191
- movable = "queued" if kind != "default" else "idle"
1192
- state = json.dumps([kind, target, [[item["name"], item["state"]] for item in items], later, session_plan_state], ensure_ascii=False)
1193
- return {"kind": kind, "message_id": target, "items": items, "later": later, "movable": [item["name"] for item in items if item["state"] == movable],
1194
- "default": [member["name"] for member in default_order(connection, room["id"], active)],
1195
- "revision": hashlib.sha256(state.encode("utf-8")).hexdigest()[:12]}
1196
-
1197
-
1198
- def reorder_speakers(connection, room, token, payload):
1199
- """Owner reorders the not-yet-spoken part of the speaking order; who already spoke or speaks now stays put.
1200
-
1201
- Read the speaking order first and send its revision: a missing revision is refused, a changed one conflicts,
1202
- and the new order must list exactly the movable members.
1203
- """
1204
- require_owner(connection, room["id"], token)
1205
- current = room["speaking_order"]
1206
- if payload.get("revision") is None:
1207
- raise ValueError("调整发言顺序前须先读取最新顺序(speaking-order),并带上其 revision")
1208
- if payload["revision"] != current["revision"]:
1209
- raise FileExistsError("发言顺序已变化,请重新读取后再调整")
1210
- order = payload.get("order")
1211
- movable = current["movable"]
1212
- if not isinstance(order, list) or sorted(map(str, order)) != sorted(movable):
1213
- given = [str(name) for name in order] if isinstance(order, list) else []
1214
- missing = [name for name in dict.fromkeys(movable) if given.count(name) < movable.count(name)]
1215
- extra = [name for name in dict.fromkeys(given) if movable.count(name) < given.count(name)]
1216
- raise ValueError("新顺序须恰好包含尚未发言的成员" + (f";缺少:{'、'.join(missing)}" if missing else "") + (f";多出:{'、'.join(extra)}" if extra else ""))
1217
- names = {member["name"]: member["id"] for member in room["members"] if member["status"] == "active"}
1218
- if current["kind"] == "queue":
1219
- connection.execute(f"DELETE FROM free_queue WHERE message_id=? AND NOT {QUEUE_ASKED.format(row='free_queue')}", (current["message_id"],))
1220
- for name in order:
1221
- connection.execute("INSERT INTO free_queue (room_id,message_id,member_id) VALUES (?,?,?)", (room["id"], current["message_id"], names[name]))
1222
- elif current["kind"] == "turns":
1223
- discussion.reorder_session_turns(connection, discussion.current_session(connection, room["id"], active_only=True), order, names)
1224
- else:
1225
- connection.execute("INSERT OR REPLACE INTO speaking_orders (room_id,member_ids) VALUES (?,?)",
1226
- (room["id"], json.dumps([names[name] for name in order])))
1227
-
1228
-
1229
- def answered_chain(connection, payload, actor):
1230
- """The chain an answer continues: the newest chain among the requests it completes."""
1231
- if actor["access"] == "manual":
1232
- ids = payload.get("request_ids") or [0]
1233
- marks = ",".join("?" * len(ids))
1234
- row = connection.execute(f"SELECT MAX(chain_id) FROM member_requests WHERE id IN ({marks})", ids).fetchone()
1235
- else:
1236
- row = connection.execute("SELECT MAX(chain_id) FROM member_requests WHERE execution_id=?", (payload["execution_id"],)).fetchone()
1237
- return row[0] or 0
1238
-
1239
-
1240
- def after_free_message(connection, room_id, message_id, actor, payload):
1241
- """Create requests for the owner's message, or close what the member's answer covers."""
1242
- now = int(time.time())
1243
- if actor == "用户":
1244
- if discussion.hold_user_message(connection, room_id, message_id, payload):
1245
- return
1246
- pairs = recipients(connection, room_id, payload)
1247
- if payload.get("sequential") is True and payload.get("to") is not None:
1248
- queue_in_turn(connection, room_id, message_id, [member_id for member_id, _ in pairs])
1249
- return
1250
- for member_id, purpose in pairs:
1251
- insert_request(connection, room_id, member_id, message_id, message_id, purpose, "user")
1252
- return
1253
- chain_id = answered_chain(connection, payload, actor)
1254
- if actor["access"] == "manual":
1255
- for request_id in payload.get("request_ids", []):
1256
- connection.execute("UPDATE member_requests SET status='completed',answer_id=? WHERE id=?", (message_id, request_id))
1257
- else:
1258
- execution_id = payload["execution_id"]
1259
- connection.execute("UPDATE member_executions SET status='completed',finished_at=?,result=? WHERE id=?",
1260
- (now, f"message:{message_id}", execution_id))
1261
- connection.execute("UPDATE member_requests SET status='completed',answer_id=? WHERE execution_id=? AND status='delivering'",
1262
- (message_id, execution_id))
1263
- connection.execute("UPDATE room_members SET read_until=MAX(read_until,?) WHERE id=?", (message_id, actor["id"]))
1264
- if actor["role"] == "host" and payload.get("to"):
1265
- dispatch(connection, room_id, actor, message_id, payload["to"], chain_id, payload.get("handoff") is True or bool(payload.get("next_review")))
1266
- settle_batches(connection, room_id)
1267
-
1268
-
1269
- def expire_executions(connection, room_id):
1270
- """Fail executions whose lease lapsed; their requests stay visible as failed, never silently retried."""
1271
- now = int(time.time())
1272
- stale = connection.execute(
1273
- "SELECT id FROM member_executions WHERE room_id=? AND status IN ('dispatched','started') AND lease_until<=?", (room_id, now)).fetchall()
1274
- for row in stale:
1275
- finish_execution(connection, row["id"], "failed", "执行租约到期,接入程序未续租", "failed")
1276
- if stale:
1277
- settle_batches(connection, room_id)
1278
-
1279
-
1280
- def record_cancel_reasons(connection, where, params, reason):
1281
- """Remember why requests are about to be cancelled; call before the status change selects them."""
1282
- connection.execute(f"INSERT OR REPLACE INTO request_cancel_reasons (request_id,reason) SELECT id,? FROM member_requests WHERE {where}", (reason, *params))
1283
-
1284
-
1285
- def finish_execution(connection, execution_id, status, result, request_status, cancel_reason=""):
1286
- """End one execution and move its delivered requests in the same transaction."""
1287
- if request_status == "cancelled" and cancel_reason:
1288
- record_cancel_reasons(connection, "execution_id=? AND status='delivering'", (execution_id,), cancel_reason)
1289
- discussion_requests = discussion.open_discussion_requests(connection, execution_id)
1290
- connection.execute("UPDATE member_executions SET status=?,finished_at=?,result=? WHERE id=? AND status IN ('dispatched','started')",
1291
- (status, int(time.time()), result[:500], execution_id))
1292
- connection.execute("UPDATE member_requests SET status=?,execution_id=CASE WHEN ?='pending' THEN NULL ELSE execution_id END WHERE execution_id=? AND status='delivering'",
1293
- (request_status, request_status, execution_id))
1294
- if status == "failed" and request_status == "failed":
1295
- for row in discussion_requests:
1296
- discussion.fail_session_request(connection, row["id"], result)
1297
-
1298
-
1299
- def open_route_messages(connection, member_id, pending_only=False):
1300
- """(message, purpose) of host requests still open, in order; they need a new host if this one leaves."""
1301
- states = "'pending'" if pending_only else "'pending','delivering'"
1302
- return [(row[0], row[1]) for row in connection.execute(
1303
- f"""SELECT message_id,purpose FROM member_requests WHERE member_id=? AND purpose IN ('route','group')
1304
- AND status IN ({states}) ORDER BY message_id""", (member_id,))]
1305
-
1306
-
1307
- def hand_over_routes(connection, room_id, routes, departed_id):
1308
- """Ask the current host to route messages left behind by the previous host."""
1309
- host = connection.execute("SELECT id FROM room_members WHERE room_id=? AND role='host' AND status='active' AND id<>?",
1310
- (room_id, departed_id or 0)).fetchone()
1311
- for message_id, purpose in routes if host else []:
1312
- connection.execute("""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,purpose,created_by,created_at)
1313
- VALUES (?,?,?,?,?,'user',?)""", (room_id, host["id"], message_id, message_id, purpose, int(time.time())))
1314
-
1315
-
1316
- def stop_member_work(connection, member_id, reason, cancel_reason, keep_pending=False):
1317
- """Void a member's live execution and, unless `keep_pending`, every open request, so nothing restarts by itself.
1318
-
1319
- The owner's stop keeps the requests that arrived while the member worked: like interrupting a turn, the member then
1320
- goes on to the messages sent after it, while a member that leaves drops them all.
1321
- """
1322
- for row in connection.execute("SELECT id FROM member_executions WHERE member_id=? AND status IN ('dispatched','started')",
1323
- (member_id,)).fetchall():
1324
- finish_execution(connection, row["id"], "cancelled", reason, "cancelled", cancel_reason)
1325
- if not keep_pending:
1326
- record_cancel_reasons(connection, "member_id=? AND status='pending'", (member_id,), cancel_reason)
1327
- connection.execute("UPDATE member_requests SET status='cancelled' WHERE member_id=? AND status='pending'", (member_id,))
1328
- discussion.after_member_stopped(connection, member_id)
1329
-
1330
-
1331
- def stop_room(connection, room_id, token):
1332
- """Owner stops the whole free room: every live execution, open request and queued turn ends, so nothing restarts by itself.
1333
-
1334
- Bound to the room alone, so it takes no revision; a running discussion is refused, since pausing it is how it stops.
1335
- """
1336
- require_owner(connection, room_id, token)
1337
- begin_write(connection)
1338
- if discussion.current_session(connection, room_id, active_only=True):
1339
- raise FileExistsError("讨论进行中,请用暂停或结束讨论")
1340
- connection.execute("DELETE FROM free_queue WHERE room_id=?", (room_id,))
1341
- pause_queue(connection, room_id)
1342
- stop_room_work(connection, room_id, "用户停止全部", "user_stop")
1343
- return free_view(connection, room_id)
1344
-
1345
-
1346
- def stop_room_work(connection, room_id, reason, cancel_reason):
1347
- """Void every live execution and open request of a room, e.g. when it is archived."""
1348
- for row in connection.execute("SELECT id FROM member_executions WHERE room_id=? AND status IN ('dispatched','started')",
1349
- (room_id,)).fetchall():
1350
- finish_execution(connection, row["id"], "cancelled", reason, "cancelled", cancel_reason)
1351
- record_cancel_reasons(connection, "room_id=? AND status='pending'", (room_id,), cancel_reason)
1352
- connection.execute("UPDATE member_requests SET status='cancelled' WHERE room_id=? AND status='pending'", (room_id,))
1353
- settle_batches(connection, room_id)
1354
-
1355
-
1356
- def inbox(connection, room_id, token):
1357
- """List what the runner should deliver or reconcile; owner credential only."""
1358
- require_owner(connection, room_id, token)
1359
- begin_write(connection)
1360
- enforce_session_limits(connection, room_id)
1361
- expire_executions(connection, room_id)
1362
- release_recommendations(connection, room_id)
1363
- archived = connection.execute("SELECT archived FROM room_directory WHERE room_id=?", (room_id,)).fetchone()[0]
1364
- members, observers = [], []
1365
- shown = member_display_names(connection, room_id)
1366
- # Manual members are never delivered to; the runner only reads them so smart scheduling can route to them too.
1367
- for row in connection.execute(f"SELECT * FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER} ORDER BY id", (room_id,)):
1368
- pending = [dict(item) for item in connection.execute("""SELECT r.id,r.message_id,r.purpose,
1369
- sr.id AS session_request_id,sr.session_id,sr.round,sr.position,sr.read_until,sr.question AS source_question,
1370
- t.cause_kind,t.cause_message_id AS source_message_id,t.cause_request_id,
1371
- (SELECT action FROM group_routes WHERE message_id=r.message_id) AS group_action
1372
- FROM member_requests r LEFT JOIN session_requests sr ON sr.member_request_id=r.id
1373
- LEFT JOIN task_message_sources t ON t.session_request_id=sr.id
1374
- WHERE r.member_id=? AND r.status='pending' ORDER BY r.id""", (row["id"],))]
1375
- member_view = {key: row[key] for key in MEMBER_FIELDS}
1376
- member_view["label"] = shown.get(row["name"], row["name"])
1377
- member_view["review_capability"] = review_capability(connection, room_id, row["id"], token)
1378
- member_view["held_until"] = held_until(connection, row["id"])
1379
- # The runner compares this with its adapter's effect and reports a change.
1380
- reported = connection.execute("SELECT rewind FROM member_capabilities WHERE member_id=?", (row["id"],)).fetchone()
1381
- member_view["rewind"] = reported[0] if reported else ""
1382
- pending = [{**request, **review_request_context(connection, request)} for request in pending]
1383
- (members if row["access"] == "runner" else observers).append({**member_view, "pending": pending})
1384
- executions = [{**public_execution(row), "member": row["name"], "retry": execution_retry(connection, row["id"])} for row in connection.execute(
1385
- """SELECT member_executions.*,room_members.name FROM member_executions JOIN room_members ON room_members.id=member_id
1386
- WHERE member_executions.room_id=? AND member_executions.status IN ('dispatched','started') ORDER BY member_executions.id""", (room_id,))]
1387
- return {"room_id": room_id, "archived": bool(archived), "members": members, "observers": observers, "executions": executions}
1388
-
1389
-
1390
- def host_route_request(connection, message_id):
1391
- """The host's request for one unnamed owner message, if the message was routed."""
1392
- return connection.execute("SELECT * FROM member_requests WHERE message_id=? AND purpose='route'", (message_id,)).fetchone()
1393
-
1394
-
1395
- def recommend_member(connection, room_id, token, payload):
1396
- """Smart scheduling suggests another member for an unnamed message; the host keeps it until the user adopts."""
1397
- require_owner(connection, room_id, token)
1398
- begin_write(connection)
1399
- confidence = payload.get("confidence")
1400
- if payload.get("provider") not in SCHEDULER_PROVIDERS or type(confidence) not in (int, float) or not 0 <= confidence <= 1:
1401
- raise ValueError("须提供智能调度供应商 provider 与 0–1 的置信度 confidence")
1402
- message_id = payload.get("message_id")
1403
- route = host_route_request(connection, message_id) if type(message_id) is int else None
1404
- if not route or route["room_id"] != room_id:
1405
- raise LookupError("只能为交给主持人的未点名消息给出推荐")
1406
- target = connection.execute(f"""SELECT * FROM room_members WHERE room_id=? AND id=? AND status='active'
1407
- AND {REGISTERED_MEMBER} AND id<>?""", (room_id, payload.get("member_id"), route["member_id"])).fetchone()
1408
- if not target:
1409
- raise LookupError("推荐对象须为在场的其他成员")
1410
- now = int(time.time())
1411
- connection.execute("""INSERT OR IGNORE INTO member_recommendations (message_id,room_id,member_id,provider,confidence,created_at,expires_at)
1412
- VALUES (?,?,?,?,?,?,?)""", (message_id, room_id, target["id"], payload["provider"], float(confidence), now, now + RECOMMEND_SECONDS))
1413
- return dict(connection.execute("SELECT * FROM member_recommendations WHERE message_id=?", (message_id,)).fetchone())
1414
-
1415
-
1416
- def release_recommendations(connection, room_id):
1417
- """Ask each adopted-later member once the host's request for that message has ended in any way."""
1418
- for row in connection.execute("""SELECT rec.* FROM member_recommendations rec JOIN member_requests r ON r.message_id=rec.message_id AND r.purpose='route'
1419
- WHERE rec.room_id=? AND rec.status='waiting' AND r.status IN ('completed','cancelled','failed')""", (room_id,)).fetchall():
1420
- connection.execute("UPDATE member_recommendations SET status='adopted' WHERE message_id=?", (row["message_id"],))
1421
- if connection.execute("SELECT 1 FROM room_members WHERE id=? AND status='active'", (row["member_id"],)).fetchone():
1422
- connection.execute("""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,purpose,created_by,created_at)
1423
- VALUES (?,?,?,?,'ask','user',?)""", (room_id, row["member_id"], row["message_id"], row["message_id"], int(time.time())))
1424
-
1425
-
1426
- def group_route_plan(connection, room_id, token, message_id):
1427
- """Read the current group, eligible members, order and revision before arranging everyone's requests."""
1428
- require_owner(connection, room_id, token)
1429
- group = connection.execute("""SELECT id,message_id,status FROM member_requests
1430
- WHERE room_id=? AND message_id=? AND purpose='group' ORDER BY id DESC LIMIT 1""", (room_id, message_id)).fetchone()
1431
- previous = connection.execute("SELECT action,request_id,message_id FROM group_routes WHERE room_id=? AND message_id=?",
1432
- (room_id, message_id)).fetchone()
1433
- if not group and not previous:
1434
- raise LookupError("这条消息没有全体待安排请求")
1435
- state = connection.execute(GROUP_STATE_SQL, (room_id,)).fetchone()["state"]
1436
- snapshot = json.loads(state)
1437
- members = [member for member in snapshot["members"] if member["status"] == "active" and member["registered"]]
1438
- rank = {member_id: index for index, member_id in enumerate(json.loads(snapshot["order"]))}
1439
- members.sort(key=lambda member: (member["id"] not in rank, rank.get(member["id"], 0), member["role"] != "host", member["id"]))
1440
- return {"message_id": message_id, "request": dict(group) if group else None, "members": members,
1441
- "title": snapshot["title"], "goal": snapshot["goal"],
1442
- "latest_id": snapshot["latest_id"], "revision": hashlib.sha256(state.encode("utf-8")).hexdigest(),
1443
- "result": dict(previous) if previous else None}
1444
-
1445
-
1446
- def route_group(connection, room_id, group, payload):
1447
- """Arrange a pending group atomically; only the single-object handback to its host needs no roster revision."""
1448
- action = payload["action"]
1449
- if action not in ("all", "sequential", "host"):
1450
- raise ValueError("请全体成员回应的消息只能选 action=all、sequential 或 host")
1451
- if group["status"] != "pending":
1452
- raise FileExistsError("这条消息已不再等待安排")
1453
- if action != "host":
1454
- if not payload.get("revision"):
1455
- raise ValueError("安排全体前须先读取 message group-plan,并带上其 revision")
1456
- state = connection.execute(GROUP_STATE_SQL, (room_id,)).fetchone()["state"]
1457
- if payload["revision"] != hashlib.sha256(state.encode("utf-8")).hexdigest():
1458
- raise FileExistsError("成员、顺序或消息已变化,请重新读取全体安排状态")
1459
- if action == "host":
1460
- connection.execute("UPDATE member_requests SET purpose='route' WHERE id=?", (group["id"],))
1461
- elif action == "sequential":
1462
- connection.execute("DELETE FROM member_requests WHERE id=?", (group["id"],))
1463
- members = connection.execute(f"SELECT id FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER}", (room_id,))
1464
- queue_in_turn(connection, room_id, group["message_id"], [row["id"] for row in members])
1465
- else:
1466
- connection.execute("UPDATE member_requests SET purpose='all' WHERE id=?", (group["id"],))
1467
- connection.execute(f"""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,purpose,created_by,created_at)
1468
- SELECT room_id,id,?,?,'all','user',? FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER} ORDER BY id""",
1469
- (group["message_id"], group["chain_id"], int(time.time()), room_id))
1470
- connection.execute("INSERT INTO group_routes (message_id,room_id,request_id,action) VALUES (?,?,?,?)",
1471
- (group["message_id"], room_id, group["id"], action))
1472
- return {"action": action, "request_id": group["id"], "message_id": group["message_id"]}
1473
-
1474
-
1475
- def route_wide(connection, room_id, message_id, payload):
1476
- """Smart scheduling answers an unnamed message with everyone (each member once, as if the user named them all, at once
1477
- or one after another), the few members it names, or no one.
1478
-
1479
- A message naming everyone (a group request) is answered by all at once or, with action sequential, one after another.
1480
- Only a host request that has not started changes; a started host already handles the message its own way.
1481
- """
1482
- action = payload["action"]
1483
- previous = connection.execute("SELECT action,request_id,message_id FROM group_routes WHERE room_id=? AND message_id=?",
1484
- (room_id, message_id)).fetchone()
1485
- if previous:
1486
- if previous["action"] != action:
1487
- raise FileExistsError("这条全体消息已经安排,不能改用其他动作")
1488
- return dict(previous)
1489
- group = connection.execute("""SELECT * FROM member_requests WHERE room_id=? AND message_id=? AND purpose='group'
1490
- ORDER BY id DESC LIMIT 1""", (room_id, message_id)).fetchone()
1491
- if group:
1492
- return route_group(connection, room_id, group, payload)
1493
- if action == "host":
1494
- raise ValueError("只有请全体成员回应的消息可以交回主持人")
1495
- if action not in ("all", "sequential", "none", "some"):
1496
- raise ValueError("未点名消息只能选 action=all、sequential、some 或 none")
1497
- route = host_route_request(connection, message_id)
1498
- if not route or route["room_id"] != room_id:
1499
- raise LookupError("只能处理交给主持人的未点名消息")
1500
- if route["status"] != "pending":
1501
- raise FileExistsError("主持人已开始处理这条消息,不再改派")
1502
- if action == "sequential":
1503
- # Same as a group answered one after another: the host's request gives way to the queue, which includes the host.
1504
- connection.execute("DELETE FROM member_requests WHERE id=?", (route["id"],))
1505
- members = connection.execute(f"SELECT id FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER}", (room_id,))
1506
- queue_in_turn(connection, room_id, message_id, [row["id"] for row in members])
1507
- connection.execute("INSERT INTO group_routes (message_id,room_id,request_id,action) VALUES (?,?,?,?)",
1508
- (message_id, room_id, route["id"], action))
1509
- elif action == "none":
1510
- record_cancel_reasons(connection, "id=?", (route["id"],), "no_reply")
1511
- connection.execute("UPDATE member_requests SET status='cancelled' WHERE id=?", (route["id"],))
1512
- elif action == "some":
1513
- route_some(connection, room_id, route, payload.get("members"))
1514
- else:
1515
- # The host keeps its row (one request per message and member) and now answers its own share, without dispatching.
1516
- connection.execute("UPDATE member_requests SET purpose='all' WHERE id=?", (route["id"],))
1517
- connection.execute(f"""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,purpose,created_by,created_at)
1518
- SELECT room_id,id,?,?,'all','user',? FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER} ORDER BY id""",
1519
- (message_id, message_id, int(time.time()), room_id))
1520
- requests = connection.execute("SELECT * FROM member_requests WHERE message_id=? ORDER BY id", (message_id,)).fetchall()
1521
- return {"action": action, "requests": [dict(row) for row in requests]}
1522
-
1523
-
1524
- def route_some(connection, room_id, route, names):
1525
- """Smart scheduling answers an unnamed message with the few members it names, each once, as if the user named them."""
1526
- if not isinstance(names, list) or not all(isinstance(name, str) for name in names) or len(set(names)) < 2:
1527
- raise ValueError("action=some 须带 members:至少两位不同的成员名")
1528
- found = {row["name"]: row["id"] for row in connection.execute(
1529
- f"SELECT id,name FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER}", (room_id,))}
1530
- missing = [name for name in dict.fromkeys(names) if name not in found]
1531
- if missing:
1532
- raise FileExistsError(f"这些成员不在场或未注册:{'、'.join(missing)}")
1533
- ids = [found[name] for name in dict.fromkeys(names)]
1534
- if route["member_id"] in ids:
1535
- # The host is one of them: it keeps its row and answers its own share, without dispatching.
1536
- connection.execute("UPDATE member_requests SET purpose='all' WHERE id=?", (route["id"],))
1537
- else:
1538
- record_cancel_reasons(connection, "id=?", (route["id"],), "rerouted")
1539
- connection.execute("UPDATE member_requests SET status='cancelled' WHERE id=?", (route["id"],))
1540
- for member_id in ids:
1541
- if member_id != route["member_id"]:
1542
- connection.execute("""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,purpose,created_by,created_at)
1543
- VALUES (?,?,?,?,'all','user',?)""", (room_id, member_id, route["message_id"], route["message_id"], int(time.time())))
1544
-
1545
-
1546
- def adopt_recommendation(connection, room_id, token, message_id, payload):
1547
- """The user adopts a recommendation before it expires: interrupt the host now, or ask the member after the host.
1548
-
1549
- Smart scheduling also sends action all or none here to have everyone or no one answer an unnamed message.
1550
- """
1551
- require_owner(connection, room_id, token)
1552
- begin_write(connection)
1553
- if payload.get("action") in ("all", "none", "sequential", "host", "some"):
1554
- return route_wide(connection, room_id, message_id, payload)
1555
- if payload.get("action") != "adopt" or payload.get("mode") not in ("interrupt", "after"):
1556
- raise ValueError("采纳须为 action=adopt,mode 为 interrupt 或 after;全体、依次、指定几位或无需回应为 action=all、sequential、some 或 none")
1557
- row = connection.execute("SELECT * FROM member_recommendations WHERE room_id=? AND message_id=?", (room_id, message_id)).fetchone()
1558
- if not row:
1559
- raise LookupError("这条消息没有智能调度推荐")
1560
- if row["status"] != "open":
1561
- return {"recommendation": dict(row), "duplicate": True}
1562
- if int(time.time()) > row["expires_at"] + RECOMMEND_GRACE_SECONDS:
1563
- raise FileExistsError("推荐已过期,可直接 @ 该成员")
1564
- if not connection.execute("SELECT 1 FROM room_members WHERE id=? AND status='active'", (row["member_id"],)).fetchone():
1565
- raise FileExistsError("推荐的成员已离开")
1566
- route = host_route_request(connection, message_id)
1567
- mode = payload["mode"]
1568
- if mode == "interrupt" and route["status"] == "delivering":
1569
- # The host's execution may carry other messages too; stopping it voids that whole execution.
1570
- finish_execution(connection, route["execution_id"], "cancelled", "智能调度改派,改由其他成员回答", "cancelled", "rerouted")
1571
- elif mode == "interrupt" and route["status"] == "pending":
1572
- record_cancel_reasons(connection, "id=?", (route["id"],), "rerouted")
1573
- connection.execute("UPDATE member_requests SET status='cancelled' WHERE id=?", (route["id"],))
1574
- connection.execute("UPDATE member_recommendations SET status='waiting',mode=? WHERE message_id=?", (mode, message_id))
1575
- release_recommendations(connection, room_id)
1576
- settle_batches(connection, room_id)
1577
- return {"recommendation": dict(connection.execute("SELECT * FROM member_recommendations WHERE message_id=?", (message_id,)).fetchone()),
1578
- "duplicate": False}
1579
-
1580
-
1581
- def recent_recommendations(connection, room_id, now):
1582
- """Recommendations shown beside their messages, with seconds left to adopt an open one."""
1583
- return [{**dict(row), "expires_in": max(0, row["expires_at"] - now)} for row in connection.execute(
1584
- """SELECT rec.*,m.name AS member FROM member_recommendations rec JOIN room_members m ON m.id=rec.member_id
1585
- WHERE rec.room_id=? ORDER BY rec.message_id DESC LIMIT ?""", (room_id, RECENT_REQUESTS))]
1586
-
1587
-
1588
- def reserve_execution(connection, room_id, token, payload):
1589
- """Bundle a member's open requests into one execution; a retried client_id returns the same one."""
1590
- require_owner(connection, room_id, token)
1591
- begin_write(connection)
1592
- if enforce_session_limits(connection, room_id):
1593
- connection.commit()
1594
- client_id = str(payload.get("client_id", ""))
1595
- if not 8 <= len(client_id) <= 100:
1596
- raise ValueError("client_id 须为 8–100 字符的本地投递编号")
1597
- previous = connection.execute("SELECT * FROM member_executions WHERE room_id=? AND client_id=?", (room_id, client_id)).fetchone()
1598
- if previous:
1599
- return execution_detail(connection, previous)
1600
- expire_executions(connection, room_id)
1601
- if connection.execute("SELECT archived FROM room_directory WHERE room_id=?", (room_id,)).fetchone()[0]:
1602
- raise FileExistsError("房间已存档,不再投递")
1603
- member = connection.execute("SELECT * FROM room_members WHERE room_id=? AND id=? AND status='active' AND access='runner'",
1604
- (room_id, payload.get("member_id"))).fetchone()
1605
- if not member:
1606
- raise LookupError("成员不存在、已离开或为手动接入")
1607
- if connection.execute("SELECT 1 FROM member_executions WHERE member_id=? AND status IN ('dispatched','started')",
1608
- (member["id"],)).fetchone():
1609
- raise FileExistsError("该成员已有进行中的执行;同一会话不并发续接")
1610
- if held_until(connection, member["id"]):
1611
- raise FileExistsError("该成员上一轮已作废但本机无法停止,可能仍在处理;等它结束或等待时限到后再投递")
1612
- # A group request waits for the runner to choose how everyone answers; it is never delivered itself.
1613
- pending = connection.execute("""SELECT id,purpose,EXISTS(SELECT 1 FROM review_plans WHERE review_batch_id=member_requests.message_id AND reviewer_id=member_requests.member_id) AS review FROM member_requests
1614
- WHERE member_id=? AND status='pending' AND purpose<>'group' ORDER BY id""", (member["id"],)).fetchall()
1615
- session_purpose = next((row["purpose"] for row in pending if row["purpose"] == "discuss"), None)
1616
- if not pending:
1617
- raise FileExistsError("该成员没有待投递的请求")
1618
- if session_purpose:
1619
- request_filter = "purpose=?"
1620
- request_values = (session_purpose,)
1621
- elif pending[0]["review"]:
1622
- request_filter, request_values = "id=?", (pending[0]["id"],)
1623
- else:
1624
- request_filter = "purpose<>'group' AND NOT EXISTS(SELECT 1 FROM review_plans WHERE review_batch_id=member_requests.message_id AND reviewer_id=member_requests.member_id)"
1625
- request_values = ()
1626
- now = int(time.time())
1627
- cursor = connection.execute("""INSERT INTO member_executions (room_id,member_id,client_id,lease_until,created_at,cwd)
1628
- VALUES (?,?,?,?,?,?)""", (room_id, member["id"], client_id, now + LEASE_SECONDS, now, str(payload.get("cwd") or "")[:MAX_PATH_LENGTH]))
1629
- connection.execute(f"UPDATE member_requests SET status='delivering',execution_id=? WHERE member_id=? AND status='pending' AND {request_filter}",
1630
- (cursor.lastrowid, member["id"], *request_values))
1631
- return execution_detail(connection, connection.execute("SELECT * FROM member_executions WHERE id=?", (cursor.lastrowid,)).fetchone())
1632
-
1633
-
1634
- def execution_detail(connection, row):
1635
- """Describe one execution with the requests it carries and the member's read cursor."""
1636
- member = connection.execute("SELECT * FROM room_members WHERE id=?", (row["member_id"],)).fetchone()
1637
- requests = [dict(item) for item in connection.execute("""SELECT r.id,r.message_id,r.status,r.purpose,
1638
- sr.id AS session_request_id,sr.session_id,sr.round,sr.position,sr.read_until,sr.question AS source_question,
1639
- t.cause_kind,t.cause_message_id AS source_message_id,t.cause_request_id
1640
- FROM member_requests r LEFT JOIN session_requests sr ON sr.member_request_id=r.id
1641
- LEFT JOIN task_message_sources t ON t.session_request_id=sr.id
1642
- WHERE r.execution_id=? ORDER BY r.id""", (row["id"],))]
1643
- requests = [{**request, **review_request_context(connection, request)} for request in requests]
1644
- return {**public_execution(row), "member": member["name"], "read_until": member["read_until"], "requests": requests,
1645
- "rewound_executions": member_rewinds(connection, member["id"]), "retry": execution_retry(connection, row["id"])}
1646
-
1647
-
1648
- def view_execution(connection, room_id, execution_id, token):
1649
- """Let the owner or the executing member read one execution."""
1650
- row = connection.execute("SELECT * FROM member_executions WHERE room_id=? AND id=?", (room_id, execution_id)).fetchone()
1651
- if not row:
1652
- raise LookupError("执行不存在")
1653
- try:
1654
- require_owner(connection, room_id, token)
1655
- except PermissionError:
1656
- member = require_member(connection, room_id, token)
1657
- if member["id"] != row["member_id"]:
1658
- raise
1659
- return execution_detail(connection, row)
1660
-
1661
-
1662
- def changed_files(payload):
1663
- """Validate the runner's observed file list; paths are relative to the execution's cwd."""
1664
- files = payload.get("changes")
1665
- if (not isinstance(files, list) or len(files) > MAX_CHANGED_FILES
1666
- or any(not isinstance(item, str) or not item or len(item) > MAX_PATH_LENGTH for item in files)):
1667
- raise ValueError(f"changes 须为最多 {MAX_CHANGED_FILES} 个相对路径")
1668
- return sorted(set(files))
1669
-
1670
-
1671
- def denied_actions(payload):
1672
- """Validate the operations a member's software refused: the tool, what it tried, and the rule that would allow it."""
1673
- denials = payload.get("denials")
1674
- def valid(item):
1675
- return (isinstance(item, dict) and isinstance(item.get("tool"), str) and 0 < len(item["tool"]) <= 40
1676
- and all(isinstance(item.get(key, ""), str) and len(item.get(key, "")) <= MAX_DENIAL_FIELD for key in ("detail", "rule")))
1677
- if not isinstance(denials, list) or not 0 < len(denials) <= MAX_DENIALS or not all(map(valid, denials)):
1678
- raise ValueError(f"denials 须为 1–{MAX_DENIALS} 条 {{tool, detail, rule}}")
1679
- return [{key: item.get(key, "") for key in ("tool", "detail", "rule")} for item in denials]
1680
-
1681
-
1682
- def renewed_activity(payload, row, now):
1683
- """Validate the runner's reported phase; its output time is kept within the execution, since the runner's clock may differ."""
1684
- activity, activity_at = payload.get("activity"), payload.get("activity_at")
1685
- if activity not in ACTIVITY_PHASES:
1686
- raise ValueError(f"activity 须为 {'、'.join(item for item in ACTIVITY_PHASES if item)} 或空")
1687
- if type(activity_at) is not int:
1688
- raise ValueError("activity_at 须为秒级时间戳")
1689
- return activity, min(max(activity_at, row["created_at"]), now)
1690
-
1691
-
1692
- def renewed_notes(payload):
1693
- """Validate progress notes carried by a renewal; a repeated sequence number is the same note resent."""
1694
- notes = payload.get("notes", [])
1695
- if (not isinstance(notes, list) or len(notes) > MAX_NOTES_PER_RENEW or any(
1696
- not isinstance(note, dict) or type(note.get("seq")) is not int or not 1 <= note["seq"] <= MAX_EXECUTION_NOTES
1697
- or not isinstance(note.get("text"), str) or not 0 < len(note["text"]) <= MAX_NOTE_LENGTH for note in notes)):
1698
- raise ValueError(f"notes 须为最多 {MAX_NOTES_PER_RENEW} 条 {{seq: 1–{MAX_EXECUTION_NOTES}, text: 1–{MAX_NOTE_LENGTH} 字}}")
1699
- return notes
1700
-
1701
-
1702
- def late_answer(connection, room_id, execution_id, token):
1703
- """Read one voided execution's late text after checking the room owner's credential."""
1704
- require_owner(connection, room_id, token)
1705
- row = connection.execute("SELECT text,created_at FROM late_answers WHERE room_id=? AND execution_id=?",
1706
- (room_id, execution_id)).fetchone()
1707
- if not row:
1708
- raise LookupError("本次执行没有迟到回答")
1709
- return dict(row)
1710
-
1711
-
1712
- def record_stop_result(connection, room_id, execution_id, result):
1713
- """Record a stop idempotently; an unconfirmed stop may later gain a confirmed outcome."""
1714
- if result not in STOP_RESULTS:
1715
- raise ValueError(f"result 须为 {'、'.join(STOP_RESULTS)}")
1716
- saved = connection.execute("""INSERT INTO execution_capabilities (execution_id,room_id,stop_result) VALUES (?,?,?)
1717
- ON CONFLICT(execution_id) DO UPDATE SET stop_result=excluded.stop_result
1718
- WHERE stop_result IN ('','unconfirmed') AND stop_result<>excluded.stop_result""", (execution_id, room_id, result))
1719
- if saved.rowcount:
1720
- return False
1721
- previous = connection.execute("SELECT stop_result FROM execution_capabilities WHERE execution_id=?", (execution_id,)).fetchone()[0]
1722
- if previous != result:
1723
- raise FileExistsError("本次执行已记录不同的停止结果")
1724
- return True
1725
-
1726
-
1727
- def execution_action(connection, room_id, execution_id, token, payload):
1728
- """Runner-side lifecycle: renew, fail, release undelivered work, report how the software was reached, whether a voided one stopped, or observed file changes."""
1729
- begin_write(connection)
1730
- action = payload.get("action")
1731
- duplicate = False
1732
- member = None
1733
- if action == "fail":
1734
- try:
1735
- require_owner(connection, room_id, token)
1736
- except PermissionError:
1737
- member = require_member(connection, room_id, token)
1738
- else:
1739
- require_owner(connection, room_id, token)
1740
- if enforce_session_limits(connection, room_id):
1741
- connection.commit()
1742
- row = connection.execute("SELECT * FROM member_executions WHERE room_id=? AND id=?", (room_id, execution_id)).fetchone()
1743
- if not row:
1744
- raise LookupError("执行不存在")
1745
- if member and member["id"] != row["member_id"]:
1746
- raise PermissionError("只能报告自己执行的失败")
1747
- now = int(time.time())
1748
- if action == "report":
1749
- # Observed after the process ends, so the execution may already be complete; recorded once.
1750
- if row["changes"]:
1751
- raise FileExistsError("本次执行的改动已记录")
1752
- connection.execute("UPDATE member_executions SET changes=? WHERE id=?", (json.dumps(changed_files(payload), ensure_ascii=False), execution_id))
1753
- elif action == "denials":
1754
- # Like file changes, read from the software's output once its process ends; recorded once.
1755
- saved = connection.execute("INSERT OR IGNORE INTO execution_denials (execution_id,room_id,denials) VALUES (?,?,?)",
1756
- (execution_id, room_id, json.dumps(denied_actions(payload), ensure_ascii=False)))
1757
- if not saved.rowcount:
1758
- raise FileExistsError("本次执行的拒绝记录已记录")
1759
- elif action == "segments":
1760
- # The last segments and notes arrive after the member's answer ended the execution; such notes are dated at its end.
1761
- finished = 0 if row["status"] in ACTIVE else (row["finished_at"] or 1)
1762
- notes = renewed_notes(payload)
1763
- save_segments(connection, "free", room_id, execution_id, row["created_at"], finished, payload, optional=bool(notes))
1764
- connection.executemany("INSERT OR IGNORE INTO execution_notes (execution_id,seq,room_id,text,created_at) VALUES (?,?,?,?,?)",
1765
- [(execution_id, note["seq"], room_id, note["text"], min(now, finished) if finished else now) for note in notes])
1766
- elif action == "stopped":
1767
- # After a voided execution: whether the runner ended its software, or could not because it works in a window or App.
1768
- if row["status"] != "cancelled":
1769
- raise FileExistsError("只有已作废的执行才报告停止结果")
1770
- duplicate = record_stop_result(connection, room_id, execution_id, payload.get("result"))
1771
- elif action == "delivery":
1772
- if row["status"] not in (*ACTIVE, "cancelled"):
1773
- raise FileExistsError("执行已结束")
1774
- # What this delivery can do, as the runner's adapter reported; a retried delivery may differ, so the latest counts.
1775
- if type(payload.get("stoppable")) is not bool or type(payload.get("progress")) is not bool:
1776
- raise ValueError("delivery 须带布尔值 stoppable 与 progress")
1777
- connection.execute("""INSERT INTO execution_capabilities (execution_id,room_id,stoppable,progress) VALUES (?,?,?,?)
1778
- ON CONFLICT(execution_id) DO UPDATE SET stoppable=excluded.stoppable,progress=excluded.progress""",
1779
- (execution_id, room_id, int(payload["stoppable"]), int(payload["progress"])))
1780
- elif action == "retry":
1781
- retry_failed(connection, room_id, row)
1782
- elif row["status"] not in ACTIVE:
1783
- raise FileExistsError("执行已结束")
1784
- elif action == "renew":
1785
- # No time limit on one execution: it runs while the runner keeps renewing.
1786
- if row["lease_until"] <= now:
1787
- raise FileExistsError("执行租约已到期,不能续租")
1788
- # Older runners renew without activity; the last reported one is kept.
1789
- activity, activity_at = renewed_activity(payload, row, now) if "activity" in payload else (row["activity"], row["activity_at"])
1790
- notes = renewed_notes(payload)
1791
- connection.execute("UPDATE member_executions SET lease_until=?,activity=?,activity_at=? WHERE id=?",
1792
- (now + LEASE_SECONDS, activity, activity_at, execution_id))
1793
- connection.executemany("INSERT OR IGNORE INTO execution_notes (execution_id,seq,room_id,text,created_at) VALUES (?,?,?,?,?)",
1794
- [(execution_id, note["seq"], room_id, note["text"], now) for note in notes])
1795
- elif action == "retry_wait":
1796
- # A manual member's session refused the delivery: the runner keeps the execution and delivers again at retry_at.
1797
- attempt, retry_at, reason = payload.get("attempt"), payload.get("retry_at"), payload.get("reason")
1798
- if (type(attempt) is not int or not 1 <= attempt <= MAX_RETRIES or type(retry_at) is not int or retry_at <= 0
1799
- or not isinstance(reason, str) or not 0 < len(reason) <= MAX_RETRY_REASON):
1800
- raise ValueError(f"retry_wait 须带 attempt(1–{MAX_RETRIES})、秒级 retry_at 与 reason(最多 {MAX_RETRY_REASON} 字)")
1801
- connection.execute("""INSERT INTO execution_retries (execution_id,room_id,attempt,retry_at,reason,stopped) VALUES (?,?,?,?,?,0)
1802
- ON CONFLICT(execution_id) DO UPDATE SET attempt=excluded.attempt,retry_at=excluded.retry_at,reason=excluded.reason,stopped=0""",
1803
- (execution_id, room_id, attempt, retry_at, reason))
1804
- elif action == "retry_start":
1805
- connection.execute("UPDATE execution_retries SET retry_at=0 WHERE execution_id=?", (execution_id,))
1806
- elif action in ("retry_now", "retry_stop"):
1807
- # The owner's answer to a waiting retry: deliver now, or stop so the runner fails the execution.
1808
- change = "retry_at=?" if action == "retry_now" else "stopped=1"
1809
- updated = connection.execute(f"UPDATE execution_retries SET {change} WHERE execution_id=? AND retry_at>0 AND stopped=0",
1810
- ((now, execution_id) if action == "retry_now" else (execution_id,)))
1811
- if not updated.rowcount:
1812
- raise FileExistsError("本次执行没有在等待自动重试")
1813
- elif action == "reject_review":
1814
- request = connection.execute("""SELECT r.id FROM member_requests r JOIN review_plans p ON p.review_batch_id=r.message_id AND p.reviewer_id=r.member_id
1815
- WHERE r.execution_id=? AND r.status='delivering' AND r.purpose='ask'""", (execution_id,)).fetchall()
1816
- count = connection.execute("SELECT COUNT(*) FROM member_requests WHERE execution_id=? AND status='delivering'", (execution_id,)).fetchone()[0]
1817
- if len(request) != 1 or count != 1 or not isinstance(payload.get("reason"), str) or not payload["reason"].strip():
1818
- raise ValueError("reject_review 只用于单个评审执行,须说明拒绝原因")
1819
- finish_execution(connection, execution_id, "cancelled", "评审未投递:" + payload["reason"][:400], "cancelled")
1820
- settle_batches(connection, room_id)
1821
- elif action == "fail":
1822
- finish_execution(connection, execution_id, "failed", str(payload.get("reason") or "执行失败"), "failed")
1823
- settle_batches(connection, room_id)
1824
- elif action == "release":
1825
- # Only for proven non-delivery: the requests return to the queue unchanged.
1826
- finish_execution(connection, execution_id, "cancelled", str(payload.get("reason") or "未送达"), "pending")
1827
- else:
1828
- raise ValueError("action 须为 renew、fail、release、report、segments、retry_wait、retry_start、retry_now、retry_stop 或 retry")
1829
- detail = execution_detail(connection, connection.execute("SELECT * FROM member_executions WHERE id=?", (execution_id,)).fetchone())
1830
- return {**detail, "duplicate": duplicate} if action == "stopped" else detail
1831
-
1832
-
1833
- def retry_failed(connection, room_id, execution):
1834
- """Owner retry of a failed execution: its requests go back to the same member's queue and the runner delivers them anew.
1835
-
1836
- The failed execution stays as history but no longer carries the requests, so a second retry is refused. A host batch
1837
- that failed as a whole reopens and its chain resumes, so the host still gets the answer back.
1838
- """
1839
- if execution["status"] != "failed":
1840
- raise FileExistsError("只有失败的执行可以重试")
1841
- requests = connection.execute("""SELECT id,batch_id,chain_id FROM member_requests r WHERE execution_id=? AND status='failed'
1842
- AND NOT EXISTS(SELECT 1 FROM session_requests s WHERE s.member_request_id=r.id)""", (execution["id"],)).fetchall()
1843
- if not requests:
1844
- raise FileExistsError("这次执行没有可重试的请求:已重试过,或是讨论发言(由主持人重新安排)")
1845
- if not connection.execute("SELECT 1 FROM room_members WHERE id=? AND status='active'", (execution["member_id"],)).fetchone():
1846
- raise FileExistsError("该成员已离开或暂离,无法重试")
1847
- connection.execute(f"UPDATE member_requests SET status='pending',execution_id=NULL WHERE id IN ({','.join('?' * len(requests))})",
1848
- [request["id"] for request in requests])
1849
- for request in requests:
1850
- batch_id, chain_id = request["batch_id"], request["chain_id"]
1851
- if batch_id and not connection.execute(
1852
- "SELECT 1 FROM member_requests WHERE room_id=? AND batch_id=? AND status='completed'", (room_id, batch_id)).fetchone():
1853
- connection.execute("UPDATE free_batches SET settled=0 WHERE room_id=? AND batch_id=?", (room_id, batch_id))
1854
- connection.execute("DELETE FROM free_chains WHERE room_id=? AND chain_id=? AND reason='all_failed'", (room_id, chain_id))
1855
-
1856
-
1857
- def owned_member(connection, room_id, member_id, token):
1858
- """Read one member after proving the owner's credential."""
1859
- require_owner(connection, room_id, token)
1860
- begin_write(connection)
1861
- member = connection.execute("SELECT * FROM room_members WHERE room_id=? AND id=?", (room_id, member_id)).fetchone()
1862
- if not member or member["status"] == "removed":
1863
- raise LookupError("成员不存在")
1864
- return member
1865
-
1866
-
1867
- def interrupt_member(connection, room_id, member_id, token):
1868
- """Owner stop: void the live execution; requests that arrived meanwhile stay queued for the member's next turn."""
1869
- owned_member(connection, room_id, member_id, token)
1870
- pause_queue(connection, room_id)
1871
- stop_member_work(connection, member_id, "用户打断", "user_stop", keep_pending=True)
1872
- settle_batches(connection, room_id)
1873
- return free_view(connection, room_id)
1874
-
1875
-
1876
- def withdrawals(connection, room_id):
1877
- """The room's taken-back messages by id, so the page keeps them in place while AI readers skip them."""
1878
- return [dict(row) for row in connection.execute("""SELECT w.message_id,w.kind FROM message_withdrawal_rooms r
1879
- JOIN message_withdrawals w ON w.message_id=r.message_id WHERE r.room_id=? ORDER BY r.message_id""", (room_id,))]
1880
-
1881
-
1882
- def owner_message(connection, room_id, message_id):
1883
- """One visible message of the user in a free room without an open discussion, which a retract or rewind may take back."""
1884
- row = connection.execute("SELECT * FROM messages WHERE room_id=? AND id=?", (room_id, message_id)).fetchone()
1885
- if not row:
1886
- raise LookupError("消息不存在")
1887
- if row["author"] != "用户":
1888
- raise ValueError("只能撤回或编辑用户自己的消息")
1889
- if connection.execute("SELECT 1 FROM room_directory WHERE room_id=? AND archived=1", (room_id,)).fetchone():
1890
- raise ValueError("房间已存档;恢复后才能撤回或编辑")
1891
- if discussion.blocks_message_change(connection, room_id, message_id):
1892
- raise FileExistsError("讨论进行中,或这条消息之后开启过讨论,不能撤回或编辑")
1893
- return row
1894
-
1895
-
1896
- def retract_message(connection, room_id, message_id, token):
1897
- """Owner takes back a message no member began to handle: its queued requests end and AI readers no longer see it.
1898
-
1899
- Bound to one known message, so it takes no revision; a message any member started, answered or failed is refused.
1900
- A member working on an earlier message may still have read it, which the room cannot know.
1901
-
1902
- Returns:
1903
- `{"message_id", "text", "duplicate"}`; the text lets the page put it back in the input for editing.
1904
- """
1905
- require_owner(connection, room_id, token)
1906
- begin_write(connection)
1907
- message = owner_message(connection, room_id, message_id)
1908
- if connection.execute("SELECT 1 FROM message_withdrawals WHERE message_id=?", (message_id,)).fetchone():
1909
- return {"message_id": message_id, "text": message["text"], "duplicate": True}
1910
- if connection.execute("SELECT 1 FROM member_requests WHERE message_id=? AND status IN ('delivering','completed','failed')", (message_id,)).fetchone():
1911
- raise FileExistsError("已有成员开始处理这条消息,不能撤回;可以编辑这条消息回退到它之前")
1912
- connection.execute("UPDATE member_requests SET status='cancelled' WHERE message_id=? AND status='pending'", (message_id,))
1913
- connection.execute("DELETE FROM free_queue WHERE message_id=?", (message_id,))
1914
- connection.execute("INSERT INTO message_withdrawals (message_id,kind,created_at) VALUES (?,'retracted',?)", (message_id, int(time.time())))
1915
- settle_batches(connection, room_id)
1916
- return {"message_id": message_id, "text": message["text"], "duplicate": False}
1917
-
1918
-
1919
- # Executions a rewind takes back: any touching a message from the rewind point on, and any still running.
1920
- REWOUND_EXECUTIONS = """SELECT e.id,e.member_id FROM member_executions e WHERE e.room_id=? AND (e.status IN ('dispatched','started')
1921
- OR EXISTS(SELECT 1 FROM member_requests r WHERE r.execution_id=e.id AND (r.message_id>=? OR r.answer_id>=?))) ORDER BY e.id"""
1922
-
1923
-
1924
- def rewind_plan(connection, room_id, message_id):
1925
- """What rewinding to just before a message takes back, and a revision of the state it was read from.
1926
-
1927
- Each member reports what happens to its session: `cut` (the runner cuts it before the first taken-back execution),
1928
- `fresh` (its context is rebuilt each time), `unchanged` (no taken-back execution) or `kept` (manual members,
1929
- and software the runner reported it cannot cut, keep what they saw). The runner reports each member's effect from its
1930
- adapter (`member_capabilities`); a member without a report is kept.
1931
- """
1932
- owner_message(connection, room_id, message_id)
1933
- if connection.execute("SELECT 1 FROM message_withdrawals WHERE message_id=?", (message_id,)).fetchone():
1934
- raise FileExistsError("这条消息已被撤回或回退")
1935
- executions = {}
1936
- for row in connection.execute(REWOUND_EXECUTIONS, (room_id, message_id, message_id)):
1937
- executions.setdefault(row["member_id"], []).append(row["id"])
1938
- members = []
1939
- for row in connection.execute("""SELECT m.id,m.name,m.access,(SELECT rewind FROM member_capabilities c WHERE c.member_id=m.id) AS rewind
1940
- FROM room_members m WHERE m.room_id=? AND m.status<>'removed' ORDER BY m.id""", (room_id,)):
1941
- taken = executions.get(row["id"], [])
1942
- effect = row["rewind"] if row["access"] == "runner" else "kept"
1943
- session = "kept" if effect in (None, "kept") else "unchanged" if not taken else effect
1944
- members.append({"id": row["id"], "name": row["name"], "executions": taken, "session": session})
1945
- count = connection.execute("""SELECT COUNT(*) FROM messages m WHERE m.room_id=? AND m.id>=?
1946
- AND NOT EXISTS(SELECT 1 FROM message_withdrawals w WHERE w.message_id=m.id)""", (room_id, message_id)).fetchone()[0]
1947
- latest = connection.execute("SELECT COALESCE(MAX(id),0) FROM messages WHERE room_id=?", (room_id,)).fetchone()[0]
1948
- state = json.dumps([message_id, latest, count, members])
1949
- return {"message_id": message_id, "messages": count, "members": members,
1950
- "revision": hashlib.sha256(state.encode("utf-8")).hexdigest()[:12]}
1951
-
1952
-
1953
- def rewind_room(connection, room_id, token, payload):
1954
- """Owner returns the room to just before one of its messages, as editing that message does.
1955
-
1956
- Read the plan first (`GET rewind?message_id=`) and send its revision: a missing revision is refused and a changed
1957
- one conflicts. The message and everything after it stay on the page but leave AI reads; running work stops, open
1958
- requests on them end, members read again from before the message, and each runner member's session is cut before
1959
- its first taken-back execution when it next works. Files the members changed are not restored.
1960
- """
1961
- require_owner(connection, room_id, token)
1962
- begin_write(connection)
1963
- message_id = payload.get("message_id")
1964
- if type(message_id) is not int:
1965
- raise ValueError("须提供要回退到其之前的消息编号 message_id")
1966
- if payload.get("revision") is None:
1967
- raise ValueError("回退前须先读取回退计划(GET rewind?message_id=),并带上其 revision")
1968
- plan = rewind_plan(connection, room_id, message_id)
1969
- if payload["revision"] != plan["revision"]:
1970
- raise FileExistsError("房间已有新的消息或执行,请重新读取回退计划后再确认")
1971
- now = int(time.time())
1972
- for row in connection.execute("SELECT id FROM member_executions WHERE room_id=? AND status IN ('dispatched','started')", (room_id,)).fetchall():
1973
- finish_execution(connection, row["id"], "cancelled", "用户回退了对话", "cancelled", "user_stop")
1974
- record_cancel_reasons(connection, "room_id=? AND message_id>=? AND status='pending'", (room_id, message_id), "user_stop")
1975
- connection.execute("UPDATE member_requests SET status='cancelled' WHERE room_id=? AND message_id>=? AND status='pending'", (room_id, message_id))
1976
- connection.execute("DELETE FROM free_queue WHERE room_id=? AND message_id>=?", (room_id, message_id))
1977
- # Batches of taken-back dispatches never hand back, and a dispatch the round limit held from them is never sent.
1978
- connection.execute("UPDATE free_batches SET settled=1 WHERE room_id=? AND batch_id>=?", (room_id, message_id))
1979
- 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))
1980
- connection.execute("""INSERT INTO message_withdrawals (message_id,kind,created_at) SELECT id,'rewound',? FROM messages m
1981
- 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))
1982
- connection.execute("UPDATE room_members SET read_until=MIN(read_until,?) WHERE room_id=?", (message_id - 1, room_id))
1983
- cuts = {str(member["id"]): member["executions"] for member in plan["members"] if member["session"] == "cut"}
1984
- cursor = connection.execute("INSERT INTO room_rewinds (room_id,message_id,cuts,created_at) VALUES (?,?,?,?)",
1985
- (room_id, message_id, json.dumps(cuts), now))
1986
- settle_batches(connection, room_id)
1987
- return {"rewind_id": cursor.lastrowid, **plan}
1988
-
1989
-
1990
- # The runner checks this many of a member's latest rewinds before each execution.
1991
- RECENT_REWINDS = 50
1992
-
1993
-
1994
- def member_rewinds(connection, member_id):
1995
- """Executions taken back from one member by recent rewinds, oldest first; the runner cuts before the earliest it finds."""
1996
- taken = set()
1997
- for row in connection.execute("SELECT cuts FROM room_rewinds WHERE room_id=(SELECT room_id FROM room_members WHERE id=?) ORDER BY id DESC LIMIT ?",
1998
- (member_id, RECENT_REWINDS)):
1999
- taken.update(json.loads(row["cuts"]).get(str(member_id), []))
2000
- return sorted(taken)
2001
-
2002
-
2003
- def add_member(connection, room_id, token, payload, now):
2004
- """Owner adds a member while the room keeps running; other members are not interrupted."""
2005
- require_owner(connection, room_id, token)
2006
- begin_write(connection)
2007
- member = member_value(payload)
2008
- existing = connection.execute("SELECT * FROM room_members WHERE room_id=? AND name=? AND status<>'removed'", (room_id, member["name"])).fetchone()
2009
- if existing and existing["status"] == "active" and existing["token_hash"] == token_hash(member["token"]) and all(
2010
- existing[key] == member[key] for key in ("adapter", "access", "session_mode")):
2011
- return free_view(connection, room_id)
2012
- if existing:
2013
- raise FileExistsError(f"{member['name']} 已是本房间成员;离开的成员请用请回")
2014
- require_fresh_member_token(connection, room_id, member["token"])
2015
- insert_member(connection, room_id, member, now)
2016
- queue_invited_expert(connection, room_id, member["name"], now)
2017
- sync_participants(connection, room_id)
2018
- return free_view(connection, room_id)
2019
-
2020
-
2021
- def queue_invited_expert(connection, room_id, name, now):
2022
- """Queue a new expert at the tail of an existing live plan, without changing the current speaker or hold.
2023
-
2024
- A manual expert is queued only once it has registered (the invite alone does not), and only once per invitation.
2025
- """
2026
- member = connection.execute("SELECT * FROM room_members WHERE room_id=? AND name=? AND status='active'", (room_id, name)).fetchone()
2027
- if not member or (member["adapter"] == "manual" and not registered_at(connection, member)):
2028
- return
2029
- session = discussion.current_session(connection, room_id)
2030
- if session and session["status"] != "concluded":
2031
- discussion.queue_session_invite(connection, session, member)
2032
-
2033
-
2034
- def adopt_manual_member(connection, room_id, member, payload):
2035
- """Hand a manual expert to the local service once its CLI reported the session it runs in; the owner's service resumes that session."""
2036
- reported = connection.execute("SELECT session_id FROM member_sessions WHERE member_id=?", (member["id"],)).fetchone()
2037
- if not registered_at(connection, member):
2038
- raise ValueError("成员尚未注册,不能转为托管")
2039
- adapter = payload.get("adapter")
2040
- if (not reported or reported["session_id"] != payload.get("session_id") or not isinstance(adapter, str)
2041
- or not ADAPTER_VALUE.fullmatch(adapter) or adapter == "manual"):
2042
- raise ValueError("成员尚未上报与之相符的会话,不能转为托管")
2043
- if member["access"] != "manual" or member["status"] != "active":
2044
- raise ValueError("只有在场的手动成员可以转为托管")
2045
- connection.execute("UPDATE room_members SET access='runner',adapter=?,session_mode='borrowed' WHERE id=?", (payload["adapter"], member["id"]))
2046
- connection.execute("DELETE FROM member_sessions WHERE member_id=?", (member["id"],))
2047
- # A free room's service answers as the member, so it takes over with a credential the owner's side generated.
2048
- if payload.get("token") is not None:
2049
- require_fresh_member_token(connection, room_id, payload["token"])
2050
- connection.execute("UPDATE room_members SET token_hash=? WHERE id=?", (token_hash(payload["token"]), member["id"]))
2051
-
2052
-
2053
- def record_adopt_failure(connection, room_id, member, payload):
2054
- """Keep why the local service could not take over the reported session, so the page says why requests stay undelivered."""
2055
- reason = payload.get("reason")
2056
- if not isinstance(reason, str) or not reason.strip():
2057
- raise ValueError("须提供 reason")
2058
- reported = connection.execute("SELECT session_id FROM member_sessions WHERE member_id=?", (member["id"],)).fetchone()
2059
- if not reported or reported["session_id"] != payload.get("session_id"):
2060
- raise ValueError("成员尚未上报与之相符的会话")
2061
- connection.execute("INSERT OR REPLACE INTO member_adopt_failures (member_id,room_id,session_id,reason) VALUES (?,?,?,?)",
2062
- (member["id"], room_id, reported["session_id"], reason.strip()[:ADOPT_REASON_LIMIT]))
2063
-
2064
-
2065
- def require_fresh_member_token(connection, room_id, token):
2066
- """Prevent owner or another member's credential from being reused for a new identity."""
2067
- hashed = token_hash(token)
2068
- if not hashed or connection.execute("SELECT 1 FROM room_members WHERE room_id=? AND token_hash=? UNION ALL SELECT 1 FROM room_modes WHERE room_id=? AND control_hash=?",
2069
- (room_id, hashed, room_id, hashed)).fetchone():
2070
- raise ValueError("须提供独立的新成员凭证")
2071
-
2072
-
2073
- def member_action(connection, room_id, member_id, token, payload):
2074
- """Owner sends a member away, brings it back with a new credential, or removes it.
2075
-
2076
- Leaving voids live work at once. Coming back requires a fresh credential, so copies of the
2077
- old one never revive; the software session is kept by the local runner, not here. The
2078
- `role` action makes one runner or manual member the AI host, replacing any previous host.
2079
- """
2080
- member = owned_member(connection, room_id, member_id, token)
2081
- action = payload.get("action")
2082
- routed = []
2083
- if action in ("away", "remove"):
2084
- if member["role"] == "host":
2085
- discussion.pause_session_for_host_change(connection, room_id)
2086
- else:
2087
- discussion.session_member_change(connection, room_id, member, action)
2088
- # Unnamed messages still waiting on a departing host move to its successor instead of being dropped.
2089
- routed = open_route_messages(connection, member_id)
2090
- stop_member_work(connection, member_id, "成员已离开" if action == "away" else "成员已移除",
2091
- "member_away" if action == "away" else "member_removed")
2092
- connection.execute("UPDATE room_members SET status=? WHERE id=?", ("away" if action == "away" else "removed", member_id))
2093
- elif action == "active":
2094
- hashed = token_hash(payload.get("token"))
2095
- if member["status"] == "active" and hashed and hmac.compare_digest(hashed, member["token_hash"]):
2096
- return free_view(connection, room_id)
2097
- if member["status"] != "away" or not hashed or hmac.compare_digest(hashed, member["token_hash"]):
2098
- raise ValueError("只有离开的成员可以请回,且须提供新的成员凭证")
2099
- require_fresh_member_token(connection, room_id, payload.get("token"))
2100
- connection.execute("UPDATE room_members SET status='active',token_hash=? WHERE id=?", (hashed, member_id))
2101
- if discussion.current_session(connection, room_id, active_only=True):
2102
- discussion.session_member_change(connection, room_id, member, action)
2103
- else:
2104
- queue_invited_expert(connection, room_id, member["name"], int(time.time()))
2105
- elif action == "adopt":
2106
- adopt_manual_member(connection, room_id, member, payload)
2107
- connection.execute("DELETE FROM member_adopt_failures WHERE member_id=?", (member_id,))
2108
- elif action == "adopt_failed":
2109
- record_adopt_failure(connection, room_id, member, payload)
2110
- return free_view(connection, room_id)
2111
- elif action == "role":
2112
- role = payload.get("role")
2113
- if role not in ("host", "member"):
2114
- raise ValueError("role 须为 host 或 member")
2115
- if role == "member" and member["role"] == "host":
2116
- raise ValueError("自由房间须由一位 AI 主持;请指定另一位成员担任主持人")
2117
- if role == "host":
2118
- discussion.pause_session_for_host_change(connection, room_id)
2119
- # Unnamed messages the old host has not started move to the new host, which carries the host rules.
2120
- old_host = connection.execute("SELECT id FROM room_members WHERE room_id=? AND role='host' AND id<>?", (room_id, member_id)).fetchone()
2121
- if old_host:
2122
- routed = open_route_messages(connection, old_host["id"], pending_only=True)
2123
- record_cancel_reasons(connection, "member_id=? AND purpose IN ('route','group') AND status='pending'", (old_host["id"],), "rerouted")
2124
- connection.execute("UPDATE member_requests SET status='cancelled' WHERE member_id=? AND purpose IN ('route','group') AND status='pending'", (old_host["id"],))
2125
- connection.execute("UPDATE room_members SET role='member' WHERE room_id=? AND role='host'", (room_id,))
2126
- connection.execute("UPDATE room_members SET role=? WHERE id=?", (role, member_id))
2127
- else:
2128
- raise ValueError("action 须为 away、active、remove、role、adopt 或 adopt_failed")
2129
- settle_batches(connection, room_id)
2130
- sync_participants(connection, room_id)
2131
- hand_over_routes(connection, room_id, routed, member_id if action in ("away", "remove") else None)
2132
- return free_view(connection, room_id)
2133
-
2134
-
2135
- # Imported last: session_room imports helpers from this module, so both load in either order.
2136
- from . import session_room as discussion # noqa: E402
1
+ """Free collaboration rooms: member credentials, durable requests and per-member executions.
2
+
3
+ A free room has no floor, turns or rounds. The user (holding the room control credential)
4
+ names members; each named member gets one durable request per message. The local runner
5
+ delivers a runner member's open requests as one execution, and the member's answer completes
6
+ them. A manual member finds its own requests and answers by request id. The user is the client, never the host: one member that
7
+ answers by itself always hosts, routes unnamed messages, names members in its answers, and is
8
+ woken once when everything it dispatched has finished. No software is launched here.
9
+ """
10
+
11
+ import hashlib
12
+ import hmac
13
+ import json
14
+ import re
15
+ import time
16
+ import uuid
17
+
18
+ from .identity import MAX_ROOM_AGENTS, default_label, is_ai
19
+ from .message_queue import pause as pause_queue, queue_view
20
+ from .runtime import (LEASE_SECONDS, MAX_EXECUTION_SEGMENTS, begin_write, enforce_session_limits,
21
+ require_owner, save_segments, token_hash)
22
+
23
+ # Adapter and client values are named by the runner's adapter layer; the site only checks their shape (AGENTS.md red line 9).
24
+ ADAPTER_VALUE = re.compile(r"[a-z][a-z0-9_]{1,31}")
25
+ MAX_REVIEW_SCOPE = 2000
26
+ MAX_REVIEW_INPUT = 60000
27
+ REVIEW_STATE_SQL = """SELECT json_object(
28
+ 'room',json_array(id,title,goal,(SELECT archived FROM room_directory WHERE room_id=rooms.id)),
29
+ 'latest',(SELECT COALESCE(MAX(id),0) FROM messages WHERE room_id=rooms.id),
30
+ 'members',(SELECT json_group_array(json_array(id,name,role,status,access,session_mode,adapter,token_hash)) FROM (SELECT * FROM room_members WHERE room_id=rooms.id ORDER BY id)),
31
+ 'caps',(SELECT json_group_array(json_array(c.member_id,c.member_revision,c.config_version,c.generation)) FROM member_review_capabilities c JOIN room_members m ON m.id=c.member_id WHERE m.room_id=rooms.id),
32
+ 'requests',(SELECT json_group_array(json_array(id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose)) FROM (SELECT * FROM member_requests WHERE room_id=rooms.id ORDER BY id)),
33
+ 'executions',(SELECT json_group_array(json_array(id,status,result,changes)) FROM (SELECT * FROM member_executions WHERE room_id=rooms.id ORDER BY id)),
34
+ 'batches',(SELECT json_group_array(json_array(batch_id,settled,host_id,chain_id)) FROM free_batches WHERE room_id=rooms.id),
35
+ 'plans',(SELECT json_group_array(json_array(batch_id,status,reviewer_id,config_version,member_revision,scope,review_batch_id)) FROM review_plans WHERE room_id=rooms.id),
36
+ 'limit',COALESCE((SELECT max_rounds FROM free_round_limits WHERE room_id=rooms.id),3),
37
+ 'holds',(SELECT json_group_array(json_array(chain_id,extra_rounds,message_id)) FROM free_chain_holds WHERE room_id=rooms.id),
38
+ 'stopped',(SELECT json_group_array(json_array(chain_id,reason)) FROM free_chains WHERE room_id=rooms.id),
39
+ 'withdrawn',(SELECT json_group_array(message_id) FROM message_withdrawals WHERE message_id IN (SELECT id FROM messages WHERE room_id=rooms.id))
40
+ ) AS state FROM rooms WHERE id=?"""
41
+ RECENT_REQUESTS = 100
42
+ ADOPT_REASON_LIMIT = 500
43
+ RECENT_EXECUTIONS = 100
44
+ # The timeline page reads this many recent executions of each kind; with `after` (?3) only those running or ended since.
45
+ # Parameters: ?1 room, ?2 limit, ?3 after (0 reads all). Shared verbatim with the Worker.
46
+ TIMELINE_EXECUTIONS = 100
47
+ # The event log page reads at most this many room events per call, newest first when paging back.
48
+ ROOM_EVENT_PAGE = 500
49
+ # The message a request's card sits under, for member_requests aliased `r`. A handoff request belongs to the dispatch
50
+ # message (`message_id`, which batch logic relies on) but happens because the batch's last answer arrived, so its card
51
+ # and answer sit under that answer; later messages (the user asking something meanwhile) then stay above it.
52
+ ANCHOR_SQL = """CASE WHEN r.purpose='handoff' THEN COALESCE((SELECT MAX(a.answer_id) FROM member_requests a
53
+ WHERE a.room_id=r.room_id AND a.batch_id=r.message_id AND a.purpose<>'handoff'), r.message_id) ELSE r.message_id END"""
54
+ # Rounds used and allowed in the room's recent chains, for "第 2/3 轮"; parameters: the room's limit, the room.
55
+ CHAIN_PROGRESS_SQL = """SELECT b.chain_id,COUNT(*) AS used,? + COALESCE((SELECT h.extra_rounds FROM free_chain_holds h
56
+ WHERE h.room_id=b.room_id AND h.chain_id=b.chain_id),0) AS allowed FROM free_batches b WHERE b.room_id=?
57
+ GROUP BY b.chain_id ORDER BY b.chain_id DESC LIMIT 10"""
58
+ TIMELINE_STATES = {"completed": "done", "failed": "failed", "cancelled": "cancelled"}
59
+ TIMELINE_FREE_IDS = "SELECT id FROM member_executions WHERE room_id=?1 AND (?3=0 OR finished_at=0 OR finished_at>=?3) ORDER BY id DESC LIMIT ?2"
60
+ # Besides times: the task an execution took on (its request message), its result (the answer's summary, else the
61
+ # answer's opening), its latest progress note, how many files it changed (NULL when unobserved) and its last output time.
62
+ TIMELINE_SQL = f"""SELECT x.*,(SELECT COALESCE(NULLIF(s.text,''),substr(a.text,1,200)) FROM messages a
63
+ LEFT JOIN message_summaries s ON s.message_id=a.id WHERE a.id=x.answer_id) AS result FROM (
64
+ SELECT 'free' AS source,e.id,m.name AS member,m.role,e.status,
65
+ COALESCE((SELECT MIN(r.created_at) FROM member_requests r WHERE r.execution_id=e.id),e.created_at) AS queued_at,
66
+ e.created_at AS dispatched_at,0 AS started_at,e.finished_at,
67
+ CASE WHEN e.status='failed' THEN e.result ELSE '' END AS reason,
68
+ (SELECT parent.execution_id FROM member_requests r JOIN member_requests parent ON parent.answer_id=r.message_id
69
+ WHERE r.execution_id=e.id AND parent.execution_id IS NOT NULL ORDER BY r.id LIMIT 1) AS parent_id,
70
+ e.activity_at,(SELECT MIN(r.message_id) FROM member_requests r WHERE r.execution_id=e.id) AS task_id,
71
+ (SELECT substr(q.text,1,160) FROM messages q WHERE q.id=(SELECT MIN(r.message_id) FROM member_requests r WHERE r.execution_id=e.id)) AS task,
72
+ (SELECT MAX(r.answer_id) FROM member_requests r WHERE r.execution_id=e.id) AS answer_id,
73
+ (SELECT n.text FROM execution_notes n WHERE n.execution_id=e.id ORDER BY n.seq DESC LIMIT 1) AS last_note,
74
+ CASE WHEN e.changes='' THEN NULL ELSE json_array_length(e.changes) END AS changes
75
+ FROM member_executions e JOIN room_members m ON m.id=e.member_id WHERE e.id IN ({TIMELINE_FREE_IDS})) x"""
76
+ TIMELINE_SEGMENTS_SQL = f"""SELECT source,execution_id,phase,tool,start_ms,end_ms FROM execution_segments WHERE room_id=?1
77
+ AND source='free' AND execution_id IN ({TIMELINE_FREE_IDS}) ORDER BY source,execution_id,seq"""
78
+ # Only the times of progress notes: the texts are read when the reader hovers one.
79
+ TIMELINE_NOTES_SQL = f"""SELECT execution_id,created_at FROM execution_notes
80
+ WHERE room_id=?1 AND execution_id IN ({TIMELINE_FREE_IDS}) ORDER BY execution_id,seq"""
81
+ RECENT_CHANGES = 50
82
+ MAX_CHANGED_FILES = 200
83
+ # What a member's software last showed it doing, as reported by the runner with each renewal.
84
+ ACTIVITY_PHASES = ("", "thinking", "command", "tool", "editing", "writing")
85
+ # Progress notes per execution, their length, and how many one renewal may carry.
86
+ MAX_EXECUTION_NOTES = 50
87
+ MAX_NOTE_LENGTH = 500
88
+ MAX_NOTES_PER_RENEW = 10
89
+ # A member description is a short note for the scheduler and the host, keyed by the member's name.
90
+ MAX_MEMBER_DESCRIPTION = 500
91
+ # A display label replaces the member's name on screen only; the name stays the member's identity everywhere else.
92
+ MAX_MEMBER_LABEL = 32
93
+ MAX_MEMBER_NAME = 80
94
+ MAX_PATH_LENGTH = 500
95
+ # Refused operations one execution may report, and the length of each field.
96
+ MAX_DENIALS = 20
97
+ MAX_DENIAL_FIELD = 300
98
+ # A manual member's delivery is retried at most this often before the execution fails.
99
+ MAX_RETRIES = 3
100
+ MAX_RETRY_REASON = 500
101
+ ACTIVE = ("dispatched", "started")
102
+ ACCESS_MODES = ("runner", "manual")
103
+ SCHEDULER_PROVIDERS = ("jev", "decisions")
104
+ RECOMMEND_SECONDS = 60
105
+ # A click sent just before the hint disappears still counts.
106
+ RECOMMEND_GRACE_SECONDS = 5
107
+ # Rounds a host may run from one owner message by default (a round: one host dispatch, its members' answers and the
108
+ # hand-back); each owner message starts a chain. Counted in rounds, so a room with more members gets no fewer of them.
109
+ CHAIN_ROUNDS = 3
110
+ # The most rounds an owner may allow per chain, or add at once when continuing a stopped chain.
111
+ MAX_CHAIN_ROUNDS = 20
112
+ # A host that left gives up the role; the first active runner member (else manual) then takes it.
113
+ RELEASE_HOST = "UPDATE room_members SET role='member' WHERE room_id=? AND role='host' AND status<>'active'"
114
+ ASSIGN_HOST = """UPDATE room_members SET role='host' WHERE id=(SELECT id FROM room_members WHERE room_id=? AND status='active'
115
+ ORDER BY access='runner' DESC, id LIMIT 1)
116
+ AND NOT EXISTS (SELECT 1 FROM room_members WHERE room_id=? AND role='host' AND status='active')"""
117
+ TERMINAL = ("completed", "cancelled", "failed")
118
+ # A queued member has been asked once its request for that message exists.
119
+ QUEUE_ASKED = "EXISTS(SELECT 1 FROM member_requests r WHERE r.message_id={row}.message_id AND r.member_id={row}.member_id)"
120
+ SESSION_MODES = ("new", "borrowed")
121
+ FREE_OPERATIONS = ("messages", "attachments", "move", "folder", "rename", "archive", "restore", "delete")
122
+ MEMBER_FIELDS = ("id", "name", "adapter", "role", "status", "access", "session_mode", "read_until")
123
+ EXECUTION_FIELDS = ("id", "member_id", "status", "session_id", "lease_until", "created_at", "finished_at", "result",
124
+ "activity", "activity_at")
125
+ # What the runner found when a voided execution's software was to stop; '' until it reports.
126
+ STOP_RESULTS = ("stopped", "unstoppable", "unconfirmed")
127
+ # How long a member voided where the runner could not stop it gets no new delivery unless its late answer shows the turn ended.
128
+ HOLD_SECONDS = 600
129
+ # What a rewind does to a runner member's session, as its adapter reports.
130
+ REWIND_EFFECTS = ("cut", "fresh", "kept")
131
+ # The reported capabilities and stop result read alongside an execution aliased `e`.
132
+ CAPABILITY_COLUMNS = """(SELECT stoppable FROM execution_capabilities c WHERE c.execution_id=e.id) AS stoppable,
133
+ (SELECT progress FROM execution_capabilities c WHERE c.execution_id=e.id) AS progress,
134
+ (SELECT stop_result FROM execution_capabilities c WHERE c.execution_id=e.id) AS stop_result"""
135
+
136
+
137
+ def reported_capabilities(row):
138
+ """`{stoppable, progress}` as the runner reported them for one execution, or None before it did (or an older runner)."""
139
+ return None if row["stoppable"] is None else {"stoppable": bool(row["stoppable"]), "progress": bool(row["progress"])}
140
+ # Columns added after the first free-room tables; Python databases created by that build gain them here.
141
+ LATER_COLUMNS = {"room_members": {"access": "TEXT NOT NULL DEFAULT 'runner'", "session_mode": "TEXT NOT NULL DEFAULT 'new'"},
142
+ "member_executions": {"changes": "TEXT NOT NULL DEFAULT ''", "cwd": "TEXT NOT NULL DEFAULT ''",
143
+ "activity": "TEXT NOT NULL DEFAULT ''", "activity_at": "INTEGER NOT NULL DEFAULT 0"},
144
+ "member_requests": {"chain_id": "INTEGER NOT NULL DEFAULT 0", "batch_id": "INTEGER NOT NULL DEFAULT 0",
145
+ "purpose": "TEXT NOT NULL DEFAULT 'ask'", "created_by": "TEXT NOT NULL DEFAULT 'user'"}}
146
+
147
+
148
+ def migrate_free_tables(connection):
149
+ """Add columns that early free-room databases lack; repeated runs change nothing."""
150
+ for table, columns in LATER_COLUMNS.items():
151
+ present = {row[1] for row in connection.execute(f"PRAGMA table_info({table})")}
152
+ for name, definition in columns.items():
153
+ if name not in present:
154
+ connection.execute(f"ALTER TABLE {table} ADD COLUMN {name} {definition}")
155
+ migrate_stop_results(connection)
156
+
157
+
158
+ def migrate_stop_results(connection):
159
+ """Keep development capability records while allowing the new unconfirmed stop result."""
160
+ row = connection.execute("SELECT sql FROM sqlite_master WHERE name='execution_capabilities'").fetchone()
161
+ if not row or "'unconfirmed'" in row[0]:
162
+ return
163
+ definition = row[0].replace("execution_capabilities", "execution_capabilities_next", 1)
164
+ connection.execute(definition.replace("'unstoppable'", "'unstoppable','unconfirmed'"))
165
+ connection.execute("INSERT INTO execution_capabilities_next SELECT * FROM execution_capabilities")
166
+ connection.execute("DROP TABLE execution_capabilities")
167
+ connection.execute("ALTER TABLE execution_capabilities_next RENAME TO execution_capabilities")
168
+
169
+
170
+ def review_member_revision(member, generation=0):
171
+ """Bind an attestation to the exact member identity and access configuration without exposing credentials."""
172
+ state = ":".join(str(member[key]) for key in ("id", "status", "adapter", "access", "session_mode", "token_hash")) + f":{generation}"
173
+ return hashlib.sha256(state.encode("utf-8")).hexdigest()
174
+
175
+
176
+ def review_capability(connection, room_id, member_id, token, payload=None):
177
+ """Read or replace one owner's local read-only attestation, guarded by the member revision."""
178
+ require_owner(connection, room_id, token)
179
+ if payload is not None:
180
+ begin_write(connection)
181
+ member = connection.execute("SELECT * FROM room_members WHERE room_id=? AND id=?", (room_id, member_id)).fetchone()
182
+ if not member:
183
+ raise LookupError("成员不存在")
184
+ previous = connection.execute("SELECT generation FROM member_review_capabilities WHERE member_id=?", (member_id,)).fetchone()
185
+ revision = review_member_revision(member, previous[0] if previous else 0)
186
+ if payload is not None:
187
+ if not payload.get("revision"):
188
+ raise ValueError("请先读取成员 review-capability,再带 revision 提交能力事实")
189
+ if payload["revision"] != revision:
190
+ raise FileExistsError("成员状态已变化,请重新读取能力状态")
191
+ if type(payload.get("read_only")) is not bool:
192
+ raise ValueError("read_only 须为布尔值")
193
+ if payload["read_only"]:
194
+ version = payload.get("config_version", "")
195
+ if not isinstance(version, str) or not re.fullmatch(r"[a-f0-9]{64}", version):
196
+ raise ValueError("config_version 须为本机配置的 SHA-256 摘要")
197
+ # Whether the software can enforce a read-only project is the runner's adapter to judge; it attests only then.
198
+ if member["status"] != "active" or member["access"] != "runner" or member["session_mode"] != "new":
199
+ raise FileExistsError("仅支持程序启动、可核验只读权限的本地 CLI 成员")
200
+ connection.execute("""INSERT INTO member_review_capabilities(member_id,member_revision,config_version,adapter,recorded_at) VALUES (?,?,?,?,?)
201
+ ON CONFLICT(member_id) DO UPDATE SET member_revision=excluded.member_revision,
202
+ config_version=excluded.config_version,adapter=excluded.adapter,recorded_at=excluded.recorded_at""",
203
+ (member_id, revision, version, member["adapter"], int(time.time())))
204
+ else:
205
+ connection.execute("UPDATE member_review_capabilities SET config_version='',member_revision='' WHERE member_id=?", (member_id,))
206
+ capability = connection.execute("SELECT config_version,adapter FROM member_review_capabilities WHERE member_id=? AND member_revision=?",
207
+ (member_id, revision)).fetchone()
208
+ return {"member_id": member_id, "member": member["name"], "revision": revision,
209
+ "capability": dict(capability) if capability else None}
210
+
211
+
212
+ def review_roster(connection, room_id, token):
213
+ """Return eligible local reviewers to the owner or current host; never publish member credentials."""
214
+ try:
215
+ require_owner(connection, room_id, token)
216
+ except PermissionError:
217
+ member = require_member(connection, room_id, token)
218
+ if member["role"] != "host":
219
+ raise PermissionError("只有房主或当前主持人可读取评审候选")
220
+ rows = connection.execute("""SELECT m.*,c.config_version,c.member_revision,c.generation FROM room_members m
221
+ JOIN member_review_capabilities c ON c.member_id=m.id WHERE m.room_id=? AND m.status='active'
222
+ AND m.access='runner' AND m.session_mode='new' ORDER BY m.id""", (room_id,)).fetchall()
223
+ members = [{"id": row["id"], "name": row["name"], "adapter": row["adapter"], "config_version": row["config_version"]}
224
+ for row in rows if row["member_revision"] == review_member_revision(row, row["generation"])]
225
+ state = connection.execute(REVIEW_STATE_SQL, (room_id,)).fetchone()["state"]
226
+ return {"members": members, "revision": hashlib.sha256(state.encode("utf-8")).hexdigest(),
227
+ "reason": "" if members else "没有可核验只读的评审成员"}
228
+
229
+
230
+ def checked_review_plan(connection, room_id, actor, payload):
231
+ """Validate one preauthorized review before the host message and implementation dispatch commit."""
232
+ if not payload.get("next_review"):
233
+ if payload.get("review_scope") or payload.get("review_roster"):
234
+ raise ValueError("review_scope / review_roster 须与 next_review 一起提交")
235
+ return None
236
+ if not isinstance(actor, dict) or actor["role"] != "host" or not payload.get("to") or payload.get("kind") == "moderation":
237
+ raise PermissionError("只有主持人派实现任务时可以声明下一步评审")
238
+ if not isinstance(payload["next_review"], str):
239
+ raise ValueError("next_review 须为成员名称")
240
+ if not payload.get("review_roster"):
241
+ raise ValueError("请先读取 review roster,再带 review_roster 声明评审")
242
+ state = connection.execute(REVIEW_STATE_SQL, (room_id,)).fetchone()["state"]
243
+ if payload["review_roster"] != hashlib.sha256(state.encode("utf-8")).hexdigest():
244
+ raise FileExistsError("评审候选或任务状态已变化,请重新读取 review roster")
245
+ scope = payload.get("review_scope", "")
246
+ if not isinstance(scope, str) or not 1 <= len(scope.strip()) <= MAX_REVIEW_SCOPE:
247
+ raise ValueError(f"评审范围须为 1–{MAX_REVIEW_SCOPE} 字")
248
+ if payload["next_review"] in payload["to"]:
249
+ raise ValueError("评审者不能是该批次的实现者")
250
+ reviewer = connection.execute("""SELECT m.*,c.config_version,c.member_revision,c.generation FROM room_members m
251
+ JOIN member_review_capabilities c ON c.member_id=m.id WHERE m.room_id=? AND m.name=?""", (room_id, payload["next_review"])).fetchone()
252
+ if not reviewer or reviewer["status"] != "active" or reviewer["member_revision"] != review_member_revision(reviewer, reviewer["generation"]) or not reviewer["config_version"]:
253
+ raise FileExistsError("没有可核验只读的评审成员:指定成员未上报有效能力或已失效")
254
+ chain_id = answered_chain(connection, payload, actor)
255
+ if not chain_id or rounds_left(connection, room_id, chain_id) < 1:
256
+ raise FileExistsError("任务链剩余额度不足,不能保存实现批次及评审授权")
257
+ return {"chain_id": chain_id, "host_id": actor["id"], "reviewer_id": reviewer["id"], "reviewer_name": reviewer["name"],
258
+ "config_version": reviewer["config_version"], "member_revision": reviewer["member_revision"], "scope": scope.strip(),
259
+ "implementers": json.dumps(sorted(set(payload["to"])), ensure_ascii=False)}
260
+
261
+
262
+ def save_review_plan(connection, room_id, batch_id, plan):
263
+ """Persist the next step only inside the implementation message transaction."""
264
+ if plan:
265
+ fields = ("chain_id", "host_id", "reviewer_id", "reviewer_name", "config_version", "member_revision", "scope", "implementers")
266
+ connection.execute(f"INSERT INTO review_plans(room_id,batch_id,{','.join(fields)}) VALUES ({','.join('?' for _ in range(10))})",
267
+ (room_id, batch_id, *(plan[key] for key in fields)))
268
+
269
+
270
+ def check_review_retry(connection, room_id, message_id, payload):
271
+ """A repeated host message cannot silently change or remove its preauthorized review."""
272
+ plan = connection.execute("SELECT * FROM review_plans WHERE room_id=? AND batch_id=?", (room_id, message_id)).fetchone()
273
+ if not plan and not payload.get("next_review"):
274
+ return
275
+ if not plan or (plan["reviewer_name"], plan["scope"], json.loads(plan["implementers"])) != (
276
+ payload.get("next_review"), str(payload.get("review_scope", "")).strip(), sorted(set(payload.get("to", [])))):
277
+ raise FileExistsError("这个提交编号已绑定不同的评审授权,请为新内容使用新编号")
278
+
279
+
280
+ def review_ready(connection, room_id, batch_id, token):
281
+ """Read original delivery evidence and the exact reasons a preauthorized step cannot advance."""
282
+ roster = review_roster(connection, room_id, token)
283
+ plan = connection.execute("SELECT * FROM review_plans WHERE room_id=? AND batch_id=?", (room_id, batch_id)).fetchone()
284
+ if not plan:
285
+ raise LookupError("该批次没有预授权评审")
286
+ plan = dict(plan)
287
+ batch = connection.execute("SELECT * FROM free_batches WHERE room_id=? AND batch_id=?", (room_id, batch_id)).fetchone()
288
+ requests = [dict(row) for row in connection.execute("""SELECT r.id,r.member_id,m.name AS member,r.status,r.answer_id,
289
+ a.text AS answer,e.id AS execution_id,e.status AS execution_status,e.result,e.changes
290
+ FROM member_requests r JOIN room_members m ON m.id=r.member_id LEFT JOIN messages a ON a.id=r.answer_id
291
+ LEFT JOIN member_executions e ON e.id=r.execution_id WHERE r.room_id=? AND r.batch_id=? ORDER BY r.id""", (room_id, batch_id))]
292
+ handoff = connection.execute("SELECT * FROM member_requests WHERE room_id=? AND message_id=? AND member_id=? AND purpose='handoff'",
293
+ (room_id, batch_id, plan["host_id"])).fetchone()
294
+ host = connection.execute("SELECT id FROM room_members WHERE room_id=? AND role='host' AND status='active'", (room_id,)).fetchone()
295
+ candidate = next((member for member in roster["members"] if member["id"] == plan["reviewer_id"]), None)
296
+ capability = connection.execute("SELECT member_revision FROM member_review_capabilities WHERE member_id=?", (plan["reviewer_id"],)).fetchone()
297
+ remaining = rounds_left(connection, room_id, plan["chain_id"])
298
+ reason = review_block_reason(plan, requests, {
299
+ "batch": bool(batch and batch["settled"]), "handoff": bool(handoff and handoff["status"] in ("pending", "delivering")),
300
+ "host": bool(host and host[0] == plan["host_id"]), "remaining": remaining,
301
+ "capability": bool(candidate and candidate["config_version"] == plan["config_version"] and capability[0] == plan["member_revision"]),
302
+ "archived": connection.execute("SELECT archived FROM room_directory WHERE room_id=?", (room_id,)).fetchone()[0]})
303
+ original = connection.execute("SELECT id,author,text FROM messages WHERE room_id=? AND id=?", (room_id, batch_id)).fetchone()
304
+ if not original:
305
+ reason = reason or "缺少原委派,不能自动交评审"
306
+ elif len(json.dumps([dict(original), requests], ensure_ascii=False, separators=(",", ":"))) > MAX_REVIEW_INPUT:
307
+ reason = reason or "交付原文与执行报告超过 60000 字符,请主持人处理"
308
+ return {"plan": plan, "original": dict(original) if original else None, "deliveries": requests, "handoff": dict(handoff) if handoff else None,
309
+ "remaining_rounds": remaining, "revision": roster["revision"], "ready": not reason, "reason": reason}
310
+
311
+
312
+ def review_block_reason(plan, requests, facts):
313
+ """Choose a concrete host-facing explanation without interpreting delivery prose."""
314
+ if plan["status"] != "pending":
315
+ return "评审授权已经消费或仅为导入历史"
316
+ if facts["archived"] or not facts["host"]:
317
+ return "房间已存档或主持人已改变,原评审授权不再有效"
318
+ if not facts["batch"] or not requests or any(item["status"] != "completed" or not item["answer_id"] or item["answer"] is None for item in requests):
319
+ return "实现批次尚未全部成功交付,或缺少交付原文"
320
+ if any(item["member_id"] == plan["reviewer_id"] for item in requests):
321
+ return "评审者不能是该批次的实现者"
322
+ if not facts["handoff"]:
323
+ return "该批次交回主持人的请求已处理或取消"
324
+ if not facts["capability"]:
325
+ return "没有可核验只读的评审成员:既定评审者离开或能力配置已变化"
326
+ if facts["remaining"] < 1:
327
+ return "任务链剩余额度不足,需用户续额或主持人另行处理"
328
+ return ""
329
+
330
+
331
+ def advance_review(connection, room_id, token, payload):
332
+ """Consume one exact review authorization and close only its handoff in one transaction."""
333
+ begin_write(connection)
334
+ host = require_member(connection, room_id, token, payload.get("actor"))
335
+ if host["role"] != "host":
336
+ raise PermissionError("只有当前主持人可以推进预授权评审")
337
+ batch_id, submission = payload.get("batch_id"), payload.get("submission_id", "")
338
+ if type(batch_id) is not int or not isinstance(submission, str) or not re.fullmatch(r"[A-Za-z0-9_-]{8,100}", submission):
339
+ raise ValueError("须提供 batch_id 和有效 submission_id")
340
+ saved = connection.execute("SELECT batch_id,review_batch_id FROM review_plans WHERE room_id=? AND advance_submission=?", (room_id, submission)).fetchone()
341
+ if saved:
342
+ if saved["batch_id"] != batch_id:
343
+ raise FileExistsError("提交编号已用于其他评审批次")
344
+ execution = connection.execute("""SELECT e.status FROM member_requests r JOIN member_executions e ON e.id=r.execution_id
345
+ JOIN review_plans p ON p.room_id=r.room_id AND p.batch_id=r.message_id AND p.host_id=r.member_id
346
+ WHERE r.room_id=? AND p.batch_id=? AND r.purpose='handoff'""", (room_id, batch_id)).fetchone()
347
+ return {"batch_id": batch_id, "review_batch_id": saved["review_batch_id"], "duplicate": True,
348
+ "execution_completed": bool(execution and execution[0] == "completed")}
349
+ if not payload.get("revision"):
350
+ raise ValueError("请先读取 review ready,再携带 revision 推进")
351
+ state = review_ready(connection, room_id, batch_id, token)
352
+ if payload["revision"] != state["revision"]:
353
+ raise FileExistsError("评审状态已变化,请重新读取 review ready")
354
+ if not state["ready"]:
355
+ raise FileExistsError(state["reason"])
356
+ plan, handoff = state["plan"], state["handoff"]
357
+ if plan["host_id"] != host["id"]:
358
+ raise PermissionError("本主持人没有该批次的评审授权")
359
+ answer_ids = sorted({item["answer_id"] for item in state["deliveries"]})
360
+ question = f"请只读评审实现批次 #{batch_id}。范围:{plan['scope']}。先读原委派 #{batch_id},再读交付原文 " + "、".join(f"#{number}" for number in answer_ids) + "。不得修改项目;评审结束交回主持人。"
361
+ cursor = connection.execute("INSERT INTO messages(room_id,author,text,source,submission_id,created_at) VALUES(?, '系统任务',?,'system',?,?)",
362
+ (room_id, question, f"review-task-{batch_id}", time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())))
363
+ review_batch_id = cursor.lastrowid
364
+ connection.execute("UPDATE review_plans SET status='advanced',review_batch_id=?,advance_submission=? WHERE room_id=? AND batch_id=?",
365
+ (review_batch_id, submission, room_id, batch_id))
366
+ connection.execute("INSERT INTO free_batches(room_id,batch_id,chain_id,host_id) VALUES(?,?,?,?)", (room_id, review_batch_id, plan["chain_id"], host["id"]))
367
+ connection.execute("INSERT INTO free_handoffs(room_id,batch_id) VALUES(?,?)", (room_id, review_batch_id))
368
+ insert_request(connection, room_id, plan["reviewer_id"], review_batch_id, plan["chain_id"], "ask", "host", batch_id=review_batch_id)
369
+ connection.execute("UPDATE member_requests SET status='completed' WHERE id=?", (handoff["id"],))
370
+ completed = finish_review_handoff(connection, handoff)
371
+ return {"batch_id": batch_id, "review_batch_id": review_batch_id, "execution_completed": completed, "duplicate": False}
372
+
373
+
374
+ def finish_review_handoff(connection, handoff):
375
+ """Finish the host execution only when its other requests have all been handled."""
376
+ execution_id = handoff["execution_id"]
377
+ if not execution_id or connection.execute("SELECT 1 FROM member_requests WHERE execution_id=? AND status='delivering'", (execution_id,)).fetchone():
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))
381
+ return True
382
+
383
+
384
+ def member_value(member):
385
+ """Validate one member and its client-generated credential."""
386
+ if not isinstance(member, dict) or not is_ai(member.get("name")):
387
+ raise ValueError("成员须为有效 AI 身份")
388
+ if not token_hash(member.get("token")):
389
+ raise ValueError("每位成员须提供 32–256 字符的成员凭证")
390
+ adapter = member.get("adapter", "")
391
+ if not isinstance(adapter, str) or len(adapter) > 40:
392
+ raise ValueError("成员适配器名称无效")
393
+ access, session_mode = member.get("access", "runner"), member.get("session_mode", "new")
394
+ if access not in ACCESS_MODES or session_mode not in SESSION_MODES:
395
+ raise ValueError("成员接入方式须为 runner/manual,会话方式须为 new/borrowed")
396
+ return {"name": member["name"], "adapter": adapter, "token": member["token"],
397
+ "access": access, "session_mode": session_mode}
398
+
399
+
400
+ def review_request_context(connection, request):
401
+ """Expose only the request's review provenance and local capability binding."""
402
+ plan = connection.execute("SELECT * FROM review_plans WHERE batch_id=? OR review_batch_id=?",
403
+ (request["message_id"], request["message_id"])).fetchone()
404
+ if not plan:
405
+ return {}
406
+ phase = "handoff" if request["message_id"] == plan["batch_id"] else "review" if request["purpose"] == "ask" else "result"
407
+ if phase == "handoff" and request["purpose"] != "handoff":
408
+ return {}
409
+ review = {key: plan[key] for key in ("batch_id", "reviewer_id", "reviewer_name", "config_version", "member_revision", "scope")}
410
+ review["phase"] = phase
411
+ result = {"review": review}
412
+ if phase != "handoff":
413
+ result.update(source_question=connection.execute("SELECT text FROM messages WHERE id=?", (request["message_id"],)).fetchone()[0],
414
+ source_message_id=plan["batch_id"], after_message_id=plan["batch_id"], cause_kind="review")
415
+ if phase == "result":
416
+ failed = connection.execute("""SELECT e.result FROM member_requests r JOIN member_executions e ON e.id=r.execution_id
417
+ WHERE r.batch_id=? AND r.status IN ('cancelled','failed') ORDER BY r.id""", (plan["review_batch_id"],)).fetchall()
418
+ review["reason"] = ";".join(row[0] for row in failed)
419
+ return result
420
+
421
+
422
+ def member_values(members):
423
+ """Validate independent member credentials for a room."""
424
+ if not isinstance(members, list) or not len(members) <= MAX_ROOM_AGENTS:
425
+ raise ValueError("房间成员须为列表,且不超过成员上限")
426
+ result = [member_value(member) for member in members]
427
+ if len({member["name"] for member in result}) != len(result):
428
+ raise ValueError("成员名称不能重复")
429
+ if len({member["token"] for member in result}) != len(result):
430
+ raise ValueError("每位成员须使用独立凭证")
431
+ return result
432
+
433
+
434
+ def insert_member(connection, room_id, member, now):
435
+ """Store one member with only its credential hash."""
436
+ connection.execute("""INSERT INTO room_members (room_id,name,adapter,token_hash,access,session_mode,created_at)
437
+ VALUES (?,?,?,?,?,?,?)""", (room_id, member["name"], member["adapter"], token_hash(member["token"]),
438
+ member["access"], member["session_mode"], now))
439
+
440
+
441
+ def save_room_access(connection, room_id, control_token, members, settings):
442
+ """Persist the mode, the owner's control hash and the members in the creating transaction."""
443
+ control = token_hash(control_token)
444
+ if not control:
445
+ raise ValueError("建房须提供 32–256 字符的房间控制凭据")
446
+ if any(token_hash(member["token"]) == control for member in members):
447
+ raise ValueError("成员凭证不能与房主控制凭据相同")
448
+ connection.execute("INSERT INTO room_modes (room_id,mode,control_hash) VALUES (?,'free',?)", (room_id, control))
449
+ for member in members:
450
+ insert_member(connection, room_id, member, settings["created_at"])
451
+ connection.execute("UPDATE room_members SET role='host' WHERE room_id=? AND name=?", (room_id, settings["host"]))
452
+
453
+
454
+ def pin_host(connection, room_id, previous_id):
455
+ """Put a newly appointed host first and the previous host second in the saved speaking order; the rest keep their order.
456
+
457
+ Without a saved order the rest follow the joining order, as the host-first default showed them.
458
+ """
459
+ host = connection.execute("SELECT id FROM room_members WHERE room_id=? AND role='host' AND status='active'", (room_id,)).fetchone()
460
+ if not host:
461
+ return
462
+ row = connection.execute("SELECT member_ids FROM speaking_orders WHERE room_id=?", (room_id,)).fetchone()
463
+ ids = json.loads(row[0]) if row else [item[0] for item in connection.execute(
464
+ "SELECT id FROM room_members WHERE room_id=? AND status<>'removed' ORDER BY id", (room_id,))]
465
+ front = [host["id"], *([previous_id] if previous_id and previous_id != host["id"] else [])]
466
+ connection.execute("INSERT OR REPLACE INTO speaking_orders (room_id,member_ids) VALUES (?,?)",
467
+ (room_id, json.dumps(front + [member_id for member_id in ids if member_id not in front])))
468
+
469
+
470
+ def sync_participants(connection, room_id):
471
+ """Keep one active AI host, then mirror member capabilities and the host into shared room metadata."""
472
+ leaving = connection.execute("SELECT id FROM room_members WHERE room_id=? AND role='host' AND status<>'active'", (room_id,)).fetchone()
473
+ connection.execute(RELEASE_HOST, (room_id,))
474
+ if connection.execute(ASSIGN_HOST, (room_id, room_id)).rowcount:
475
+ pin_host(connection, room_id, leaving["id"] if leaving else None)
476
+ members = connection.execute("""SELECT name,role,status FROM room_members
477
+ WHERE room_id=? AND status<>'removed' ORDER BY id""", (room_id,)).fetchall()
478
+ experts = {row["name"]: "cli" for row in members}
479
+ host = next((row["name"] for row in members if row["role"] == "host" and row["status"] == "active"), "用户")
480
+ connection.execute("UPDATE topic_settings SET experts=?,host=? WHERE room_id=?",
481
+ (json.dumps(experts, ensure_ascii=False), host, room_id))
482
+
483
+
484
+ def public_execution(row):
485
+ """Return execution state without any credential material."""
486
+ return {key: row[key] for key in EXECUTION_FIELDS} if row else None
487
+
488
+
489
+ def held_until(connection, member_id):
490
+ """Until when a member's next delivery waits, or 0: its latest execution was voided where the runner reported it cannot
491
+ stop the software (a window or App), which may still be on that turn and would take a new task mid-turn. Its late
492
+ answer ends the wait."""
493
+ row = connection.execute("""SELECT e.finished_at FROM member_executions e JOIN execution_capabilities c ON c.execution_id=e.id
494
+ WHERE e.id=(SELECT MAX(id) FROM member_executions WHERE member_id=?) AND e.status='cancelled'
495
+ AND c.stop_result<>'stopped' AND (c.stoppable=0 OR c.stop_result IN ('unstoppable','unconfirmed'))
496
+ AND NOT EXISTS(SELECT 1 FROM late_answers l WHERE l.execution_id=e.id)""", (member_id,)).fetchone()
497
+ until = row["finished_at"] + HOLD_SECONDS if row else 0
498
+ return until if until > int(time.time()) else 0
499
+
500
+
501
+ def member_capability(connection, room_id, member_id, token, payload):
502
+ """Record what a rewind does to one runner member's session, as the runner's adapter for it reports.
503
+
504
+ Bound to one known member and replaced whole, so it takes no revision. Returns `{member_id, rewind}`.
505
+ """
506
+ require_owner(connection, room_id, token)
507
+ begin_write(connection)
508
+ if not connection.execute("SELECT 1 FROM room_members WHERE room_id=? AND id=?", (room_id, member_id)).fetchone():
509
+ raise LookupError("成员不存在")
510
+ if payload.get("rewind") not in REWIND_EFFECTS:
511
+ raise ValueError(f"rewind 须为 {'、'.join(REWIND_EFFECTS)}")
512
+ connection.execute("""INSERT INTO member_capabilities (member_id,room_id,rewind) VALUES (?,?,?)
513
+ ON CONFLICT(member_id) DO UPDATE SET rewind=excluded.rewind""", (member_id, room_id, payload["rewind"]))
514
+ return {"member_id": member_id, "rewind": payload["rewind"]}
515
+
516
+
517
+ def execution_retry(connection, execution_id):
518
+ """What the runner reported about a waiting retry of one execution, or None."""
519
+ row = connection.execute("SELECT attempt,retry_at,reason,stopped FROM execution_retries WHERE execution_id=?", (execution_id,)).fetchone()
520
+ return dict(row) if row else None
521
+
522
+
523
+ def member_descriptions(connection, room_id):
524
+ """The owner's descriptions of members by name; everyone who can read the room may read them."""
525
+ return {row["name"]: row["description"] for row in connection.execute(
526
+ "SELECT name,description FROM member_descriptions WHERE room_id=? ORDER BY name", (room_id,))}
527
+
528
+
529
+ def describe_members(connection, room_id, token, payload):
530
+ """Owner sets members' descriptions by name; an empty text removes that member's description."""
531
+ require_owner(connection, room_id, token)
532
+ descriptions = payload.get("descriptions")
533
+ if (not isinstance(descriptions, dict) or not descriptions or len(descriptions) > MAX_ROOM_AGENTS
534
+ or any(not isinstance(name, str) or not 0 < len(name) <= MAX_MEMBER_NAME or not isinstance(text, str)
535
+ or len(text) > MAX_MEMBER_DESCRIPTION for name, text in descriptions.items())):
536
+ raise ValueError(f"descriptions 须为 {{成员名: 描述}},最多 {MAX_ROOM_AGENTS} 位,每段最多 {MAX_MEMBER_DESCRIPTION} 字")
537
+ begin_write(connection)
538
+ now = int(time.time())
539
+ for name, text in descriptions.items():
540
+ if text.strip():
541
+ connection.execute("""INSERT INTO member_descriptions (room_id,name,description,updated_at) VALUES (?,?,?,?)
542
+ ON CONFLICT(room_id,name) DO UPDATE SET description=excluded.description,updated_at=excluded.updated_at""",
543
+ (room_id, name, text.strip(), now))
544
+ else:
545
+ connection.execute("DELETE FROM member_descriptions WHERE room_id=? AND name=?", (room_id, name))
546
+ return {"descriptions": member_descriptions(connection, room_id)}
547
+
548
+
549
+ def member_labels(connection, room_id):
550
+ """The owner's display labels of members by name; screens show them and the page turns a user's @label into the member name, never credentials."""
551
+ return {row["name"]: row["label"] for row in connection.execute(
552
+ "SELECT name,label FROM member_labels WHERE room_id=? ORDER BY name", (room_id,))}
553
+
554
+
555
+ def member_display_names(connection, room_id):
556
+ """What each present member is called in text: the owner's label, else its platform nickname, else its ID."""
557
+ labels = member_labels(connection, room_id)
558
+ return {row["name"]: labels.get(row["name"]) or default_label(row["name"]) for row in connection.execute(
559
+ "SELECT name FROM room_members WHERE room_id=? AND status<>'removed' ORDER BY id", (room_id,))}
560
+
561
+
562
+ def member_handles(connection, room_id):
563
+ """Every way to name a present member, mapped to its ID: the ID, and its display name unless another member shares it."""
564
+ shown = member_display_names(connection, room_id)
565
+ handles = {name: name for name in shown}
566
+ taken = list(shown.values())
567
+ for name, label in shown.items():
568
+ if label not in handles and taken.count(label) == 1:
569
+ handles[label] = name
570
+ return handles
571
+
572
+
573
+ def resolve_member_fields(connection, room_id, payload):
574
+ """Turn display names in a write's member fields into IDs, so members are named as they are seen; IDs still work."""
575
+ handles = member_handles(connection, room_id)
576
+ resolve = lambda value: handles.get(value.strip(), value) if isinstance(value, str) else value
577
+ if isinstance(payload.get("to"), list):
578
+ payload["to"] = [resolve(item) for item in payload["to"]]
579
+ for key in ("next_review", "expert"):
580
+ if key in payload:
581
+ payload[key] = resolve(payload[key])
582
+ if isinstance(payload.get("turns"), list):
583
+ payload["turns"] = [{**item, "expert": resolve(item.get("expert"))} if isinstance(item, dict) else item for item in payload["turns"]]
584
+ return payload
585
+
586
+
587
+ def label_members(connection, room_id, token, payload):
588
+ """Owner sets members' display labels by name; an empty text removes that member's label."""
589
+ require_owner(connection, room_id, token)
590
+ labels = payload.get("labels")
591
+ if (not isinstance(labels, dict) or not labels or len(labels) > MAX_ROOM_AGENTS
592
+ or any(not isinstance(name, str) or not 0 < len(name) <= MAX_MEMBER_NAME or not isinstance(text, str)
593
+ or len(text.strip()) > MAX_MEMBER_LABEL for name, text in labels.items())):
594
+ raise ValueError(f"labels 须为 {{成员名: 显示名}},最多 {MAX_ROOM_AGENTS} 位,每个最多 {MAX_MEMBER_LABEL} 字")
595
+ begin_write(connection)
596
+ now = int(time.time())
597
+ for name, text in labels.items():
598
+ if text.strip():
599
+ connection.execute("""INSERT INTO member_labels (room_id,name,label,updated_at) VALUES (?,?,?,?)
600
+ ON CONFLICT(room_id,name) DO UPDATE SET label=excluded.label,updated_at=excluded.updated_at""",
601
+ (room_id, name, text.strip(), now))
602
+ else:
603
+ connection.execute("DELETE FROM member_labels WHERE room_id=? AND name=?", (room_id, name))
604
+ return {"labels": member_labels(connection, room_id)}
605
+
606
+ def note_count(connection, execution_id):
607
+ """How many progress notes an execution has; the texts are read separately so room views stay small."""
608
+ return connection.execute("SELECT COUNT(*) FROM execution_notes WHERE execution_id=?", (execution_id,)).fetchone()[0]
609
+
610
+
611
+ def execution_notes(connection, room_id, execution_id, token=""):
612
+ """Read one execution's progress notes in order, with the same access as the room view."""
613
+ execution = connection.execute("SELECT member_id FROM member_executions WHERE room_id=? AND id=?", (room_id, execution_id)).fetchone()
614
+ if not execution:
615
+ raise LookupError("执行不存在")
616
+ boundary = discussion.session_message_access(connection, room_id, {}, token)
617
+ if boundary is not None:
618
+ try:
619
+ member = require_member(connection, room_id, token)
620
+ except PermissionError:
621
+ return {"notes": []}
622
+ if member["id"] != execution["member_id"]:
623
+ return {"notes": []}
624
+ return {"notes": [dict(row) for row in connection.execute(
625
+ "SELECT seq,text,created_at FROM execution_notes WHERE execution_id=? ORDER BY seq", (execution_id,))]}
626
+
627
+
628
+ def execution_timeline(connection, room_id, after=0):
629
+ """Read the room's recent executions with their phase segments and note times, for the timeline page.
630
+
631
+ Times are epoch seconds and segments are millisecond offsets from `dispatched_at`. An execution's parent is the
632
+ execution that wrote the message it answers. `state` is running, done, failed
633
+ or cancelled, so the page never interprets execution statuses itself. A positive `after` reads only executions
634
+ still running or ended since then, so a page following live work rereads little.
635
+ """
636
+ if not connection.execute("SELECT 1 FROM rooms WHERE id=?", (room_id,)).fetchone():
637
+ raise LookupError("房间不存在")
638
+ params = (room_id, TIMELINE_EXECUTIONS, max(0, int(after)))
639
+ rows = [dict(row) for row in connection.execute(TIMELINE_SQL, params)]
640
+ segments, notes = {}, {}
641
+ for row in connection.execute(TIMELINE_SEGMENTS_SQL, params):
642
+ segments.setdefault((row["source"], row["execution_id"]), []).append(
643
+ {key: row[key] for key in ("phase", "tool", "start_ms", "end_ms")})
644
+ for row in connection.execute(TIMELINE_NOTES_SQL, params):
645
+ notes.setdefault(row["execution_id"], []).append(row["created_at"])
646
+ for row in rows:
647
+ row["state"] = TIMELINE_STATES.get(row["status"], "running")
648
+ row["segments"] = segments.get((row["source"], row["id"]), [])
649
+ row["notes"] = notes.get(row["id"], [])
650
+ return {"now": int(time.time()), "after": params[2], "executions": rows, "execution_limit": TIMELINE_EXECUTIONS,
651
+ "segment_limit": MAX_EXECUTION_SEGMENTS}
652
+
653
+
654
+ def room_event_log(connection, room_id, token, after=0, before=0):
655
+ """Read the owner's append-only room event log written by the schema triggers.
656
+
657
+ Args:
658
+ after: return events with a larger id (live follow), oldest first.
659
+ before: with no `after`, return the page of events just below this id (0 = the newest page), oldest first.
660
+
661
+ Returns:
662
+ `events` (id, at in epoch ms, kind, data) ordered by `at` then id, `members` id → name for resolving
663
+ `member_id`/`host_id`, `first_id`/`last_id` as paging cursors, and `more` when older events remain.
664
+ A write request (`api.*`) is stored after it finishes, so its id follows the changes it caused; its `at` is
665
+ when it arrived, and ordering by `at` puts the input before its effects.
666
+ """
667
+ require_owner(connection, room_id, token)
668
+ after, before = max(0, int(after)), max(0, int(before))
669
+ if after:
670
+ rows = connection.execute("SELECT id,at,kind,data FROM room_events WHERE room_id=? AND id>? ORDER BY id LIMIT ?",
671
+ (room_id, after, ROOM_EVENT_PAGE)).fetchall()
672
+ else:
673
+ rows = connection.execute("""SELECT id,at,kind,data FROM room_events WHERE room_id=? AND (?=0 OR id<?)
674
+ ORDER BY id DESC LIMIT ?""", (room_id, before, before, ROOM_EVENT_PAGE)).fetchall()
675
+ first_id = min((row["id"] for row in rows), default=0)
676
+ more = bool(rows) and not after and connection.execute(
677
+ "SELECT 1 FROM room_events WHERE room_id=? AND id<? LIMIT 1", (room_id, first_id)).fetchone() is not None
678
+ members = {str(row["id"]): row["name"] for row in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (room_id,))}
679
+ events = sorted(({**dict(row), "data": json.loads(row["data"])} for row in rows), key=lambda event: (event["at"], event["id"]))
680
+ return {"events": events, "members": members, "more": more, "first_id": first_id,
681
+ "last_id": max((row["id"] for row in rows), default=after)}
682
+
683
+
684
+ def recent_executions(connection, room_id):
685
+ """Return bounded public reports, including requests no longer in the recent request list."""
686
+ rows = connection.execute(f"""SELECT e.*,m.name AS member,
687
+ (SELECT json_group_array(id) FROM member_requests WHERE execution_id=e.id) AS request_ids,
688
+ (SELECT MAX(answer_id) FROM member_requests WHERE execution_id=e.id) AS answer_id,
689
+ (SELECT MIN(message_id) FROM member_requests WHERE execution_id=e.id) AS request_message_id,
690
+ (SELECT MIN({ANCHOR_SQL}) FROM member_requests r WHERE r.execution_id=e.id) AS anchor_message_id,
691
+ (SELECT COUNT(*) FROM execution_notes WHERE execution_id=e.id) AS note_count,
692
+ (SELECT denials FROM execution_denials WHERE execution_id=e.id) AS denials,{CAPABILITY_COLUMNS},
693
+ (SELECT json_object('created_at',created_at) FROM late_answers WHERE execution_id=e.id) AS late_answer
694
+ FROM member_executions e JOIN room_members m ON m.id=e.member_id WHERE e.room_id=? ORDER BY e.id DESC LIMIT ?""", (room_id, RECENT_EXECUTIONS))
695
+ executions = []
696
+ for row in rows:
697
+ requests = []
698
+ for request in connection.execute("SELECT id,message_id,purpose,status,answer_id FROM member_requests WHERE execution_id=? ORDER BY id", (row["id"],)):
699
+ requests.append({**dict(request), **session_request_source(connection, request["id"], request["message_id"])})
700
+ executions.append({**public_execution(row), "member": row["member"], "cwd": row["cwd"], "changes": json.loads(row["changes"] or "[]"),
701
+ "changes_recorded": bool(row["changes"]), "request_ids": json.loads(row["request_ids"]),
702
+ "answer_id": row["answer_id"], "request_message_id": row["request_message_id"],
703
+ "anchor_message_id": row["anchor_message_id"], "requests": requests,
704
+ "notes": row["note_count"], "denials": json.loads(row["denials"] or "[]"),
705
+ "capabilities": reported_capabilities(row), "stop_result": row["stop_result"] or "",
706
+ "late_answer": json.loads(row["late_answer"]) if row["late_answer"] else None,
707
+ "retry": execution_retry(connection, row["id"])})
708
+ return executions
709
+
710
+
711
+ def session_request_source(connection, member_request_id, message_id):
712
+ """Expose session provenance and a visible transcript anchor for a member request."""
713
+ origin = discussion.session_request_origin(connection, member_request_id)
714
+ if origin:
715
+ return origin
716
+ request = connection.execute("SELECT message_id,purpose FROM member_requests WHERE id=?", (member_request_id,)).fetchone()
717
+ return {"session_request_id": None, "discussion_session_id": None, "round": None, "source_question": "",
718
+ "source_message_id": None, "cause_kind": None, "cause_request_id": None, "after_message_id": message_id,
719
+ **review_request_context(connection, request)}
720
+
721
+
722
+ def possible_conflicts(connection, room_id, names):
723
+ """Report files changed by executions of different members whose time windows overlapped.
724
+
725
+ This is an observation, not attribution: it cannot tell who wrote which part or catch
726
+ writes outside the scanned working directory.
727
+ """
728
+ now = int(time.time())
729
+ rows = [dict(row) for row in connection.execute(
730
+ """SELECT id,member_id,created_at,finished_at,changes,cwd FROM member_executions
731
+ WHERE room_id=? AND changes<>'' ORDER BY id DESC LIMIT ?""", (room_id, RECENT_CHANGES))]
732
+ for row in rows:
733
+ row["files"] = set(json.loads(row["changes"]))
734
+ row["end"] = row["finished_at"] or now
735
+ conflicts = []
736
+ for index, first in enumerate(rows):
737
+ for second in rows[index + 1:]:
738
+ if first["member_id"] == second["member_id"] or first["cwd"] != second["cwd"]:
739
+ continue
740
+ if first["created_at"] > second["end"] or second["created_at"] > first["end"]:
741
+ continue
742
+ files = sorted(first["files"] & second["files"])
743
+ if files:
744
+ conflicts.append({"members": [names.get(second["member_id"], ""), names.get(first["member_id"], "")],
745
+ "executions": [second["id"], first["id"]], "files": files})
746
+ return conflicts
747
+
748
+
749
+ # An invited manual member that has not registered counts as absent: it receives no request and is never a target.
750
+ REGISTERED_MEMBER = """(room_members.access<>'manual' OR EXISTS(SELECT 1 FROM member_registrations r
751
+ WHERE r.member_id=room_members.id AND r.token_hash=room_members.token_hash))"""
752
+
753
+ # One snapshot for group routing; the Worker uses the same SQL inside its atomic write assertion.
754
+ GROUP_STATE_SQL = f"""SELECT json_object(
755
+ 'latest_id',(SELECT COALESCE(MAX(id),0) FROM messages WHERE room_id=rooms.id),
756
+ 'title',rooms.title,'goal',rooms.goal,
757
+ 'archived',(SELECT archived FROM room_directory WHERE room_id=rooms.id),
758
+ 'order',COALESCE((SELECT member_ids FROM speaking_orders WHERE room_id=rooms.id),'[]'),
759
+ 'members',(SELECT json_group_array(json_object('id',id,'name',name,'role',role,'status',status,'access',access,
760
+ 'registered',{REGISTERED_MEMBER},'description',COALESCE((SELECT description FROM member_descriptions d
761
+ WHERE d.room_id=room_members.room_id AND d.name=room_members.name),'')))
762
+ FROM (SELECT * FROM room_members WHERE room_id=rooms.id ORDER BY id) AS room_members)) AS state
763
+ FROM rooms WHERE id=?"""
764
+
765
+
766
+ def free_view(connection, room_id):
767
+ """Summarize members, their live execution, recent requests and possible file conflicts."""
768
+ now = int(time.time())
769
+ members = []
770
+ for row in connection.execute("SELECT * FROM room_members WHERE room_id=? AND status<>'removed' ORDER BY id", (room_id,)):
771
+ member = {key: row[key] for key in MEMBER_FIELDS}
772
+ member["registered_at"] = registered_at(connection, row)
773
+ reported = connection.execute("SELECT session_id,client FROM member_sessions WHERE member_id=?", (row["id"],)).fetchone()
774
+ member["reported_session"], member["reported_client"] = (reported["session_id"], reported["client"]) if reported else ("", "")
775
+ failure = connection.execute("SELECT reason FROM member_adopt_failures WHERE member_id=? AND session_id=?",
776
+ (row["id"], member["reported_session"])).fetchone() if reported else None
777
+ member["adopt_error"] = failure["reason"] if failure else ""
778
+ latest = connection.execute(f"SELECT e.*,{CAPABILITY_COLUMNS} FROM member_executions e WHERE member_id=? ORDER BY id DESC LIMIT 1",
779
+ (row["id"],)).fetchone()
780
+ member["execution"] = public_execution(latest)
781
+ if latest:
782
+ member["execution"].update(capabilities=reported_capabilities(latest), stop_result=latest["stop_result"] or "")
783
+ if latest and latest["status"] in ACTIVE:
784
+ member["execution"]["stale"] = latest["lease_until"] <= now
785
+ member["execution"]["notes"] = note_count(connection, latest["id"])
786
+ member["execution"]["retry"] = execution_retry(connection, latest["id"])
787
+ member["pending"] = connection.execute("SELECT COUNT(*) FROM member_requests WHERE member_id=? AND status='pending'",
788
+ (row["id"],)).fetchone()[0]
789
+ member["held_until"] = held_until(connection, row["id"])
790
+ members.append(member)
791
+ names = {row["id"]: row["name"] for row in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (room_id,))}
792
+ requests = [{**dict(row), "member": names.get(row["member_id"], ""),
793
+ **session_request_source(connection, row["id"], row["message_id"])} for row in connection.execute(
794
+ """SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by,created_at,
795
+ (SELECT reason FROM request_cancel_reasons c WHERE c.request_id=member_requests.id) AS cancel_reason FROM member_requests
796
+ WHERE room_id=? ORDER BY id DESC LIMIT ?""", (room_id, RECENT_REQUESTS))]
797
+ chains = [dict(row) for row in connection.execute(
798
+ "SELECT chain_id,reason FROM free_chains WHERE room_id=? ORDER BY chain_id DESC LIMIT 10", (room_id,))]
799
+ max_rounds = chain_rounds(connection, room_id)
800
+ progress = [dict(row) for row in connection.execute(CHAIN_PROGRESS_SQL, (max_rounds, room_id))]
801
+ return {"members": members, "requests": requests[::-1], "executions": recent_executions(connection, room_id),
802
+ "conflicts": possible_conflicts(connection, room_id, names), "halted_chains": chains, "max_rounds": max_rounds,
803
+ "chain_rounds": progress, "recommendations": recent_recommendations(connection, room_id, now)[::-1],
804
+ "queued_messages": queue_view(connection, room_id)}
805
+
806
+
807
+ def require_member(connection, room_id, token, name=None):
808
+ """Resolve an active member strictly from its own credential."""
809
+ hashed = token_hash(token)
810
+ row = connection.execute("SELECT * FROM room_members WHERE room_id=? AND token_hash=? AND status='active'",
811
+ (room_id, hashed)).fetchone() if hashed else None
812
+ if not row or (name is not None and row["name"] != name):
813
+ # A borrowed session handed to the local service keeps writing only through the tasks it is given; say so,
814
+ # since its old manual credential was replaced on purpose, not lost.
815
+ claimed = connection.execute("SELECT session_mode,access FROM room_members WHERE room_id=? AND name=? AND status='active'",
816
+ (room_id, name)).fetchone() if name and not row else None
817
+ if claimed and claimed["access"] == "runner" and claimed["session_mode"] == "borrowed":
818
+ raise PermissionError(f"{name} 已转为本机托管,原手动凭证已停用:请在收到的房间任务里,用任务给出的凭证回答")
819
+ raise PermissionError("成员凭证无效、已停用或与发言身份不符")
820
+ return dict(row)
821
+
822
+
823
+ def registered_at(connection, member):
824
+ """Return when the member registered with its current credential, or '' while it has not."""
825
+ row = connection.execute("SELECT registered_at FROM member_registrations WHERE member_id=? AND token_hash=?",
826
+ (member["id"], member["token_hash"])).fetchone()
827
+ return row[0] if row else ""
828
+
829
+
830
+ def register_member(connection, room_id, token, now):
831
+ """A manual member announces itself with its own credential; repeating it keeps the first time.
832
+
833
+ The session ID is not part of the request: the CLI attaches it to every call and the room records it separately.
834
+ """
835
+ begin_write(connection)
836
+ member = require_member(connection, room_id, token)
837
+ if member["access"] != "manual":
838
+ raise ValueError("只有手动接入的成员需要注册")
839
+ previous = registered_at(connection, member)
840
+ stamp = previous or now
841
+ connection.execute("""INSERT INTO member_registrations (member_id,room_id,token_hash,registered_at) VALUES (?,?,?,?)
842
+ ON CONFLICT(member_id) DO UPDATE SET token_hash=excluded.token_hash,registered_at=excluded.registered_at""",
843
+ (member["id"], room_id, member["token_hash"], stamp))
844
+ if not previous:
845
+ queue_invited_expert(connection, room_id, member["name"], now)
846
+ reported = connection.execute("SELECT client FROM member_sessions WHERE member_id=?", (member["id"],)).fetchone()
847
+ return {"member": member["name"], "registered_at": stamp, "session_client": reported[0] if reported else ""}
848
+
849
+
850
+ def authorize_free_write(connection, room_id, token, operation, payload):
851
+ """Decide who is writing: the owner, or one member posting its own answer.
852
+
853
+ Returns:
854
+ "用户" for the owner, or the member row for a member credential.
855
+ """
856
+ begin_write(connection)
857
+ if operation not in FREE_OPERATIONS:
858
+ raise ValueError("自由房间不使用发言权、安排、阶段或轮次")
859
+ try:
860
+ require_owner(connection, room_id, token)
861
+ return "用户"
862
+ except PermissionError:
863
+ if operation not in ("messages", "attachments"):
864
+ raise
865
+ author = payload.get("author")
866
+ try:
867
+ member = require_member(connection, room_id, token)
868
+ except PermissionError:
869
+ if author in (None, "用户"):
870
+ raise PermissionError("此浏览器没有本房间的房主凭据:请用创建房间的浏览器,或登录管理员后再发言") from None
871
+ raise
872
+ if author == member["name"] and payload.get("submitted_by", author) == author:
873
+ return member
874
+ raise PermissionError("成员只能以本人身份发言")
875
+
876
+
877
+ def active_members(connection, room_id, registered_only=True):
878
+ """Map active member names to their rows; unregistered manual members are left out unless a discussion start needs them pending."""
879
+ return {row["name"]: row for row in connection.execute(
880
+ f"SELECT * FROM room_members WHERE room_id=? AND status='active' AND ({REGISTERED_MEMBER} OR ?=0)", (room_id, int(registered_only)))}
881
+
882
+
883
+ def named_members(active, names, exclude=None):
884
+ """Validate a structured recipient list against the active members."""
885
+ if not isinstance(names, list) or any(name not in active or name == exclude for name in names):
886
+ raise ValueError("点名对象须为本房间在场成员" + (",且不能点名自己" if exclude else ""))
887
+ return [active[name]["id"] for name in dict.fromkeys(names)]
888
+
889
+
890
+ def recipients(connection, room_id, payload):
891
+ """Resolve an owner message's recipients as (member id, purpose) pairs.
892
+
893
+ A single active member is named by default. With several, an unnamed message goes to the
894
+ AI host for routing; a room without one leaves it a plain note.
895
+ Naming everyone with order auto leaves the host a group request: the runner has everyone answer
896
+ at once or one after another; a host the runner does not wake has them all answer at once.
897
+ """
898
+ # Rooms created while the user could host get their AI host on the next message.
899
+ sync_participants(connection, room_id)
900
+ active = active_members(connection, room_id)
901
+ if payload.get("to") is not None:
902
+ named = named_members(active, payload["to"])
903
+ host = next((row for row in active.values() if row["role"] == "host"), None)
904
+ if payload.get("order") == "auto" and len(named) > 1 and host and host["access"] == "runner":
905
+ return [(host["id"], "group")]
906
+ return [(member_id, "ask") for member_id in named]
907
+ if len(active) == 1:
908
+ return [(next(iter(active.values()))["id"], "ask")]
909
+ host = next((row for row in active.values() if row["role"] == "host"), None)
910
+ return [(host["id"], "route")] if host else []
911
+
912
+
913
+ def check_answer(connection, room_id, member, payload):
914
+ """A runner member completes a live execution; a manual member may close its own open requests.
915
+
916
+ Only the AI host dispatches work; other members' targets remain suggestions.
917
+ """
918
+ if payload.get("to") is not None:
919
+ named_members(active_members(connection, room_id), payload["to"], exclude=member["name"])
920
+ if payload.get("handoff") not in (None, False) and (payload["handoff"] is not True or member["role"] != "host" or not payload.get("to")):
921
+ raise ValueError("handoff 只能由主持人与 to 一起使用,值为 true")
922
+ if member["access"] == "manual":
923
+ request_ids = payload.get("request_ids", [])
924
+ if payload.get("execution_id") is not None or not isinstance(request_ids, list) or any(type(item) is not int for item in request_ids):
925
+ raise ValueError("手动接入成员用 request_ids 关联请求,不使用执行编号")
926
+ purposes = []
927
+ requests = []
928
+ for request_id in request_ids:
929
+ row = connection.execute("SELECT * FROM member_requests WHERE room_id=? AND id=?", (room_id, request_id)).fetchone()
930
+ if not row or row["member_id"] != member["id"] or row["status"] != "pending":
931
+ raise FileExistsError(f"请求 {request_id} 不属于本成员或已处理")
932
+ purposes.append(row["purpose"])
933
+ requests.append(row)
934
+ discussion.validate_session_request_answer(connection, room_id, requests)
935
+ check_dispatch(member, payload, purposes)
936
+ return None
937
+ execution_id = payload.get("execution_id")
938
+ if type(execution_id) is not int:
939
+ raise ValueError("成员回答须关联本次执行编号 execution_id")
940
+ row = connection.execute("SELECT * FROM member_executions WHERE room_id=? AND id=?", (room_id, execution_id)).fetchone()
941
+ if not row or row["member_id"] != member["id"]:
942
+ raise PermissionError("执行编号不属于本成员")
943
+ if row["status"] not in ACTIVE or row["lease_until"] <= int(time.time()):
944
+ if row["status"] == "cancelled":
945
+ # Kept apart from the transcript: the page shows it folded, and it marks the end of a turn the runner could not stop.
946
+ connection.execute("INSERT OR IGNORE INTO late_answers (execution_id,room_id,text,created_at) VALUES (?,?,?,?)",
947
+ (execution_id, room_id, str(payload.get("text") or ""), int(time.time())))
948
+ connection.commit()
949
+ raise FileExistsError("本次执行已结束、被打断或已过期;回答不能改作普通发言,请保留草稿")
950
+ requests = [dict(item) for item in connection.execute(
951
+ "SELECT id,purpose FROM member_requests WHERE execution_id=? AND status='delivering'", (execution_id,))]
952
+ discussion.validate_session_request_answer(connection, room_id, requests)
953
+ check_dispatch(member, payload, [item["purpose"] for item in requests])
954
+ return row
955
+
956
+
957
+ def check_dispatch(member, payload, purposes):
958
+ """A host answering only its share of an everyone request answers as a member, so it may not dispatch others."""
959
+ if member["role"] == "host" and payload.get("to") and purposes and all(purpose == "all" for purpose in purposes):
960
+ raise ValueError("这次是请全体成员各自回应,其他成员已收到请求;主持人只回答自己的部分,不能用 --to 派活")
961
+
962
+
963
+ def insert_request(connection, room_id, member_id, message_id, chain_id, purpose, created_by, batch_id=0):
964
+ """Queue one request; every request belongs to the chain its owner message started."""
965
+ connection.execute("""INSERT INTO member_requests (room_id,member_id,message_id,chain_id,batch_id,purpose,created_by,created_at)
966
+ VALUES (?,?,?,?,?,?,?,?)""", (room_id, member_id, message_id, chain_id, batch_id, purpose, created_by, int(time.time())))
967
+
968
+
969
+ def mention_prose(text):
970
+ """Remove Markdown code and quote blocks before interpreting response suggestions."""
971
+ lines, fence, quoted = [], "", False
972
+ for line in text.splitlines():
973
+ marker = re.match(r"^ {0,3}(`{3,}|~{3,})", line)
974
+ if marker:
975
+ value = marker[1]
976
+ if not fence:
977
+ fence = value
978
+ elif value[0] == fence[0] and len(value) >= len(fence):
979
+ fence = ""
980
+ continue
981
+ if fence or re.match(r"^( {4}|\t)", line):
982
+ continue
983
+ if re.match(r"^ {0,3}>", line):
984
+ quoted = True
985
+ continue
986
+ if not line.strip():
987
+ quoted = False
988
+ if not quoted:
989
+ lines.append(line)
990
+ return re.sub(r"(`+)[\s\S]*?\1(?!`)", "", "\n".join(lines))
991
+
992
+
993
+ def save_suggestions(connection, room_id, message_id, member, payload):
994
+ """Save ordinary members' explicit targets and prose mentions without waking anyone."""
995
+ if member == "用户" or member["role"] == "host":
996
+ return
997
+ active = active_members(connection, room_id)
998
+ targets = set(named_members(active, payload["to"] if payload.get("to") is not None else [], exclude=member["name"]))
999
+ prose = mention_prose(str(payload.get("text", "")))
1000
+ for handle, name in member_handles(connection, room_id).items():
1001
+ if name in active and name != member["name"] and re.search(
1002
+ r"(^|[\s((,,。;;::])@" + re.escape(handle) + r"(?![A-Za-z0-9_\-\u4e00-\u9fff])", prose):
1003
+ targets.add(active[name]["id"])
1004
+ for target_id in targets:
1005
+ connection.execute("INSERT INTO member_suggestions (message_id,member_id) VALUES (?,?)", (message_id, target_id))
1006
+
1007
+
1008
+ def message_suggestions(connection, message_id):
1009
+ """Expose suggested members and the durable request, including terminal conversions."""
1010
+ return [dict(row) for row in connection.execute("""SELECT s.member_id,m.name AS member,m.status,
1011
+ r.id AS request_id,r.status AS request_status FROM member_suggestions s
1012
+ JOIN room_members m ON m.id=s.member_id
1013
+ LEFT JOIN member_requests r ON r.message_id=s.message_id AND r.member_id=s.member_id
1014
+ WHERE s.message_id=? ORDER BY s.member_id""", (message_id,))]
1015
+
1016
+
1017
+ def convert_suggestion(connection, room_id, token, payload):
1018
+ """Let the owner turn a saved suggestion into one request and a fresh user chain."""
1019
+ begin_write(connection)
1020
+ require_owner(connection, room_id, token)
1021
+ message_id, member_id = payload.get("message_id"), payload.get("member_id")
1022
+ if type(message_id) is not int or type(member_id) is not int:
1023
+ raise ValueError("须提供原消息编号 message_id 与成员编号 member_id")
1024
+ target = connection.execute("""SELECT m.name,m.status FROM member_suggestions s
1025
+ JOIN messages source ON source.id=s.message_id JOIN room_members m ON m.id=s.member_id
1026
+ WHERE source.room_id=? AND m.room_id=? AND s.message_id=? AND s.member_id=?""",
1027
+ (room_id, room_id, message_id, member_id)).fetchone()
1028
+ if not target:
1029
+ raise LookupError("这条消息没有该成员的回应建议")
1030
+ if target["status"] != "active":
1031
+ raise FileExistsError("该成员已离开,不能转为请求")
1032
+ if connection.execute("SELECT 1 FROM room_directory WHERE room_id=? AND archived=1", (room_id,)).fetchone():
1033
+ raise ValueError("房间已存档;恢复后才能继续协作")
1034
+ previous = connection.execute("SELECT * FROM member_requests WHERE message_id=? AND member_id=?", (message_id, member_id)).fetchone()
1035
+ if previous:
1036
+ return {"request": dict(previous), "duplicate": True}
1037
+ confirmation = connection.execute("""INSERT INTO messages (room_id,author,text,reply_to,source,submission_id,created_at)
1038
+ VALUES (?,'用户',?,?,'user',?,strftime('%Y-%m-%dT%H:%M:%fZ','now'))""",
1039
+ (room_id, f"请 @{target['name']} 回应 #{message_id} 的建议。", message_id, uuid.uuid4().hex))
1040
+ connection.execute("INSERT INTO message_details (message_id,kind,submitted_by) VALUES (?,'chat','用户')", (confirmation.lastrowid,))
1041
+ insert_request(connection, room_id, member_id, message_id, confirmation.lastrowid, "ask", "user")
1042
+ request = connection.execute("SELECT * FROM member_requests WHERE message_id=? AND member_id=?", (message_id, member_id)).fetchone()
1043
+ return {"request": dict(request), "duplicate": False}
1044
+
1045
+
1046
+ def halt_chain(connection, room_id, chain_id, reason):
1047
+ """Stop automatic progress on a chain; the user's next message starts a new one."""
1048
+ connection.execute("INSERT OR IGNORE INTO free_chains (room_id,chain_id,reason) VALUES (?,?,?)", (room_id, chain_id, reason))
1049
+
1050
+
1051
+ def rounds_left(connection, room_id, chain_id):
1052
+ """Rounds a chain may still start: the room's limit plus what the owner added, less the dispatches made; none once halted."""
1053
+ if connection.execute("SELECT 1 FROM free_chains WHERE room_id=? AND chain_id=?", (room_id, chain_id)).fetchone():
1054
+ return 0
1055
+ used = connection.execute("SELECT COUNT(*) FROM free_batches WHERE room_id=? AND chain_id=?", (room_id, chain_id)).fetchone()[0]
1056
+ extra = connection.execute("SELECT extra_rounds FROM free_chain_holds WHERE room_id=? AND chain_id=?", (room_id, chain_id)).fetchone()
1057
+ return chain_rounds(connection, room_id) + (extra[0] if extra else 0) - used
1058
+
1059
+
1060
+ def chain_rounds(connection, room_id):
1061
+ """Rounds a host may run from one owner message in this room."""
1062
+ row = connection.execute("SELECT max_rounds FROM free_round_limits WHERE room_id=?", (room_id,)).fetchone()
1063
+ return row[0] if row else CHAIN_ROUNDS
1064
+
1065
+
1066
+ def set_chain_rounds(connection, room_id, token, payload):
1067
+ """Owner sets the room's round limit; it applies to chains still running and to later ones."""
1068
+ require_owner(connection, room_id, token)
1069
+ value = payload.get("max_rounds")
1070
+ if type(value) is not int or not 1 <= value <= MAX_CHAIN_ROUNDS:
1071
+ raise ValueError(f"max_rounds 须为 1–{MAX_CHAIN_ROUNDS} 的整数")
1072
+ begin_write(connection)
1073
+ connection.execute("""INSERT INTO free_round_limits (room_id,max_rounds) VALUES (?,?)
1074
+ ON CONFLICT(room_id) DO UPDATE SET max_rounds=excluded.max_rounds""", (room_id, value))
1075
+ return {"max_rounds": value}
1076
+
1077
+
1078
+ def continue_chain(connection, room_id, token, chain_id, payload):
1079
+ """Owner lets a chain the round limit stopped run on: adds rounds and sends the dispatch the limit held back.
1080
+
1081
+ Bound to one known chain, so it takes no revision; a chain that is not stopped by its limit is refused.
1082
+ """
1083
+ require_owner(connection, room_id, token)
1084
+ rounds = payload.get("rounds")
1085
+ if type(rounds) is not int or not 1 <= rounds <= MAX_CHAIN_ROUNDS:
1086
+ raise ValueError(f"rounds 须为 1–{MAX_CHAIN_ROUNDS} 的整数")
1087
+ begin_write(connection)
1088
+ halted = connection.execute("SELECT reason FROM free_chains WHERE room_id=? AND chain_id=?", (room_id, chain_id)).fetchone()
1089
+ if not halted or halted[0] != "budget":
1090
+ raise FileExistsError("这条任务链没有因轮数上限停下,无需继续")
1091
+ host = connection.execute("SELECT * FROM room_members WHERE room_id=? AND role='host' AND status='active'", (room_id,)).fetchone()
1092
+ if not host:
1093
+ raise ValueError("房间没有在场的主持人,无法继续")
1094
+ connection.execute("""INSERT INTO free_chain_holds (room_id,chain_id,extra_rounds) VALUES (?,?,?)
1095
+ ON CONFLICT(room_id,chain_id) DO UPDATE SET extra_rounds=extra_rounds+excluded.extra_rounds""", (room_id, chain_id, rounds))
1096
+ connection.execute("DELETE FROM free_chains WHERE room_id=? AND chain_id=?", (room_id, chain_id))
1097
+ held = connection.execute("SELECT message_id,names,handoff FROM free_chain_holds WHERE room_id=? AND chain_id=?", (room_id, chain_id)).fetchone()
1098
+ connection.execute("UPDATE free_chain_holds SET message_id=0,host_id=0,names='[]',handoff=0 WHERE room_id=? AND chain_id=?", (room_id, chain_id))
1099
+ if held["message_id"]:
1100
+ dispatch(connection, room_id, host, held["message_id"], json.loads(held["names"]), chain_id, bool(held["handoff"]))
1101
+ return free_view(connection, room_id)
1102
+
1103
+
1104
+ def hold_dispatch(connection, room_id, host, message_id, names, chain_id, handoff):
1105
+ """Stop a chain at its round limit, keeping the dispatch it held back so the owner can send it by continuing."""
1106
+ halt_chain(connection, room_id, chain_id, "budget")
1107
+ connection.execute("""INSERT INTO free_chain_holds (room_id,chain_id,message_id,host_id,names,handoff) VALUES (?,?,?,?,?,?)
1108
+ ON CONFLICT(room_id,chain_id) DO UPDATE SET message_id=excluded.message_id,host_id=excluded.host_id,names=excluded.names,
1109
+ handoff=excluded.handoff""", (room_id, chain_id, message_id, host["id"], json.dumps(list(names), ensure_ascii=False), int(handoff)))
1110
+
1111
+
1112
+ def dispatch(connection, room_id, host, message_id, names, chain_id, handoff=False):
1113
+ """Queue the host's named members as one batch, one round; several members, or one the host asked back, hand back when it ends."""
1114
+ member_ids = named_members(active_members(connection, room_id), names, exclude=host["name"])
1115
+ if not member_ids or not chain_id:
1116
+ return
1117
+ if rounds_left(connection, room_id, chain_id) < 1:
1118
+ hold_dispatch(connection, room_id, host, message_id, names, chain_id, handoff)
1119
+ return
1120
+ for member_id in member_ids:
1121
+ insert_request(connection, room_id, member_id, message_id, chain_id, "ask", "host", batch_id=message_id)
1122
+ connection.execute("INSERT INTO free_batches (room_id,batch_id,chain_id,host_id) VALUES (?,?,?,?)",
1123
+ (room_id, message_id, chain_id, host["id"]))
1124
+ # Work handed out under a goal: when it ends and the room is idle, the host is woken to carry on.
1125
+ connection.execute("UPDATE room_goals SET armed=1 WHERE room_id=? AND status='active'", (room_id,))
1126
+ if handoff:
1127
+ connection.execute("INSERT INTO free_handoffs (room_id,batch_id) VALUES (?,?)", (room_id, message_id))
1128
+
1129
+
1130
+ def settle_batches(connection, room_id):
1131
+ """Hand each finished batch back to its host once; an all-failed batch halts its chain instead."""
1132
+ for batch in connection.execute("SELECT * FROM free_batches WHERE room_id=? AND settled=0", (room_id,)).fetchall():
1133
+ statuses = [row[0] for row in connection.execute("SELECT status FROM member_requests WHERE room_id=? AND batch_id=?",
1134
+ (room_id, batch["batch_id"]))]
1135
+ if any(status not in TERMINAL for status in statuses):
1136
+ continue
1137
+ connection.execute("UPDATE free_batches SET settled=1 WHERE room_id=? AND batch_id=?", (room_id, batch["batch_id"]))
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()
1140
+ if "completed" not in statuses:
1141
+ halt_chain(connection, room_id, batch["chain_id"], "all_failed")
1142
+ if review and host:
1143
+ insert_request(connection, room_id, host["id"], batch["batch_id"], batch["chain_id"], "handoff", "host")
1144
+ elif not host or len(statuses) < 2 and not connection.execute(
1145
+ "SELECT 1 FROM free_handoffs WHERE room_id=? AND batch_id=?", (room_id, batch["batch_id"])).fetchone():
1146
+ # One member's answer reaches the user directly unless the host asked for it back, e.g. to send it to a reviewer.
1147
+ continue
1148
+ else:
1149
+ # The hand-back closes the round it belongs to, so it is never held back: the last round still gets its summary.
1150
+ insert_request(connection, room_id, host["id"], batch["batch_id"], batch["chain_id"], "handoff", "host")
1151
+ advance_queues(connection, room_id)
1152
+
1153
+
1154
+ def queue_in_turn(connection, room_id, message_id, member_ids):
1155
+ """Line the named members up for one owner message in the room's speaking order; only the first is asked now."""
1156
+ chosen = set(member_ids)
1157
+ members = [dict(row) for row in connection.execute("SELECT id,name,role FROM room_members WHERE room_id=?", (room_id,))]
1158
+ for member in default_order(connection, room_id, [member for member in members if member["id"] in chosen]):
1159
+ connection.execute("INSERT INTO free_queue (room_id,message_id,member_id) VALUES (?,?,?)", (room_id, message_id, member["id"]))
1160
+ advance_queues(connection, room_id)
1161
+
1162
+
1163
+ def advance_queues(connection, room_id):
1164
+ """Ask the next queued member of each message whose previous request ended; members no longer present are skipped.
1165
+
1166
+ Asked rows stay, so the speaking order can still show who already answered.
1167
+ """
1168
+ connection.execute("""DELETE FROM free_queue WHERE id IN (SELECT q.id FROM free_queue_waiting q WHERE q.room_id=?
1169
+ AND NOT EXISTS(SELECT 1 FROM room_members m WHERE m.id=q.member_id AND m.status='active'))""", (room_id,))
1170
+ connection.execute("""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,batch_id,purpose,created_by,created_at)
1171
+ SELECT q.room_id,q.member_id,q.message_id,q.message_id,0,'ask','user',? FROM free_queue_waiting q
1172
+ WHERE q.id IN (SELECT MIN(id) FROM free_queue_waiting WHERE room_id=? GROUP BY message_id)
1173
+ AND NOT EXISTS(SELECT 1 FROM member_requests r WHERE r.message_id=q.message_id AND r.status IN ('pending','delivering'))""",
1174
+ (int(time.time()), room_id))
1175
+
1176
+
1177
+ def default_order(connection, room_id, members):
1178
+ """Members in the room's saved speaking order; without one (or for members it lacks), the host first, then by joining order."""
1179
+ row = connection.execute("SELECT member_ids FROM speaking_orders WHERE room_id=?", (room_id,)).fetchone()
1180
+ rank = {member_id: index for index, member_id in enumerate(json.loads(row[0]) if row else [])}
1181
+ return sorted(members, key=lambda member: (member["id"] not in rank, rank.get(member["id"], 0), member["role"] != "host", member["id"]))
1182
+
1183
+
1184
+ def speaking_order(connection, room):
1185
+ """Who spoke, who speaks now and who is next; only the not-yet-spoken part may be reordered.
1186
+
1187
+ Without an open discussion the room shows its latest unfinished one-after-another queue, else every member in the room's
1188
+ default order; an open discussion shows its open round: speakers so far, the current speaker, then this round's queued
1189
+ speakers; a plan reaching into later rounds lists them apart in `later` (one name list per round), read-only. The
1190
+ revision changes with any of it.
1191
+ """
1192
+ active = [member for member in room["members"] if member["status"] == "active"]
1193
+ by_id = {member["id"]: member for member in active}
1194
+ ordered = [member["name"] for member in default_order(connection, room["id"], active)]
1195
+ items, kind, target, later = [], "default", 0, []
1196
+ session_plan_state = []
1197
+ session = discussion.current_session(connection, room["id"], active_only=True)
1198
+ if session:
1199
+ kind = "turns"
1200
+ target = session["id"]
1201
+ items, later, session_plan_state = discussion.session_speaking_order(connection, session)
1202
+ else:
1203
+ target = connection.execute("""SELECT MAX(message_id) FROM (
1204
+ SELECT MAX(message_id) AS message_id FROM free_queue_waiting WHERE room_id=?
1205
+ UNION ALL SELECT MAX(r.message_id) FROM member_requests r WHERE r.room_id=? AND r.status IN ('pending','delivering')
1206
+ AND EXISTS(SELECT 1 FROM free_queue q WHERE q.message_id=r.message_id AND q.member_id=r.member_id))""",
1207
+ (room["id"], room["id"])).fetchone()[0] or 0
1208
+ if target:
1209
+ kind = "queue"
1210
+ for row in connection.execute("""SELECT q.member_id,r.status FROM free_queue q LEFT JOIN member_requests r
1211
+ ON r.message_id=q.message_id AND r.member_id=q.member_id WHERE q.message_id=? ORDER BY q.id""", (target,)):
1212
+ if row["member_id"] in by_id:
1213
+ state = "queued" if row["status"] is None else "speaking" if row["status"] in ("pending", "delivering") else "done"
1214
+ items.append({"name": by_id[row["member_id"]]["name"], "state": state})
1215
+ listed = {item["name"] for item in items}
1216
+ items += [{"name": name, "state": "idle"} for name in dict.fromkeys(ordered) if name not in listed]
1217
+ 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)
1219
+ return {"kind": kind, "message_id": target, "items": items, "later": later, "movable": [item["name"] for item in items if item["state"] == movable],
1220
+ "default": [member["name"] for member in default_order(connection, room["id"], active)],
1221
+ "revision": hashlib.sha256(state.encode("utf-8")).hexdigest()[:12]}
1222
+
1223
+
1224
+ def reorder_speakers(connection, room, token, payload):
1225
+ """Owner reorders the not-yet-spoken part of the speaking order; who already spoke or speaks now stays put.
1226
+
1227
+ Read the speaking order first and send its revision: a missing revision is refused, a changed one conflicts,
1228
+ and the new order must list exactly the movable members.
1229
+ """
1230
+ require_owner(connection, room["id"], token)
1231
+ current = room["speaking_order"]
1232
+ if payload.get("revision") is None:
1233
+ raise ValueError("调整发言顺序前须先读取最新顺序(speaking-order),并带上其 revision")
1234
+ if payload["revision"] != current["revision"]:
1235
+ raise FileExistsError("发言顺序已变化,请重新读取后再调整")
1236
+ order = payload.get("order")
1237
+ movable = current["movable"]
1238
+ if not isinstance(order, list) or sorted(map(str, order)) != sorted(movable):
1239
+ given = [str(name) for name in order] if isinstance(order, list) else []
1240
+ missing = [name for name in dict.fromkeys(movable) if given.count(name) < movable.count(name)]
1241
+ extra = [name for name in dict.fromkeys(given) if movable.count(name) < given.count(name)]
1242
+ raise ValueError("新顺序须恰好包含尚未发言的成员" + (f";缺少:{'、'.join(missing)}" if missing else "") + (f";多出:{'、'.join(extra)}" if extra else ""))
1243
+ names = {member["name"]: member["id"] for member in room["members"] if member["status"] == "active"}
1244
+ if current["kind"] == "queue":
1245
+ connection.execute(f"DELETE FROM free_queue WHERE message_id=? AND NOT {QUEUE_ASKED.format(row='free_queue')}", (current["message_id"],))
1246
+ for name in order:
1247
+ connection.execute("INSERT INTO free_queue (room_id,message_id,member_id) VALUES (?,?,?)", (room["id"], current["message_id"], names[name]))
1248
+ elif current["kind"] == "turns":
1249
+ discussion.reorder_session_turns(connection, discussion.current_session(connection, room["id"], active_only=True), order, names)
1250
+ else:
1251
+ connection.execute("INSERT OR REPLACE INTO speaking_orders (room_id,member_ids) VALUES (?,?)",
1252
+ (room["id"], json.dumps([names[name] for name in order])))
1253
+
1254
+
1255
+ def answered_chain(connection, payload, actor):
1256
+ """The chain an answer continues: the newest chain among the requests it completes."""
1257
+ if actor["access"] == "manual":
1258
+ ids = payload.get("request_ids") or [0]
1259
+ marks = ",".join("?" * len(ids))
1260
+ row = connection.execute(f"SELECT MAX(chain_id) FROM member_requests WHERE id IN ({marks})", ids).fetchone()
1261
+ else:
1262
+ row = connection.execute("SELECT MAX(chain_id) FROM member_requests WHERE execution_id=?", (payload["execution_id"],)).fetchone()
1263
+ return row[0] or 0
1264
+
1265
+
1266
+ def after_free_message(connection, room_id, message_id, actor, payload):
1267
+ """Create requests for the owner's message, or close what the member's answer covers."""
1268
+ now = int(time.time())
1269
+ if actor == "用户":
1270
+ # New owner input lets an active goal wake the host again once the room falls idle.
1271
+ connection.execute("UPDATE room_goals SET armed=1 WHERE room_id=? AND status='active'", (room_id,))
1272
+ if discussion.hold_user_message(connection, room_id, message_id, payload):
1273
+ return
1274
+ pairs = recipients(connection, room_id, payload)
1275
+ if payload.get("sequential") is True and payload.get("to") is not None:
1276
+ queue_in_turn(connection, room_id, message_id, [member_id for member_id, _ in pairs])
1277
+ return
1278
+ for member_id, purpose in pairs:
1279
+ insert_request(connection, room_id, member_id, message_id, message_id, purpose, "user")
1280
+ return
1281
+ chain_id = answered_chain(connection, payload, actor)
1282
+ if actor["access"] == "manual":
1283
+ for request_id in payload.get("request_ids", []):
1284
+ connection.execute("UPDATE member_requests SET status='completed',answer_id=? WHERE id=?", (message_id, request_id))
1285
+ else:
1286
+ execution_id = payload["execution_id"]
1287
+ connection.execute("UPDATE member_executions SET status='completed',finished_at=?,result=? WHERE id=?",
1288
+ (now, f"message:{message_id}", execution_id))
1289
+ connection.execute("UPDATE member_requests SET status='completed',answer_id=? WHERE execution_id=? AND status='delivering'",
1290
+ (message_id, execution_id))
1291
+ 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"):
1293
+ dispatch(connection, room_id, actor, message_id, payload["to"], chain_id, payload.get("handoff") is True or bool(payload.get("next_review")))
1294
+ settle_batches(connection, room_id)
1295
+
1296
+
1297
+ def expire_executions(connection, room_id):
1298
+ """Fail executions whose lease lapsed; their requests stay visible as failed, never silently retried."""
1299
+ now = int(time.time())
1300
+ stale = connection.execute(
1301
+ "SELECT id FROM member_executions WHERE room_id=? AND status IN ('dispatched','started') AND lease_until<=?", (room_id, now)).fetchall()
1302
+ for row in stale:
1303
+ finish_execution(connection, row["id"], "failed", "执行租约到期,接入程序未续租", "failed")
1304
+ if stale:
1305
+ settle_batches(connection, room_id)
1306
+
1307
+
1308
+ def record_cancel_reasons(connection, where, params, reason):
1309
+ """Remember why requests are about to be cancelled; call before the status change selects them."""
1310
+ connection.execute(f"INSERT OR REPLACE INTO request_cancel_reasons (request_id,reason) SELECT id,? FROM member_requests WHERE {where}", (reason, *params))
1311
+
1312
+
1313
+ def finish_execution(connection, execution_id, status, result, request_status, cancel_reason=""):
1314
+ """End one execution and move its delivered requests in the same transaction."""
1315
+ if request_status == "cancelled" and cancel_reason:
1316
+ record_cancel_reasons(connection, "execution_id=? AND status='delivering'", (execution_id,), cancel_reason)
1317
+ discussion_requests = discussion.open_discussion_requests(connection, execution_id)
1318
+ connection.execute("UPDATE member_executions SET status=?,finished_at=?,result=? WHERE id=? AND status IN ('dispatched','started')",
1319
+ (status, int(time.time()), result[:500], execution_id))
1320
+ connection.execute("UPDATE member_requests SET status=?,execution_id=CASE WHEN ?='pending' THEN NULL ELSE execution_id END WHERE execution_id=? AND status='delivering'",
1321
+ (request_status, request_status, execution_id))
1322
+ if status == "failed" and request_status == "failed":
1323
+ for row in discussion_requests:
1324
+ discussion.fail_session_request(connection, row["id"], result)
1325
+
1326
+
1327
+ def open_route_messages(connection, member_id, pending_only=False):
1328
+ """(message, purpose) of host requests still open, in order; they need a new host if this one leaves."""
1329
+ states = "'pending'" if pending_only else "'pending','delivering'"
1330
+ return [(row[0], row[1]) for row in connection.execute(
1331
+ f"""SELECT message_id,purpose FROM member_requests WHERE member_id=? AND purpose IN ('route','group')
1332
+ AND status IN ({states}) ORDER BY message_id""", (member_id,))]
1333
+
1334
+
1335
+ def hand_over_routes(connection, room_id, routes, departed_id):
1336
+ """Ask the current host to route messages left behind by the previous host."""
1337
+ host = connection.execute("SELECT id FROM room_members WHERE room_id=? AND role='host' AND status='active' AND id<>?",
1338
+ (room_id, departed_id or 0)).fetchone()
1339
+ for message_id, purpose in routes if host else []:
1340
+ connection.execute("""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,purpose,created_by,created_at)
1341
+ VALUES (?,?,?,?,?,'user',?)""", (room_id, host["id"], message_id, message_id, purpose, int(time.time())))
1342
+
1343
+
1344
+ def stop_member_work(connection, member_id, reason, cancel_reason, keep_pending=False):
1345
+ """Void a member's live execution and, unless `keep_pending`, every open request, so nothing restarts by itself.
1346
+
1347
+ The owner's stop keeps the requests that arrived while the member worked: like interrupting a turn, the member then
1348
+ goes on to the messages sent after it, while a member that leaves drops them all.
1349
+ """
1350
+ for row in connection.execute("SELECT id FROM member_executions WHERE member_id=? AND status IN ('dispatched','started')",
1351
+ (member_id,)).fetchall():
1352
+ finish_execution(connection, row["id"], "cancelled", reason, "cancelled", cancel_reason)
1353
+ if not keep_pending:
1354
+ record_cancel_reasons(connection, "member_id=? AND status='pending'", (member_id,), cancel_reason)
1355
+ connection.execute("UPDATE member_requests SET status='cancelled' WHERE member_id=? AND status='pending'", (member_id,))
1356
+ discussion.after_member_stopped(connection, member_id)
1357
+
1358
+
1359
+ def stop_room(connection, room_id, token):
1360
+ """Owner stops the whole free room: every live execution, open request and queued turn ends, so nothing restarts by itself.
1361
+
1362
+ Bound to the room alone, so it takes no revision; a running discussion is refused, since pausing it is how it stops.
1363
+ """
1364
+ require_owner(connection, room_id, token)
1365
+ begin_write(connection)
1366
+ if discussion.current_session(connection, room_id, active_only=True):
1367
+ raise FileExistsError("讨论进行中,请用暂停或结束讨论")
1368
+ connection.execute("DELETE FROM free_queue WHERE room_id=?", (room_id,))
1369
+ pause_queue(connection, room_id)
1370
+ stop_room_work(connection, room_id, "用户停止全部", "user_stop")
1371
+ return free_view(connection, room_id)
1372
+
1373
+
1374
+ def stop_room_work(connection, room_id, reason, cancel_reason):
1375
+ """Void every live execution and open request of a room, e.g. when it is archived."""
1376
+ for row in connection.execute("SELECT id FROM member_executions WHERE room_id=? AND status IN ('dispatched','started')",
1377
+ (room_id,)).fetchall():
1378
+ finish_execution(connection, row["id"], "cancelled", reason, "cancelled", cancel_reason)
1379
+ record_cancel_reasons(connection, "room_id=? AND status='pending'", (room_id,), cancel_reason)
1380
+ connection.execute("UPDATE member_requests SET status='cancelled' WHERE room_id=? AND status='pending'", (room_id,))
1381
+ # Stopping everything also stops a goal from waking the host again; the owner resumes it explicitly.
1382
+ connection.execute("UPDATE room_goals SET status='paused',paused_at=? WHERE room_id=? AND status='active'", (int(time.time()), room_id))
1383
+ settle_batches(connection, room_id)
1384
+
1385
+
1386
+ def inbox(connection, room_id, token):
1387
+ """List what the runner should deliver or reconcile; owner credential only."""
1388
+ require_owner(connection, room_id, token)
1389
+ begin_write(connection)
1390
+ enforce_session_limits(connection, room_id)
1391
+ expire_executions(connection, room_id)
1392
+ release_recommendations(connection, room_id)
1393
+ archived = connection.execute("SELECT archived FROM room_directory WHERE room_id=?", (room_id,)).fetchone()[0]
1394
+ members, observers = [], []
1395
+ shown = member_display_names(connection, room_id)
1396
+ # Manual members are never delivered to; the runner only reads them so smart scheduling can route to them too.
1397
+ for row in connection.execute(f"SELECT * FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER} ORDER BY id", (room_id,)):
1398
+ pending = [dict(item) for item in connection.execute("""SELECT r.id,r.message_id,r.purpose,
1399
+ sr.id AS session_request_id,sr.session_id,sr.round,sr.position,sr.read_until,sr.question AS source_question,
1400
+ t.cause_kind,t.cause_message_id AS source_message_id,t.cause_request_id,
1401
+ (SELECT action FROM group_routes WHERE message_id=r.message_id) AS group_action
1402
+ FROM member_requests r LEFT JOIN session_requests sr ON sr.member_request_id=r.id
1403
+ LEFT JOIN task_message_sources t ON t.session_request_id=sr.id
1404
+ WHERE r.member_id=? AND r.status='pending' ORDER BY r.id""", (row["id"],))]
1405
+ member_view = {key: row[key] for key in MEMBER_FIELDS}
1406
+ member_view["label"] = shown.get(row["name"], row["name"])
1407
+ member_view["review_capability"] = review_capability(connection, room_id, row["id"], token)
1408
+ member_view["held_until"] = held_until(connection, row["id"])
1409
+ # The runner compares this with its adapter's effect and reports a change.
1410
+ reported = connection.execute("SELECT rewind FROM member_capabilities WHERE member_id=?", (row["id"],)).fetchone()
1411
+ member_view["rewind"] = reported[0] if reported else ""
1412
+ pending = [{**request, **review_request_context(connection, request)} for request in pending]
1413
+ (members if row["access"] == "runner" else observers).append({**member_view, "pending": pending})
1414
+ executions = [{**public_execution(row), "member": row["name"], "retry": execution_retry(connection, row["id"])} for row in connection.execute(
1415
+ """SELECT member_executions.*,room_members.name FROM member_executions JOIN room_members ON room_members.id=member_id
1416
+ WHERE member_executions.room_id=? AND member_executions.status IN ('dispatched','started') ORDER BY member_executions.id""", (room_id,))]
1417
+ return {"room_id": room_id, "archived": bool(archived), "members": members, "observers": observers, "executions": executions}
1418
+
1419
+
1420
+ def host_route_request(connection, message_id):
1421
+ """The host's request for one unnamed owner message, if the message was routed."""
1422
+ return connection.execute("SELECT * FROM member_requests WHERE message_id=? AND purpose='route'", (message_id,)).fetchone()
1423
+
1424
+
1425
+ def recommend_member(connection, room_id, token, payload):
1426
+ """Smart scheduling suggests another member for an unnamed message; the host keeps it until the user adopts."""
1427
+ require_owner(connection, room_id, token)
1428
+ begin_write(connection)
1429
+ confidence = payload.get("confidence")
1430
+ if payload.get("provider") not in SCHEDULER_PROVIDERS or type(confidence) not in (int, float) or not 0 <= confidence <= 1:
1431
+ raise ValueError("须提供智能调度供应商 provider 与 0–1 的置信度 confidence")
1432
+ message_id = payload.get("message_id")
1433
+ route = host_route_request(connection, message_id) if type(message_id) is int else None
1434
+ if not route or route["room_id"] != room_id:
1435
+ raise LookupError("只能为交给主持人的未点名消息给出推荐")
1436
+ target = connection.execute(f"""SELECT * FROM room_members WHERE room_id=? AND id=? AND status='active'
1437
+ AND {REGISTERED_MEMBER} AND id<>?""", (room_id, payload.get("member_id"), route["member_id"])).fetchone()
1438
+ if not target:
1439
+ raise LookupError("推荐对象须为在场的其他成员")
1440
+ now = int(time.time())
1441
+ connection.execute("""INSERT OR IGNORE INTO member_recommendations (message_id,room_id,member_id,provider,confidence,created_at,expires_at)
1442
+ VALUES (?,?,?,?,?,?,?)""", (message_id, room_id, target["id"], payload["provider"], float(confidence), now, now + RECOMMEND_SECONDS))
1443
+ return dict(connection.execute("SELECT * FROM member_recommendations WHERE message_id=?", (message_id,)).fetchone())
1444
+
1445
+
1446
+ def release_recommendations(connection, room_id):
1447
+ """Ask each adopted-later member once the host's request for that message has ended in any way."""
1448
+ for row in connection.execute("""SELECT rec.* FROM member_recommendations rec JOIN member_requests r ON r.message_id=rec.message_id AND r.purpose='route'
1449
+ WHERE rec.room_id=? AND rec.status='waiting' AND r.status IN ('completed','cancelled','failed')""", (room_id,)).fetchall():
1450
+ connection.execute("UPDATE member_recommendations SET status='adopted' WHERE message_id=?", (row["message_id"],))
1451
+ if connection.execute("SELECT 1 FROM room_members WHERE id=? AND status='active'", (row["member_id"],)).fetchone():
1452
+ connection.execute("""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,purpose,created_by,created_at)
1453
+ VALUES (?,?,?,?,'ask','user',?)""", (room_id, row["member_id"], row["message_id"], row["message_id"], int(time.time())))
1454
+
1455
+
1456
+ def group_route_plan(connection, room_id, token, message_id):
1457
+ """Read the current group, eligible members, order and revision before arranging everyone's requests."""
1458
+ require_owner(connection, room_id, token)
1459
+ group = connection.execute("""SELECT id,message_id,status FROM member_requests
1460
+ WHERE room_id=? AND message_id=? AND purpose='group' ORDER BY id DESC LIMIT 1""", (room_id, message_id)).fetchone()
1461
+ previous = connection.execute("SELECT action,request_id,message_id FROM group_routes WHERE room_id=? AND message_id=?",
1462
+ (room_id, message_id)).fetchone()
1463
+ if not group and not previous:
1464
+ raise LookupError("这条消息没有全体待安排请求")
1465
+ state = connection.execute(GROUP_STATE_SQL, (room_id,)).fetchone()["state"]
1466
+ snapshot = json.loads(state)
1467
+ members = [member for member in snapshot["members"] if member["status"] == "active" and member["registered"]]
1468
+ rank = {member_id: index for index, member_id in enumerate(json.loads(snapshot["order"]))}
1469
+ members.sort(key=lambda member: (member["id"] not in rank, rank.get(member["id"], 0), member["role"] != "host", member["id"]))
1470
+ return {"message_id": message_id, "request": dict(group) if group else None, "members": members,
1471
+ "title": snapshot["title"], "goal": snapshot["goal"],
1472
+ "latest_id": snapshot["latest_id"], "revision": hashlib.sha256(state.encode("utf-8")).hexdigest(),
1473
+ "result": dict(previous) if previous else None}
1474
+
1475
+
1476
+ def route_group(connection, room_id, group, payload):
1477
+ """Arrange a pending group atomically; only the single-object handback to its host needs no roster revision."""
1478
+ action = payload["action"]
1479
+ if action not in ("all", "sequential", "host"):
1480
+ raise ValueError("请全体成员回应的消息只能选 action=all、sequential 或 host")
1481
+ if group["status"] != "pending":
1482
+ raise FileExistsError("这条消息已不再等待安排")
1483
+ if action != "host":
1484
+ if not payload.get("revision"):
1485
+ raise ValueError("安排全体前须先读取 message group-plan,并带上其 revision")
1486
+ state = connection.execute(GROUP_STATE_SQL, (room_id,)).fetchone()["state"]
1487
+ if payload["revision"] != hashlib.sha256(state.encode("utf-8")).hexdigest():
1488
+ raise FileExistsError("成员、顺序或消息已变化,请重新读取全体安排状态")
1489
+ if action == "host":
1490
+ connection.execute("UPDATE member_requests SET purpose='route' WHERE id=?", (group["id"],))
1491
+ elif action == "sequential":
1492
+ connection.execute("DELETE FROM member_requests WHERE id=?", (group["id"],))
1493
+ members = connection.execute(f"SELECT id FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER}", (room_id,))
1494
+ queue_in_turn(connection, room_id, group["message_id"], [row["id"] for row in members])
1495
+ else:
1496
+ connection.execute("UPDATE member_requests SET purpose='all' WHERE id=?", (group["id"],))
1497
+ connection.execute(f"""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,purpose,created_by,created_at)
1498
+ SELECT room_id,id,?,?,'all','user',? FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER} ORDER BY id""",
1499
+ (group["message_id"], group["chain_id"], int(time.time()), room_id))
1500
+ connection.execute("INSERT INTO group_routes (message_id,room_id,request_id,action) VALUES (?,?,?,?)",
1501
+ (group["message_id"], room_id, group["id"], action))
1502
+ return {"action": action, "request_id": group["id"], "message_id": group["message_id"]}
1503
+
1504
+
1505
+ def route_wide(connection, room_id, message_id, payload):
1506
+ """Smart scheduling answers an unnamed message with everyone (each member once, as if the user named them all, at once
1507
+ or one after another), the few members it names, or no one.
1508
+
1509
+ A message naming everyone (a group request) is answered by all at once or, with action sequential, one after another.
1510
+ Only a host request that has not started changes; a started host already handles the message its own way.
1511
+ """
1512
+ action = payload["action"]
1513
+ previous = connection.execute("SELECT action,request_id,message_id FROM group_routes WHERE room_id=? AND message_id=?",
1514
+ (room_id, message_id)).fetchone()
1515
+ if previous:
1516
+ if previous["action"] != action:
1517
+ raise FileExistsError("这条全体消息已经安排,不能改用其他动作")
1518
+ return dict(previous)
1519
+ group = connection.execute("""SELECT * FROM member_requests WHERE room_id=? AND message_id=? AND purpose='group'
1520
+ ORDER BY id DESC LIMIT 1""", (room_id, message_id)).fetchone()
1521
+ if group:
1522
+ return route_group(connection, room_id, group, payload)
1523
+ if action == "host":
1524
+ raise ValueError("只有请全体成员回应的消息可以交回主持人")
1525
+ if action not in ("all", "sequential", "none", "some"):
1526
+ raise ValueError("未点名消息只能选 action=all、sequential、some 或 none")
1527
+ route = host_route_request(connection, message_id)
1528
+ if not route or route["room_id"] != room_id:
1529
+ raise LookupError("只能处理交给主持人的未点名消息")
1530
+ if route["status"] != "pending":
1531
+ raise FileExistsError("主持人已开始处理这条消息,不再改派")
1532
+ if action == "sequential":
1533
+ # Same as a group answered one after another: the host's request gives way to the queue, which includes the host.
1534
+ connection.execute("DELETE FROM member_requests WHERE id=?", (route["id"],))
1535
+ members = connection.execute(f"SELECT id FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER}", (room_id,))
1536
+ queue_in_turn(connection, room_id, message_id, [row["id"] for row in members])
1537
+ connection.execute("INSERT INTO group_routes (message_id,room_id,request_id,action) VALUES (?,?,?,?)",
1538
+ (message_id, room_id, route["id"], action))
1539
+ elif action == "none":
1540
+ record_cancel_reasons(connection, "id=?", (route["id"],), "no_reply")
1541
+ connection.execute("UPDATE member_requests SET status='cancelled' WHERE id=?", (route["id"],))
1542
+ elif action == "some":
1543
+ route_some(connection, room_id, route, payload.get("members"))
1544
+ else:
1545
+ # The host keeps its row (one request per message and member) and now answers its own share, without dispatching.
1546
+ connection.execute("UPDATE member_requests SET purpose='all' WHERE id=?", (route["id"],))
1547
+ connection.execute(f"""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,purpose,created_by,created_at)
1548
+ SELECT room_id,id,?,?,'all','user',? FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER} ORDER BY id""",
1549
+ (message_id, message_id, int(time.time()), room_id))
1550
+ requests = connection.execute("SELECT * FROM member_requests WHERE message_id=? ORDER BY id", (message_id,)).fetchall()
1551
+ return {"action": action, "requests": [dict(row) for row in requests]}
1552
+
1553
+
1554
+ def route_some(connection, room_id, route, names):
1555
+ """Smart scheduling answers an unnamed message with the few members it names, each once, as if the user named them."""
1556
+ if not isinstance(names, list) or not all(isinstance(name, str) for name in names) or len(set(names)) < 2:
1557
+ raise ValueError("action=some 须带 members:至少两位不同的成员名")
1558
+ found = {row["name"]: row["id"] for row in connection.execute(
1559
+ f"SELECT id,name FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER}", (room_id,))}
1560
+ missing = [name for name in dict.fromkeys(names) if name not in found]
1561
+ if missing:
1562
+ raise FileExistsError(f"这些成员不在场或未注册:{'、'.join(missing)}")
1563
+ ids = [found[name] for name in dict.fromkeys(names)]
1564
+ if route["member_id"] in ids:
1565
+ # The host is one of them: it keeps its row and answers its own share, without dispatching.
1566
+ connection.execute("UPDATE member_requests SET purpose='all' WHERE id=?", (route["id"],))
1567
+ else:
1568
+ record_cancel_reasons(connection, "id=?", (route["id"],), "rerouted")
1569
+ connection.execute("UPDATE member_requests SET status='cancelled' WHERE id=?", (route["id"],))
1570
+ for member_id in ids:
1571
+ if member_id != route["member_id"]:
1572
+ connection.execute("""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,purpose,created_by,created_at)
1573
+ VALUES (?,?,?,?,'all','user',?)""", (room_id, member_id, route["message_id"], route["message_id"], int(time.time())))
1574
+
1575
+
1576
+ def adopt_recommendation(connection, room_id, token, message_id, payload):
1577
+ """The user adopts a recommendation before it expires: interrupt the host now, or ask the member after the host.
1578
+
1579
+ Smart scheduling also sends action all or none here to have everyone or no one answer an unnamed message.
1580
+ """
1581
+ require_owner(connection, room_id, token)
1582
+ begin_write(connection)
1583
+ if payload.get("action") in ("all", "none", "sequential", "host", "some"):
1584
+ return route_wide(connection, room_id, message_id, payload)
1585
+ if payload.get("action") != "adopt" or payload.get("mode") not in ("interrupt", "after"):
1586
+ raise ValueError("采纳须为 action=adopt,mode 为 interrupt 或 after;全体、依次、指定几位或无需回应为 action=all、sequential、some 或 none")
1587
+ row = connection.execute("SELECT * FROM member_recommendations WHERE room_id=? AND message_id=?", (room_id, message_id)).fetchone()
1588
+ if not row:
1589
+ raise LookupError("这条消息没有智能调度推荐")
1590
+ if row["status"] != "open":
1591
+ return {"recommendation": dict(row), "duplicate": True}
1592
+ if int(time.time()) > row["expires_at"] + RECOMMEND_GRACE_SECONDS:
1593
+ raise FileExistsError("推荐已过期,可直接 @ 该成员")
1594
+ if not connection.execute("SELECT 1 FROM room_members WHERE id=? AND status='active'", (row["member_id"],)).fetchone():
1595
+ raise FileExistsError("推荐的成员已离开")
1596
+ route = host_route_request(connection, message_id)
1597
+ mode = payload["mode"]
1598
+ if mode == "interrupt" and route["status"] == "delivering":
1599
+ # The host's execution may carry other messages too; stopping it voids that whole execution.
1600
+ finish_execution(connection, route["execution_id"], "cancelled", "智能调度改派,改由其他成员回答", "cancelled", "rerouted")
1601
+ elif mode == "interrupt" and route["status"] == "pending":
1602
+ record_cancel_reasons(connection, "id=?", (route["id"],), "rerouted")
1603
+ connection.execute("UPDATE member_requests SET status='cancelled' WHERE id=?", (route["id"],))
1604
+ connection.execute("UPDATE member_recommendations SET status='waiting',mode=? WHERE message_id=?", (mode, message_id))
1605
+ release_recommendations(connection, room_id)
1606
+ settle_batches(connection, room_id)
1607
+ return {"recommendation": dict(connection.execute("SELECT * FROM member_recommendations WHERE message_id=?", (message_id,)).fetchone()),
1608
+ "duplicate": False}
1609
+
1610
+
1611
+ def recent_recommendations(connection, room_id, now):
1612
+ """Recommendations shown beside their messages, with seconds left to adopt an open one."""
1613
+ return [{**dict(row), "expires_in": max(0, row["expires_at"] - now)} for row in connection.execute(
1614
+ """SELECT rec.*,m.name AS member FROM member_recommendations rec JOIN room_members m ON m.id=rec.member_id
1615
+ WHERE rec.room_id=? ORDER BY rec.message_id DESC LIMIT ?""", (room_id, RECENT_REQUESTS))]
1616
+
1617
+
1618
+ def reserve_execution(connection, room_id, token, payload):
1619
+ """Bundle a member's open requests into one execution; a retried client_id returns the same one."""
1620
+ require_owner(connection, room_id, token)
1621
+ begin_write(connection)
1622
+ if enforce_session_limits(connection, room_id):
1623
+ connection.commit()
1624
+ client_id = str(payload.get("client_id", ""))
1625
+ if not 8 <= len(client_id) <= 100:
1626
+ raise ValueError("client_id 须为 8–100 字符的本地投递编号")
1627
+ previous = connection.execute("SELECT * FROM member_executions WHERE room_id=? AND client_id=?", (room_id, client_id)).fetchone()
1628
+ if previous:
1629
+ return execution_detail(connection, previous)
1630
+ expire_executions(connection, room_id)
1631
+ if connection.execute("SELECT archived FROM room_directory WHERE room_id=?", (room_id,)).fetchone()[0]:
1632
+ raise FileExistsError("房间已存档,不再投递")
1633
+ member = connection.execute("SELECT * FROM room_members WHERE room_id=? AND id=? AND status='active' AND access='runner'",
1634
+ (room_id, payload.get("member_id"))).fetchone()
1635
+ if not member:
1636
+ raise LookupError("成员不存在、已离开或为手动接入")
1637
+ if connection.execute("SELECT 1 FROM member_executions WHERE member_id=? AND status IN ('dispatched','started')",
1638
+ (member["id"],)).fetchone():
1639
+ raise FileExistsError("该成员已有进行中的执行;同一会话不并发续接")
1640
+ if held_until(connection, member["id"]):
1641
+ raise FileExistsError("该成员上一轮已作废但本机无法停止,可能仍在处理;等它结束或等待时限到后再投递")
1642
+ # A group request waits for the runner to choose how everyone answers; it is never delivered itself.
1643
+ pending = connection.execute("""SELECT id,purpose,EXISTS(SELECT 1 FROM review_plans WHERE review_batch_id=member_requests.message_id AND reviewer_id=member_requests.member_id) AS review FROM member_requests
1644
+ WHERE member_id=? AND status='pending' AND purpose<>'group' ORDER BY id""", (member["id"],)).fetchall()
1645
+ session_purpose = next((row["purpose"] for row in pending if row["purpose"] == "discuss"), None)
1646
+ if not pending:
1647
+ raise FileExistsError("该成员没有待投递的请求")
1648
+ if session_purpose:
1649
+ request_filter = "purpose=?"
1650
+ request_values = (session_purpose,)
1651
+ elif pending[0]["review"]:
1652
+ request_filter, request_values = "id=?", (pending[0]["id"],)
1653
+ else:
1654
+ request_filter = "purpose<>'group' AND NOT EXISTS(SELECT 1 FROM review_plans WHERE review_batch_id=member_requests.message_id AND reviewer_id=member_requests.member_id)"
1655
+ request_values = ()
1656
+ now = int(time.time())
1657
+ cursor = connection.execute("""INSERT INTO member_executions (room_id,member_id,client_id,lease_until,created_at,cwd)
1658
+ VALUES (?,?,?,?,?,?)""", (room_id, member["id"], client_id, now + LEASE_SECONDS, now, str(payload.get("cwd") or "")[:MAX_PATH_LENGTH]))
1659
+ connection.execute(f"UPDATE member_requests SET status='delivering',execution_id=? WHERE member_id=? AND status='pending' AND {request_filter}",
1660
+ (cursor.lastrowid, member["id"], *request_values))
1661
+ return execution_detail(connection, connection.execute("SELECT * FROM member_executions WHERE id=?", (cursor.lastrowid,)).fetchone())
1662
+
1663
+
1664
+ def execution_detail(connection, row):
1665
+ """Describe one execution with the requests it carries and the member's read cursor."""
1666
+ member = connection.execute("SELECT * FROM room_members WHERE id=?", (row["member_id"],)).fetchone()
1667
+ # remote_entry: the request, or the owner message its chain started from, came through the remote entry.
1668
+ requests = [dict(item) for item in connection.execute("""SELECT r.id,r.message_id,r.status,r.purpose,
1669
+ sr.id AS session_request_id,sr.session_id,sr.round,sr.position,sr.read_until,sr.question AS source_question,
1670
+ t.cause_kind,t.cause_message_id AS source_message_id,t.cause_request_id,
1671
+ EXISTS(SELECT 1 FROM remote_entry_messages e WHERE e.message_id IN (r.message_id, r.chain_id)) AS remote_entry
1672
+ FROM member_requests r LEFT JOIN session_requests sr ON sr.member_request_id=r.id
1673
+ LEFT JOIN task_message_sources t ON t.session_request_id=sr.id
1674
+ WHERE r.execution_id=? ORDER BY r.id""", (row["id"],))]
1675
+ requests = [{**request, **review_request_context(connection, request)} for request in requests]
1676
+ return {**public_execution(row), "member": member["name"], "read_until": member["read_until"], "requests": requests,
1677
+ "rewound_executions": member_rewinds(connection, member["id"]), "retry": execution_retry(connection, row["id"])}
1678
+
1679
+
1680
+ def view_execution(connection, room_id, execution_id, token):
1681
+ """Let the owner or the executing member read one execution."""
1682
+ row = connection.execute("SELECT * FROM member_executions WHERE room_id=? AND id=?", (room_id, execution_id)).fetchone()
1683
+ if not row:
1684
+ raise LookupError("执行不存在")
1685
+ try:
1686
+ require_owner(connection, room_id, token)
1687
+ except PermissionError:
1688
+ member = require_member(connection, room_id, token)
1689
+ if member["id"] != row["member_id"]:
1690
+ raise
1691
+ return execution_detail(connection, row)
1692
+
1693
+
1694
+ def changed_files(payload):
1695
+ """Validate the runner's observed file list; paths are relative to the execution's cwd."""
1696
+ files = payload.get("changes")
1697
+ if (not isinstance(files, list) or len(files) > MAX_CHANGED_FILES
1698
+ or any(not isinstance(item, str) or not item or len(item) > MAX_PATH_LENGTH for item in files)):
1699
+ raise ValueError(f"changes 须为最多 {MAX_CHANGED_FILES} 个相对路径")
1700
+ return sorted(set(files))
1701
+
1702
+
1703
+ def denied_actions(payload):
1704
+ """Validate the operations a member's software refused: the tool, what it tried, and the rule that would allow it."""
1705
+ denials = payload.get("denials")
1706
+ def valid(item):
1707
+ return (isinstance(item, dict) and isinstance(item.get("tool"), str) and 0 < len(item["tool"]) <= 40
1708
+ and all(isinstance(item.get(key, ""), str) and len(item.get(key, "")) <= MAX_DENIAL_FIELD for key in ("detail", "rule")))
1709
+ if not isinstance(denials, list) or not 0 < len(denials) <= MAX_DENIALS or not all(map(valid, denials)):
1710
+ raise ValueError(f"denials 须为 1–{MAX_DENIALS} 条 {{tool, detail, rule}}")
1711
+ return [{key: item.get(key, "") for key in ("tool", "detail", "rule")} for item in denials]
1712
+
1713
+
1714
+ def renewed_activity(payload, row, now):
1715
+ """Validate the runner's reported phase; its output time is kept within the execution, since the runner's clock may differ."""
1716
+ activity, activity_at = payload.get("activity"), payload.get("activity_at")
1717
+ if activity not in ACTIVITY_PHASES:
1718
+ raise ValueError(f"activity 须为 {'、'.join(item for item in ACTIVITY_PHASES if item)} 或空")
1719
+ if type(activity_at) is not int:
1720
+ raise ValueError("activity_at 须为秒级时间戳")
1721
+ return activity, min(max(activity_at, row["created_at"]), now)
1722
+
1723
+
1724
+ def renewed_notes(payload):
1725
+ """Validate progress notes carried by a renewal; a repeated sequence number is the same note resent."""
1726
+ notes = payload.get("notes", [])
1727
+ if (not isinstance(notes, list) or len(notes) > MAX_NOTES_PER_RENEW or any(
1728
+ not isinstance(note, dict) or type(note.get("seq")) is not int or not 1 <= note["seq"] <= MAX_EXECUTION_NOTES
1729
+ or not isinstance(note.get("text"), str) or not 0 < len(note["text"]) <= MAX_NOTE_LENGTH for note in notes)):
1730
+ raise ValueError(f"notes 须为最多 {MAX_NOTES_PER_RENEW} 条 {{seq: 1–{MAX_EXECUTION_NOTES}, text: 1–{MAX_NOTE_LENGTH} 字}}")
1731
+ return notes
1732
+
1733
+
1734
+ def late_answer(connection, room_id, execution_id, token):
1735
+ """Read one voided execution's late text after checking the room owner's credential."""
1736
+ require_owner(connection, room_id, token)
1737
+ row = connection.execute("SELECT text,created_at FROM late_answers WHERE room_id=? AND execution_id=?",
1738
+ (room_id, execution_id)).fetchone()
1739
+ if not row:
1740
+ raise LookupError("本次执行没有迟到回答")
1741
+ return dict(row)
1742
+
1743
+
1744
+ def record_stop_result(connection, room_id, execution_id, result):
1745
+ """Record a stop idempotently; an unconfirmed stop may later gain a confirmed outcome."""
1746
+ if result not in STOP_RESULTS:
1747
+ raise ValueError(f"result 须为 {'、'.join(STOP_RESULTS)}")
1748
+ saved = connection.execute("""INSERT INTO execution_capabilities (execution_id,room_id,stop_result) VALUES (?,?,?)
1749
+ ON CONFLICT(execution_id) DO UPDATE SET stop_result=excluded.stop_result
1750
+ WHERE stop_result IN ('','unconfirmed') AND stop_result<>excluded.stop_result""", (execution_id, room_id, result))
1751
+ if saved.rowcount:
1752
+ return False
1753
+ previous = connection.execute("SELECT stop_result FROM execution_capabilities WHERE execution_id=?", (execution_id,)).fetchone()[0]
1754
+ if previous != result:
1755
+ raise FileExistsError("本次执行已记录不同的停止结果")
1756
+ return True
1757
+
1758
+
1759
+ def execution_action(connection, room_id, execution_id, token, payload):
1760
+ """Runner-side lifecycle: renew, fail, release undelivered work, report how the software was reached, whether a voided one stopped, or observed file changes."""
1761
+ begin_write(connection)
1762
+ action = payload.get("action")
1763
+ duplicate = False
1764
+ member = None
1765
+ if action == "fail":
1766
+ try:
1767
+ require_owner(connection, room_id, token)
1768
+ except PermissionError:
1769
+ member = require_member(connection, room_id, token)
1770
+ else:
1771
+ require_owner(connection, room_id, token)
1772
+ if enforce_session_limits(connection, room_id):
1773
+ connection.commit()
1774
+ row = connection.execute("SELECT * FROM member_executions WHERE room_id=? AND id=?", (room_id, execution_id)).fetchone()
1775
+ if not row:
1776
+ raise LookupError("执行不存在")
1777
+ if member and member["id"] != row["member_id"]:
1778
+ raise PermissionError("只能报告自己执行的失败")
1779
+ now = int(time.time())
1780
+ if action == "report":
1781
+ # Observed after the process ends, so the execution may already be complete; recorded once.
1782
+ if row["changes"]:
1783
+ raise FileExistsError("本次执行的改动已记录")
1784
+ connection.execute("UPDATE member_executions SET changes=? WHERE id=?", (json.dumps(changed_files(payload), ensure_ascii=False), execution_id))
1785
+ elif action == "denials":
1786
+ # Like file changes, read from the software's output once its process ends; recorded once.
1787
+ saved = connection.execute("INSERT OR IGNORE INTO execution_denials (execution_id,room_id,denials) VALUES (?,?,?)",
1788
+ (execution_id, room_id, json.dumps(denied_actions(payload), ensure_ascii=False)))
1789
+ if not saved.rowcount:
1790
+ raise FileExistsError("本次执行的拒绝记录已记录")
1791
+ elif action == "segments":
1792
+ # The last segments and notes arrive after the member's answer ended the execution; such notes are dated at its end.
1793
+ finished = 0 if row["status"] in ACTIVE else (row["finished_at"] or 1)
1794
+ notes = renewed_notes(payload)
1795
+ save_segments(connection, "free", room_id, execution_id, row["created_at"], finished, payload, optional=bool(notes))
1796
+ connection.executemany("INSERT OR IGNORE INTO execution_notes (execution_id,seq,room_id,text,created_at) VALUES (?,?,?,?,?)",
1797
+ [(execution_id, note["seq"], room_id, note["text"], min(now, finished) if finished else now) for note in notes])
1798
+ elif action == "stopped":
1799
+ # After a voided execution: whether the runner ended its software, or could not because it works in a window or App.
1800
+ if row["status"] != "cancelled":
1801
+ raise FileExistsError("只有已作废的执行才报告停止结果")
1802
+ duplicate = record_stop_result(connection, room_id, execution_id, payload.get("result"))
1803
+ elif action == "delivery":
1804
+ if row["status"] not in (*ACTIVE, "cancelled"):
1805
+ raise FileExistsError("执行已结束")
1806
+ # What this delivery can do, as the runner's adapter reported; a retried delivery may differ, so the latest counts.
1807
+ if type(payload.get("stoppable")) is not bool or type(payload.get("progress")) is not bool:
1808
+ raise ValueError("delivery 须带布尔值 stoppable 与 progress")
1809
+ connection.execute("""INSERT INTO execution_capabilities (execution_id,room_id,stoppable,progress) VALUES (?,?,?,?)
1810
+ ON CONFLICT(execution_id) DO UPDATE SET stoppable=excluded.stoppable,progress=excluded.progress""",
1811
+ (execution_id, room_id, int(payload["stoppable"]), int(payload["progress"])))
1812
+ elif action == "retry":
1813
+ retry_failed(connection, room_id, row)
1814
+ elif row["status"] not in ACTIVE:
1815
+ raise FileExistsError("执行已结束")
1816
+ elif action == "renew":
1817
+ # No time limit on one execution: it runs while the runner keeps renewing.
1818
+ if row["lease_until"] <= now:
1819
+ raise FileExistsError("执行租约已到期,不能续租")
1820
+ # Older runners renew without activity; the last reported one is kept.
1821
+ activity, activity_at = renewed_activity(payload, row, now) if "activity" in payload else (row["activity"], row["activity_at"])
1822
+ notes = renewed_notes(payload)
1823
+ connection.execute("UPDATE member_executions SET lease_until=?,activity=?,activity_at=? WHERE id=?",
1824
+ (now + LEASE_SECONDS, activity, activity_at, execution_id))
1825
+ connection.executemany("INSERT OR IGNORE INTO execution_notes (execution_id,seq,room_id,text,created_at) VALUES (?,?,?,?,?)",
1826
+ [(execution_id, note["seq"], room_id, note["text"], now) for note in notes])
1827
+ elif action == "retry_wait":
1828
+ # A manual member's session refused the delivery: the runner keeps the execution and delivers again at retry_at.
1829
+ attempt, retry_at, reason = payload.get("attempt"), payload.get("retry_at"), payload.get("reason")
1830
+ if (type(attempt) is not int or not 1 <= attempt <= MAX_RETRIES or type(retry_at) is not int or retry_at <= 0
1831
+ or not isinstance(reason, str) or not 0 < len(reason) <= MAX_RETRY_REASON):
1832
+ raise ValueError(f"retry_wait 须带 attempt(1–{MAX_RETRIES})、秒级 retry_at 与 reason(最多 {MAX_RETRY_REASON} 字)")
1833
+ connection.execute("""INSERT INTO execution_retries (execution_id,room_id,attempt,retry_at,reason,stopped) VALUES (?,?,?,?,?,0)
1834
+ ON CONFLICT(execution_id) DO UPDATE SET attempt=excluded.attempt,retry_at=excluded.retry_at,reason=excluded.reason,stopped=0""",
1835
+ (execution_id, room_id, attempt, retry_at, reason))
1836
+ elif action == "retry_start":
1837
+ connection.execute("UPDATE execution_retries SET retry_at=0 WHERE execution_id=?", (execution_id,))
1838
+ elif action in ("retry_now", "retry_stop"):
1839
+ # The owner's answer to a waiting retry: deliver now, or stop so the runner fails the execution.
1840
+ change = "retry_at=?" if action == "retry_now" else "stopped=1"
1841
+ updated = connection.execute(f"UPDATE execution_retries SET {change} WHERE execution_id=? AND retry_at>0 AND stopped=0",
1842
+ ((now, execution_id) if action == "retry_now" else (execution_id,)))
1843
+ if not updated.rowcount:
1844
+ raise FileExistsError("本次执行没有在等待自动重试")
1845
+ elif action == "reject_review":
1846
+ request = connection.execute("""SELECT r.id FROM member_requests r JOIN review_plans p ON p.review_batch_id=r.message_id AND p.reviewer_id=r.member_id
1847
+ WHERE r.execution_id=? AND r.status='delivering' AND r.purpose='ask'""", (execution_id,)).fetchall()
1848
+ count = connection.execute("SELECT COUNT(*) FROM member_requests WHERE execution_id=? AND status='delivering'", (execution_id,)).fetchone()[0]
1849
+ if len(request) != 1 or count != 1 or not isinstance(payload.get("reason"), str) or not payload["reason"].strip():
1850
+ raise ValueError("reject_review 只用于单个评审执行,须说明拒绝原因")
1851
+ finish_execution(connection, execution_id, "cancelled", "评审未投递:" + payload["reason"][:400], "cancelled")
1852
+ settle_batches(connection, room_id)
1853
+ elif action == "fail":
1854
+ finish_execution(connection, execution_id, "failed", str(payload.get("reason") or "执行失败"), "failed")
1855
+ settle_batches(connection, room_id)
1856
+ elif action == "release":
1857
+ # Only for proven non-delivery: the requests return to the queue unchanged.
1858
+ finish_execution(connection, execution_id, "cancelled", str(payload.get("reason") or "未送达"), "pending")
1859
+ else:
1860
+ raise ValueError("action 须为 renew、fail、release、report、segments、retry_wait、retry_start、retry_now、retry_stop 或 retry")
1861
+ detail = execution_detail(connection, connection.execute("SELECT * FROM member_executions WHERE id=?", (execution_id,)).fetchone())
1862
+ return {**detail, "duplicate": duplicate} if action == "stopped" else detail
1863
+
1864
+
1865
+ def retry_failed(connection, room_id, execution):
1866
+ """Owner retry of a failed execution: its requests go back to the same member's queue and the runner delivers them anew.
1867
+
1868
+ The failed execution stays as history but no longer carries the requests, so a second retry is refused. A host batch
1869
+ that failed as a whole reopens and its chain resumes, so the host still gets the answer back.
1870
+ """
1871
+ if execution["status"] != "failed":
1872
+ raise FileExistsError("只有失败的执行可以重试")
1873
+ requests = connection.execute("""SELECT id,batch_id,chain_id FROM member_requests r WHERE execution_id=? AND status='failed'
1874
+ AND NOT EXISTS(SELECT 1 FROM session_requests s WHERE s.member_request_id=r.id)""", (execution["id"],)).fetchall()
1875
+ if not requests:
1876
+ raise FileExistsError("这次执行没有可重试的请求:已重试过,或是讨论发言(由主持人重新安排)")
1877
+ if not connection.execute("SELECT 1 FROM room_members WHERE id=? AND status='active'", (execution["member_id"],)).fetchone():
1878
+ raise FileExistsError("该成员已离开或暂离,无法重试")
1879
+ connection.execute(f"UPDATE member_requests SET status='pending',execution_id=NULL WHERE id IN ({','.join('?' * len(requests))})",
1880
+ [request["id"] for request in requests])
1881
+ for request in requests:
1882
+ batch_id, chain_id = request["batch_id"], request["chain_id"]
1883
+ if batch_id and not connection.execute(
1884
+ "SELECT 1 FROM member_requests WHERE room_id=? AND batch_id=? AND status='completed'", (room_id, batch_id)).fetchone():
1885
+ connection.execute("UPDATE free_batches SET settled=0 WHERE room_id=? AND batch_id=?", (room_id, batch_id))
1886
+ connection.execute("DELETE FROM free_chains WHERE room_id=? AND chain_id=? AND reason='all_failed'", (room_id, chain_id))
1887
+
1888
+
1889
+ def owned_member(connection, room_id, member_id, token):
1890
+ """Read one member after proving the owner's credential."""
1891
+ require_owner(connection, room_id, token)
1892
+ begin_write(connection)
1893
+ member = connection.execute("SELECT * FROM room_members WHERE room_id=? AND id=?", (room_id, member_id)).fetchone()
1894
+ if not member or member["status"] == "removed":
1895
+ raise LookupError("成员不存在")
1896
+ return member
1897
+
1898
+
1899
+ def interrupt_member(connection, room_id, member_id, token):
1900
+ """Owner stop: void the live execution; requests that arrived meanwhile stay queued for the member's next turn."""
1901
+ owned_member(connection, room_id, member_id, token)
1902
+ pause_queue(connection, room_id)
1903
+ stop_member_work(connection, member_id, "用户打断", "user_stop", keep_pending=True)
1904
+ settle_batches(connection, room_id)
1905
+ return free_view(connection, room_id)
1906
+
1907
+
1908
+ def withdrawals(connection, room_id):
1909
+ """The room's taken-back messages by id, so the page keeps them in place while AI readers skip them."""
1910
+ return [dict(row) for row in connection.execute("""SELECT w.message_id,w.kind FROM message_withdrawal_rooms r
1911
+ JOIN message_withdrawals w ON w.message_id=r.message_id WHERE r.room_id=? ORDER BY r.message_id""", (room_id,))]
1912
+
1913
+
1914
+ def owner_message(connection, room_id, message_id):
1915
+ """One visible message of the user in a free room without an open discussion, which a retract or rewind may take back."""
1916
+ row = connection.execute("SELECT * FROM messages WHERE room_id=? AND id=?", (room_id, message_id)).fetchone()
1917
+ if not row:
1918
+ raise LookupError("消息不存在")
1919
+ if row["author"] != "用户":
1920
+ raise ValueError("只能撤回或编辑用户自己的消息")
1921
+ if connection.execute("SELECT 1 FROM room_directory WHERE room_id=? AND archived=1", (room_id,)).fetchone():
1922
+ raise ValueError("房间已存档;恢复后才能撤回或编辑")
1923
+ if discussion.blocks_message_change(connection, room_id, message_id):
1924
+ raise FileExistsError("讨论进行中,或这条消息之后开启过讨论,不能撤回或编辑")
1925
+ return row
1926
+
1927
+
1928
+ def retract_message(connection, room_id, message_id, token):
1929
+ """Owner takes back a message no member began to handle: its queued requests end and AI readers no longer see it.
1930
+
1931
+ Bound to one known message, so it takes no revision; a message any member started, answered or failed is refused.
1932
+ A member working on an earlier message may still have read it, which the room cannot know.
1933
+
1934
+ Returns:
1935
+ `{"message_id", "text", "duplicate"}`; the text lets the page put it back in the input for editing.
1936
+ """
1937
+ require_owner(connection, room_id, token)
1938
+ begin_write(connection)
1939
+ message = owner_message(connection, room_id, message_id)
1940
+ if connection.execute("SELECT 1 FROM message_withdrawals WHERE message_id=?", (message_id,)).fetchone():
1941
+ return {"message_id": message_id, "text": message["text"], "duplicate": True}
1942
+ if connection.execute("SELECT 1 FROM member_requests WHERE message_id=? AND status IN ('delivering','completed','failed')", (message_id,)).fetchone():
1943
+ raise FileExistsError("已有成员开始处理这条消息,不能撤回;可以编辑这条消息回退到它之前")
1944
+ connection.execute("UPDATE member_requests SET status='cancelled' WHERE message_id=? AND status='pending'", (message_id,))
1945
+ connection.execute("DELETE FROM free_queue WHERE message_id=?", (message_id,))
1946
+ connection.execute("INSERT INTO message_withdrawals (message_id,kind,created_at) VALUES (?,'retracted',?)", (message_id, int(time.time())))
1947
+ settle_batches(connection, room_id)
1948
+ return {"message_id": message_id, "text": message["text"], "duplicate": False}
1949
+
1950
+
1951
+ # Executions a rewind takes back: any touching a message from the rewind point on, and any still running.
1952
+ REWOUND_EXECUTIONS = """SELECT e.id,e.member_id FROM member_executions e WHERE e.room_id=? AND (e.status IN ('dispatched','started')
1953
+ OR EXISTS(SELECT 1 FROM member_requests r WHERE r.execution_id=e.id AND (r.message_id>=? OR r.answer_id>=?))) ORDER BY e.id"""
1954
+
1955
+
1956
+ def rewind_plan(connection, room_id, message_id):
1957
+ """What rewinding to just before a message takes back, and a revision of the state it was read from.
1958
+
1959
+ Each member reports what happens to its session: `cut` (the runner cuts it before the first taken-back execution),
1960
+ `fresh` (its context is rebuilt each time), `unchanged` (no taken-back execution) or `kept` (manual members,
1961
+ and software the runner reported it cannot cut, keep what they saw). The runner reports each member's effect from its
1962
+ adapter (`member_capabilities`); a member without a report is kept.
1963
+ """
1964
+ owner_message(connection, room_id, message_id)
1965
+ if connection.execute("SELECT 1 FROM message_withdrawals WHERE message_id=?", (message_id,)).fetchone():
1966
+ raise FileExistsError("这条消息已被撤回或回退")
1967
+ executions = {}
1968
+ for row in connection.execute(REWOUND_EXECUTIONS, (room_id, message_id, message_id)):
1969
+ executions.setdefault(row["member_id"], []).append(row["id"])
1970
+ members = []
1971
+ for row in connection.execute("""SELECT m.id,m.name,m.access,(SELECT rewind FROM member_capabilities c WHERE c.member_id=m.id) AS rewind
1972
+ FROM room_members m WHERE m.room_id=? AND m.status<>'removed' ORDER BY m.id""", (room_id,)):
1973
+ taken = executions.get(row["id"], [])
1974
+ effect = row["rewind"] if row["access"] == "runner" else "kept"
1975
+ session = "kept" if effect in (None, "kept") else "unchanged" if not taken else effect
1976
+ members.append({"id": row["id"], "name": row["name"], "executions": taken, "session": session})
1977
+ count = connection.execute("""SELECT COUNT(*) FROM messages m WHERE m.room_id=? AND m.id>=?
1978
+ AND NOT EXISTS(SELECT 1 FROM message_withdrawals w WHERE w.message_id=m.id)""", (room_id, message_id)).fetchone()[0]
1979
+ latest = connection.execute("SELECT COALESCE(MAX(id),0) FROM messages WHERE room_id=?", (room_id,)).fetchone()[0]
1980
+ state = json.dumps([message_id, latest, count, members])
1981
+ return {"message_id": message_id, "messages": count, "members": members,
1982
+ "revision": hashlib.sha256(state.encode("utf-8")).hexdigest()[:12]}
1983
+
1984
+
1985
+ def rewind_room(connection, room_id, token, payload):
1986
+ """Owner returns the room to just before one of its messages, as editing that message does.
1987
+
1988
+ Read the plan first (`GET rewind?message_id=`) and send its revision: a missing revision is refused and a changed
1989
+ one conflicts. The message and everything after it stay on the page but leave AI reads; running work stops, open
1990
+ requests on them end, members read again from before the message, and each runner member's session is cut before
1991
+ its first taken-back execution when it next works. Files the members changed are not restored.
1992
+ """
1993
+ require_owner(connection, room_id, token)
1994
+ begin_write(connection)
1995
+ message_id = payload.get("message_id")
1996
+ if type(message_id) is not int:
1997
+ raise ValueError("须提供要回退到其之前的消息编号 message_id")
1998
+ if payload.get("revision") is None:
1999
+ raise ValueError("回退前须先读取回退计划(GET rewind?message_id=),并带上其 revision")
2000
+ plan = rewind_plan(connection, room_id, message_id)
2001
+ if payload["revision"] != plan["revision"]:
2002
+ raise FileExistsError("房间已有新的消息或执行,请重新读取回退计划后再确认")
2003
+ now = int(time.time())
2004
+ for row in connection.execute("SELECT id FROM member_executions WHERE room_id=? AND status IN ('dispatched','started')", (room_id,)).fetchall():
2005
+ finish_execution(connection, row["id"], "cancelled", "用户回退了对话", "cancelled", "user_stop")
2006
+ record_cancel_reasons(connection, "room_id=? AND message_id>=? AND status='pending'", (room_id, message_id), "user_stop")
2007
+ connection.execute("UPDATE member_requests SET status='cancelled' WHERE room_id=? AND message_id>=? AND status='pending'", (room_id, message_id))
2008
+ connection.execute("DELETE FROM free_queue WHERE room_id=? AND message_id>=?", (room_id, message_id))
2009
+ # Batches of taken-back dispatches never hand back, and a dispatch the round limit held from them is never sent.
2010
+ connection.execute("UPDATE free_batches SET settled=1 WHERE room_id=? AND batch_id>=?", (room_id, message_id))
2011
+ 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))
2012
+ connection.execute("""INSERT INTO message_withdrawals (message_id,kind,created_at) SELECT id,'rewound',? FROM messages m
2013
+ 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))
2014
+ connection.execute("UPDATE room_members SET read_until=MIN(read_until,?) WHERE room_id=?", (message_id - 1, room_id))
2015
+ cuts = {str(member["id"]): member["executions"] for member in plan["members"] if member["session"] == "cut"}
2016
+ cursor = connection.execute("INSERT INTO room_rewinds (room_id,message_id,cuts,created_at) VALUES (?,?,?,?)",
2017
+ (room_id, message_id, json.dumps(cuts), now))
2018
+ settle_batches(connection, room_id)
2019
+ return {"rewind_id": cursor.lastrowid, **plan}
2020
+
2021
+
2022
+ # The runner checks this many of a member's latest rewinds before each execution.
2023
+ RECENT_REWINDS = 50
2024
+
2025
+
2026
+ def member_rewinds(connection, member_id):
2027
+ """Executions taken back from one member by recent rewinds, oldest first; the runner cuts before the earliest it finds."""
2028
+ taken = set()
2029
+ for row in connection.execute("SELECT cuts FROM room_rewinds WHERE room_id=(SELECT room_id FROM room_members WHERE id=?) ORDER BY id DESC LIMIT ?",
2030
+ (member_id, RECENT_REWINDS)):
2031
+ taken.update(json.loads(row["cuts"]).get(str(member_id), []))
2032
+ return sorted(taken)
2033
+
2034
+
2035
+ def add_member(connection, room_id, token, payload, now):
2036
+ """Owner adds a member while the room keeps running; other members are not interrupted."""
2037
+ require_owner(connection, room_id, token)
2038
+ begin_write(connection)
2039
+ member = member_value(payload)
2040
+ existing = connection.execute("SELECT * FROM room_members WHERE room_id=? AND name=? AND status<>'removed'", (room_id, member["name"])).fetchone()
2041
+ if existing and existing["status"] == "active" and existing["token_hash"] == token_hash(member["token"]) and all(
2042
+ existing[key] == member[key] for key in ("adapter", "access", "session_mode")):
2043
+ return free_view(connection, room_id)
2044
+ if existing:
2045
+ raise FileExistsError(f"{member['name']} 已是本房间成员;离开的成员请用请回")
2046
+ require_fresh_member_token(connection, room_id, member["token"])
2047
+ insert_member(connection, room_id, member, now)
2048
+ queue_invited_expert(connection, room_id, member["name"], now)
2049
+ sync_participants(connection, room_id)
2050
+ return free_view(connection, room_id)
2051
+
2052
+
2053
+ def queue_invited_expert(connection, room_id, name, now):
2054
+ """Queue a new expert at the tail of an existing live plan, without changing the current speaker or hold.
2055
+
2056
+ A manual expert is queued only once it has registered (the invite alone does not), and only once per invitation.
2057
+ """
2058
+ member = connection.execute("SELECT * FROM room_members WHERE room_id=? AND name=? AND status='active'", (room_id, name)).fetchone()
2059
+ if not member or (member["adapter"] == "manual" and not registered_at(connection, member)):
2060
+ return
2061
+ session = discussion.current_session(connection, room_id)
2062
+ if session and session["status"] != "concluded":
2063
+ discussion.queue_session_invite(connection, session, member)
2064
+
2065
+
2066
+ def adopt_manual_member(connection, room_id, member, payload):
2067
+ """Hand a manual expert to the local service once its CLI reported the session it runs in; the owner's service resumes that session."""
2068
+ reported = connection.execute("SELECT session_id FROM member_sessions WHERE member_id=?", (member["id"],)).fetchone()
2069
+ if not registered_at(connection, member):
2070
+ raise ValueError("成员尚未注册,不能转为托管")
2071
+ adapter = payload.get("adapter")
2072
+ if (not reported or reported["session_id"] != payload.get("session_id") or not isinstance(adapter, str)
2073
+ or not ADAPTER_VALUE.fullmatch(adapter) or adapter == "manual"):
2074
+ raise ValueError("成员尚未上报与之相符的会话,不能转为托管")
2075
+ if member["access"] != "manual" or member["status"] != "active":
2076
+ raise ValueError("只有在场的手动成员可以转为托管")
2077
+ connection.execute("UPDATE room_members SET access='runner',adapter=?,session_mode='borrowed' WHERE id=?", (payload["adapter"], member["id"]))
2078
+ connection.execute("DELETE FROM member_sessions WHERE member_id=?", (member["id"],))
2079
+ # A free room's service answers as the member, so it takes over with a credential the owner's side generated.
2080
+ if payload.get("token") is not None:
2081
+ require_fresh_member_token(connection, room_id, payload["token"])
2082
+ connection.execute("UPDATE room_members SET token_hash=? WHERE id=?", (token_hash(payload["token"]), member["id"]))
2083
+
2084
+
2085
+ def record_adopt_failure(connection, room_id, member, payload):
2086
+ """Keep why the local service could not take over the reported session, so the page says why requests stay undelivered."""
2087
+ reason = payload.get("reason")
2088
+ if not isinstance(reason, str) or not reason.strip():
2089
+ raise ValueError("须提供 reason")
2090
+ reported = connection.execute("SELECT session_id FROM member_sessions WHERE member_id=?", (member["id"],)).fetchone()
2091
+ if not reported or reported["session_id"] != payload.get("session_id"):
2092
+ raise ValueError("成员尚未上报与之相符的会话")
2093
+ connection.execute("INSERT OR REPLACE INTO member_adopt_failures (member_id,room_id,session_id,reason) VALUES (?,?,?,?)",
2094
+ (member["id"], room_id, reported["session_id"], reason.strip()[:ADOPT_REASON_LIMIT]))
2095
+
2096
+
2097
+ def require_fresh_member_token(connection, room_id, token):
2098
+ """Prevent owner or another member's credential from being reused for a new identity."""
2099
+ hashed = token_hash(token)
2100
+ if not hashed or connection.execute("SELECT 1 FROM room_members WHERE room_id=? AND token_hash=? UNION ALL SELECT 1 FROM room_modes WHERE room_id=? AND control_hash=?",
2101
+ (room_id, hashed, room_id, hashed)).fetchone():
2102
+ raise ValueError("须提供独立的新成员凭证")
2103
+
2104
+
2105
+ def member_action(connection, room_id, member_id, token, payload):
2106
+ """Owner sends a member away, brings it back with a new credential, or removes it.
2107
+
2108
+ Leaving voids live work at once. Coming back requires a fresh credential, so copies of the
2109
+ old one never revive; the software session is kept by the local runner, not here. The
2110
+ `role` action makes one runner or manual member the AI host, replacing any previous host.
2111
+ """
2112
+ member = owned_member(connection, room_id, member_id, token)
2113
+ action = payload.get("action")
2114
+ routed = []
2115
+ if action in ("away", "remove"):
2116
+ if member["role"] == "host":
2117
+ discussion.pause_session_for_host_change(connection, room_id)
2118
+ else:
2119
+ discussion.session_member_change(connection, room_id, member, action)
2120
+ # Unnamed messages still waiting on a departing host move to its successor instead of being dropped.
2121
+ routed = open_route_messages(connection, member_id)
2122
+ stop_member_work(connection, member_id, "成员已离开" if action == "away" else "成员已移除",
2123
+ "member_away" if action == "away" else "member_removed")
2124
+ connection.execute("UPDATE room_members SET status=? WHERE id=?", ("away" if action == "away" else "removed", member_id))
2125
+ elif action == "active":
2126
+ hashed = token_hash(payload.get("token"))
2127
+ if member["status"] == "active" and hashed and hmac.compare_digest(hashed, member["token_hash"]):
2128
+ return free_view(connection, room_id)
2129
+ if member["status"] != "away" or not hashed or hmac.compare_digest(hashed, member["token_hash"]):
2130
+ raise ValueError("只有离开的成员可以请回,且须提供新的成员凭证")
2131
+ require_fresh_member_token(connection, room_id, payload.get("token"))
2132
+ connection.execute("UPDATE room_members SET status='active',token_hash=? WHERE id=?", (hashed, member_id))
2133
+ if discussion.current_session(connection, room_id, active_only=True):
2134
+ discussion.session_member_change(connection, room_id, member, action)
2135
+ else:
2136
+ queue_invited_expert(connection, room_id, member["name"], int(time.time()))
2137
+ elif action == "adopt":
2138
+ adopt_manual_member(connection, room_id, member, payload)
2139
+ connection.execute("DELETE FROM member_adopt_failures WHERE member_id=?", (member_id,))
2140
+ elif action == "adopt_failed":
2141
+ record_adopt_failure(connection, room_id, member, payload)
2142
+ return free_view(connection, room_id)
2143
+ elif action == "role":
2144
+ role = payload.get("role")
2145
+ if role not in ("host", "member"):
2146
+ raise ValueError("role 须为 host 或 member")
2147
+ if role == "member" and member["role"] == "host":
2148
+ raise ValueError("自由房间须由一位 AI 主持;请指定另一位成员担任主持人")
2149
+ if role == "host":
2150
+ discussion.pause_session_for_host_change(connection, room_id)
2151
+ # Unnamed messages the old host has not started move to the new host, which carries the host rules.
2152
+ old_host = connection.execute("SELECT id FROM room_members WHERE room_id=? AND role='host' AND id<>?", (room_id, member_id)).fetchone()
2153
+ if old_host:
2154
+ routed = open_route_messages(connection, old_host["id"], pending_only=True)
2155
+ record_cancel_reasons(connection, "member_id=? AND purpose IN ('route','group') AND status='pending'", (old_host["id"],), "rerouted")
2156
+ connection.execute("UPDATE member_requests SET status='cancelled' WHERE member_id=? AND purpose IN ('route','group') AND status='pending'", (old_host["id"],))
2157
+ connection.execute("UPDATE room_members SET role='member' WHERE room_id=? AND role='host'", (room_id,))
2158
+ connection.execute("UPDATE room_members SET role=? WHERE id=?", (role, member_id))
2159
+ if role == "host":
2160
+ pin_host(connection, room_id, old_host["id"] if old_host else None)
2161
+ else:
2162
+ raise ValueError("action 须为 away、active、remove、role、adopt 或 adopt_failed")
2163
+ settle_batches(connection, room_id)
2164
+ sync_participants(connection, room_id)
2165
+ hand_over_routes(connection, room_id, routed, member_id if action in ("away", "remove") else None)
2166
+ return free_view(connection, room_id)
2167
+
2168
+
2169
+ # Imported last: session_room imports helpers from this module, so both load in either order.
2170
+ from . import session_room as discussion # noqa: E402