ai_co_discuss 0.2.99 → 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 (39) 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 +3 -3
  6. package/.agents/skills/discuss-participant/references/discussion.md +29 -27
  7. package/.agents/skills/discuss-participant/references/free.md +2 -2
  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 +360 -68
  14. package/backend/room_import.py +75 -15
  15. package/backend/runtime.py +81 -169
  16. package/backend/schema.sql +136 -244
  17. package/backend/server.py +602 -166
  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 +9 -6
  23. package/docs/runner.md +3 -1
  24. package/docs/workflow.md +11 -8
  25. package/package.json +2 -2
  26. package/runner/api_participant.mjs +47 -44
  27. package/runner/discuss.py +77 -58
  28. package/runner/discussion_adapters.mjs +6 -3
  29. package/runner/discussion_launch.mjs +98 -70
  30. package/runner/discussion_runner.mjs +38 -496
  31. package/runner/discussion_service.mjs +21 -74
  32. package/runner/discussion_sidecar.mjs +4 -13
  33. package/runner/expert_prompts.mjs +29 -21
  34. package/runner/free_runner.mjs +84 -19
  35. package/runner/session_driver.mjs +175 -0
  36. package/web/app.js +193 -128
  37. package/web/backup.js +1 -1
  38. package/web/modern.css +1 -1
  39. package/web/modern.js +5482 -6646
@@ -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
@@ -79,8 +80,12 @@ MAX_PATH_LENGTH = 500
79
80
  # Refused operations one execution may report, and the length of each field.
80
81
  MAX_DENIALS = 20
81
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
82
86
  ACTIVE = ("dispatched", "started")
83
- ACCESS_MODES = ("runner", "manual", "online")
87
+ ACCESS_MODES = ("runner", "manual")
88
+ LEGACY_ACCESS_MODES = ("runner", "manual", "online")
84
89
  SCHEDULER_PROVIDERS = ("jev", "decisions")
85
90
  RECOMMEND_SECONDS = 60
86
91
  # A click sent just before the hint disappears still counts.
@@ -93,7 +98,8 @@ MAX_CHAIN_ROUNDS = 20
93
98
  # A host that left gives up the role; the first active runner member (else manual) then takes it.
94
99
  RELEASE_HOST = "UPDATE room_members SET role='member' WHERE room_id=? AND role='host' AND status<>'active'"
95
100
  ASSIGN_HOST = """UPDATE room_members SET role='host' WHERE id=(SELECT id FROM room_members WHERE room_id=? AND status='active'
96
- 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)
97
103
  AND NOT EXISTS (SELECT 1 FROM room_members WHERE room_id=? AND role='host' AND status='active')"""
98
104
  TERMINAL = ("completed", "cancelled", "failed")
99
105
  # A queued member has been asked once its request for that message exists.
@@ -118,6 +124,13 @@ def migrate_free_tables(connection):
118
124
  for name, definition in columns.items():
119
125
  if name not in present:
120
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'")
121
134
 
122
135
 
123
136
  def room_mode(connection, room_id):
@@ -136,9 +149,15 @@ def member_value(member):
136
149
  if not isinstance(adapter, str) or len(adapter) > 40:
137
150
  raise ValueError("成员适配器名称无效")
138
151
  access, session_mode = member.get("access", "runner"), member.get("session_mode", "new")
139
- 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:
140
153
  raise ValueError("成员接入方式须为 runner/manual/online,会话方式须为 new/borrowed")
141
- 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}
142
161
 
143
162
 
144
163
  def member_values(members):
@@ -158,6 +177,10 @@ def insert_member(connection, room_id, member, now):
158
177
  connection.execute("""INSERT INTO room_members (room_id,name,adapter,token_hash,access,session_mode,created_at)
159
178
  VALUES (?,?,?,?,?,?,?)""", (room_id, member["name"], member["adapter"], token_hash(member["token"]),
160
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,))
161
184
 
162
185
 
163
186
  def save_room_access(connection, room_id, control_token, members, settings):
@@ -173,10 +196,12 @@ def save_room_access(connection, room_id, control_token, members, settings):
173
196
  connection.execute("UPDATE room_members SET role='host' WHERE room_id=? AND name=?", (room_id, settings["host"]))
174
197
 
175
198
 
176
- def discussion_access_ok(access, capability, automatic, is_host):
177
- """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."""
178
201
  if capability == "online":
179
- return access == "online"
202
+ return access == "manual" and manual_source == "web" and not is_host
203
+ if manual_source == "web":
204
+ return False
180
205
  if not automatic:
181
206
  return access == "manual"
182
207
  return access == "runner" or (access == "manual" and not is_host)
@@ -194,24 +219,28 @@ def discussion_members(payload, settings):
194
219
  raise ValueError("成员凭证名单须与参与者名单一致")
195
220
  for member in members:
196
221
  if not discussion_access_ok(member["access"], settings["experts"][member["name"]],
197
- payload.get("runtime_mode") == "automatic", member["name"] == settings["host"]):
222
+ payload.get("runtime_mode") == "automatic", member["name"] == settings["host"],
223
+ member["manual_source"]):
198
224
  raise ValueError("成员接入方式与房间参与能力、任务交付方式不符")
199
225
  return [mark_manual(member, payload.get("runtime_mode") == "automatic") for member in members]
200
226
 
201
227
 
202
228
  def sync_discussion_members(connection, room_id, payload, settings):
203
229
  """Keep existing credentials; require fresh credentials for added or changed members."""
204
- 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,))}
205
233
  supplied = {member["name"]: member_value(member) for member in payload.get("members", [])}
206
234
  run = connection.execute("SELECT mode FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
207
235
  for name, capability in settings["experts"].items():
208
236
  automatic = bool(run and run[0] == "automatic")
209
237
  old = current.get(name)
210
- if old and (old["access"] == "online") == (capability == "online"):
238
+ if old and (old["manual_source"] == "web") == (capability == "online"):
211
239
  if name in supplied and token_hash(supplied[name]["token"]) != old["token_hash"]:
212
240
  raise FileExistsError("成员凭证与已登记身份不符;请核对原接入文件")
213
241
  continue
214
- 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"]):
215
244
  raise ValueError(f"新增或改变接入方式的成员 {name} 须提供独立的新凭证")
216
245
  member = mark_manual(supplied[name], automatic)
217
246
  hashed = token_hash(member["token"])
@@ -228,8 +257,10 @@ def sync_participants(connection, room_id):
228
257
  """Keep one active AI host, then mirror member capabilities and the host into shared room metadata."""
229
258
  connection.execute(RELEASE_HOST, (room_id,))
230
259
  connection.execute(ASSIGN_HOST, (room_id, room_id))
231
- members = connection.execute("SELECT name,access,role,status FROM room_members WHERE room_id=? AND status<>'removed' ORDER BY id", (room_id,)).fetchall()
232
- 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}
233
264
  host = next((row["name"] for row in members if row["role"] == "host" and row["status"] == "active"), "用户")
234
265
  connection.execute("UPDATE topic_settings SET experts=?,host=? WHERE room_id=?",
235
266
  (json.dumps(experts, ensure_ascii=False), host, room_id))
@@ -237,6 +268,12 @@ def sync_participants(connection, room_id):
237
268
 
238
269
  def execution_minutes(connection, room_id):
239
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
240
277
  row = connection.execute("SELECT execution_minutes FROM discussion_limits WHERE room_id=?", (room_id,)).fetchone()
241
278
  return (row[0] if row and row[0] else FREE_EXECUTION_MINUTES)
242
279
 
@@ -246,6 +283,12 @@ def public_execution(row):
246
283
  return {key: row[key] for key in EXECUTION_FIELDS} if row else None
247
284
 
248
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
+
249
292
  def member_descriptions(connection, room_id):
250
293
  """The owner's descriptions of members by name; everyone who can read the room may read them."""
251
294
  return {row["name"]: row["description"] for row in connection.execute(
@@ -302,10 +345,21 @@ def note_count(connection, execution_id):
302
345
  return connection.execute("SELECT COUNT(*) FROM execution_notes WHERE execution_id=?", (execution_id,)).fetchone()[0]
303
346
 
304
347
 
305
- def execution_notes(connection, room_id, execution_id):
348
+ def execution_notes(connection, room_id, execution_id, token=""):
306
349
  """Read one execution's progress notes in order, with the same access as the room view."""
307
- 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:
308
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": []}
309
363
  return {"notes": [dict(row) for row in connection.execute(
310
364
  "SELECT seq,text,created_at FROM execution_notes WHERE execution_id=? ORDER BY seq", (execution_id,))]}
311
365
 
@@ -338,17 +392,42 @@ def execution_timeline(connection, room_id, after=0):
338
392
 
339
393
  def recent_executions(connection, room_id):
340
394
  """Return bounded public reports, including requests no longer in the recent request list."""
341
- rows = connection.execute("""SELECT e.*,
395
+ rows = connection.execute("""SELECT e.*,m.name AS member,
342
396
  (SELECT json_group_array(id) FROM member_requests WHERE execution_id=e.id) AS request_ids,
343
397
  (SELECT MAX(answer_id) FROM member_requests WHERE execution_id=e.id) AS answer_id,
344
398
  (SELECT MIN(message_id) FROM member_requests WHERE execution_id=e.id) AS request_message_id,
345
399
  (SELECT COUNT(*) FROM execution_notes WHERE execution_id=e.id) AS note_count,
346
400
  (SELECT denials FROM execution_denials WHERE execution_id=e.id) AS denials
347
- FROM member_executions e WHERE room_id=? ORDER BY id DESC LIMIT ?""", (room_id, RECENT_EXECUTIONS))
348
- return [{**public_execution(row), "cwd": row["cwd"], "changes": json.loads(row["changes"] or "[]"),
349
- "changes_recorded": bool(row["changes"]), "request_ids": json.loads(row["request_ids"]),
350
- "answer_id": row["answer_id"], "request_message_id": row["request_message_id"], "notes": row["note_count"],
351
- "denials": json.loads(row["denials"] or "[]")} for row in rows]
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}
352
431
 
353
432
 
354
433
  def possible_conflicts(connection, room_id, names):
@@ -379,7 +458,8 @@ def possible_conflicts(connection, room_id, names):
379
458
 
380
459
 
381
460
  # An invited manual member that has not registered counts as absent: it receives no request and is never a target.
382
- 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
383
463
  WHERE r.member_id=room_members.id AND r.token_hash=room_members.token_hash))"""
384
464
 
385
465
 
@@ -389,6 +469,8 @@ def free_view(connection, room_id):
389
469
  members = []
390
470
  for row in connection.execute("SELECT * FROM room_members WHERE room_id=? AND status<>'removed' ORDER BY id", (room_id,)):
391
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 ""
392
474
  member["registered_at"] = registered_at(connection, row)
393
475
  reported = connection.execute("SELECT session_id,client FROM member_sessions WHERE member_id=?", (row["id"],)).fetchone()
394
476
  member["reported_session"], member["reported_client"] = (reported["session_id"], reported["client"]) if reported else ("", "")
@@ -398,11 +480,13 @@ def free_view(connection, room_id):
398
480
  if latest and latest["status"] in ACTIVE:
399
481
  member["execution"]["stale"] = latest["lease_until"] <= now
400
482
  member["execution"]["notes"] = note_count(connection, latest["id"])
483
+ member["execution"]["retry"] = execution_retry(connection, latest["id"])
401
484
  member["pending"] = connection.execute("SELECT COUNT(*) FROM member_requests WHERE member_id=? AND status='pending'",
402
485
  (row["id"],)).fetchone()[0]
403
486
  members.append(member)
404
487
  names = {row["id"]: row["name"] for row in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (room_id,))}
405
- 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(
406
490
  """SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by,created_at,
407
491
  (SELECT reason FROM request_cancel_reasons c WHERE c.request_id=member_requests.id) AS cancel_reason FROM member_requests
408
492
  WHERE room_id=? ORDER BY id DESC LIMIT ?""", (room_id, RECENT_REQUESTS))]
@@ -422,7 +506,9 @@ def require_member(connection, room_id, token, name=None):
422
506
  (room_id, hashed)).fetchone() if hashed else None
423
507
  if not row or (name is not None and row["name"] != name):
424
508
  raise PermissionError("成员凭证无效、已停用或与发言身份不符")
425
- 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)
426
512
 
427
513
 
428
514
  def registered_at(connection, member):
@@ -468,9 +554,14 @@ def authorize_free_write(connection, room_id, token, operation, payload):
468
554
  if operation not in ("messages", "attachments"):
469
555
  raise
470
556
  author = payload.get("author")
471
- if author == "用户" or payload.get("submitted_by", author) != author:
472
- raise PermissionError("需要本房间的控制凭据;成员不能以用户身份或代他人发言")
473
- 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("成员只能以本人身份发言;只有主持人可以代发在线成员回答")
474
565
 
475
566
 
476
567
  def active_members(connection, room_id, registered_only=True):
@@ -525,11 +616,13 @@ def route_held_messages(connection, room_id, host):
525
616
 
526
617
  def proxy_member(connection, room_id, author):
527
618
  """Return the active online member the owner may forward an answer for."""
528
- row = connection.execute("SELECT * FROM room_members WHERE room_id=? AND name=? AND status='active' AND access='online'",
529
- (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()
530
623
  if not row:
531
624
  raise PermissionError("房间控制凭据只能以用户身份发言,或代发在线成员的回答")
532
- return row
625
+ return dict(row)
533
626
 
534
627
 
535
628
  def check_answer(connection, room_id, member, payload):
@@ -548,11 +641,14 @@ def check_answer(connection, room_id, member, payload):
548
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):
549
642
  raise ValueError("手动接入成员用 request_ids 关联请求,不使用执行编号")
550
643
  purposes = []
644
+ requests = []
551
645
  for request_id in request_ids:
552
646
  row = connection.execute("SELECT * FROM member_requests WHERE room_id=? AND id=?", (room_id, request_id)).fetchone()
553
647
  if not row or row["member_id"] != member["id"] or row["status"] != "pending":
554
648
  raise FileExistsError(f"请求 {request_id} 不属于本成员或已处理")
555
649
  purposes.append(row["purpose"])
650
+ requests.append(row)
651
+ validate_session_request_answer(connection, room_id, requests)
556
652
  check_dispatch(member, payload, purposes)
557
653
  return None
558
654
  execution_id = payload.get("execution_id")
@@ -563,8 +659,10 @@ def check_answer(connection, room_id, member, payload):
563
659
  raise PermissionError("执行编号不属于本成员")
564
660
  if row["status"] not in ACTIVE or row["lease_until"] <= int(time.time()):
565
661
  raise FileExistsError("本次执行已结束、被打断或已过期;回答不能改作普通发言,请保留草稿")
566
- check_dispatch(member, payload, [item[0] for item in connection.execute(
567
- "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])
568
666
  return row
569
667
 
570
668
 
@@ -574,15 +672,33 @@ def check_dispatch(member, payload, purposes):
574
672
  raise ValueError("这次是请全体成员各自回应,其他成员已收到请求;主持人只回答自己的部分,不能用 --to 派活")
575
673
 
576
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
+
577
690
  def check_proxy(connection, room_id, member, payload):
578
691
  """The owner forwards an online member's answer and may close that member's open requests."""
579
692
  request_ids = payload.get("request_ids", [])
580
693
  if not isinstance(request_ids, list) or any(type(item) is not int for item in request_ids):
581
694
  raise ValueError("request_ids 须为请求编号列表")
695
+ requests = []
582
696
  for request_id in request_ids:
583
697
  row = connection.execute("SELECT * FROM member_requests WHERE room_id=? AND id=?", (room_id, request_id)).fetchone()
584
698
  if not row or row["member_id"] != member["id"] or row["status"] != "pending":
585
699
  raise FileExistsError(f"请求 {request_id} 不属于该在线成员或已处理")
700
+ requests.append(row)
701
+ validate_session_request_answer(connection, room_id, requests)
586
702
 
587
703
 
588
704
  def insert_request(connection, room_id, member_id, message_id, chain_id, purpose, created_by, batch_id=0):
@@ -811,16 +927,47 @@ def speaking_order(connection, room):
811
927
  by_id = {member["id"]: member for member in active}
812
928
  ordered = [member["name"] for member in default_order(connection, room["id"], active)]
813
929
  items, kind, target, later = [], "default", 0, []
930
+ session_plan_state = []
814
931
  if room["mode"] == "free":
815
- target = connection.execute("""SELECT MAX(q.message_id) FROM free_queue q LEFT JOIN member_requests r ON r.message_id=q.message_id
816
- 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
817
- if target:
818
- kind = "queue"
819
- for row in connection.execute("""SELECT q.member_id,r.status FROM free_queue q LEFT JOIN member_requests r
820
- ON r.message_id=q.message_id AND r.member_id=q.member_id WHERE q.message_id=? ORDER BY q.id""", (target,)):
821
- if row["member_id"] in by_id:
822
- state = "queued" if row["status"] is None else "speaking" if row["status"] in ("pending", "delivering") else "done"
823
- 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})
824
971
  else:
825
972
  kind = "turns"
826
973
  last = room["rounds"][-1] if room["rounds"] else None
@@ -837,7 +984,7 @@ def speaking_order(connection, room):
837
984
  listed = {item["name"] for item in items}
838
985
  items += [{"name": name, "state": "idle"} for name in dict.fromkeys(ordered) if name not in listed]
839
986
  movable = "queued" if kind != "default" else "idle"
840
- 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)
841
988
  return {"kind": kind, "message_id": target, "items": items, "later": later, "movable": [item["name"] for item in items if item["state"] == movable],
842
989
  "default": [member["name"] for member in default_order(connection, room["id"], active)],
843
990
  "revision": hashlib.sha256(state.encode("utf-8")).hexdigest()[:12]}
@@ -880,7 +1027,24 @@ def reorder_speakers(connection, room, token, payload):
880
1027
  for name in order:
881
1028
  connection.execute("INSERT INTO free_queue (room_id,message_id,member_id) VALUES (?,?,?)", (room["id"], current["message_id"], names[name]))
882
1029
  elif current["kind"] == "turns":
883
- 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)
884
1048
  else:
885
1049
  connection.execute("INSERT OR REPLACE INTO speaking_orders (room_id,member_ids) VALUES (?,?)",
886
1050
  (room["id"], json.dumps([names[name] for name in order])))
@@ -919,6 +1083,15 @@ def after_free_message(connection, room_id, message_id, actor, payload):
919
1083
  """Create requests for the owner's message, or close what the member's answer covers."""
920
1084
  now = int(time.time())
921
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
922
1095
  pairs = recipients(connection, room_id, payload)
923
1096
  if payload.get("sequential") is True and payload.get("to") is not None:
924
1097
  queue_in_turn(connection, room_id, message_id, [member_id for member_id, _ in pairs])
@@ -926,7 +1099,7 @@ def after_free_message(connection, room_id, message_id, actor, payload):
926
1099
  for member_id, purpose in pairs:
927
1100
  insert_request(connection, room_id, member_id, message_id, message_id, purpose, "user")
928
1101
  return
929
- if actor["access"] == "online":
1102
+ if actor.get("manual_source") == "web":
930
1103
  for request_id in payload.get("request_ids", []):
931
1104
  connection.execute("UPDATE member_requests SET status='completed',answer_id=? WHERE id=?", (message_id, request_id))
932
1105
  settle_batches(connection, room_id)
@@ -1003,10 +1176,17 @@ def finish_execution(connection, execution_id, status, result, request_status, c
1003
1176
  """End one execution and move its delivered requests in the same transaction."""
1004
1177
  if request_status == "cancelled" and cancel_reason:
1005
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()
1006
1182
  connection.execute("UPDATE member_executions SET status=?,finished_at=?,result=? WHERE id=? AND status IN ('dispatched','started')",
1007
1183
  (status, int(time.time()), result[:500], execution_id))
1008
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'",
1009
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)
1010
1190
 
1011
1191
 
1012
1192
  def open_route_messages(connection, member_id, pending_only=False):
@@ -1033,6 +1213,26 @@ def stop_member_work(connection, member_id, reason, cancel_reason):
1033
1213
  finish_execution(connection, row["id"], "cancelled", reason, "cancelled", cancel_reason)
1034
1214
  record_cancel_reasons(connection, "member_id=? AND status='pending'", (member_id,), cancel_reason)
1035
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']}"})
1036
1236
 
1037
1237
 
1038
1238
  def stop_room_work(connection, room_id, reason, cancel_reason):
@@ -1049,16 +1249,24 @@ def inbox(connection, room_id, token):
1049
1249
  """List what the runner should deliver or reconcile; owner credential only."""
1050
1250
  require_owner(connection, room_id, token)
1051
1251
  begin_write(connection)
1252
+ enforce_session_limits(connection, room_id)
1052
1253
  expire_executions(connection, room_id)
1053
1254
  release_recommendations(connection, room_id)
1054
1255
  archived = connection.execute("SELECT archived FROM room_directory WHERE room_id=?", (room_id,)).fetchone()[0]
1055
1256
  members, observers = [], []
1056
1257
  # Manual and online members are never delivered to; the runner only reads them so smart scheduling can route to them too.
1057
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,)):
1058
- pending = [dict(item) for item in connection.execute(
1059
- "SELECT id,message_id,purpose FROM member_requests WHERE member_id=? AND status='pending' ORDER BY id", (row["id"],))]
1060
- (members if row["access"] == "runner" else observers).append({**{key: row[key] for key in MEMBER_FIELDS}, "pending": pending})
1061
- 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(
1062
1270
  """SELECT member_executions.*,room_members.name FROM member_executions JOIN room_members ON room_members.id=member_id
1063
1271
  WHERE member_executions.room_id=? AND member_executions.status IN ('dispatched','started') ORDER BY member_executions.id""", (room_id,))]
1064
1272
  return {"room_id": room_id, "archived": bool(archived), "members": members, "observers": observers, "executions": executions,
@@ -1187,6 +1395,8 @@ def reserve_execution(connection, room_id, token, payload):
1187
1395
  """Bundle a member's open requests into one execution; a retried client_id returns the same one."""
1188
1396
  require_owner(connection, room_id, token)
1189
1397
  begin_write(connection)
1398
+ if enforce_session_limits(connection, room_id):
1399
+ connection.commit()
1190
1400
  client_id = str(payload.get("client_id", ""))
1191
1401
  if not 8 <= len(client_id) <= 100:
1192
1402
  raise ValueError("client_id 须为 8–100 字符的本地投递编号")
@@ -1203,27 +1413,37 @@ def reserve_execution(connection, room_id, token, payload):
1203
1413
  if connection.execute("SELECT 1 FROM member_executions WHERE member_id=? AND status IN ('dispatched','started')",
1204
1414
  (member["id"],)).fetchone():
1205
1415
  raise FileExistsError("该成员已有进行中的执行;同一会话不并发续接")
1206
- if connection.execute("SELECT 1 FROM discussion_events WHERE room_id=? AND participant=? AND status IN ('dispatched','started')",
1207
- (room_id, member["name"])).fetchone():
1208
- raise FileExistsError("该成员正在处理讨论安排;同一会话不并发续接")
1209
1416
  # A group request waits for the runner to choose how everyone answers; it is never delivered itself.
1210
- 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:
1211
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 = ()
1212
1428
  now = int(time.time())
1213
1429
  cursor = connection.execute("""INSERT INTO member_executions (room_id,member_id,client_id,lease_until,created_at,cwd)
1214
1430
  VALUES (?,?,?,?,?,?)""", (room_id, member["id"], client_id, now + LEASE_SECONDS, now, str(payload.get("cwd") or "")[:MAX_PATH_LENGTH]))
1215
- connection.execute("UPDATE member_requests SET status='delivering',execution_id=? WHERE member_id=? AND status='pending' AND purpose<>'group'",
1216
- (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))
1217
1433
  return execution_detail(connection, connection.execute("SELECT * FROM member_executions WHERE id=?", (cursor.lastrowid,)).fetchone())
1218
1434
 
1219
1435
 
1220
1436
  def execution_detail(connection, row):
1221
1437
  """Describe one execution with the requests it carries and the member's read cursor."""
1222
1438
  member = connection.execute("SELECT * FROM room_members WHERE id=?", (row["member_id"],)).fetchone()
1223
- requests = [dict(item) for item in connection.execute(
1224
- "SELECT id,message_id,status,purpose FROM member_requests WHERE execution_id=? ORDER BY id", (row["id"],))]
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"],))]
1225
1445
  return {**public_execution(row), "member": member["name"], "read_until": member["read_until"], "requests": requests,
1226
- "rewound_executions": member_rewinds(connection, member["id"])}
1446
+ "rewound_executions": member_rewinds(connection, member["id"]), "retry": execution_retry(connection, row["id"])}
1227
1447
 
1228
1448
 
1229
1449
  def view_execution(connection, room_id, execution_id, token):
@@ -1282,12 +1502,23 @@ def renewed_notes(payload):
1282
1502
 
1283
1503
  def execution_action(connection, room_id, execution_id, token, payload):
1284
1504
  """Runner-side lifecycle: renew, fail, release undelivered work, or report observed file changes."""
1285
- require_owner(connection, room_id, token)
1286
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()
1287
1517
  row = connection.execute("SELECT * FROM member_executions WHERE room_id=? AND id=?", (room_id, execution_id)).fetchone()
1288
1518
  if not row:
1289
1519
  raise LookupError("执行不存在")
1290
- action = payload.get("action")
1520
+ if member and member["id"] != row["member_id"]:
1521
+ raise PermissionError("只能报告自己执行的失败")
1291
1522
  now = int(time.time())
1292
1523
  if action == "report":
1293
1524
  # Observed after the process ends, so the execution may already be complete; recorded once.
@@ -1320,6 +1551,24 @@ def execution_action(connection, room_id, execution_id, token, payload):
1320
1551
  (min(now + LEASE_SECONDS, limit), activity, activity_at, execution_id))
1321
1552
  connection.executemany("INSERT OR IGNORE INTO execution_notes (execution_id,seq,room_id,text,created_at) VALUES (?,?,?,?,?)",
1322
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("本次执行没有在等待自动重试")
1323
1572
  elif action == "fail":
1324
1573
  finish_execution(connection, execution_id, "failed", str(payload.get("reason") or "执行失败"), "failed")
1325
1574
  settle_batches(connection, room_id)
@@ -1327,7 +1576,7 @@ def execution_action(connection, room_id, execution_id, token, payload):
1327
1576
  # Only for proven non-delivery: the requests return to the queue unchanged.
1328
1577
  finish_execution(connection, execution_id, "cancelled", str(payload.get("reason") or "未送达"), "pending")
1329
1578
  else:
1330
- 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")
1331
1580
  return execution_detail(connection, connection.execute("SELECT * FROM member_executions WHERE id=?", (execution_id,)).fetchone())
1332
1581
 
1333
1582
 
@@ -1493,6 +1742,7 @@ def add_member(connection, room_id, token, payload, now):
1493
1742
  raise FileExistsError(f"{member['name']} 已是本房间成员;离开的成员请用请回")
1494
1743
  require_fresh_member_token(connection, room_id, member["token"])
1495
1744
  insert_member(connection, room_id, member, now)
1745
+ queue_invited_expert(connection, room_id, member["name"], now)
1496
1746
  sync_participants(connection, room_id)
1497
1747
  return free_view(connection, room_id)
1498
1748
 
@@ -1516,6 +1766,32 @@ def queue_invited_expert(connection, room_id, name, now):
1516
1766
  member = connection.execute("SELECT * FROM room_members WHERE room_id=? AND name=? AND status='active'", (room_id, name)).fetchone()
1517
1767
  if not member or (member["adapter"] == "manual" and not registered_at(connection, member)):
1518
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
1519
1795
  connection.execute("""INSERT OR IGNORE INTO discussion_turns (room_id,expert,question,status,submission_id,created_at)
1520
1796
  SELECT settings.room_id,member.name,
1521
1797
  CASE WHEN settings.phase='initial' THEN '欢迎加入:请先读取议题,独立给出你的开场观点。'
@@ -1556,7 +1832,7 @@ def discussion_member_action(connection, room_id, member_id, token, payload):
1556
1832
  """Owner sends an expert away (keeping its seat), brings it back with a new credential, or removes it.
1557
1833
 
1558
1834
  Leaving cancels the expert's open assignments; the host keeps its role and cannot leave, and an
1559
- 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.
1560
1836
  """
1561
1837
  member = owned_member(connection, room_id, member_id, token)
1562
1838
  host, experts, phase = discussion_roster(connection, room_id)
@@ -1564,10 +1840,8 @@ def discussion_member_action(connection, room_id, member_id, token, payload):
1564
1840
  if action in ("away", "remove"):
1565
1841
  if member["name"] == host:
1566
1842
  raise ValueError("主持人不能离开或移除;请先更换主持人")
1567
- speaking = connection.execute("SELECT 1 FROM discussion_turns WHERE room_id=? AND expert=? AND status IN ('pending','notified')",
1568
- (room_id, member["name"])).fetchone()
1569
- if speaking and phase != "paused":
1570
- raise ValueError("该专家正在发言;请先暂停讨论再离开或移除")
1843
+ from .session_room import session_member_change
1844
+ session_member_change(connection, room_id, member, action)
1571
1845
  connection.execute("UPDATE discussion_turns SET status='cancelled' WHERE room_id=? AND expert=? AND status IN ('pending','notified')",
1572
1846
  (room_id, member["name"]))
1573
1847
  connection.execute("UPDATE discussion_turns SET status='skipped' WHERE room_id=? AND expert=? AND status='queued'", (room_id, member["name"]))
@@ -1580,9 +1854,13 @@ def discussion_member_action(connection, room_id, member_id, token, payload):
1580
1854
  if member["status"] != "away" or not hashed or hmac.compare_digest(hashed, member["token_hash"]):
1581
1855
  raise ValueError("只有离开的成员可以请回,且须提供新的成员凭证")
1582
1856
  require_fresh_member_token(connection, room_id, payload.get("token"))
1583
- connection.execute("UPDATE discussion_turns SET status='skipped' WHERE room_id=? AND status='queued'", (room_id,))
1584
1857
  connection.execute("UPDATE room_members SET status='active',token_hash=? WHERE id=?", (hashed, member_id))
1585
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()))
1586
1864
  elif action == "adopt":
1587
1865
  adopt_manual_member(connection, room_id, member, payload)
1588
1866
  return
@@ -1628,9 +1906,16 @@ def member_action(connection, room_id, member_id, token, payload):
1628
1906
  `role` action makes one runner or manual member the AI host, replacing any previous host.
1629
1907
  """
1630
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")
1631
1911
  action = payload.get("action")
1632
1912
  routed = []
1633
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)
1634
1919
  # Unnamed messages still waiting on a departing host move to its successor instead of being dropped.
1635
1920
  routed = open_route_messages(connection, member_id)
1636
1921
  stop_member_work(connection, member_id, "成员已离开" if action == "away" else "成员已移除",
@@ -1644,15 +1929,22 @@ def member_action(connection, room_id, member_id, token, payload):
1644
1929
  raise ValueError("只有离开的成员可以请回,且须提供新的成员凭证")
1645
1930
  require_fresh_member_token(connection, room_id, payload.get("token"))
1646
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()))
1647
1937
  elif action == "adopt":
1648
1938
  adopt_manual_member(connection, room_id, member, payload)
1649
1939
  elif action == "role":
1650
1940
  role = payload.get("role")
1651
- 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)):
1652
1942
  raise ValueError("role 须为 host 或 member;在线成员不能担任主持人")
1653
1943
  if role == "member" and member["role"] == "host":
1654
1944
  raise ValueError("自由房间须由一位 AI 主持;请指定另一位成员担任主持人")
1655
1945
  if role == "host":
1946
+ from .session_room import pause_session_for_host_change
1947
+ pause_session_for_host_change(connection, room_id)
1656
1948
  # Unnamed messages the old host has not started move to the new host, which carries the host rules.
1657
1949
  old_host = connection.execute("SELECT id FROM room_members WHERE room_id=? AND role='host' AND id<>?", (room_id, member_id)).fetchone()
1658
1950
  if old_host: