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
@@ -1,10 +1,9 @@
1
1
  """Import one room backup as a new, owner-controlled room on this site.
2
2
 
3
- The room keeps its ID, so importing the same backup twice is skipped. Message, turn, member and
4
- request IDs are renumbered after this database's current maximum, and `#ID` references in texts
5
- follow the messages. Rows are written in an order that keeps the floor, turn and runtime triggers
6
- from treating history as new activity; the room arrives without a run, its brainstorm phase paused
7
- and its members away, so the owner invites them back with new credentials.
3
+ The room keeps its ID, so importing the same backup twice is skipped. Message, turn, member,
4
+ request, session and session-request IDs are renumbered after this database's current maximum.
5
+ Imported discussions are historical, concluded sessions; active work and credentials are never
6
+ restored.
8
7
  """
9
8
 
10
9
  import hashlib
@@ -23,18 +22,24 @@ ACTIVE_PHASES = ("initial", "discussing", "reviewing")
23
22
  OPEN_REQUESTS = ("pending", "delivering")
24
23
  LIMIT_FIELDS = ("max_rounds", "max_minutes", "execution_minutes", "on_limit", "summary_minutes",
25
24
  "started_at", "closing_at", "stop_reason", "revision")
25
+ VERSION_2_TABLES = ("discussion_sessions", "discussion_conclusions", "session_requests", "task_message_sources",
26
+ "session_state", "session_limits", "session_round_budgets", "session_options", "session_pauses")
26
27
  # Tables whose integer IDs are renumbered, with the backup table that holds their rows.
27
28
  RENUMBERED = {"messages": "messages", "discussion_turns": "discussion_turns",
28
- "room_members": "room_members", "member_requests": "member_requests"}
29
+ "room_members": "room_members", "member_requests": "member_requests",
30
+ "discussion_sessions": "discussion_sessions", "session_requests": "session_requests"}
29
31
 
30
32
 
31
33
  def backup_tables(backup):
32
34
  """Validate the backup envelope and return its tables and the single room row."""
33
- if not isinstance(backup, dict) or backup.get("format") != BACKUP_FORMAT or backup.get("version") != 1:
34
- raise ValueError("不是可导入的房间备份(需要 ai-codiscuss-room-backup 第 1 版)")
35
+ if (not isinstance(backup, dict) or backup.get("format") != BACKUP_FORMAT
36
+ or backup.get("version") not in (1, 2)):
37
+ raise ValueError("不是可导入的房间备份(需要 ai-codiscuss-room-backup 第 1 或第 2 版)")
35
38
  tables = backup.get("tables")
36
39
  if not isinstance(tables, dict) or not isinstance(tables.get("rooms"), list) or len(tables["rooms"]) != 1:
37
40
  raise ValueError("备份须恰好包含一个房间")
41
+ if backup["version"] == 2 and any(name not in tables for name in VERSION_2_TABLES):
42
+ raise ValueError("第 2 版备份缺少讨论会话历史表,拒绝静默丢弃新字段")
38
43
  for name, rows in tables.items():
39
44
  if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows):
40
45
  raise ValueError(f"备份表 {name} 格式无效")
@@ -102,8 +107,7 @@ def history_statements(tables, room_id, maps):
102
107
  for old, row in details.items():
103
108
  statements += [("INSERT INTO message_details (message_id,kind,submitted_by) VALUES (?,'import',?)", (messages[old], row["submitted_by"])),
104
109
  ("UPDATE message_details SET kind=?,turn_id=? WHERE message_id=?", (row["kind"], turns.get(row.get("turn_id")), messages[old]))]
105
- # A held relay stops an answered turn from promoting the next queued one; open turns are restored last.
106
- statements.append(("INSERT INTO turn_relay (room_id,held_since) VALUES (?,1) ON CONFLICT(room_id) DO UPDATE SET held_since=1", (room_id,)))
110
+ # Keep active rows queued until the other active row is restored, preserving the legacy unique index.
107
111
  order = {"notified": 1, "pending": 2}
108
112
  for row in sorted(tables.get("discussion_turns", []), key=lambda turn: order.get(turn["status"], 0)):
109
113
  if row["status"] != "queued":
@@ -161,8 +165,9 @@ def record_statements(tables, room_id, maps):
161
165
  statements += [("""INSERT INTO member_requests (id,room_id,member_id,message_id,status,answer_id,created_at,chain_id,batch_id,purpose,created_by)
162
166
  VALUES (?,?,?,?,?,?,?,?,?,?,?)""", (requests[row["id"]], room_id, members[row["member_id"]], messages[row["message_id"]],
163
167
  "cancelled" if row["status"] in OPEN_REQUESTS else row["status"],
164
- messages.get(row.get("answer_id")), row["created_at"], row.get("chain_id", 0),
165
- row.get("batch_id", 0), row.get("purpose", "ask"), row.get("created_by", "user")))
168
+ messages.get(row.get("answer_id")), row["created_at"],
169
+ messages.get(row.get("chain_id"), 0), messages.get(row.get("batch_id"), 0),
170
+ row.get("purpose", "ask"), row.get("created_by", "user")))
166
171
  for row in tables.get("member_requests", [])]
167
172
  statements += [("INSERT INTO member_suggestions (message_id,member_id) VALUES (?,?)", (messages[row["message_id"]], members[row["member_id"]]))
168
173
  for row in tables.get("member_suggestions", [])]
@@ -176,14 +181,69 @@ def record_statements(tables, room_id, maps):
176
181
  return statements
177
182
 
178
183
 
184
+ def session_statements(tables, room_id, maps):
185
+ """Restore session history and its provenance without restoring active work."""
186
+ messages = maps["messages"]
187
+ sessions = maps["discussion_sessions"]
188
+ requests = maps["session_requests"]
189
+ members = maps["room_members"]
190
+ member_requests = maps["member_requests"]
191
+ task_messages = {row["message_id"] for row in tables.get("task_message_sources", [])}
192
+ latest_visible_message = max((new for old, new in messages.items() if old not in task_messages), default=0)
193
+ statements = []
194
+ for row in tables.get("discussion_sessions", []):
195
+ active = row["status"] in ("active", "paused")
196
+ statements.append(("""INSERT INTO discussion_sessions
197
+ (id,room_id,goal,status,started_message_id,ended_message_id,created_at)
198
+ VALUES (?,?,?,?,?,?,?)""", (sessions[row["id"]], room_id, remap_text(row["goal"], messages),
199
+ "concluded" if active else row["status"],
200
+ remap_until(row.get("started_message_id"), messages),
201
+ latest_visible_message if active else remap_until(row.get("ended_message_id"), messages),
202
+ row["created_at"])))
203
+ statements += [("""INSERT INTO discussion_conclusions
204
+ (session_id,room_id,stop_reason,max_rounds,max_minutes,brief,state,state_until,concluded_at)
205
+ VALUES (?,?,?,?,?,?,?,?,?)""", (sessions[row["session_id"]], room_id, row["stop_reason"],
206
+ row["max_rounds"], row["max_minutes"], remap_text(row["brief"], messages),
207
+ remap_text(row["state"], messages), remap_until(row["state_until"], messages),
208
+ row["concluded_at"])) for row in tables.get("discussion_conclusions", [])]
209
+ for name, fields in (("session_state", ("phase", "revision")),
210
+ ("session_limits", ("max_rounds", "max_minutes", "execution_minutes", "started_at", "stop_reason", "revision")),
211
+ ("session_round_budgets", ("base", "cap", "extra")),
212
+ ("session_pauses", ("paused_at", "paused_seconds")),
213
+ ("session_options", ("named_followup",))):
214
+ columns = ("session_id", *fields)
215
+ statements += [(f"INSERT INTO {name} ({','.join(columns)}) VALUES ({','.join('?' for _ in columns)})",
216
+ (sessions[row["session_id"]], *(row[field] for field in fields)))
217
+ for row in tables.get(name, [])]
218
+ for row in tables.get("session_state", []):
219
+ statements.append(("UPDATE session_state SET read_until=?,held_since=? WHERE session_id=?",
220
+ (remap_until(row["read_until"], messages), remap_until(row["held_since"], messages),
221
+ sessions[row["session_id"]])))
222
+ statements += [("""INSERT INTO session_requests
223
+ (id,session_id,round,position,member_id,purpose,question,status,member_request_id,submission_id,read_until,created_at)
224
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", (requests[row["id"]], sessions[row["session_id"]], row["round"], row["position"],
225
+ members[row["member_id"]], row["purpose"], remap_text(row["question"], messages),
226
+ "cancelled" if row["status"] in ("queued", "open") else row["status"],
227
+ member_requests.get(row.get("member_request_id")), row["submission_id"],
228
+ None if row.get("read_until") is None else remap_until(row["read_until"], messages),
229
+ row["created_at"])) for row in tables.get("session_requests", [])]
230
+ statements += [("""INSERT INTO task_message_sources
231
+ (message_id,session_id,session_request_id,cause_kind,cause_message_id,cause_request_id)
232
+ VALUES (?,?,?,?,?,?)""", (messages[row["message_id"]], sessions[row["session_id"]],
233
+ requests[row["session_request_id"]], row["cause_kind"],
234
+ messages.get(row.get("cause_message_id")),
235
+ member_requests.get(row.get("cause_request_id"))))
236
+ for row in tables.get("task_message_sources", [])]
237
+ return statements
238
+
239
+
179
240
  def import_statements(tables, room, maps, control_hash, now):
180
241
  """Build the ordered writes that recreate one backup; the D1 adapter builds the same sequence."""
181
- mode = next((row["mode"] for row in tables.get("room_modes", [])), "brainstorm")
182
242
  return [*room_statements(tables, room, maps, control_hash), *member_statements(tables, room["id"], maps),
183
243
  *history_statements(tables, room["id"], maps), *settings_statements(tables, room, maps, now),
184
244
  *record_statements(tables, room["id"], maps),
185
- ("UPDATE room_modes SET mode=? WHERE room_id=?", (mode, room["id"])),
186
- ("DELETE FROM turn_relay WHERE room_id=?", (room["id"],))]
245
+ *session_statements(tables, room["id"], maps),
246
+ ]
187
247
 
188
248
 
189
249
  def import_room(connection, backup, control_token, now):
@@ -1,15 +1,14 @@
1
1
  """Opt-in discussion execution; no app launching or model calls live here."""
2
2
 
3
3
  import hashlib
4
- import json
5
4
  import secrets
6
5
  import time
7
- import uuid
6
+ from collections import Counter
7
+ from fractions import Fraction
8
8
 
9
9
  LEASE_SECONDS = 120
10
10
  # Never leaves this process; a client cannot guess it to pose as a stored hash.
11
11
  OWNER_PROXY_PREFIX = secrets.token_hex(16) + ":"
12
- MAX_RUN_SECONDS = 86400
13
12
  LIMIT_DEFAULTS = {"max_rounds": 0, "max_minutes": 0, "execution_minutes": 0}
14
13
  # Older clients may still send these; reaching a limit always pauses, so only the pause value is accepted and the reserve is ignored.
15
14
  LEGACY_LIMIT_FIELDS = ("on_limit", "summary_minutes")
@@ -132,10 +131,68 @@ def require_within_limits(room):
132
131
  raise FileExistsError("已达到讨论上限;请检查上限,讨论随之结束并回到自由协作")
133
132
 
134
133
 
135
- def execution_seconds(connection, room_id):
136
- """Read the per-execution budget shared by entry, renewal and writes."""
137
- row = connection.execute("SELECT execution_minutes FROM discussion_limits WHERE room_id=?", (room_id,)).fetchone()
138
- return (row[0] if row else LIMIT_DEFAULTS["execution_minutes"]) * 60
134
+ def session_limits_view(connection, room_id):
135
+ """Read request-model session progress, excluding paused time and ordinary collaboration replies."""
136
+ from .session_room import current_session
137
+
138
+ session = current_session(connection, room_id)
139
+ if not session:
140
+ return None
141
+ result = dict(connection.execute("SELECT * FROM session_limits WHERE session_id=?", (session["id"],)).fetchone())
142
+ pause = connection.execute("SELECT * FROM session_pauses WHERE session_id=?", (session["id"],)).fetchone()
143
+ budget = dict(connection.execute("SELECT base,cap,extra FROM session_round_budgets WHERE session_id=?", (session["id"],)).fetchone())
144
+ answered = connection.execute("""SELECT COUNT(*) FROM session_requests
145
+ WHERE session_id=? AND purpose='discuss' AND status='answered'""", (session["id"],)).fetchone()[0]
146
+ now = int(time.time())
147
+ if session["status"] == "concluded":
148
+ finished = connection.execute("SELECT CAST(strftime('%s',concluded_at) AS INTEGER) FROM discussion_conclusions WHERE session_id=?",
149
+ (session["id"],)).fetchone()
150
+ now = finished[0] if finished and finished[0] else now
151
+ idle = pause["paused_seconds"] + (max(0, now - pause["paused_at"]) if pause["paused_at"] else 0)
152
+ deadline = result["started_at"] + result["max_minutes"] * 60 + idle if result["started_at"] and result["max_minutes"] else 0
153
+ completed = _session_answer_rounds(connection, session["id"])
154
+ segment_rounds = _session_answer_rounds(connection, session["id"], budget["base"])
155
+ round_cap = budget["cap"] or result["max_rounds"]
156
+ reason = result["stop_reason"] if result["stop_reason"] in ("time_limit", "round_limit") else ""
157
+ if not reason and deadline and now >= deadline:
158
+ reason = "time_limit"
159
+ elif not reason and round_cap and segment_rounds >= round_cap:
160
+ reason = "round_limit"
161
+ result.update(deadline=deadline, reached=bool(reason), reason=reason, completed_rounds=completed,
162
+ segment_rounds=segment_rounds, round_cap=round_cap, extra_rounds=budget["extra"],
163
+ remaining_seconds=max(0, deadline - now) if deadline else None,
164
+ elapsed_seconds=max(0, now - result["started_at"] - idle), answered=answered)
165
+ return result
166
+
167
+
168
+ def _session_answer_rounds(connection, session_id, base=0):
169
+ """Count answer volume across rounds, adding the host only to rounds where the host is scheduled."""
170
+ from .session_room import _expert_denominator
171
+
172
+ rounds = Counter(row[0] for row in connection.execute("""SELECT round FROM session_requests
173
+ WHERE session_id=? AND purpose='discuss' AND status='answered' ORDER BY id LIMIT -1 OFFSET ?""",
174
+ (session_id, base)))
175
+ progress = Fraction(0)
176
+ for round_number, answered in rounds.items():
177
+ denominator = _expert_denominator(connection, session_id, round_number)
178
+ if denominator:
179
+ progress += Fraction(answered, denominator)
180
+ return int(progress)
181
+
182
+
183
+ def enforce_session_limits(connection, room_id):
184
+ """End an active request-model session at its configured limit under the caller's write transaction."""
185
+ from .session_room import _conclude_session, current_session
186
+
187
+ begin_write(connection)
188
+ session = current_session(connection, room_id, active_only=True)
189
+ if not session or session["status"] != "active":
190
+ return False
191
+ limits = session_limits_view(connection, room_id)
192
+ if not limits["reached"]:
193
+ return False
194
+ _conclude_session(connection, session, limits["reason"])
195
+ return True
139
196
 
140
197
 
141
198
  def runtime_view(connection, room_id):
@@ -154,6 +211,12 @@ def runtime_view(connection, room_id):
154
211
  return result
155
212
 
156
213
 
214
+ def has_session_request_model(connection, room_id):
215
+ """Return whether this room has request-model session history, including concluded sessions."""
216
+ return bool(connection.execute("""SELECT 1 FROM discussion_sessions AS s
217
+ JOIN session_state AS state ON state.session_id=s.id WHERE s.room_id=? LIMIT 1""", (room_id,)).fetchone())
218
+
219
+
157
220
  def failed_executions(connection, room_id):
158
221
  """Keep failed attempts readable after a retry, without exposing execution credentials."""
159
222
  return [dict(row) for row in connection.execute("""SELECT e.id,e.participant,e.role,e.turn_id,
@@ -236,7 +299,7 @@ def authorize_participant(connection, room_id, identity, operation, payload):
236
299
  def authorize_write(connection, room_id, token, operation, payload):
237
300
  """Check a capability under the same writer lock as the following mutation."""
238
301
  begin_write(connection)
239
- run = connection.execute("SELECT * FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
302
+ run = connection.execute("SELECT mode FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
240
303
  hashed = token_hash(token)
241
304
  owner = connection.execute("SELECT 1 FROM room_modes WHERE room_id=? AND control_hash=?", (room_id, hashed)).fetchone() if hashed else None
242
305
  if owner:
@@ -247,100 +310,7 @@ def authorize_write(connection, room_id, token, operation, payload):
247
310
  if member and (not run or run["mode"] == "manual" or member["access"] == "manual"):
248
311
  authorize_participant(connection, room_id, member, operation, payload)
249
312
  return
250
- if not run or not hashed:
251
- raise PermissionError("需要房主或本成员的有效凭据;没有凭据只能阅读")
252
- actor = write_actor(operation, payload)
253
- work_seconds = execution_seconds(connection, room_id)
254
- event = connection.execute("""SELECT * FROM discussion_events WHERE room_id=? AND token_hash=?
255
- AND run_id=? AND generation=? AND status='started' AND lease_until>?
256
- AND (?=0 OR started_at+?>?)""", (room_id, hashed, run["run_id"], run["generation"],
257
- int(time.time()), work_seconds, work_seconds, int(time.time()))).fetchone()
258
- if not hashed or not event or run["status"] != "running" or 0 < run["deadline"] <= int(time.time()):
259
- raise PermissionError("执行资格无效、已结束或已到期;请重新读取运行状态")
260
- if actor != event["participant"]:
261
- raise PermissionError("执行凭据与实际参与身份不符")
262
- authorize_participant(connection, room_id, {"name": event["participant"], "role": event["role"]}, operation, payload)
263
- if operation == "messages":
264
- if payload.get("author") != actor:
265
- raise PermissionError("自动执行不能代发其他参与者")
266
- if event["role"] == "expert" and payload.get("turn_id") not in (None, event["turn_id"]):
267
- raise PermissionError("回答必须关联本次安排")
268
- if event["role"] == "expert" and payload.get("turn_id") is None:
269
- # Without a named arrangement the answer belongs to the expert's open one, which must be this execution's.
270
- open_turn = connection.execute("SELECT id FROM discussion_turns WHERE room_id=? AND expert=? AND status IN ('pending','notified')",
271
- (room_id, actor)).fetchone()
272
- if not open_turn or open_turn["id"] != event["turn_id"]:
273
- raise PermissionError("回答必须关联本次安排")
274
- elif operation == "attachments":
275
- pass
276
- elif event["role"] != "host" or operation not in ("topic", "state", "turns", "limits"):
277
- raise PermissionError("本次执行不能进行此操作")
278
-
279
-
280
- def configure_runtime(connection, room_id, token, payload):
281
- """Apply one versioned, explicit mode or lifecycle action."""
282
- begin_write(connection)
283
- require_owner(connection, room_id, token)
284
- current = runtime_view(connection, room_id)
285
- if payload.get("revision") != current["revision"]:
286
- raise FileExistsError("运行状态已变化,请重新读取 revision")
287
- hashed = token_hash(token)
288
- if not current["protected"] and not hashed:
289
- raise ValueError("运行凭据须为 32–256 字符的随机值")
290
- action = payload.get("action", "configure")
291
- mode = payload.get("mode", "automatic" if action == "start" else current["mode"])
292
- if mode not in ("manual", "automatic") or action not in ("configure", "start", "pause", "stop"):
293
- raise ValueError("请选择 manual/automatic 和 configure/start/pause/stop")
294
- if action == "start" and mode != "automatic":
295
- raise ValueError("start 只用于全自动运行;手动方式无需运行启动")
296
- if action in ("configure", "start") and current["status"] == "running":
297
- raise FileExistsError("讨论仍在运行;请先暂停,再切换模式或继续")
298
- if action in ("configure", "start") and connection.execute(
299
- "SELECT 1 FROM discussion_events WHERE room_id=? AND status IN ('dispatched','started')", (room_id,)).fetchone():
300
- raise FileExistsError("存在在途执行;先暂停并核对原会话已停止,再切换或继续")
301
- status = {"configure": "ready", "start": "running", "pause": "paused", "stop": "stopped"}[action]
302
- start_payload = {"phase": current.get("resume_phase", "initial"), **payload}
303
- deadline = validate_start(connection, room_id, start_payload) if action == "start" else current.get("deadline", 0)
304
- connection.execute("""INSERT INTO discussion_runtime
305
- (room_id,mode,run_id,status,control_hash,deadline,updated_at) VALUES (?,?,?,?,?,?,datetime('now'))
306
- ON CONFLICT(room_id) DO UPDATE SET mode=excluded.mode,status=excluded.status,
307
- generation=generation+1,revision=revision+1,deadline=excluded.deadline,updated_at=excluded.updated_at""",
308
- (room_id, mode, str(uuid.uuid4()), "ready", hashed, deadline))
309
- if action in ("pause", "stop"):
310
- connection.execute("UPDATE topic_settings SET phase='paused' WHERE room_id=? AND phase<>'concluded'", (room_id,))
311
- connection.execute("UPDATE discussion_runtime SET status=? WHERE room_id=?", (status, room_id))
312
- # Only a real mode change converts members; a manual expert of a woken discussion (adapter 'manual') stays manual.
313
- if mode != current["mode"]:
314
- connection.execute("UPDATE room_members SET access='manual' WHERE room_id=? AND access='runner'" if mode == "manual" else
315
- "UPDATE room_members SET access='runner' WHERE room_id=? AND access='manual' AND adapter<>'manual'", (room_id,))
316
- return runtime_view(connection, room_id)
317
-
318
-
319
- def validate_start(connection, room_id, payload):
320
- """Validate an AI-hosted run; zero deadline leaves only the configured limits."""
321
- row = connection.execute("SELECT * FROM topic_settings JOIN room_directory USING(room_id) WHERE room_id=?",
322
- (room_id,)).fetchone()
323
- limits = connection.execute(LIMITS_SQL, (room_id,)).fetchone()
324
- configured = ((limits["started_at"] or int(time.time())) + limits["max_minutes"] * 60 + limits["idle_seconds"]
325
- if limits["max_minutes"] else 0)
326
- deadline = payload.get("deadline", configured)
327
- if type(deadline) is int and configured:
328
- deadline = min(deadline or configured, configured)
329
- if type(deadline) is not int or (deadline != 0 and not int(time.time()) < deadline <= int(time.time()) + MAX_RUN_SECONDS):
330
- raise ValueError("deadline 须为 0(不限时长)或未来 24 小时内的 Unix 秒数")
331
- experts = json.loads(row["experts"])
332
- if row["archived"] or row["phase"] == "concluded" or row["host"] == "用户":
333
- raise ValueError("自动运行需要未归档、未收束的话题及 AI 主持人")
334
- if not experts or any(mode != "cli" for mode in experts.values()):
335
- raise ValueError("自动运行的参与者须均具有本地 CLI 读写能力")
336
- phase = payload.get("phase", "initial" if row["phase"] == "preparing" else "discussing")
337
- if phase not in ("initial", "discussing"):
338
- raise ValueError("恢复阶段必须为 initial/discussing")
339
- if row["phase"] in ("preparing", "paused"):
340
- connection.execute("UPDATE topic_settings SET phase=? WHERE room_id=?", (phase, room_id))
341
- connection.execute("UPDATE discussion_limits SET started_at=? WHERE room_id=? AND started_at=0",
342
- (int(time.time()), room_id))
343
- return deadline
313
+ raise PermissionError("需要房主或本成员的有效凭据;旧讨论执行凭据已停用")
344
314
 
345
315
 
346
316
  def list_events(connection, room_id, token, after=0):
@@ -352,75 +322,17 @@ def list_events(connection, room_id, token, after=0):
352
322
  return {"events": events, "next_after": events[-1]["id"] if events else int(after)}
353
323
 
354
324
 
355
- def event_action(connection, room_id, event_id, token, payload):
356
- """Reserve, enter or renew one persisted execution, with no implicit replay."""
357
- begin_write(connection)
325
+ def event_view(connection, room_id, event_id, token, payload):
326
+ """Read one legacy event without allowing it to resume the retired runtime."""
327
+ if payload.get("action") != "view":
328
+ raise ValueError("旧讨论执行仅保留历史读取")
358
329
  event = connection.execute("SELECT * FROM discussion_events WHERE room_id=? AND id=?",
359
330
  (room_id, event_id)).fetchone()
360
331
  if not event:
361
332
  raise LookupError("执行不存在")
362
- run = connection.execute("SELECT * FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
363
- now = int(time.time())
364
- work_seconds = execution_seconds(connection, room_id)
365
- action = payload.get("action")
366
- work_deadline = (now if action == "enter" else event["started_at"]) + work_seconds if work_seconds else 0
367
- lease_until = min(now + LEASE_SECONDS, work_deadline or now + LEASE_SECONDS, run["deadline"] or now + LEASE_SECONDS)
368
- controller = token_hash(token) == run["control_hash"]
369
- if not controller and (not token_hash(token) or token_hash(token) != event["token_hash"]):
333
+ run = connection.execute("SELECT control_hash FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
334
+ hashed = token_hash(token)
335
+ controller = bool(run and hashed and hashed == run["control_hash"])
336
+ if not controller and (not hashed or hashed != event["token_hash"]):
370
337
  raise PermissionError("执行凭据无效")
371
- if action == "view":
372
- return {key: event[key] for key in PUBLIC_EVENT_FIELDS}
373
- if action == "segments":
374
- # A timeline report comes from the runner, also just after the execution ended or its run stopped.
375
- if not controller:
376
- raise PermissionError("需要本房间的运行控制凭据")
377
- timing = connection.execute("SELECT dispatched_at,finished_at FROM event_timings WHERE event_id=?", (event_id,)).fetchone()
378
- if not timing or not timing["dispatched_at"]:
379
- raise FileExistsError("执行尚未交付")
380
- finished = 0 if event["status"] in ("dispatched", "started") else (timing["finished_at"] or 1)
381
- save_segments(connection, "discussion", room_id, event_id, timing["dispatched_at"], finished, payload)
382
- return {key: event[key] for key in PUBLIC_EVENT_FIELDS}
383
- if run["status"] != "running" or 0 < run["deadline"] <= now or event["generation"] != run["generation"]:
384
- raise FileExistsError("本次运行已停止或执行已过期")
385
- if action == "reserve" and controller:
386
- reserve_event(connection, event, payload)
387
- elif action == "enter" and not controller:
388
- if event["status"] != "dispatched" or not payload.get("session_id"):
389
- raise FileExistsError("本次任务已进入或不可进入;不要重复执行")
390
- connection.execute("UPDATE discussion_events SET status='started',session_id=?,started_at=?,lease_until=? WHERE id=?",
391
- (str(payload["session_id"])[:200], now, lease_until, event_id))
392
- elif action == "renew" and controller and event["status"] == "started":
393
- if event["lease_until"] <= now or 0 < work_deadline <= now:
394
- raise FileExistsError("执行租约已到期,不能自动重新激活")
395
- connection.execute("UPDATE discussion_events SET lease_until=? WHERE id=?",
396
- (lease_until, event_id))
397
- elif action == "fail" and event["status"] in ("dispatched", "started"):
398
- connection.execute("UPDATE discussion_events SET status='failed',result=? WHERE id=?",
399
- (str(payload.get("reason", "需要用户处理"))[:500], event_id))
400
- connection.execute("UPDATE topic_settings SET phase='paused' WHERE room_id=?", (room_id,))
401
- else:
402
- raise FileExistsError("执行状态不允许此操作")
403
- result = connection.execute("SELECT * FROM discussion_events WHERE id=?", (event_id,)).fetchone()
404
- return {key: result[key] for key in PUBLIC_EVENT_FIELDS}
405
-
406
-
407
- def reserve_event(connection, event, payload):
408
- """Reserve only current work and merge pending host wakeups before dispatch."""
409
- hashed = token_hash(payload.get("execution_token"))
410
- if event["status"] != "pending" or not hashed:
411
- raise FileExistsError("执行已被领取或缺少有效的执行凭据")
412
- turn = connection.execute("SELECT * FROM discussion_turns WHERE room_id=? AND status IN ('pending','notified')",
413
- (event["room_id"],)).fetchone()
414
- if (event["role"] == "host" and turn) or (event["role"] == "expert" and (not turn or turn["id"] != event["turn_id"])):
415
- raise FileExistsError("当前安排与执行不符,请重新读取")
416
- if connection.execute("SELECT 1 FROM discussion_events WHERE room_id=? AND status IN ('dispatched','started')",
417
- (event["room_id"],)).fetchone():
418
- raise FileExistsError("已有在途执行")
419
- if connection.execute("""SELECT 1 FROM member_executions JOIN room_members ON room_members.id=member_executions.member_id
420
- WHERE member_executions.room_id=? AND room_members.name=? AND member_executions.status IN ('dispatched','started')""",
421
- (event["room_id"], event["participant"])).fetchone():
422
- raise FileExistsError("该参与者正在回答用户的直接提问;同一会话不并发续接")
423
- connection.execute("UPDATE discussion_events SET status='dispatched',token_hash=? WHERE id=?", (hashed, event["id"]))
424
- if event["role"] == "host":
425
- connection.execute("UPDATE discussion_events SET status='completed',result=? WHERE room_id=? AND role='host' AND status='pending' AND id<?",
426
- (f"merged:{event['id']}", event["room_id"], event["id"]))
338
+ return {key: event[key] for key in PUBLIC_EVENT_FIELDS}