ai_co_discuss 0.2.98 → 0.3.0

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 (44) hide show
  1. package/.agents/skills/discuss-host/SKILL.md +3 -3
  2. package/.agents/skills/discuss-host/references/discussion.md +16 -16
  3. package/.agents/skills/discuss-host/references/launch.md +1 -3
  4. package/.agents/skills/discuss-host/references/manual.md +14 -77
  5. package/.agents/skills/discuss-participant/SKILL.md +4 -4
  6. package/.agents/skills/discuss-participant/references/discussion.md +29 -27
  7. package/.agents/skills/discuss-participant/references/free.md +3 -3
  8. package/.agents/skills/discuss-participant/references/manual.md +8 -10
  9. package/.agents/skills/discuss-participant/references/writing.md +9 -9
  10. package/.github/SECURITY.md +1 -1
  11. package/README.md +3 -3
  12. package/README.zh-CN.md +3 -3
  13. package/backend/free_room.py +542 -69
  14. package/backend/room_import.py +77 -15
  15. package/backend/runtime.py +81 -169
  16. package/backend/schema.sql +167 -244
  17. package/backend/server.py +639 -175
  18. package/backend/session_room.py +919 -0
  19. package/docs/architecture.md +3 -2
  20. package/docs/en/architecture.md +4 -3
  21. package/docs/en/runner.md +3 -1
  22. package/docs/en/workflow.md +101 -96
  23. package/docs/runner.md +3 -1
  24. package/docs/workflow.md +101 -96
  25. package/package.json +2 -2
  26. package/runner/api_participant.mjs +47 -44
  27. package/runner/codex_app_client.mjs +13 -0
  28. package/runner/discuss.py +77 -58
  29. package/runner/discussion_adapters.mjs +210 -28
  30. package/runner/discussion_launch.mjs +234 -85
  31. package/runner/discussion_models.mjs +89 -1
  32. package/runner/discussion_runner.mjs +49 -504
  33. package/runner/discussion_service.mjs +119 -93
  34. package/runner/discussion_sidecar.mjs +4 -13
  35. package/runner/expert_prompts.mjs +100 -8
  36. package/runner/free_runner.mjs +118 -35
  37. package/runner/scheduler.mjs +4 -4
  38. package/runner/session_cut.mjs +167 -0
  39. package/runner/session_driver.mjs +175 -0
  40. package/web/app.js +421 -154
  41. package/web/backup.js +1 -1
  42. package/web/markdown.css +1 -1
  43. package/web/modern.css +1 -1
  44. package/web/modern.js +6544 -7299
@@ -17,7 +17,8 @@ import time
17
17
  import uuid
18
18
 
19
19
  from .identity import MAX_ROOM_AGENTS, is_ai
20
- from .runtime import LEASE_SECONDS, MAX_EXECUTION_SEGMENTS, begin_write, require_owner, save_segments, token_hash
20
+ from .runtime import (LEASE_SECONDS, MAX_EXECUTION_SEGMENTS, begin_write, enforce_session_limits,
21
+ require_owner, save_segments, token_hash)
21
22
 
22
23
  FREE_EXECUTION_MINUTES = 20
23
24
  RECENT_REQUESTS = 100
@@ -72,10 +73,19 @@ MAX_NOTE_LENGTH = 500
72
73
  MAX_NOTES_PER_RENEW = 10
73
74
  # A member description is a short note for the scheduler and the host, keyed by the member's name.
74
75
  MAX_MEMBER_DESCRIPTION = 500
76
+ # A display label replaces the member's name on screen only; the name stays the member's identity everywhere else.
77
+ MAX_MEMBER_LABEL = 32
75
78
  MAX_MEMBER_NAME = 80
76
79
  MAX_PATH_LENGTH = 500
80
+ # Refused operations one execution may report, and the length of each field.
81
+ MAX_DENIALS = 20
82
+ MAX_DENIAL_FIELD = 300
83
+ # A manual member's delivery is retried at most this often before the execution fails.
84
+ MAX_RETRIES = 3
85
+ MAX_RETRY_REASON = 500
77
86
  ACTIVE = ("dispatched", "started")
78
- ACCESS_MODES = ("runner", "manual", "online")
87
+ ACCESS_MODES = ("runner", "manual")
88
+ LEGACY_ACCESS_MODES = ("runner", "manual", "online")
79
89
  SCHEDULER_PROVIDERS = ("jev", "decisions")
80
90
  RECOMMEND_SECONDS = 60
81
91
  # A click sent just before the hint disappears still counts.
@@ -88,7 +98,8 @@ MAX_CHAIN_ROUNDS = 20
88
98
  # A host that left gives up the role; the first active runner member (else manual) then takes it.
89
99
  RELEASE_HOST = "UPDATE room_members SET role='member' WHERE room_id=? AND role='host' AND status<>'active'"
90
100
  ASSIGN_HOST = """UPDATE room_members SET role='host' WHERE id=(SELECT id FROM room_members WHERE room_id=? AND status='active'
91
- AND access<>'online' ORDER BY access='runner' DESC, id LIMIT 1)
101
+ AND NOT EXISTS(SELECT 1 FROM member_manual_sources source WHERE source.member_id=room_members.id AND source.source='web')
102
+ ORDER BY access='runner' DESC, id LIMIT 1)
92
103
  AND NOT EXISTS (SELECT 1 FROM room_members WHERE room_id=? AND role='host' AND status='active')"""
93
104
  TERMINAL = ("completed", "cancelled", "failed")
94
105
  # A queued member has been asked once its request for that message exists.
@@ -113,6 +124,13 @@ def migrate_free_tables(connection):
113
124
  for name, definition in columns.items():
114
125
  if name not in present:
115
126
  connection.execute(f"ALTER TABLE {table} ADD COLUMN {name} {definition}")
127
+ connection.execute("""CREATE TABLE IF NOT EXISTS member_manual_sources (
128
+ member_id INTEGER PRIMARY KEY REFERENCES room_members(id) ON DELETE CASCADE,
129
+ source TEXT NOT NULL CHECK(source='web'))""")
130
+ connection.execute("""INSERT OR IGNORE INTO member_manual_sources(member_id,source)
131
+ SELECT id,'web' FROM room_members WHERE access='online'""")
132
+ connection.execute("UPDATE room_members SET access='manual' WHERE access='online'")
133
+ connection.execute("UPDATE room_modes SET mode='free' WHERE mode='brainstorm'")
116
134
 
117
135
 
118
136
  def room_mode(connection, room_id):
@@ -131,9 +149,15 @@ def member_value(member):
131
149
  if not isinstance(adapter, str) or len(adapter) > 40:
132
150
  raise ValueError("成员适配器名称无效")
133
151
  access, session_mode = member.get("access", "runner"), member.get("session_mode", "new")
134
- if access not in ACCESS_MODES or session_mode not in SESSION_MODES:
152
+ if access not in LEGACY_ACCESS_MODES or session_mode not in SESSION_MODES:
135
153
  raise ValueError("成员接入方式须为 runner/manual/online,会话方式须为 new/borrowed")
136
- return {"name": member["name"], "adapter": adapter, "token": member["token"], "access": access, "session_mode": session_mode}
154
+ manual_source = member.get("manual_source", "")
155
+ if access == "online":
156
+ manual_source, access = "web", "manual"
157
+ if manual_source not in ("", "web") or (manual_source == "web" and access != "manual"):
158
+ raise ValueError("manual_source 只能为 manual 成员配置 web")
159
+ return {"name": member["name"], "adapter": adapter, "token": member["token"],
160
+ "access": access, "session_mode": session_mode, "manual_source": manual_source}
137
161
 
138
162
 
139
163
  def member_values(members):
@@ -153,6 +177,10 @@ def insert_member(connection, room_id, member, now):
153
177
  connection.execute("""INSERT INTO room_members (room_id,name,adapter,token_hash,access,session_mode,created_at)
154
178
  VALUES (?,?,?,?,?,?,?)""", (room_id, member["name"], member["adapter"], token_hash(member["token"]),
155
179
  member["access"], member["session_mode"], now))
180
+ if member.get("manual_source") == "web" or member.get("access") == "online":
181
+ member_id = connection.execute("SELECT id FROM room_members WHERE room_id=? AND name=? AND token_hash=?",
182
+ (room_id, member["name"], token_hash(member["token"]))).fetchone()[0]
183
+ connection.execute("INSERT OR IGNORE INTO member_manual_sources(member_id,source) VALUES(?,'web')", (member_id,))
156
184
 
157
185
 
158
186
  def save_room_access(connection, room_id, control_token, members, settings):
@@ -168,10 +196,12 @@ def save_room_access(connection, room_id, control_token, members, settings):
168
196
  connection.execute("UPDATE room_members SET role='host' WHERE room_id=? AND name=?", (room_id, settings["host"]))
169
197
 
170
198
 
171
- def discussion_access_ok(access, capability, automatic, is_host):
172
- """Online experts stay online; a woken discussion also takes manual experts, but never a manual host."""
199
+ def discussion_access_ok(access, capability, automatic, is_host, manual_source=""):
200
+ """Require web relays to stay read-only while local participants use runner or manual access."""
173
201
  if capability == "online":
174
- return access == "online"
202
+ return access == "manual" and manual_source == "web" and not is_host
203
+ if manual_source == "web":
204
+ return False
175
205
  if not automatic:
176
206
  return access == "manual"
177
207
  return access == "runner" or (access == "manual" and not is_host)
@@ -189,24 +219,28 @@ def discussion_members(payload, settings):
189
219
  raise ValueError("成员凭证名单须与参与者名单一致")
190
220
  for member in members:
191
221
  if not discussion_access_ok(member["access"], settings["experts"][member["name"]],
192
- payload.get("runtime_mode") == "automatic", member["name"] == settings["host"]):
222
+ payload.get("runtime_mode") == "automatic", member["name"] == settings["host"],
223
+ member["manual_source"]):
193
224
  raise ValueError("成员接入方式与房间参与能力、任务交付方式不符")
194
225
  return [mark_manual(member, payload.get("runtime_mode") == "automatic") for member in members]
195
226
 
196
227
 
197
228
  def sync_discussion_members(connection, room_id, payload, settings):
198
229
  """Keep existing credentials; require fresh credentials for added or changed members."""
199
- current = {row["name"]: row for row in connection.execute("SELECT * FROM room_members WHERE room_id=? AND status='active'", (room_id,))}
230
+ current = {row["name"]: row for row in connection.execute("""SELECT m.*,
231
+ COALESCE(s.source,'') AS manual_source FROM room_members m
232
+ LEFT JOIN member_manual_sources s ON s.member_id=m.id WHERE m.room_id=? AND m.status='active'""", (room_id,))}
200
233
  supplied = {member["name"]: member_value(member) for member in payload.get("members", [])}
201
234
  run = connection.execute("SELECT mode FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
202
235
  for name, capability in settings["experts"].items():
203
236
  automatic = bool(run and run[0] == "automatic")
204
237
  old = current.get(name)
205
- if old and (old["access"] == "online") == (capability == "online"):
238
+ if old and (old["manual_source"] == "web") == (capability == "online"):
206
239
  if name in supplied and token_hash(supplied[name]["token"]) != old["token_hash"]:
207
240
  raise FileExistsError("成员凭证与已登记身份不符;请核对原接入文件")
208
241
  continue
209
- if name not in supplied or not discussion_access_ok(supplied[name]["access"], capability, automatic, name == settings["host"]):
242
+ if name not in supplied or not discussion_access_ok(supplied[name]["access"], capability, automatic,
243
+ name == settings["host"], supplied[name]["manual_source"]):
210
244
  raise ValueError(f"新增或改变接入方式的成员 {name} 须提供独立的新凭证")
211
245
  member = mark_manual(supplied[name], automatic)
212
246
  hashed = token_hash(member["token"])
@@ -223,8 +257,10 @@ def sync_participants(connection, room_id):
223
257
  """Keep one active AI host, then mirror member capabilities and the host into shared room metadata."""
224
258
  connection.execute(RELEASE_HOST, (room_id,))
225
259
  connection.execute(ASSIGN_HOST, (room_id, room_id))
226
- members = connection.execute("SELECT name,access,role,status FROM room_members WHERE room_id=? AND status<>'removed' ORDER BY id", (room_id,)).fetchall()
227
- experts = {row["name"]: "online" if row["access"] == "online" else "cli" for row in members}
260
+ members = connection.execute("""SELECT m.name,m.access,m.role,m.status,COALESCE(s.source,'') AS manual_source
261
+ FROM room_members m LEFT JOIN member_manual_sources s ON s.member_id=m.id
262
+ WHERE m.room_id=? AND m.status<>'removed' ORDER BY m.id""", (room_id,)).fetchall()
263
+ experts = {row["name"]: "online" if row["manual_source"] == "web" else "cli" for row in members}
228
264
  host = next((row["name"] for row in members if row["role"] == "host" and row["status"] == "active"), "用户")
229
265
  connection.execute("UPDATE topic_settings SET experts=?,host=? WHERE room_id=?",
230
266
  (json.dumps(experts, ensure_ascii=False), host, room_id))
@@ -232,6 +268,12 @@ def sync_participants(connection, room_id):
232
268
 
233
269
  def execution_minutes(connection, room_id):
234
270
  """Free rooms always bound one execution; zero in the shared limits means the default."""
271
+ session = connection.execute("""SELECT l.execution_minutes FROM session_limits l
272
+ JOIN discussion_sessions s ON s.id=l.session_id
273
+ WHERE s.room_id=? AND s.status='active' AND EXISTS(SELECT 1 FROM session_state st WHERE st.session_id=s.id)
274
+ ORDER BY s.id DESC LIMIT 1""", (room_id,)).fetchone()
275
+ if session:
276
+ return session[0] or FREE_EXECUTION_MINUTES
235
277
  row = connection.execute("SELECT execution_minutes FROM discussion_limits WHERE room_id=?", (room_id,)).fetchone()
236
278
  return (row[0] if row and row[0] else FREE_EXECUTION_MINUTES)
237
279
 
@@ -241,6 +283,12 @@ def public_execution(row):
241
283
  return {key: row[key] for key in EXECUTION_FIELDS} if row else None
242
284
 
243
285
 
286
+ def execution_retry(connection, execution_id):
287
+ """What the runner reported about a waiting retry of one execution, or None."""
288
+ row = connection.execute("SELECT attempt,retry_at,reason,stopped FROM execution_retries WHERE execution_id=?", (execution_id,)).fetchone()
289
+ return dict(row) if row else None
290
+
291
+
244
292
  def member_descriptions(connection, room_id):
245
293
  """The owner's descriptions of members by name; everyone who can read the room may read them."""
246
294
  return {row["name"]: row["description"] for row in connection.execute(
@@ -267,15 +315,51 @@ def describe_members(connection, room_id, token, payload):
267
315
  return {"descriptions": member_descriptions(connection, room_id)}
268
316
 
269
317
 
318
+ def member_labels(connection, room_id):
319
+ """The owner's display labels of members by name; only screens use them, never addressing or credentials."""
320
+ return {row["name"]: row["label"] for row in connection.execute(
321
+ "SELECT name,label FROM member_labels WHERE room_id=? ORDER BY name", (room_id,))}
322
+
323
+
324
+ def label_members(connection, room_id, token, payload):
325
+ """Owner sets members' display labels by name; an empty text removes that member's label."""
326
+ require_owner(connection, room_id, token)
327
+ labels = payload.get("labels")
328
+ if (not isinstance(labels, dict) or not labels or len(labels) > MAX_ROOM_AGENTS
329
+ or any(not isinstance(name, str) or not 0 < len(name) <= MAX_MEMBER_NAME or not isinstance(text, str)
330
+ or len(text.strip()) > MAX_MEMBER_LABEL for name, text in labels.items())):
331
+ raise ValueError(f"labels 须为 {{成员名: 显示名}},最多 {MAX_ROOM_AGENTS} 位,每个最多 {MAX_MEMBER_LABEL} 字")
332
+ begin_write(connection)
333
+ now = int(time.time())
334
+ for name, text in labels.items():
335
+ if text.strip():
336
+ connection.execute("""INSERT INTO member_labels (room_id,name,label,updated_at) VALUES (?,?,?,?)
337
+ ON CONFLICT(room_id,name) DO UPDATE SET label=excluded.label,updated_at=excluded.updated_at""",
338
+ (room_id, name, text.strip(), now))
339
+ else:
340
+ connection.execute("DELETE FROM member_labels WHERE room_id=? AND name=?", (room_id, name))
341
+ return {"labels": member_labels(connection, room_id)}
342
+
270
343
  def note_count(connection, execution_id):
271
344
  """How many progress notes an execution has; the texts are read separately so room views stay small."""
272
345
  return connection.execute("SELECT COUNT(*) FROM execution_notes WHERE execution_id=?", (execution_id,)).fetchone()[0]
273
346
 
274
347
 
275
- def execution_notes(connection, room_id, execution_id):
348
+ def execution_notes(connection, room_id, execution_id, token=""):
276
349
  """Read one execution's progress notes in order, with the same access as the room view."""
277
- if not connection.execute("SELECT 1 FROM member_executions WHERE room_id=? AND id=?", (room_id, execution_id)).fetchone():
350
+ execution = connection.execute("SELECT member_id FROM member_executions WHERE room_id=? AND id=?", (room_id, execution_id)).fetchone()
351
+ if not execution:
278
352
  raise LookupError("执行不存在")
353
+ from .session_room import session_message_access
354
+
355
+ boundary = session_message_access(connection, room_id, {}, token)
356
+ if boundary is not None:
357
+ try:
358
+ member = require_member(connection, room_id, token)
359
+ except PermissionError:
360
+ return {"notes": []}
361
+ if member["id"] != execution["member_id"]:
362
+ return {"notes": []}
279
363
  return {"notes": [dict(row) for row in connection.execute(
280
364
  "SELECT seq,text,created_at FROM execution_notes WHERE execution_id=? ORDER BY seq", (execution_id,))]}
281
365
 
@@ -308,15 +392,42 @@ def execution_timeline(connection, room_id, after=0):
308
392
 
309
393
  def recent_executions(connection, room_id):
310
394
  """Return bounded public reports, including requests no longer in the recent request list."""
311
- rows = connection.execute("""SELECT e.*,
395
+ rows = connection.execute("""SELECT e.*,m.name AS member,
312
396
  (SELECT json_group_array(id) FROM member_requests WHERE execution_id=e.id) AS request_ids,
313
397
  (SELECT MAX(answer_id) FROM member_requests WHERE execution_id=e.id) AS answer_id,
314
398
  (SELECT MIN(message_id) FROM member_requests WHERE execution_id=e.id) AS request_message_id,
315
- (SELECT COUNT(*) FROM execution_notes WHERE execution_id=e.id) AS note_count
316
- FROM member_executions e WHERE room_id=? ORDER BY id DESC LIMIT ?""", (room_id, RECENT_EXECUTIONS))
317
- return [{**public_execution(row), "cwd": row["cwd"], "changes": json.loads(row["changes"] or "[]"),
318
- "changes_recorded": bool(row["changes"]), "request_ids": json.loads(row["request_ids"]),
319
- "answer_id": row["answer_id"], "request_message_id": row["request_message_id"], "notes": row["note_count"]} for row in rows]
399
+ (SELECT COUNT(*) FROM execution_notes WHERE execution_id=e.id) AS note_count,
400
+ (SELECT denials FROM execution_denials WHERE execution_id=e.id) AS denials
401
+ 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))
402
+ executions = []
403
+ for row in rows:
404
+ requests = []
405
+ for request in connection.execute("SELECT id,message_id,purpose,status,answer_id FROM member_requests WHERE execution_id=? ORDER BY id", (row["id"],)):
406
+ requests.append({**dict(request), **session_request_source(connection, request["id"], request["message_id"])})
407
+ executions.append({**public_execution(row), "member": row["member"], "cwd": row["cwd"], "changes": json.loads(row["changes"] or "[]"),
408
+ "changes_recorded": bool(row["changes"]), "request_ids": json.loads(row["request_ids"]),
409
+ "answer_id": row["answer_id"], "request_message_id": row["request_message_id"], "requests": requests,
410
+ "notes": row["note_count"], "denials": json.loads(row["denials"] or "[]"),
411
+ "retry": execution_retry(connection, row["id"])})
412
+ return executions
413
+
414
+
415
+ def session_request_source(connection, member_request_id, message_id):
416
+ """Expose session provenance and a visible transcript anchor for a member request."""
417
+ row = connection.execute("""SELECT q.id AS session_request_id,q.session_id AS discussion_session_id,q.round,
418
+ q.question AS source_question,t.cause_kind,t.cause_message_id AS source_message_id,t.cause_request_id,
419
+ CASE WHEN q.id IS NULL THEN r.message_id ELSE COALESCE((SELECT MAX(m.id) FROM messages m
420
+ WHERE m.room_id=r.room_id AND m.id<r.message_id
421
+ AND NOT EXISTS(SELECT 1 FROM task_message_sources hidden WHERE hidden.message_id=m.id)),0) END AS session_anchor
422
+ FROM member_requests r LEFT JOIN session_requests q ON q.member_request_id=r.id
423
+ LEFT JOIN task_message_sources t ON t.session_request_id=q.id WHERE r.id=?""", (member_request_id,)).fetchone()
424
+ if row and row["session_request_id"] is not None:
425
+ return {"session_request_id": row["session_request_id"], "discussion_session_id": row["discussion_session_id"],
426
+ "round": row["round"], "source_question": row["source_question"],
427
+ "source_message_id": row["source_message_id"], "cause_kind": row["cause_kind"],
428
+ "cause_request_id": row["cause_request_id"], "after_message_id": row["session_anchor"]}
429
+ return {"session_request_id": None, "discussion_session_id": None, "round": None, "source_question": "",
430
+ "source_message_id": None, "cause_kind": None, "cause_request_id": None, "after_message_id": message_id}
320
431
 
321
432
 
322
433
  def possible_conflicts(connection, room_id, names):
@@ -347,7 +458,8 @@ def possible_conflicts(connection, room_id, names):
347
458
 
348
459
 
349
460
  # An invited manual member that has not registered counts as absent: it receives no request and is never a target.
350
- REGISTERED_MEMBER = """(room_members.access<>'manual' OR EXISTS(SELECT 1 FROM member_registrations r
461
+ REGISTERED_MEMBER = """(room_members.access<>'manual' OR EXISTS(SELECT 1 FROM member_manual_sources source
462
+ WHERE source.member_id=room_members.id AND source.source='web') OR EXISTS(SELECT 1 FROM member_registrations r
351
463
  WHERE r.member_id=room_members.id AND r.token_hash=room_members.token_hash))"""
352
464
 
353
465
 
@@ -357,6 +469,8 @@ def free_view(connection, room_id):
357
469
  members = []
358
470
  for row in connection.execute("SELECT * FROM room_members WHERE room_id=? AND status<>'removed' ORDER BY id", (room_id,)):
359
471
  member = {key: row[key] for key in MEMBER_FIELDS}
472
+ source = connection.execute("SELECT source FROM member_manual_sources WHERE member_id=?", (row["id"],)).fetchone()
473
+ member["manual_source"] = source[0] if source else ""
360
474
  member["registered_at"] = registered_at(connection, row)
361
475
  reported = connection.execute("SELECT session_id,client FROM member_sessions WHERE member_id=?", (row["id"],)).fetchone()
362
476
  member["reported_session"], member["reported_client"] = (reported["session_id"], reported["client"]) if reported else ("", "")
@@ -366,11 +480,13 @@ def free_view(connection, room_id):
366
480
  if latest and latest["status"] in ACTIVE:
367
481
  member["execution"]["stale"] = latest["lease_until"] <= now
368
482
  member["execution"]["notes"] = note_count(connection, latest["id"])
483
+ member["execution"]["retry"] = execution_retry(connection, latest["id"])
369
484
  member["pending"] = connection.execute("SELECT COUNT(*) FROM member_requests WHERE member_id=? AND status='pending'",
370
485
  (row["id"],)).fetchone()[0]
371
486
  members.append(member)
372
487
  names = {row["id"]: row["name"] for row in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (room_id,))}
373
- requests = [{**dict(row), "member": names.get(row["member_id"], "")} for row in connection.execute(
488
+ requests = [{**dict(row), "member": names.get(row["member_id"], ""),
489
+ **session_request_source(connection, row["id"], row["message_id"])} for row in connection.execute(
374
490
  """SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by,created_at,
375
491
  (SELECT reason FROM request_cancel_reasons c WHERE c.request_id=member_requests.id) AS cancel_reason FROM member_requests
376
492
  WHERE room_id=? ORDER BY id DESC LIMIT ?""", (room_id, RECENT_REQUESTS))]
@@ -390,7 +506,9 @@ def require_member(connection, room_id, token, name=None):
390
506
  (room_id, hashed)).fetchone() if hashed else None
391
507
  if not row or (name is not None and row["name"] != name):
392
508
  raise PermissionError("成员凭证无效、已停用或与发言身份不符")
393
- return row
509
+ if connection.execute("SELECT 1 FROM member_manual_sources WHERE member_id=? AND source='web'", (row["id"],)).fetchone():
510
+ raise PermissionError("网页成员只读;由房主凭据代发其回答")
511
+ return dict(row)
394
512
 
395
513
 
396
514
  def registered_at(connection, member):
@@ -436,9 +554,14 @@ def authorize_free_write(connection, room_id, token, operation, payload):
436
554
  if operation not in ("messages", "attachments"):
437
555
  raise
438
556
  author = payload.get("author")
439
- if author == "用户" or payload.get("submitted_by", author) != author:
440
- raise PermissionError("需要本房间的控制凭据;成员不能以用户身份或代他人发言")
441
- return require_member(connection, room_id, token, author)
557
+ member = require_member(connection, room_id, token)
558
+ if author == member["name"] and payload.get("submitted_by", author) == author:
559
+ return member
560
+ if member["role"] == "host" and author != member["name"] and payload.get("submitted_by") == member["name"]:
561
+ proxy = proxy_member(connection, room_id, author)
562
+ proxy["proxy_by"] = member["name"]
563
+ return proxy
564
+ raise PermissionError("成员只能以本人身份发言;只有主持人可以代发在线成员回答")
442
565
 
443
566
 
444
567
  def active_members(connection, room_id, registered_only=True):
@@ -493,11 +616,13 @@ def route_held_messages(connection, room_id, host):
493
616
 
494
617
  def proxy_member(connection, room_id, author):
495
618
  """Return the active online member the owner may forward an answer for."""
496
- row = connection.execute("SELECT * FROM room_members WHERE room_id=? AND name=? AND status='active' AND access='online'",
497
- (room_id, author)).fetchone()
619
+ row = connection.execute("""SELECT m.*,s.source AS manual_source FROM room_members m
620
+ JOIN member_manual_sources s ON s.member_id=m.id AND s.source='web'
621
+ WHERE m.room_id=? AND m.name=? AND m.status='active' AND m.access='manual'""",
622
+ (room_id, author)).fetchone()
498
623
  if not row:
499
624
  raise PermissionError("房间控制凭据只能以用户身份发言,或代发在线成员的回答")
500
- return row
625
+ return dict(row)
501
626
 
502
627
 
503
628
  def check_answer(connection, room_id, member, payload):
@@ -516,11 +641,14 @@ def check_answer(connection, room_id, member, payload):
516
641
  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):
517
642
  raise ValueError("手动接入成员用 request_ids 关联请求,不使用执行编号")
518
643
  purposes = []
644
+ requests = []
519
645
  for request_id in request_ids:
520
646
  row = connection.execute("SELECT * FROM member_requests WHERE room_id=? AND id=?", (room_id, request_id)).fetchone()
521
647
  if not row or row["member_id"] != member["id"] or row["status"] != "pending":
522
648
  raise FileExistsError(f"请求 {request_id} 不属于本成员或已处理")
523
649
  purposes.append(row["purpose"])
650
+ requests.append(row)
651
+ validate_session_request_answer(connection, room_id, requests)
524
652
  check_dispatch(member, payload, purposes)
525
653
  return None
526
654
  execution_id = payload.get("execution_id")
@@ -531,8 +659,10 @@ def check_answer(connection, room_id, member, payload):
531
659
  raise PermissionError("执行编号不属于本成员")
532
660
  if row["status"] not in ACTIVE or row["lease_until"] <= int(time.time()):
533
661
  raise FileExistsError("本次执行已结束、被打断或已过期;回答不能改作普通发言,请保留草稿")
534
- check_dispatch(member, payload, [item[0] for item in connection.execute(
535
- "SELECT purpose FROM member_requests WHERE execution_id=? AND status='delivering'", (execution_id,))])
662
+ requests = [dict(item) for item in connection.execute(
663
+ "SELECT id,purpose FROM member_requests WHERE execution_id=? AND status='delivering'", (execution_id,))]
664
+ validate_session_request_answer(connection, room_id, requests)
665
+ check_dispatch(member, payload, [item["purpose"] for item in requests])
536
666
  return row
537
667
 
538
668
 
@@ -542,15 +672,33 @@ def check_dispatch(member, payload, purposes):
542
672
  raise ValueError("这次是请全体成员各自回应,其他成员已收到请求;主持人只回答自己的部分,不能用 --to 派活")
543
673
 
544
674
 
675
+ def validate_session_request_answer(connection, room_id, requests):
676
+ """Keep a discussion speaker request separate and live when answering it."""
677
+ discussions = [request for request in requests if request["purpose"] == "discuss"]
678
+ if not discussions:
679
+ return
680
+ if len(requests) != 1 or len(discussions) != 1:
681
+ raise ValueError("讨论安排须单独回答,不能与普通请求合并")
682
+ request_id = discussions[0]["id"]
683
+ plan = connection.execute("""SELECT sr.status,s.status AS session_status FROM session_requests sr
684
+ JOIN discussion_sessions s ON s.id=sr.session_id JOIN member_requests r ON r.id=sr.member_request_id
685
+ WHERE s.room_id=? AND r.id=? AND sr.purpose='discuss'""", (room_id, request_id)).fetchone()
686
+ if not plan or plan["session_status"] != "active" or plan["status"] != "open":
687
+ raise FileExistsError("讨论安排已暂停、结束或取消,不能继续回答")
688
+
689
+
545
690
  def check_proxy(connection, room_id, member, payload):
546
691
  """The owner forwards an online member's answer and may close that member's open requests."""
547
692
  request_ids = payload.get("request_ids", [])
548
693
  if not isinstance(request_ids, list) or any(type(item) is not int for item in request_ids):
549
694
  raise ValueError("request_ids 须为请求编号列表")
695
+ requests = []
550
696
  for request_id in request_ids:
551
697
  row = connection.execute("SELECT * FROM member_requests WHERE room_id=? AND id=?", (room_id, request_id)).fetchone()
552
698
  if not row or row["member_id"] != member["id"] or row["status"] != "pending":
553
699
  raise FileExistsError(f"请求 {request_id} 不属于该在线成员或已处理")
700
+ requests.append(row)
701
+ validate_session_request_answer(connection, room_id, requests)
554
702
 
555
703
 
556
704
  def insert_request(connection, room_id, member_id, message_id, chain_id, purpose, created_by, batch_id=0):
@@ -779,16 +927,47 @@ def speaking_order(connection, room):
779
927
  by_id = {member["id"]: member for member in active}
780
928
  ordered = [member["name"] for member in default_order(connection, room["id"], active)]
781
929
  items, kind, target, later = [], "default", 0, []
930
+ session_plan_state = []
782
931
  if room["mode"] == "free":
783
- target = connection.execute("""SELECT MAX(q.message_id) FROM free_queue q LEFT JOIN member_requests r ON r.message_id=q.message_id
784
- AND r.member_id=q.member_id WHERE q.room_id=? AND (r.id IS NULL OR r.status IN ('pending','delivering'))""", (room["id"],)).fetchone()[0] or 0
785
- if target:
786
- kind = "queue"
787
- for row in connection.execute("""SELECT q.member_id,r.status FROM free_queue q LEFT JOIN member_requests r
788
- ON r.message_id=q.message_id AND r.member_id=q.member_id WHERE q.message_id=? ORDER BY q.id""", (target,)):
789
- if row["member_id"] in by_id:
790
- state = "queued" if row["status"] is None else "speaking" if row["status"] in ("pending", "delivering") else "done"
791
- items.append({"name": by_id[row["member_id"]]["name"], "state": state})
932
+ from .session_room import current_session
933
+ session = current_session(connection, room["id"], active_only=True)
934
+ if session:
935
+ kind = "turns"
936
+ target = session["id"]
937
+ session_plan_state = [list(row) for row in connection.execute("""SELECT id,round,position,member_id,question,status,submission_id
938
+ FROM session_requests WHERE session_id=? AND purpose='discuss' ORDER BY round,position,id""", (session["id"],))]
939
+ active_round = connection.execute("SELECT MIN(round) FROM session_requests WHERE session_id=? AND purpose='discuss' AND status IN ('open','queued')",
940
+ (session["id"],)).fetchone()[0]
941
+ if active_round is None:
942
+ active_round = connection.execute("SELECT COALESCE(MAX(round),1) FROM session_requests WHERE session_id=? AND purpose='discuss'",
943
+ (session["id"],)).fetchone()[0]
944
+ answered = [row["name"] for row in connection.execute("""SELECT m.name FROM session_requests sr JOIN room_members m ON m.id=sr.member_id
945
+ WHERE sr.session_id=? AND sr.purpose='discuss' AND sr.round=? AND sr.status='answered' ORDER BY sr.position,sr.id""",
946
+ (session["id"], active_round))]
947
+ items.extend({"name": name, "state": "done"} for name in answered)
948
+ open_turn = connection.execute("""SELECT m.name FROM session_requests sr JOIN room_members m ON m.id=sr.member_id
949
+ WHERE sr.session_id=? AND sr.purpose='discuss' AND sr.round=? AND sr.status='open'
950
+ ORDER BY sr.position,sr.id LIMIT 1""", (session["id"], active_round)).fetchone()
951
+ if open_turn:
952
+ items.append({"name": open_turn["name"], "state": "speaking"})
953
+ queued = connection.execute("""SELECT sr.round,m.name FROM session_requests sr JOIN room_members m ON m.id=sr.member_id
954
+ WHERE sr.session_id=? AND sr.purpose='discuss' AND sr.status='queued' ORDER BY sr.round,sr.position,sr.id""",
955
+ (session["id"],)).fetchall()
956
+ for row in queued:
957
+ if row["round"] == active_round:
958
+ items.append({"name": row["name"], "state": "queued"})
959
+ later = [[row["name"] for row in queued if row["round"] == round_number]
960
+ for round_number in sorted({row["round"] for row in queued if row["round"] > active_round})]
961
+ else:
962
+ target = connection.execute("""SELECT MAX(q.message_id) FROM free_queue q LEFT JOIN member_requests r ON r.message_id=q.message_id
963
+ AND r.member_id=q.member_id WHERE q.room_id=? AND (r.id IS NULL OR r.status IN ('pending','delivering'))""", (room["id"],)).fetchone()[0] or 0
964
+ if target:
965
+ kind = "queue"
966
+ for row in connection.execute("""SELECT q.member_id,r.status FROM free_queue q LEFT JOIN member_requests r
967
+ ON r.message_id=q.message_id AND r.member_id=q.member_id WHERE q.message_id=? ORDER BY q.id""", (target,)):
968
+ if row["member_id"] in by_id:
969
+ state = "queued" if row["status"] is None else "speaking" if row["status"] in ("pending", "delivering") else "done"
970
+ items.append({"name": by_id[row["member_id"]]["name"], "state": state})
792
971
  else:
793
972
  kind = "turns"
794
973
  last = room["rounds"][-1] if room["rounds"] else None
@@ -805,7 +984,7 @@ def speaking_order(connection, room):
805
984
  listed = {item["name"] for item in items}
806
985
  items += [{"name": name, "state": "idle"} for name in dict.fromkeys(ordered) if name not in listed]
807
986
  movable = "queued" if kind != "default" else "idle"
808
- state = json.dumps([kind, target, [[item["name"], item["state"]] for item in items], later], ensure_ascii=False)
987
+ state = json.dumps([kind, target, [[item["name"], item["state"]] for item in items], later, session_plan_state], ensure_ascii=False)
809
988
  return {"kind": kind, "message_id": target, "items": items, "later": later, "movable": [item["name"] for item in items if item["state"] == movable],
810
989
  "default": [member["name"] for member in default_order(connection, room["id"], active)],
811
990
  "revision": hashlib.sha256(state.encode("utf-8")).hexdigest()[:12]}
@@ -848,7 +1027,24 @@ def reorder_speakers(connection, room, token, payload):
848
1027
  for name in order:
849
1028
  connection.execute("INSERT INTO free_queue (room_id,message_id,member_id) VALUES (?,?,?)", (room["id"], current["message_id"], names[name]))
850
1029
  elif current["kind"] == "turns":
851
- reorder_turns(connection, room["id"], order)
1030
+ from .session_room import current_session
1031
+ session = current_session(connection, room["id"], active_only=True)
1032
+ if session:
1033
+ active_round = connection.execute("SELECT MIN(round) FROM session_requests WHERE session_id=? AND purpose='discuss' AND status IN ('open','queued')",
1034
+ (session["id"],)).fetchone()[0]
1035
+ active_round = active_round or 1
1036
+ open_position = connection.execute("SELECT COALESCE(MAX(position),0) FROM session_requests WHERE session_id=? AND purpose='discuss' AND round=? AND status IN ('answered','open')",
1037
+ (session["id"], active_round)).fetchone()[0]
1038
+ rows = connection.execute("SELECT id FROM session_requests WHERE session_id=? AND purpose='discuss' AND round=? AND status='queued'",
1039
+ (session["id"], active_round)).fetchall()
1040
+ connection.execute("UPDATE session_requests SET position=-id WHERE id IN (%s)" % ",".join("?" for _ in rows),
1041
+ [row["id"] for row in rows]) if rows else None
1042
+ for offset, name in enumerate(order, 1):
1043
+ connection.execute("UPDATE session_requests SET position=? WHERE session_id=? AND purpose='discuss' AND round=? AND status='queued' AND member_id=?",
1044
+ (open_position + offset, session["id"], active_round, names[name]))
1045
+ connection.execute("UPDATE session_state SET revision=revision+1 WHERE session_id=?", (session["id"],))
1046
+ else:
1047
+ reorder_turns(connection, room["id"], order)
852
1048
  else:
853
1049
  connection.execute("INSERT OR REPLACE INTO speaking_orders (room_id,member_ids) VALUES (?,?)",
854
1050
  (room["id"], json.dumps([names[name] for name in order])))
@@ -887,6 +1083,15 @@ def after_free_message(connection, room_id, message_id, actor, payload):
887
1083
  """Create requests for the owner's message, or close what the member's answer covers."""
888
1084
  now = int(time.time())
889
1085
  if actor == "用户":
1086
+ from .session_room import create_host_request, current_session
1087
+ session = current_session(connection, room_id, active_only=True)
1088
+ if session and session["status"] == "active" and payload.get("to") is None:
1089
+ create_host_request(connection, session["id"],
1090
+ f"用户留言 #{message_id}:请处理并决定是否安排专家。",
1091
+ {"kind": "user_message", "message_id": message_id, "key": f"message-{message_id}"})
1092
+ connection.execute("UPDATE session_state SET held_since=?,revision=revision+1 WHERE session_id=?",
1093
+ (message_id, session["id"]))
1094
+ return
890
1095
  pairs = recipients(connection, room_id, payload)
891
1096
  if payload.get("sequential") is True and payload.get("to") is not None:
892
1097
  queue_in_turn(connection, room_id, message_id, [member_id for member_id, _ in pairs])
@@ -894,7 +1099,7 @@ def after_free_message(connection, room_id, message_id, actor, payload):
894
1099
  for member_id, purpose in pairs:
895
1100
  insert_request(connection, room_id, member_id, message_id, message_id, purpose, "user")
896
1101
  return
897
- if actor["access"] == "online":
1102
+ if actor.get("manual_source") == "web":
898
1103
  for request_id in payload.get("request_ids", []):
899
1104
  connection.execute("UPDATE member_requests SET status='completed',answer_id=? WHERE id=?", (message_id, request_id))
900
1105
  settle_batches(connection, room_id)
@@ -971,10 +1176,17 @@ def finish_execution(connection, execution_id, status, result, request_status, c
971
1176
  """End one execution and move its delivered requests in the same transaction."""
972
1177
  if request_status == "cancelled" and cancel_reason:
973
1178
  record_cancel_reasons(connection, "execution_id=? AND status='delivering'", (execution_id,), cancel_reason)
1179
+ session_requests = connection.execute("""SELECT r.id FROM member_requests r JOIN session_requests sr
1180
+ ON sr.member_request_id=r.id WHERE r.execution_id=? AND sr.purpose='discuss' AND sr.status='open'""",
1181
+ (execution_id,)).fetchall()
974
1182
  connection.execute("UPDATE member_executions SET status=?,finished_at=?,result=? WHERE id=? AND status IN ('dispatched','started')",
975
1183
  (status, int(time.time()), result[:500], execution_id))
976
1184
  connection.execute("UPDATE member_requests SET status=?,execution_id=CASE WHEN ?='pending' THEN NULL ELSE execution_id END WHERE execution_id=? AND status='delivering'",
977
1185
  (request_status, request_status, execution_id))
1186
+ if status == "failed" and request_status == "failed":
1187
+ from .session_room import fail_session_request
1188
+ for row in session_requests:
1189
+ fail_session_request(connection, row["id"], result)
978
1190
 
979
1191
 
980
1192
  def open_route_messages(connection, member_id, pending_only=False):
@@ -1001,6 +1213,26 @@ def stop_member_work(connection, member_id, reason, cancel_reason):
1001
1213
  finish_execution(connection, row["id"], "cancelled", reason, "cancelled", cancel_reason)
1002
1214
  record_cancel_reasons(connection, "member_id=? AND status='pending'", (member_id,), cancel_reason)
1003
1215
  connection.execute("UPDATE member_requests SET status='cancelled' WHERE member_id=? AND status='pending'", (member_id,))
1216
+ from .session_room import advance_session, create_host_request, current_session
1217
+
1218
+ member = connection.execute("SELECT room_id,name FROM room_members WHERE id=?", (member_id,)).fetchone()
1219
+ session = current_session(connection, member["room_id"], active_only=True) if member else None
1220
+ if not session:
1221
+ return
1222
+ plans = connection.execute("""SELECT id,member_request_id FROM session_requests
1223
+ WHERE session_id=? AND member_id=? AND purpose='discuss' AND status='open'""",
1224
+ (session["id"], member_id)).fetchall()
1225
+ for plan in plans:
1226
+ connection.execute("UPDATE session_requests SET status='cancelled' WHERE id=?", (plan["id"],))
1227
+ if not plans:
1228
+ return
1229
+ connection.execute("UPDATE session_state SET revision=revision+1 WHERE session_id=?", (session["id"],))
1230
+ if advance_session(connection, session["id"]):
1231
+ return
1232
+ request_id = plans[0]["member_request_id"]
1233
+ create_host_request(connection, session["id"],
1234
+ f"专家 {member['name']} 的讨论发言已被打断,请检查余下安排并决定如何继续。",
1235
+ {"kind": "request_failed", "request_id": request_id, "key": f"interrupted-{request_id or plans[0]['id']}"})
1004
1236
 
1005
1237
 
1006
1238
  def stop_room_work(connection, room_id, reason, cancel_reason):
@@ -1017,16 +1249,24 @@ def inbox(connection, room_id, token):
1017
1249
  """List what the runner should deliver or reconcile; owner credential only."""
1018
1250
  require_owner(connection, room_id, token)
1019
1251
  begin_write(connection)
1252
+ enforce_session_limits(connection, room_id)
1020
1253
  expire_executions(connection, room_id)
1021
1254
  release_recommendations(connection, room_id)
1022
1255
  archived = connection.execute("SELECT archived FROM room_directory WHERE room_id=?", (room_id,)).fetchone()[0]
1023
1256
  members, observers = [], []
1024
1257
  # Manual and online members are never delivered to; the runner only reads them so smart scheduling can route to them too.
1025
1258
  for row in connection.execute(f"SELECT * FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER} ORDER BY id", (room_id,)):
1026
- pending = [dict(item) for item in connection.execute(
1027
- "SELECT id,message_id,purpose FROM member_requests WHERE member_id=? AND status='pending' ORDER BY id", (row["id"],))]
1028
- (members if row["access"] == "runner" else observers).append({**{key: row[key] for key in MEMBER_FIELDS}, "pending": pending})
1029
- executions = [{**public_execution(row), "member": row["name"]} for row in connection.execute(
1259
+ pending = [dict(item) for item in connection.execute("""SELECT r.id,r.message_id,r.purpose,
1260
+ sr.id AS session_request_id,sr.session_id,sr.round,sr.position,sr.read_until,sr.question AS source_question,
1261
+ t.cause_kind,t.cause_message_id AS source_message_id,t.cause_request_id
1262
+ FROM member_requests r LEFT JOIN session_requests sr ON sr.member_request_id=r.id
1263
+ LEFT JOIN task_message_sources t ON t.session_request_id=sr.id
1264
+ WHERE r.member_id=? AND r.status='pending' ORDER BY r.id""", (row["id"],))]
1265
+ manual_source = connection.execute("SELECT source FROM member_manual_sources WHERE member_id=?", (row["id"],)).fetchone()
1266
+ member_view = {key: row[key] for key in MEMBER_FIELDS}
1267
+ member_view["manual_source"] = manual_source[0] if manual_source else ""
1268
+ (members if row["access"] == "runner" else observers).append({**member_view, "pending": pending})
1269
+ executions = [{**public_execution(row), "member": row["name"], "retry": execution_retry(connection, row["id"])} for row in connection.execute(
1030
1270
  """SELECT member_executions.*,room_members.name FROM member_executions JOIN room_members ON room_members.id=member_id
1031
1271
  WHERE member_executions.room_id=? AND member_executions.status IN ('dispatched','started') ORDER BY member_executions.id""", (room_id,))]
1032
1272
  return {"room_id": room_id, "archived": bool(archived), "members": members, "observers": observers, "executions": executions,
@@ -1155,6 +1395,8 @@ def reserve_execution(connection, room_id, token, payload):
1155
1395
  """Bundle a member's open requests into one execution; a retried client_id returns the same one."""
1156
1396
  require_owner(connection, room_id, token)
1157
1397
  begin_write(connection)
1398
+ if enforce_session_limits(connection, room_id):
1399
+ connection.commit()
1158
1400
  client_id = str(payload.get("client_id", ""))
1159
1401
  if not 8 <= len(client_id) <= 100:
1160
1402
  raise ValueError("client_id 须为 8–100 字符的本地投递编号")
@@ -1171,26 +1413,37 @@ def reserve_execution(connection, room_id, token, payload):
1171
1413
  if connection.execute("SELECT 1 FROM member_executions WHERE member_id=? AND status IN ('dispatched','started')",
1172
1414
  (member["id"],)).fetchone():
1173
1415
  raise FileExistsError("该成员已有进行中的执行;同一会话不并发续接")
1174
- if connection.execute("SELECT 1 FROM discussion_events WHERE room_id=? AND participant=? AND status IN ('dispatched','started')",
1175
- (room_id, member["name"])).fetchone():
1176
- raise FileExistsError("该成员正在处理讨论安排;同一会话不并发续接")
1177
1416
  # A group request waits for the runner to choose how everyone answers; it is never delivered itself.
1178
- if not connection.execute("SELECT 1 FROM member_requests WHERE member_id=? AND status='pending' AND purpose<>'group'", (member["id"],)).fetchone():
1417
+ pending = connection.execute("""SELECT purpose FROM member_requests
1418
+ WHERE member_id=? AND status='pending' AND purpose<>'group' ORDER BY id""", (member["id"],)).fetchall()
1419
+ session_purpose = next((row["purpose"] for row in pending if row["purpose"] == "discuss"), None)
1420
+ if not pending:
1179
1421
  raise FileExistsError("该成员没有待投递的请求")
1422
+ if session_purpose:
1423
+ request_filter = "purpose=?"
1424
+ request_values = (session_purpose,)
1425
+ else:
1426
+ request_filter = "purpose<>'group'"
1427
+ request_values = ()
1180
1428
  now = int(time.time())
1181
1429
  cursor = connection.execute("""INSERT INTO member_executions (room_id,member_id,client_id,lease_until,created_at,cwd)
1182
1430
  VALUES (?,?,?,?,?,?)""", (room_id, member["id"], client_id, now + LEASE_SECONDS, now, str(payload.get("cwd") or "")[:MAX_PATH_LENGTH]))
1183
- connection.execute("UPDATE member_requests SET status='delivering',execution_id=? WHERE member_id=? AND status='pending' AND purpose<>'group'",
1184
- (cursor.lastrowid, member["id"]))
1431
+ connection.execute(f"UPDATE member_requests SET status='delivering',execution_id=? WHERE member_id=? AND status='pending' AND {request_filter}",
1432
+ (cursor.lastrowid, member["id"], *request_values))
1185
1433
  return execution_detail(connection, connection.execute("SELECT * FROM member_executions WHERE id=?", (cursor.lastrowid,)).fetchone())
1186
1434
 
1187
1435
 
1188
1436
  def execution_detail(connection, row):
1189
1437
  """Describe one execution with the requests it carries and the member's read cursor."""
1190
1438
  member = connection.execute("SELECT * FROM room_members WHERE id=?", (row["member_id"],)).fetchone()
1191
- requests = [dict(item) for item in connection.execute(
1192
- "SELECT id,message_id,status,purpose FROM member_requests WHERE execution_id=? ORDER BY id", (row["id"],))]
1193
- return {**public_execution(row), "member": member["name"], "read_until": member["read_until"], "requests": requests}
1439
+ requests = [dict(item) for item in connection.execute("""SELECT r.id,r.message_id,r.status,r.purpose,
1440
+ sr.id AS session_request_id,sr.session_id,sr.round,sr.position,sr.read_until,sr.question AS source_question,
1441
+ t.cause_kind,t.cause_message_id AS source_message_id,t.cause_request_id
1442
+ FROM member_requests r LEFT JOIN session_requests sr ON sr.member_request_id=r.id
1443
+ LEFT JOIN task_message_sources t ON t.session_request_id=sr.id
1444
+ WHERE r.execution_id=? ORDER BY r.id""", (row["id"],))]
1445
+ return {**public_execution(row), "member": member["name"], "read_until": member["read_until"], "requests": requests,
1446
+ "rewound_executions": member_rewinds(connection, member["id"]), "retry": execution_retry(connection, row["id"])}
1194
1447
 
1195
1448
 
1196
1449
  def view_execution(connection, room_id, execution_id, token):
@@ -1216,6 +1469,17 @@ def changed_files(payload):
1216
1469
  return sorted(set(files))
1217
1470
 
1218
1471
 
1472
+ def denied_actions(payload):
1473
+ """Validate the operations a member's software refused: the tool, what it tried, and the rule that would allow it."""
1474
+ denials = payload.get("denials")
1475
+ def valid(item):
1476
+ return (isinstance(item, dict) and isinstance(item.get("tool"), str) and 0 < len(item["tool"]) <= 40
1477
+ and all(isinstance(item.get(key, ""), str) and len(item.get(key, "")) <= MAX_DENIAL_FIELD for key in ("detail", "rule")))
1478
+ if not isinstance(denials, list) or not 0 < len(denials) <= MAX_DENIALS or not all(map(valid, denials)):
1479
+ raise ValueError(f"denials 须为 1–{MAX_DENIALS} 条 {{tool, detail, rule}}")
1480
+ return [{key: item.get(key, "") for key in ("tool", "detail", "rule")} for item in denials]
1481
+
1482
+
1219
1483
  def renewed_activity(payload, row, now):
1220
1484
  """Validate the runner's reported phase; its output time is kept within the execution, since the runner's clock may differ."""
1221
1485
  activity, activity_at = payload.get("activity"), payload.get("activity_at")
@@ -1238,18 +1502,35 @@ def renewed_notes(payload):
1238
1502
 
1239
1503
  def execution_action(connection, room_id, execution_id, token, payload):
1240
1504
  """Runner-side lifecycle: renew, fail, release undelivered work, or report observed file changes."""
1241
- require_owner(connection, room_id, token)
1242
1505
  begin_write(connection)
1506
+ action = payload.get("action")
1507
+ member = None
1508
+ if action == "fail":
1509
+ try:
1510
+ require_owner(connection, room_id, token)
1511
+ except PermissionError:
1512
+ member = require_member(connection, room_id, token)
1513
+ else:
1514
+ require_owner(connection, room_id, token)
1515
+ if enforce_session_limits(connection, room_id):
1516
+ connection.commit()
1243
1517
  row = connection.execute("SELECT * FROM member_executions WHERE room_id=? AND id=?", (room_id, execution_id)).fetchone()
1244
1518
  if not row:
1245
1519
  raise LookupError("执行不存在")
1246
- action = payload.get("action")
1520
+ if member and member["id"] != row["member_id"]:
1521
+ raise PermissionError("只能报告自己执行的失败")
1247
1522
  now = int(time.time())
1248
1523
  if action == "report":
1249
1524
  # Observed after the process ends, so the execution may already be complete; recorded once.
1250
1525
  if row["changes"]:
1251
1526
  raise FileExistsError("本次执行的改动已记录")
1252
1527
  connection.execute("UPDATE member_executions SET changes=? WHERE id=?", (json.dumps(changed_files(payload), ensure_ascii=False), execution_id))
1528
+ elif action == "denials":
1529
+ # Like file changes, read from the software's output once its process ends; recorded once.
1530
+ saved = connection.execute("INSERT OR IGNORE INTO execution_denials (execution_id,room_id,denials) VALUES (?,?,?)",
1531
+ (execution_id, room_id, json.dumps(denied_actions(payload), ensure_ascii=False)))
1532
+ if not saved.rowcount:
1533
+ raise FileExistsError("本次执行的拒绝记录已记录")
1253
1534
  elif action == "segments":
1254
1535
  # The last segments and notes arrive after the member's answer ended the execution; such notes are dated at its end.
1255
1536
  finished = 0 if row["status"] in ACTIVE else (row["finished_at"] or 1)
@@ -1270,6 +1551,24 @@ def execution_action(connection, room_id, execution_id, token, payload):
1270
1551
  (min(now + LEASE_SECONDS, limit), activity, activity_at, execution_id))
1271
1552
  connection.executemany("INSERT OR IGNORE INTO execution_notes (execution_id,seq,room_id,text,created_at) VALUES (?,?,?,?,?)",
1272
1553
  [(execution_id, note["seq"], room_id, note["text"], now) for note in notes])
1554
+ elif action == "retry_wait":
1555
+ # A manual member's session refused the delivery: the runner keeps the execution and delivers again at retry_at.
1556
+ attempt, retry_at, reason = payload.get("attempt"), payload.get("retry_at"), payload.get("reason")
1557
+ if (type(attempt) is not int or not 1 <= attempt <= MAX_RETRIES or type(retry_at) is not int or retry_at <= 0
1558
+ or not isinstance(reason, str) or not 0 < len(reason) <= MAX_RETRY_REASON):
1559
+ raise ValueError(f"retry_wait 须带 attempt(1–{MAX_RETRIES})、秒级 retry_at 与 reason(最多 {MAX_RETRY_REASON} 字)")
1560
+ connection.execute("""INSERT INTO execution_retries (execution_id,room_id,attempt,retry_at,reason,stopped) VALUES (?,?,?,?,?,0)
1561
+ ON CONFLICT(execution_id) DO UPDATE SET attempt=excluded.attempt,retry_at=excluded.retry_at,reason=excluded.reason,stopped=0""",
1562
+ (execution_id, room_id, attempt, retry_at, reason))
1563
+ elif action == "retry_start":
1564
+ connection.execute("UPDATE execution_retries SET retry_at=0 WHERE execution_id=?", (execution_id,))
1565
+ elif action in ("retry_now", "retry_stop"):
1566
+ # The owner's answer to a waiting retry: deliver now, or stop so the runner fails the execution.
1567
+ change = "retry_at=?" if action == "retry_now" else "stopped=1"
1568
+ updated = connection.execute(f"UPDATE execution_retries SET {change} WHERE execution_id=? AND retry_at>0 AND stopped=0",
1569
+ ((now, execution_id) if action == "retry_now" else (execution_id,)))
1570
+ if not updated.rowcount:
1571
+ raise FileExistsError("本次执行没有在等待自动重试")
1273
1572
  elif action == "fail":
1274
1573
  finish_execution(connection, execution_id, "failed", str(payload.get("reason") or "执行失败"), "failed")
1275
1574
  settle_batches(connection, room_id)
@@ -1277,7 +1576,7 @@ def execution_action(connection, room_id, execution_id, token, payload):
1277
1576
  # Only for proven non-delivery: the requests return to the queue unchanged.
1278
1577
  finish_execution(connection, execution_id, "cancelled", str(payload.get("reason") or "未送达"), "pending")
1279
1578
  else:
1280
- raise ValueError("action 须为 renew、fail、release、report 或 segments")
1579
+ raise ValueError("action 须为 renew、fail、release、report、segments、retry_wait、retry_start、retry_now 或 retry_stop")
1281
1580
  return execution_detail(connection, connection.execute("SELECT * FROM member_executions WHERE id=?", (execution_id,)).fetchone())
1282
1581
 
1283
1582
 
@@ -1299,6 +1598,137 @@ def interrupt_member(connection, room_id, member_id, token):
1299
1598
  return free_view(connection, room_id)
1300
1599
 
1301
1600
 
1601
+ def withdrawals(connection, room_id):
1602
+ """The room's taken-back messages by id, so the page keeps them in place while AI readers skip them."""
1603
+ return [dict(row) for row in connection.execute("""SELECT w.message_id,w.kind FROM message_withdrawals w
1604
+ JOIN messages m ON m.id=w.message_id WHERE m.room_id=? ORDER BY w.message_id""", (room_id,))]
1605
+
1606
+
1607
+ def owner_message(connection, room_id, message_id):
1608
+ """One visible message of the user in a free room without an open discussion, which a retract or rewind may take back."""
1609
+ row = connection.execute("SELECT * FROM messages WHERE room_id=? AND id=?", (room_id, message_id)).fetchone()
1610
+ if not row:
1611
+ raise LookupError("消息不存在")
1612
+ if row["author"] != "用户":
1613
+ raise ValueError("只能撤回或编辑用户自己的消息")
1614
+ if room_mode(connection, room_id) != "free":
1615
+ raise ValueError("只有协作房间的消息可以撤回或编辑")
1616
+ if connection.execute("SELECT 1 FROM room_directory WHERE room_id=? AND archived=1", (room_id,)).fetchone():
1617
+ raise ValueError("房间已存档;恢复后才能撤回或编辑")
1618
+ # A discussion's host reads the user's messages itself, with no request that shows whether it began.
1619
+ if connection.execute("SELECT 1 FROM discussion_sessions WHERE room_id=? AND (status IN ('active','paused') OR started_message_id>=?)",
1620
+ (room_id, message_id)).fetchone():
1621
+ raise FileExistsError("讨论进行中,或这条消息之后开启过讨论,不能撤回或编辑")
1622
+ return row
1623
+
1624
+
1625
+ def retract_message(connection, room_id, message_id, token):
1626
+ """Owner takes back a message no member began to handle: its queued requests end and AI readers no longer see it.
1627
+
1628
+ Bound to one known message, so it takes no revision; a message any member started, answered or failed is refused.
1629
+ A member working on an earlier message may still have read it, which the room cannot know.
1630
+
1631
+ Returns:
1632
+ `{"message_id", "text", "duplicate"}`; the text lets the page put it back in the input for editing.
1633
+ """
1634
+ require_owner(connection, room_id, token)
1635
+ begin_write(connection)
1636
+ message = owner_message(connection, room_id, message_id)
1637
+ if connection.execute("SELECT 1 FROM message_withdrawals WHERE message_id=?", (message_id,)).fetchone():
1638
+ return {"message_id": message_id, "text": message["text"], "duplicate": True}
1639
+ if connection.execute("SELECT 1 FROM member_requests WHERE message_id=? AND status IN ('delivering','completed','failed')", (message_id,)).fetchone():
1640
+ raise FileExistsError("已有成员开始处理这条消息,不能撤回;可以编辑这条消息回退到它之前")
1641
+ connection.execute("UPDATE member_requests SET status='cancelled' WHERE message_id=? AND status='pending'", (message_id,))
1642
+ connection.execute("DELETE FROM free_queue WHERE message_id=?", (message_id,))
1643
+ connection.execute("INSERT INTO message_withdrawals (message_id,kind,created_at) VALUES (?,'retracted',?)", (message_id, int(time.time())))
1644
+ settle_batches(connection, room_id)
1645
+ return {"message_id": message_id, "text": message["text"], "duplicate": False}
1646
+
1647
+
1648
+ # Software whose session the runner can cut before a taken-back execution; an API member rebuilds its context each time.
1649
+ CUTTABLE_ADAPTERS = ("claude", "codex", "codex_cli", "antigravity_cli")
1650
+ # Executions a rewind takes back: any touching a message from the rewind point on, and any still running.
1651
+ REWOUND_EXECUTIONS = """SELECT e.id,e.member_id FROM member_executions e WHERE e.room_id=? AND (e.status IN ('dispatched','started')
1652
+ 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"""
1653
+
1654
+
1655
+ def rewind_plan(connection, room_id, message_id):
1656
+ """What rewinding to just before a message takes back, and a revision of the state it was read from.
1657
+
1658
+ Each member reports what happens to its session: `cut` (the runner cuts it before the first taken-back execution),
1659
+ `fresh` (an API member, which rebuilds its context), `unchanged` (no taken-back execution) or `kept` (manual, online or
1660
+ App software whose own window keeps what it saw).
1661
+ """
1662
+ owner_message(connection, room_id, message_id)
1663
+ if connection.execute("SELECT 1 FROM message_withdrawals WHERE message_id=?", (message_id,)).fetchone():
1664
+ raise FileExistsError("这条消息已被撤回或回退")
1665
+ executions = {}
1666
+ for row in connection.execute(REWOUND_EXECUTIONS, (room_id, message_id, message_id)):
1667
+ executions.setdefault(row["member_id"], []).append(row["id"])
1668
+ members = []
1669
+ for row in connection.execute("SELECT id,name,access,adapter FROM room_members WHERE room_id=? AND status<>'removed' ORDER BY id", (room_id,)):
1670
+ taken = executions.get(row["id"], [])
1671
+ session = ("kept" if row["access"] != "runner" or row["adapter"] not in (*CUTTABLE_ADAPTERS, "api")
1672
+ else "unchanged" if not taken else "fresh" if row["adapter"] == "api" else "cut")
1673
+ members.append({"id": row["id"], "name": row["name"], "executions": taken, "session": session})
1674
+ count = connection.execute("""SELECT COUNT(*) FROM messages m WHERE m.room_id=? AND m.id>=?
1675
+ AND NOT EXISTS(SELECT 1 FROM message_withdrawals w WHERE w.message_id=m.id)""", (room_id, message_id)).fetchone()[0]
1676
+ latest = connection.execute("SELECT COALESCE(MAX(id),0) FROM messages WHERE room_id=?", (room_id,)).fetchone()[0]
1677
+ state = json.dumps([message_id, latest, count, members])
1678
+ return {"message_id": message_id, "messages": count, "members": members,
1679
+ "revision": hashlib.sha256(state.encode("utf-8")).hexdigest()[:12]}
1680
+
1681
+
1682
+ def rewind_room(connection, room_id, token, payload):
1683
+ """Owner returns the room to just before one of its messages, as editing that message does.
1684
+
1685
+ Read the plan first (`GET rewind?message_id=`) and send its revision: a missing revision is refused and a changed
1686
+ one conflicts. The message and everything after it stay on the page but leave AI reads; running work stops, open
1687
+ requests on them end, members read again from before the message, and each runner member's session is cut before
1688
+ its first taken-back execution when it next works. Files the members changed are not restored.
1689
+ """
1690
+ require_owner(connection, room_id, token)
1691
+ begin_write(connection)
1692
+ message_id = payload.get("message_id")
1693
+ if type(message_id) is not int:
1694
+ raise ValueError("须提供要回退到其之前的消息编号 message_id")
1695
+ if payload.get("revision") is None:
1696
+ raise ValueError("回退前须先读取回退计划(GET rewind?message_id=),并带上其 revision")
1697
+ plan = rewind_plan(connection, room_id, message_id)
1698
+ if payload["revision"] != plan["revision"]:
1699
+ raise FileExistsError("房间已有新的消息或执行,请重新读取回退计划后再确认")
1700
+ now = int(time.time())
1701
+ for row in connection.execute("SELECT id FROM member_executions WHERE room_id=? AND status IN ('dispatched','started')", (room_id,)).fetchall():
1702
+ finish_execution(connection, row["id"], "cancelled", "用户回退了对话", "cancelled", "user_stop")
1703
+ record_cancel_reasons(connection, "room_id=? AND message_id>=? AND status='pending'", (room_id, message_id), "user_stop")
1704
+ connection.execute("UPDATE member_requests SET status='cancelled' WHERE room_id=? AND message_id>=? AND status='pending'", (room_id, message_id))
1705
+ connection.execute("DELETE FROM free_queue WHERE room_id=? AND message_id>=?", (room_id, message_id))
1706
+ # Batches of taken-back dispatches never hand back, and a dispatch the round limit held from them is never sent.
1707
+ connection.execute("UPDATE free_batches SET settled=1 WHERE room_id=? AND batch_id>=?", (room_id, message_id))
1708
+ 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))
1709
+ connection.execute("""INSERT INTO message_withdrawals (message_id,kind,created_at) SELECT id,'rewound',? FROM messages m
1710
+ 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))
1711
+ connection.execute("UPDATE room_members SET read_until=MIN(read_until,?) WHERE room_id=?", (message_id - 1, room_id))
1712
+ cuts = {str(member["id"]): member["executions"] for member in plan["members"] if member["session"] == "cut"}
1713
+ cursor = connection.execute("INSERT INTO room_rewinds (room_id,message_id,cuts,created_at) VALUES (?,?,?,?)",
1714
+ (room_id, message_id, json.dumps(cuts), now))
1715
+ settle_batches(connection, room_id)
1716
+ return {"rewind_id": cursor.lastrowid, **plan}
1717
+
1718
+
1719
+ # The runner checks this many of a member's latest rewinds before each execution.
1720
+ RECENT_REWINDS = 50
1721
+
1722
+
1723
+ def member_rewinds(connection, member_id):
1724
+ """Executions taken back from one member by recent rewinds, oldest first; the runner cuts before the earliest it finds."""
1725
+ taken = set()
1726
+ 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 ?",
1727
+ (member_id, RECENT_REWINDS)):
1728
+ taken.update(json.loads(row["cuts"]).get(str(member_id), []))
1729
+ return sorted(taken)
1730
+
1731
+
1302
1732
  def add_member(connection, room_id, token, payload, now):
1303
1733
  """Owner adds a member while the room keeps running; other members are not interrupted."""
1304
1734
  require_owner(connection, room_id, token)
@@ -1312,6 +1742,7 @@ def add_member(connection, room_id, token, payload, now):
1312
1742
  raise FileExistsError(f"{member['name']} 已是本房间成员;离开的成员请用请回")
1313
1743
  require_fresh_member_token(connection, room_id, member["token"])
1314
1744
  insert_member(connection, room_id, member, now)
1745
+ queue_invited_expert(connection, room_id, member["name"], now)
1315
1746
  sync_participants(connection, room_id)
1316
1747
  return free_view(connection, room_id)
1317
1748
 
@@ -1335,6 +1766,32 @@ def queue_invited_expert(connection, room_id, name, now):
1335
1766
  member = connection.execute("SELECT * FROM room_members WHERE room_id=? AND name=? AND status='active'", (room_id, name)).fetchone()
1336
1767
  if not member or (member["adapter"] == "manual" and not registered_at(connection, member)):
1337
1768
  return
1769
+ from .session_room import current_session
1770
+ session = current_session(connection, room_id)
1771
+ if session and session["status"] != "concluded":
1772
+ planned = connection.execute("SELECT 1 FROM session_requests WHERE session_id=? AND purpose='discuss' AND status IN ('open','queued') LIMIT 1",
1773
+ (session["id"],)).fetchone()
1774
+ if not planned:
1775
+ return
1776
+ round_number = connection.execute("SELECT MIN(round) FROM session_requests WHERE session_id=? AND purpose='discuss' AND status IN ('open','queued')",
1777
+ (session["id"],)).fetchone()[0]
1778
+ if round_number is None:
1779
+ round_number = 1
1780
+ position = connection.execute("SELECT COALESCE(MAX(position),0)+1 FROM session_requests WHERE session_id=? AND purpose='discuss' AND round=?",
1781
+ (session["id"], round_number)).fetchone()[0]
1782
+ invitation = connection.execute("SELECT COUNT(*) FROM session_requests WHERE session_id=? AND member_id=? AND submission_id LIKE 'invite:%'",
1783
+ (session["id"], member["id"])).fetchone()[0] + 1
1784
+ state = connection.execute("SELECT phase,read_until FROM session_state WHERE session_id=?", (session["id"],)).fetchone()
1785
+ question = ("欢迎加入:请先读取议题,独立给出你的开场观点。" if state and state["phase"] == "initial"
1786
+ else "欢迎加入:请先读取议题和已有回答,给出你的观点。")
1787
+ read_until = state["read_until"] if state and state["phase"] == "initial" and round_number == 1 else None
1788
+ connection.execute("""INSERT OR IGNORE INTO session_requests
1789
+ (session_id,round,position,member_id,purpose,question,status,submission_id,read_until,created_at)
1790
+ VALUES(?,?,?,?,'discuss',?,'queued',?,?,?)""",
1791
+ (session["id"], round_number, position, member["id"], question,
1792
+ f"invite:{member['id']}:{invitation}", read_until,
1793
+ time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())))
1794
+ return
1338
1795
  connection.execute("""INSERT OR IGNORE INTO discussion_turns (room_id,expert,question,status,submission_id,created_at)
1339
1796
  SELECT settings.room_id,member.name,
1340
1797
  CASE WHEN settings.phase='initial' THEN '欢迎加入:请先读取议题,独立给出你的开场观点。'
@@ -1375,7 +1832,7 @@ def discussion_member_action(connection, room_id, member_id, token, payload):
1375
1832
  """Owner sends an expert away (keeping its seat), brings it back with a new credential, or removes it.
1376
1833
 
1377
1834
  Leaving cancels the expert's open assignments; the host keeps its role and cannot leave, and an
1378
- expert with an open assignment can only leave while the discussion is paused.
1835
+ leaving cancels its active plan and asks the host to re-plan without pausing the discussion.
1379
1836
  """
1380
1837
  member = owned_member(connection, room_id, member_id, token)
1381
1838
  host, experts, phase = discussion_roster(connection, room_id)
@@ -1383,10 +1840,8 @@ def discussion_member_action(connection, room_id, member_id, token, payload):
1383
1840
  if action in ("away", "remove"):
1384
1841
  if member["name"] == host:
1385
1842
  raise ValueError("主持人不能离开或移除;请先更换主持人")
1386
- speaking = connection.execute("SELECT 1 FROM discussion_turns WHERE room_id=? AND expert=? AND status IN ('pending','notified')",
1387
- (room_id, member["name"])).fetchone()
1388
- if speaking and phase != "paused":
1389
- raise ValueError("该专家正在发言;请先暂停讨论再离开或移除")
1843
+ from .session_room import session_member_change
1844
+ session_member_change(connection, room_id, member, action)
1390
1845
  connection.execute("UPDATE discussion_turns SET status='cancelled' WHERE room_id=? AND expert=? AND status IN ('pending','notified')",
1391
1846
  (room_id, member["name"]))
1392
1847
  connection.execute("UPDATE discussion_turns SET status='skipped' WHERE room_id=? AND expert=? AND status='queued'", (room_id, member["name"]))
@@ -1399,9 +1854,13 @@ def discussion_member_action(connection, room_id, member_id, token, payload):
1399
1854
  if member["status"] != "away" or not hashed or hmac.compare_digest(hashed, member["token_hash"]):
1400
1855
  raise ValueError("只有离开的成员可以请回,且须提供新的成员凭证")
1401
1856
  require_fresh_member_token(connection, room_id, payload.get("token"))
1402
- connection.execute("UPDATE discussion_turns SET status='skipped' WHERE room_id=? AND status='queued'", (room_id,))
1403
1857
  connection.execute("UPDATE room_members SET status='active',token_hash=? WHERE id=?", (hashed, member_id))
1404
1858
  experts[member["name"]] = "online" if member["access"] == "online" else "cli"
1859
+ from .session_room import current_session, session_member_change
1860
+ if current_session(connection, room_id, active_only=True):
1861
+ session_member_change(connection, room_id, member, action)
1862
+ else:
1863
+ queue_invited_expert(connection, room_id, member["name"], int(time.time()))
1405
1864
  elif action == "adopt":
1406
1865
  adopt_manual_member(connection, room_id, member, payload)
1407
1866
  return
@@ -1410,7 +1869,7 @@ def discussion_member_action(connection, room_id, member_id, token, payload):
1410
1869
  save_discussion_experts(connection, room_id, experts)
1411
1870
 
1412
1871
 
1413
- ADOPTABLE_ADAPTERS = ("claude", "codex", "codex_cli", "antigravity_cli")
1872
+ ADOPTABLE_ADAPTERS = ("claude", "codex", "codex_cli", "antigravity", "antigravity_cli")
1414
1873
 
1415
1874
 
1416
1875
  def adopt_manual_member(connection, room_id, member, payload):
@@ -1447,9 +1906,16 @@ def member_action(connection, room_id, member_id, token, payload):
1447
1906
  `role` action makes one runner or manual member the AI host, replacing any previous host.
1448
1907
  """
1449
1908
  member = owned_member(connection, room_id, member_id, token)
1909
+ manual_source = connection.execute("SELECT source FROM member_manual_sources WHERE member_id=?", (member_id,)).fetchone()
1910
+ is_web_member = bool(manual_source and manual_source["source"] == "web")
1450
1911
  action = payload.get("action")
1451
1912
  routed = []
1452
1913
  if action in ("away", "remove"):
1914
+ from .session_room import pause_session_for_host_change, session_member_change
1915
+ if member["role"] == "host":
1916
+ pause_session_for_host_change(connection, room_id)
1917
+ else:
1918
+ session_member_change(connection, room_id, member, action)
1453
1919
  # Unnamed messages still waiting on a departing host move to its successor instead of being dropped.
1454
1920
  routed = open_route_messages(connection, member_id)
1455
1921
  stop_member_work(connection, member_id, "成员已离开" if action == "away" else "成员已移除",
@@ -1463,15 +1929,22 @@ def member_action(connection, room_id, member_id, token, payload):
1463
1929
  raise ValueError("只有离开的成员可以请回,且须提供新的成员凭证")
1464
1930
  require_fresh_member_token(connection, room_id, payload.get("token"))
1465
1931
  connection.execute("UPDATE room_members SET status='active',token_hash=? WHERE id=?", (hashed, member_id))
1932
+ from .session_room import current_session, session_member_change
1933
+ if current_session(connection, room_id, active_only=True):
1934
+ session_member_change(connection, room_id, member, action)
1935
+ else:
1936
+ queue_invited_expert(connection, room_id, member["name"], int(time.time()))
1466
1937
  elif action == "adopt":
1467
1938
  adopt_manual_member(connection, room_id, member, payload)
1468
1939
  elif action == "role":
1469
1940
  role = payload.get("role")
1470
- if role not in ("host", "member") or (role == "host" and member["access"] == "online"):
1941
+ if role not in ("host", "member") or (role == "host" and (member["access"] == "online" or is_web_member)):
1471
1942
  raise ValueError("role 须为 host 或 member;在线成员不能担任主持人")
1472
1943
  if role == "member" and member["role"] == "host":
1473
1944
  raise ValueError("自由房间须由一位 AI 主持;请指定另一位成员担任主持人")
1474
1945
  if role == "host":
1946
+ from .session_room import pause_session_for_host_change
1947
+ pause_session_for_host_change(connection, room_id)
1475
1948
  # Unnamed messages the old host has not started move to the new host, which carries the host rules.
1476
1949
  old_host = connection.execute("SELECT id FROM room_members WHERE room_id=? AND role='host' AND id<>?", (room_id, member_id)).fetchone()
1477
1950
  if old_host: