ai_co_discuss 0.2.95 → 0.2.97

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.
@@ -1,416 +1,426 @@
1
- """Opt-in discussion execution; no app launching or model calls live here."""
2
-
3
- import hashlib
4
- import json
5
- import time
6
- import uuid
7
-
8
- LEASE_SECONDS = 120
9
- MAX_RUN_SECONDS = 86400
10
- LIMIT_DEFAULTS = {"max_rounds": 0, "max_minutes": 0, "execution_minutes": 0}
11
- # Older clients may still send these; reaching a limit always pauses, so only the pause value is accepted and the reserve is ignored.
12
- LEGACY_LIMIT_FIELDS = ("on_limit", "summary_minutes")
13
- PUBLIC_EVENT_FIELDS = ("id", "room_id", "run_id", "generation", "cause", "participant",
14
- "role", "turn_id", "status", "session_id", "lease_until", "result", "created_at")
15
- LIMITS_SQL = """SELECT limits.*,COALESCE(pauses.paused_seconds,0)+CASE WHEN COALESCE(pauses.paused_at,0)>0
16
- THEN MAX(0,CAST(strftime('%s','now') AS INTEGER)-pauses.paused_at) ELSE 0 END AS idle_seconds
17
- FROM discussion_limits AS limits LEFT JOIN discussion_pauses AS pauses USING(room_id) WHERE limits.room_id=?"""
18
- TIMING_ITEMS = 20
19
- # Phase segments the runner reports for the execution timeline: the phases, how many one execution keeps and one report
20
- # carries, a tool name's length, and how long after its end an execution still takes its last report.
21
- SEGMENT_PHASES = ("thinking", "command", "tool", "editing", "writing")
22
- MAX_EXECUTION_SEGMENTS = 400
23
- MAX_SEGMENTS_PER_REPORT = 40
24
- MAX_SEGMENT_TOOL = 60
25
- SEGMENT_GRACE_SECONDS = 600
26
- # Queue: created -> dispatched; wake: dispatched -> entered; work: entered -> finished. Open stages run to now.
27
- TIMING_SQL = """SELECT e.id,e.participant,e.role,e.cause,e.status,
28
- MAX(0,CASE WHEN t.dispatched_at>0 THEN t.dispatched_at ELSE ? END-CAST(strftime('%s',e.created_at) AS INTEGER)) AS queue_seconds,
29
- CASE WHEN t.dispatched_at>0 THEN MAX(0,COALESCE(NULLIF(e.started_at,0),NULLIF(t.finished_at,0),?)-t.dispatched_at) END AS wake_seconds,
30
- CASE WHEN e.started_at>0 THEN MAX(0,COALESCE(NULLIF(t.finished_at,0),?)-e.started_at) END AS work_seconds
31
- FROM discussion_events AS e LEFT JOIN event_timings AS t ON t.event_id=e.id
32
- WHERE e.room_id=? AND e.run_id=? AND (t.dispatched_at>0 OR e.status='pending') ORDER BY e.id DESC"""
33
-
34
-
35
- def token_hash(token):
36
- """Hash a bounded opaque capability without storing its original value."""
37
- if not isinstance(token, str) or not 32 <= len(token) <= 256:
38
- return ""
39
- return hashlib.sha256(token.encode()).hexdigest()
40
-
41
-
42
- def save_segments(connection, source, room_id, execution_id, anchor, finished, payload, optional=False):
43
- """Record an execution's phase segments as millisecond offsets from its delivery at `anchor`.
44
-
45
- The runner measures them on its own clock, so they are kept within the execution's own span; `finished` is 0 while
46
- it runs. A resent segment keeps the later end, so the open segment can be reported again as it runs on. `optional`
47
- allows a report with no segments, when it carries progress notes instead.
48
- """
49
- now = int(time.time())
50
- if finished and finished + SEGMENT_GRACE_SECONDS < now:
51
- raise FileExistsError("执行已结束较久,不再接收阶段记录")
52
- segments = payload.get("segments", [])
53
- if (not isinstance(segments, list) or len(segments) > MAX_SEGMENTS_PER_REPORT or (not segments and not optional) or any(
54
- not isinstance(item, dict) or type(item.get("seq")) is not int or not 1 <= item["seq"] <= MAX_EXECUTION_SEGMENTS
55
- or item.get("phase") not in SEGMENT_PHASES or not isinstance(item.get("tool", ""), str)
56
- or len(item.get("tool", "")) > MAX_SEGMENT_TOOL or type(item.get("start_ms")) is not int
57
- or type(item.get("end_ms")) is not int for item in segments)):
58
- raise ValueError(f"segments 须为 1–{MAX_SEGMENTS_PER_REPORT} 段 {{seq: 1–{MAX_EXECUTION_SEGMENTS}, "
59
- f"phase: {'、'.join(SEGMENT_PHASES)}, tool: 最多 {MAX_SEGMENT_TOOL} 字, start_ms, end_ms}}")
60
- limit = max(0, (finished or now) - anchor + 1) * 1000
61
- rows = []
62
- for item in segments:
63
- start = min(max(item["start_ms"], 0), limit)
64
- rows.append((source, execution_id, item["seq"], room_id, item["phase"], item.get("tool", ""), start,
65
- min(max(item["end_ms"], start), limit)))
66
- connection.executemany("""INSERT INTO execution_segments (source,execution_id,seq,room_id,phase,tool,start_ms,end_ms)
67
- VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(source,execution_id,seq) DO UPDATE SET end_ms=MAX(end_ms,excluded.end_ms)""", rows)
68
-
69
-
70
- def begin_write(connection):
71
- """Acquire the writer lock once, including when routing already acquired it."""
72
- if not connection.in_transaction:
73
- connection.execute("BEGIN IMMEDIATE")
74
-
75
-
76
- def limit_values(payload, current=None):
77
- """Validate bounded discussion settings; zero means no round/time preset."""
78
- if not isinstance(payload, dict) or set(payload) - set(LIMIT_DEFAULTS) - set(LEGACY_LIMIT_FIELDS):
79
- raise ValueError("limits 仅接受讨论上限配置字段")
80
- if payload.get("on_limit", "pause") != "pause":
81
- raise ValueError("到限后只会暂停;需要总结时请主持人在暂停后按你的要求写出")
82
- result = {key: (current or {}).get(key, value) for key, value in LIMIT_DEFAULTS.items()}
83
- result.update({key: value for key, value in payload.items() if key in LIMIT_DEFAULTS})
84
- for key, minimum, maximum in (("max_rounds", 0, 100), ("max_minutes", 0, 1440), ("execution_minutes", 0, 60)):
85
- if type(result[key]) is not int or not minimum <= result[key] <= maximum:
86
- raise ValueError(f"{key} 须为 {minimum}–{maximum} 的整数")
87
- return result
88
-
89
-
90
- def limits_view(row, room):
91
- """Derive progress without changing state during reads."""
92
- result = dict(row)
93
- now = int(time.time())
94
- idle = result.get("idle_seconds") or 0
95
- deadline = result["started_at"] + result["max_minutes"] * 60 + idle if result["started_at"] and result["max_minutes"] else 0
96
- if room.get("runtime", {}).get("deadline") and (room["runtime"]["status"] == "running" or result["stop_reason"]):
97
- runtime_deadline = room["runtime"]["deadline"]
98
- deadline = min(deadline or runtime_deadline, runtime_deadline)
99
- reason = ""
100
- budget = room.get("round_budget")
101
- experts = len(room.get("experts") or {})
102
- # A budgeted room counts rounds from the budget base; a legacy room counts whole-roster rounds from the start.
103
- if budget:
104
- segment_rounds = max(0, room["opinion_count"] - budget["base"]) // experts if experts else 0
105
- else:
106
- segment_rounds = room["completed_rounds"]
107
- round_cap = (budget["cap"] or result["max_rounds"]) if budget else result["max_rounds"]
108
- if deadline and now >= deadline:
109
- reason = "time_limit"
110
- elif round_cap and segment_rounds >= round_cap:
111
- reason = "round_limit"
112
- result.update(deadline=deadline, reached=bool(reason), reason=reason, completed_rounds=room["completed_rounds"],
113
- segment_rounds=segment_rounds, round_cap=round_cap, extra_rounds=budget["extra"] if budget else 0,
114
- remaining_seconds=max(0, deadline - now) if deadline else None,
115
- elapsed_seconds=max(0, now - result["started_at"] - idle) if result["started_at"] else 0)
116
- return result
117
-
118
-
119
- def require_within_limits(room):
120
- """Stop new expert work at the configured boundary, preserving user notes."""
121
- if room["limits"]["reached"]:
122
- raise FileExistsError("已达到讨论上限;请检查上限,讨论随之结束并回到自由协作")
123
-
124
-
125
- def execution_seconds(connection, room_id):
126
- """Read the per-execution budget shared by entry, renewal and writes."""
127
- row = connection.execute("SELECT execution_minutes FROM discussion_limits WHERE room_id=?", (room_id,)).fetchone()
128
- return (row[0] if row else LIMIT_DEFAULTS["execution_minutes"]) * 60
129
-
130
-
131
- def runtime_view(connection, room_id):
132
- """Return public execution state, never capability hashes."""
133
- row = connection.execute("SELECT * FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
134
- if not row:
135
- return {"mode": "manual", "status": "ready", "revision": 0, "protected": False}
136
- result = {key: row[key] for key in row.keys() if key != "control_hash"}
137
- result["protected"] = True
138
- result["expired"] = row["status"] == "running" and 0 < row["deadline"] <= int(time.time())
139
- event = connection.execute("SELECT id,participant,role,status,result FROM discussion_events WHERE room_id=? ORDER BY id DESC LIMIT 1", (room_id,)).fetchone()
140
- result["latest_execution"] = dict(event) if event else None
141
- result["participants"] = participant_executions(connection, room_id)
142
- result["failures"] = failed_executions(connection, room_id)
143
- result["timings"] = execution_timings(connection, room_id, row["run_id"])
144
- return result
145
-
146
-
147
- def failed_executions(connection, room_id):
148
- """Keep failed attempts readable after a retry, without exposing execution credentials."""
149
- return [dict(row) for row in connection.execute("""SELECT e.id,e.participant,e.role,e.turn_id,
150
- e.result,e.created_at,CASE WHEN t.dispatched_at>0 AND t.finished_at>0
151
- THEN MAX(0,t.finished_at-t.dispatched_at) END AS elapsed_seconds
152
- FROM discussion_events AS e LEFT JOIN event_timings AS t ON t.event_id=e.id
153
- WHERE e.room_id=? AND e.status='failed' ORDER BY e.id""", (room_id,))]
154
-
155
-
156
- def execution_timings(connection, room_id, run_id):
157
- """Summarize stage durations of the current run so slow stages are visible."""
158
- now = int(time.time())
159
- rows = [dict(row) for row in connection.execute(TIMING_SQL, (now, now, now, room_id, run_id))]
160
- stages = ("queue_seconds", "wake_seconds", "work_seconds")
161
- totals = {stage: sum(row[stage] or 0 for row in rows) for stage in stages}
162
- return {"items": rows[:TIMING_ITEMS], "totals": totals, "count": len(rows)}
163
-
164
-
165
- def participant_executions(connection, room_id):
166
- """Summarize each participant's latest execution and failure count for the progress panel."""
167
- rows = connection.execute("""SELECT latest.id,latest.participant,latest.role,latest.status,latest.result,
168
- latest.turn_id,latest.lease_until,COALESCE((SELECT bound.session_id FROM discussion_events AS bound
169
- WHERE bound.room_id=latest.room_id AND bound.participant=latest.participant AND bound.session_id<>''
170
- ORDER BY bound.id DESC LIMIT 1),'') AS session_id,(SELECT COUNT(*) FROM discussion_events AS failed
171
- WHERE failed.room_id=latest.room_id AND failed.participant=latest.participant
172
- AND failed.status='failed') AS failures
173
- FROM discussion_events AS latest WHERE latest.room_id=? AND latest.id=(SELECT MAX(id)
174
- FROM discussion_events AS other WHERE other.room_id=latest.room_id
175
- AND other.participant=latest.participant) ORDER BY latest.id""", (room_id,)).fetchall()
176
- now = int(time.time())
177
- return [{key: row[key] for key in row.keys() if key != "lease_until"}
178
- | {"stale": row["status"] == "started" and row["lease_until"] <= now} for row in rows]
179
-
180
-
181
- def require_control(connection, room_id, token):
182
- """Validate the room owner capability for control and dispatcher operations."""
183
- row = connection.execute("SELECT * FROM discussion_runtime WHERE room_id=? AND control_hash=?",
184
- (room_id, token_hash(token))).fetchone()
185
- if not row:
186
- raise PermissionError("需要本房间的运行控制凭据")
187
- return row
188
-
189
-
190
- def require_owner(connection, room_id, token):
191
- """Require the persistent room owner credential, independent of execution mode."""
192
- hashed = token_hash(token)
193
- row = connection.execute("SELECT * FROM room_modes WHERE room_id=? AND control_hash=?",
194
- (room_id, hashed)).fetchone() if hashed else None
195
- if not row:
196
- raise PermissionError("需要本房间的控制凭据;没有凭据只能阅读")
197
- return row
198
-
199
-
200
- def write_actor(operation, payload):
201
- """Use the same message attribution defaults as persisted message details."""
202
- if operation == "messages":
203
- return payload.get("submitted_by", payload.get("author") if payload.get("source") in ("cli", "api") else "用户")
204
- return payload.get("actor", "用户")
205
-
206
-
207
- def authorize_participant(connection, room_id, identity, operation, payload):
208
- """Limit authenticated participants to their identity and assigned host duties."""
209
- actor = write_actor(operation, payload)
210
- if actor != identity["name"]:
211
- raise PermissionError("凭据与实际参与身份不符")
212
- if operation == "messages":
213
- author = payload.get("author")
214
- online = connection.execute("SELECT 1 FROM room_members WHERE room_id=? AND name=? AND access='online' AND status='active'",
215
- (room_id, author)).fetchone()
216
- if author != actor and not (online and identity["role"] in ("owner", "host")):
217
- raise PermissionError("只能提交本人观点,或由房主、主持人代发在线成员原文")
218
- elif operation != "attachments" and identity["role"] != "owner":
219
- if identity["role"] != "host" or operation not in ("topic", "state", "turns", "limits"):
220
- raise PermissionError("本成员没有此管理权限")
221
- if operation == "topic":
222
- if any(field in payload for field in ("host", "experts", "members")):
223
- raise PermissionError("只有房主可以修改成员和主持人")
224
-
225
-
226
- def authorize_write(connection, room_id, token, operation, payload):
227
- """Check a capability under the same writer lock as the following mutation."""
228
- begin_write(connection)
229
- run = connection.execute("SELECT * FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
230
- hashed = token_hash(token)
231
- owner = connection.execute("SELECT 1 FROM room_modes WHERE room_id=? AND control_hash=?", (room_id, hashed)).fetchone() if hashed else None
232
- if owner:
233
- authorize_participant(connection, room_id, {"name": "用户", "role": "owner"}, operation, payload)
234
- return
235
- member = connection.execute("SELECT * FROM room_members WHERE room_id=? AND token_hash=? AND status='active' AND access<>'online'",
236
- (room_id, hashed)).fetchone() if hashed else None
237
- if member and (not run or run["mode"] == "manual" or member["access"] == "manual"):
238
- authorize_participant(connection, room_id, member, operation, payload)
239
- return
240
- if not run or not hashed:
241
- raise PermissionError("需要房主或本成员的有效凭据;没有凭据只能阅读")
242
- actor = write_actor(operation, payload)
243
- work_seconds = execution_seconds(connection, room_id)
244
- event = connection.execute("""SELECT * FROM discussion_events WHERE room_id=? AND token_hash=?
245
- AND run_id=? AND generation=? AND status='started' AND lease_until>?
246
- AND (?=0 OR started_at+?>?)""", (room_id, hashed, run["run_id"], run["generation"],
247
- int(time.time()), work_seconds, work_seconds, int(time.time()))).fetchone()
248
- if not hashed or not event or run["status"] != "running" or 0 < run["deadline"] <= int(time.time()):
249
- raise PermissionError("执行资格无效、已结束或已到期;请重新读取运行状态")
250
- if actor != event["participant"]:
251
- raise PermissionError("执行凭据与实际参与身份不符")
252
- authorize_participant(connection, room_id, {"name": event["participant"], "role": event["role"]}, operation, payload)
253
- if operation == "messages":
254
- if payload.get("author") != actor:
255
- raise PermissionError("自动执行不能代发其他参与者")
256
- if event["role"] == "expert" and payload.get("turn_id") not in (None, event["turn_id"]):
257
- raise PermissionError("回答必须关联本次安排")
258
- if event["role"] == "expert" and payload.get("turn_id") is None:
259
- # Without a named arrangement the answer belongs to the expert's open one, which must be this execution's.
260
- open_turn = connection.execute("SELECT id FROM discussion_turns WHERE room_id=? AND expert=? AND status IN ('pending','notified')",
261
- (room_id, actor)).fetchone()
262
- if not open_turn or open_turn["id"] != event["turn_id"]:
263
- raise PermissionError("回答必须关联本次安排")
264
- elif operation == "attachments":
265
- pass
266
- elif event["role"] != "host" or operation not in ("topic", "state", "turns", "limits"):
267
- raise PermissionError("本次执行不能进行此操作")
268
-
269
-
270
- def configure_runtime(connection, room_id, token, payload):
271
- """Apply one versioned, explicit mode or lifecycle action."""
272
- begin_write(connection)
273
- require_owner(connection, room_id, token)
274
- current = runtime_view(connection, room_id)
275
- if payload.get("revision") != current["revision"]:
276
- raise FileExistsError("运行状态已变化,请重新读取 revision")
277
- hashed = token_hash(token)
278
- if not current["protected"] and not hashed:
279
- raise ValueError("运行凭据须为 32–256 字符的随机值")
280
- action = payload.get("action", "configure")
281
- mode = payload.get("mode", "automatic" if action == "start" else current["mode"])
282
- if mode not in ("manual", "automatic") or action not in ("configure", "start", "pause", "stop"):
283
- raise ValueError("请选择 manual/automatic 和 configure/start/pause/stop")
284
- if action == "start" and mode != "automatic":
285
- raise ValueError("start 只用于全自动运行;手动方式无需运行启动")
286
- if action in ("configure", "start") and current["status"] == "running":
287
- raise FileExistsError("讨论仍在运行;请先暂停,再切换模式或继续")
288
- if action in ("configure", "start") and connection.execute(
289
- "SELECT 1 FROM discussion_events WHERE room_id=? AND status IN ('dispatched','started')", (room_id,)).fetchone():
290
- raise FileExistsError("存在在途执行;先暂停并核对原会话已停止,再切换或继续")
291
- status = {"configure": "ready", "start": "running", "pause": "paused", "stop": "stopped"}[action]
292
- start_payload = {"phase": current.get("resume_phase", "initial"), **payload}
293
- deadline = validate_start(connection, room_id, start_payload) if action == "start" else current.get("deadline", 0)
294
- connection.execute("""INSERT INTO discussion_runtime
295
- (room_id,mode,run_id,status,control_hash,deadline,updated_at) VALUES (?,?,?,?,?,?,datetime('now'))
296
- ON CONFLICT(room_id) DO UPDATE SET mode=excluded.mode,status=excluded.status,
297
- generation=generation+1,revision=revision+1,deadline=excluded.deadline,updated_at=excluded.updated_at""",
298
- (room_id, mode, str(uuid.uuid4()), "ready", hashed, deadline))
299
- if action in ("pause", "stop"):
300
- connection.execute("UPDATE topic_settings SET phase='paused' WHERE room_id=? AND phase<>'concluded'", (room_id,))
301
- connection.execute("UPDATE discussion_runtime SET status=? WHERE room_id=?", (status, room_id))
302
- # Only a real mode change converts members; a manual expert of a woken discussion (adapter 'manual') stays manual.
303
- if mode != current["mode"]:
304
- connection.execute("UPDATE room_members SET access='manual' WHERE room_id=? AND access='runner'" if mode == "manual" else
305
- "UPDATE room_members SET access='runner' WHERE room_id=? AND access='manual' AND adapter<>'manual'", (room_id,))
306
- return runtime_view(connection, room_id)
307
-
308
-
309
- def validate_start(connection, room_id, payload):
310
- """Validate an AI-hosted run; zero deadline leaves only the configured limits."""
311
- row = connection.execute("SELECT * FROM topic_settings JOIN room_directory USING(room_id) WHERE room_id=?",
312
- (room_id,)).fetchone()
313
- limits = connection.execute(LIMITS_SQL, (room_id,)).fetchone()
314
- configured = ((limits["started_at"] or int(time.time())) + limits["max_minutes"] * 60 + limits["idle_seconds"]
315
- if limits["max_minutes"] else 0)
316
- deadline = payload.get("deadline", configured)
317
- if type(deadline) is int and configured:
318
- deadline = min(deadline or configured, configured)
319
- if type(deadline) is not int or (deadline != 0 and not int(time.time()) < deadline <= int(time.time()) + MAX_RUN_SECONDS):
320
- raise ValueError("deadline 须为 0(不限时长)或未来 24 小时内的 Unix 秒数")
321
- experts = json.loads(row["experts"])
322
- if row["archived"] or row["phase"] == "concluded" or row["host"] == "用户":
323
- raise ValueError("自动运行需要未归档、未收束的话题及 AI 主持人")
324
- if not experts or any(mode != "cli" for mode in experts.values()):
325
- raise ValueError("自动运行的参与者须均具有本地 CLI 读写能力")
326
- phase = payload.get("phase", "initial" if row["phase"] == "preparing" else "discussing")
327
- if phase not in ("initial", "discussing"):
328
- raise ValueError("恢复阶段必须为 initial/discussing")
329
- if row["phase"] in ("preparing", "paused"):
330
- connection.execute("UPDATE topic_settings SET phase=? WHERE room_id=?", (phase, room_id))
331
- connection.execute("UPDATE discussion_limits SET started_at=? WHERE room_id=? AND started_at=0",
332
- (int(time.time()), room_id))
333
- return deadline
334
-
335
-
336
- def list_events(connection, room_id, token, after=0):
337
- """Read a bounded durable inbox after an explicit cursor."""
338
- require_control(connection, room_id, token)
339
- rows = connection.execute("SELECT * FROM discussion_events WHERE room_id=? AND id>? ORDER BY id LIMIT 100",
340
- (room_id, max(0, int(after)))).fetchall()
341
- events = [{key: row[key] for key in PUBLIC_EVENT_FIELDS} for row in rows]
342
- return {"events": events, "next_after": events[-1]["id"] if events else int(after)}
343
-
344
-
345
- def event_action(connection, room_id, event_id, token, payload):
346
- """Reserve, enter or renew one persisted execution, with no implicit replay."""
347
- begin_write(connection)
348
- event = connection.execute("SELECT * FROM discussion_events WHERE room_id=? AND id=?",
349
- (room_id, event_id)).fetchone()
350
- if not event:
351
- raise LookupError("执行不存在")
352
- run = connection.execute("SELECT * FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
353
- now = int(time.time())
354
- work_seconds = execution_seconds(connection, room_id)
355
- action = payload.get("action")
356
- work_deadline = (now if action == "enter" else event["started_at"]) + work_seconds if work_seconds else 0
357
- lease_until = min(now + LEASE_SECONDS, work_deadline or now + LEASE_SECONDS, run["deadline"] or now + LEASE_SECONDS)
358
- controller = token_hash(token) == run["control_hash"]
359
- if not controller and (not token_hash(token) or token_hash(token) != event["token_hash"]):
360
- raise PermissionError("执行凭据无效")
361
- if action == "view":
362
- return {key: event[key] for key in PUBLIC_EVENT_FIELDS}
363
- if action == "segments":
364
- # A timeline report comes from the runner, also just after the execution ended or its run stopped.
365
- if not controller:
366
- raise PermissionError("需要本房间的运行控制凭据")
367
- timing = connection.execute("SELECT dispatched_at,finished_at FROM event_timings WHERE event_id=?", (event_id,)).fetchone()
368
- if not timing or not timing["dispatched_at"]:
369
- raise FileExistsError("执行尚未交付")
370
- finished = 0 if event["status"] in ("dispatched", "started") else (timing["finished_at"] or 1)
371
- save_segments(connection, "discussion", room_id, event_id, timing["dispatched_at"], finished, payload)
372
- return {key: event[key] for key in PUBLIC_EVENT_FIELDS}
373
- if run["status"] != "running" or 0 < run["deadline"] <= now or event["generation"] != run["generation"]:
374
- raise FileExistsError("本次运行已停止或执行已过期")
375
- if action == "reserve" and controller:
376
- reserve_event(connection, event, payload)
377
- elif action == "enter" and not controller:
378
- if event["status"] != "dispatched" or not payload.get("session_id"):
379
- raise FileExistsError("本次任务已进入或不可进入;不要重复执行")
380
- connection.execute("UPDATE discussion_events SET status='started',session_id=?,started_at=?,lease_until=? WHERE id=?",
381
- (str(payload["session_id"])[:200], now, lease_until, event_id))
382
- elif action == "renew" and controller and event["status"] == "started":
383
- if event["lease_until"] <= now or 0 < work_deadline <= now:
384
- raise FileExistsError("执行租约已到期,不能自动重新激活")
385
- connection.execute("UPDATE discussion_events SET lease_until=? WHERE id=?",
386
- (lease_until, event_id))
387
- elif action == "fail" and event["status"] in ("dispatched", "started"):
388
- connection.execute("UPDATE discussion_events SET status='failed',result=? WHERE id=?",
389
- (str(payload.get("reason", "需要用户处理"))[:500], event_id))
390
- connection.execute("UPDATE topic_settings SET phase='paused' WHERE room_id=?", (room_id,))
391
- else:
392
- raise FileExistsError("执行状态不允许此操作")
393
- result = connection.execute("SELECT * FROM discussion_events WHERE id=?", (event_id,)).fetchone()
394
- return {key: result[key] for key in PUBLIC_EVENT_FIELDS}
395
-
396
-
397
- def reserve_event(connection, event, payload):
398
- """Reserve only current work and merge pending host wakeups before dispatch."""
399
- hashed = token_hash(payload.get("execution_token"))
400
- if event["status"] != "pending" or not hashed:
401
- raise FileExistsError("执行已被领取或缺少有效的执行凭据")
402
- turn = connection.execute("SELECT * FROM discussion_turns WHERE room_id=? AND status IN ('pending','notified')",
403
- (event["room_id"],)).fetchone()
404
- if (event["role"] == "host" and turn) or (event["role"] == "expert" and (not turn or turn["id"] != event["turn_id"])):
405
- raise FileExistsError("当前安排与执行不符,请重新读取")
406
- if connection.execute("SELECT 1 FROM discussion_events WHERE room_id=? AND status IN ('dispatched','started')",
407
- (event["room_id"],)).fetchone():
408
- raise FileExistsError("已有在途执行")
409
- if connection.execute("""SELECT 1 FROM member_executions JOIN room_members ON room_members.id=member_executions.member_id
410
- WHERE member_executions.room_id=? AND room_members.name=? AND member_executions.status IN ('dispatched','started')""",
411
- (event["room_id"], event["participant"])).fetchone():
412
- raise FileExistsError("该参与者正在回答用户的直接提问;同一会话不并发续接")
413
- connection.execute("UPDATE discussion_events SET status='dispatched',token_hash=? WHERE id=?", (hashed, event["id"]))
414
- if event["role"] == "host":
415
- connection.execute("UPDATE discussion_events SET status='completed',result=? WHERE room_id=? AND role='host' AND status='pending' AND id<?",
416
- (f"merged:{event['id']}", event["room_id"], event["id"]))
1
+ """Opt-in discussion execution; no app launching or model calls live here."""
2
+
3
+ import hashlib
4
+ import json
5
+ import secrets
6
+ import time
7
+ import uuid
8
+
9
+ LEASE_SECONDS = 120
10
+ # Never leaves this process; a client cannot guess it to pose as a stored hash.
11
+ OWNER_PROXY_PREFIX = secrets.token_hex(16) + ":"
12
+ MAX_RUN_SECONDS = 86400
13
+ LIMIT_DEFAULTS = {"max_rounds": 0, "max_minutes": 0, "execution_minutes": 0}
14
+ # Older clients may still send these; reaching a limit always pauses, so only the pause value is accepted and the reserve is ignored.
15
+ LEGACY_LIMIT_FIELDS = ("on_limit", "summary_minutes")
16
+ PUBLIC_EVENT_FIELDS = ("id", "room_id", "run_id", "generation", "cause", "participant",
17
+ "role", "turn_id", "status", "session_id", "lease_until", "result", "created_at")
18
+ LIMITS_SQL = """SELECT limits.*,COALESCE(pauses.paused_seconds,0)+CASE WHEN COALESCE(pauses.paused_at,0)>0
19
+ THEN MAX(0,CAST(strftime('%s','now') AS INTEGER)-pauses.paused_at) ELSE 0 END AS idle_seconds
20
+ FROM discussion_limits AS limits LEFT JOIN discussion_pauses AS pauses USING(room_id) WHERE limits.room_id=?"""
21
+ TIMING_ITEMS = 20
22
+ # Phase segments the runner reports for the execution timeline: the phases, how many one execution keeps and one report
23
+ # carries, a tool name's length, and how long after its end an execution still takes its last report.
24
+ SEGMENT_PHASES = ("thinking", "command", "tool", "editing", "writing")
25
+ MAX_EXECUTION_SEGMENTS = 400
26
+ MAX_SEGMENTS_PER_REPORT = 40
27
+ MAX_SEGMENT_TOOL = 60
28
+ SEGMENT_GRACE_SECONDS = 600
29
+ # Queue: created -> dispatched; wake: dispatched -> entered; work: entered -> finished. Open stages run to now.
30
+ TIMING_SQL = """SELECT e.id,e.participant,e.role,e.cause,e.status,
31
+ MAX(0,CASE WHEN t.dispatched_at>0 THEN t.dispatched_at ELSE ? END-CAST(strftime('%s',e.created_at) AS INTEGER)) AS queue_seconds,
32
+ CASE WHEN t.dispatched_at>0 THEN MAX(0,COALESCE(NULLIF(e.started_at,0),NULLIF(t.finished_at,0),?)-t.dispatched_at) END AS wake_seconds,
33
+ CASE WHEN e.started_at>0 THEN MAX(0,COALESCE(NULLIF(t.finished_at,0),?)-e.started_at) END AS work_seconds
34
+ FROM discussion_events AS e LEFT JOIN event_timings AS t ON t.event_id=e.id
35
+ WHERE e.room_id=? AND e.run_id=? AND (t.dispatched_at>0 OR e.status='pending') ORDER BY e.id DESC"""
36
+
37
+
38
+ def owner_proxy_token(control_hash):
39
+ """An in-process stand-in for a room's control credential, so an administrator session writes as its owner."""
40
+ return OWNER_PROXY_PREFIX + control_hash
41
+
42
+
43
+ def token_hash(token):
44
+ """Hash a bounded opaque capability without storing its original value."""
45
+ if isinstance(token, str) and token.startswith(OWNER_PROXY_PREFIX):
46
+ return token[len(OWNER_PROXY_PREFIX):]
47
+ if not isinstance(token, str) or not 32 <= len(token) <= 256:
48
+ return ""
49
+ return hashlib.sha256(token.encode()).hexdigest()
50
+
51
+
52
+ def save_segments(connection, source, room_id, execution_id, anchor, finished, payload, optional=False):
53
+ """Record an execution's phase segments as millisecond offsets from its delivery at `anchor`.
54
+
55
+ The runner measures them on its own clock, so they are kept within the execution's own span; `finished` is 0 while
56
+ it runs. A resent segment keeps the later end, so the open segment can be reported again as it runs on. `optional`
57
+ allows a report with no segments, when it carries progress notes instead.
58
+ """
59
+ now = int(time.time())
60
+ if finished and finished + SEGMENT_GRACE_SECONDS < now:
61
+ raise FileExistsError("执行已结束较久,不再接收阶段记录")
62
+ segments = payload.get("segments", [])
63
+ if (not isinstance(segments, list) or len(segments) > MAX_SEGMENTS_PER_REPORT or (not segments and not optional) or any(
64
+ not isinstance(item, dict) or type(item.get("seq")) is not int or not 1 <= item["seq"] <= MAX_EXECUTION_SEGMENTS
65
+ or item.get("phase") not in SEGMENT_PHASES or not isinstance(item.get("tool", ""), str)
66
+ or len(item.get("tool", "")) > MAX_SEGMENT_TOOL or type(item.get("start_ms")) is not int
67
+ or type(item.get("end_ms")) is not int for item in segments)):
68
+ raise ValueError(f"segments 须为 1–{MAX_SEGMENTS_PER_REPORT} 段 {{seq: 1–{MAX_EXECUTION_SEGMENTS}, "
69
+ f"phase: {'、'.join(SEGMENT_PHASES)}, tool: 最多 {MAX_SEGMENT_TOOL} 字, start_ms, end_ms}}")
70
+ limit = max(0, (finished or now) - anchor + 1) * 1000
71
+ rows = []
72
+ for item in segments:
73
+ start = min(max(item["start_ms"], 0), limit)
74
+ rows.append((source, execution_id, item["seq"], room_id, item["phase"], item.get("tool", ""), start,
75
+ min(max(item["end_ms"], start), limit)))
76
+ connection.executemany("""INSERT INTO execution_segments (source,execution_id,seq,room_id,phase,tool,start_ms,end_ms)
77
+ VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(source,execution_id,seq) DO UPDATE SET end_ms=MAX(end_ms,excluded.end_ms)""", rows)
78
+
79
+
80
+ def begin_write(connection):
81
+ """Acquire the writer lock once, including when routing already acquired it."""
82
+ if not connection.in_transaction:
83
+ connection.execute("BEGIN IMMEDIATE")
84
+
85
+
86
+ def limit_values(payload, current=None):
87
+ """Validate bounded discussion settings; zero means no round/time preset."""
88
+ if not isinstance(payload, dict) or set(payload) - set(LIMIT_DEFAULTS) - set(LEGACY_LIMIT_FIELDS):
89
+ raise ValueError("limits 仅接受讨论上限配置字段")
90
+ if payload.get("on_limit", "pause") != "pause":
91
+ raise ValueError("到限后只会暂停;需要总结时请主持人在暂停后按你的要求写出")
92
+ result = {key: (current or {}).get(key, value) for key, value in LIMIT_DEFAULTS.items()}
93
+ result.update({key: value for key, value in payload.items() if key in LIMIT_DEFAULTS})
94
+ for key, minimum, maximum in (("max_rounds", 0, 100), ("max_minutes", 0, 1440), ("execution_minutes", 0, 60)):
95
+ if type(result[key]) is not int or not minimum <= result[key] <= maximum:
96
+ raise ValueError(f"{key} 须为 {minimum}–{maximum} 的整数")
97
+ return result
98
+
99
+
100
+ def limits_view(row, room):
101
+ """Derive progress without changing state during reads."""
102
+ result = dict(row)
103
+ now = int(time.time())
104
+ idle = result.get("idle_seconds") or 0
105
+ deadline = result["started_at"] + result["max_minutes"] * 60 + idle if result["started_at"] and result["max_minutes"] else 0
106
+ if room.get("runtime", {}).get("deadline") and (room["runtime"]["status"] == "running" or result["stop_reason"]):
107
+ runtime_deadline = room["runtime"]["deadline"]
108
+ deadline = min(deadline or runtime_deadline, runtime_deadline)
109
+ reason = ""
110
+ budget = room.get("round_budget")
111
+ experts = len(room.get("experts") or {})
112
+ # A budgeted room counts rounds from the budget base; a legacy room counts whole-roster rounds from the start.
113
+ if budget:
114
+ segment_rounds = max(0, room["opinion_count"] - budget["base"]) // experts if experts else 0
115
+ else:
116
+ segment_rounds = room["completed_rounds"]
117
+ round_cap = (budget["cap"] or result["max_rounds"]) if budget else result["max_rounds"]
118
+ if deadline and now >= deadline:
119
+ reason = "time_limit"
120
+ elif round_cap and segment_rounds >= round_cap:
121
+ reason = "round_limit"
122
+ result.update(deadline=deadline, reached=bool(reason), reason=reason, completed_rounds=room["completed_rounds"],
123
+ segment_rounds=segment_rounds, round_cap=round_cap, extra_rounds=budget["extra"] if budget else 0,
124
+ remaining_seconds=max(0, deadline - now) if deadline else None,
125
+ elapsed_seconds=max(0, now - result["started_at"] - idle) if result["started_at"] else 0)
126
+ return result
127
+
128
+
129
+ def require_within_limits(room):
130
+ """Stop new expert work at the configured boundary, preserving user notes."""
131
+ if room["limits"]["reached"]:
132
+ raise FileExistsError("已达到讨论上限;请检查上限,讨论随之结束并回到自由协作")
133
+
134
+
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
139
+
140
+
141
+ def runtime_view(connection, room_id):
142
+ """Return public execution state, never capability hashes."""
143
+ row = connection.execute("SELECT * FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
144
+ if not row:
145
+ return {"mode": "manual", "status": "ready", "revision": 0, "protected": False}
146
+ result = {key: row[key] for key in row.keys() if key != "control_hash"}
147
+ result["protected"] = True
148
+ result["expired"] = row["status"] == "running" and 0 < row["deadline"] <= int(time.time())
149
+ event = connection.execute("SELECT id,participant,role,status,result FROM discussion_events WHERE room_id=? ORDER BY id DESC LIMIT 1", (room_id,)).fetchone()
150
+ result["latest_execution"] = dict(event) if event else None
151
+ result["participants"] = participant_executions(connection, room_id)
152
+ result["failures"] = failed_executions(connection, room_id)
153
+ result["timings"] = execution_timings(connection, room_id, row["run_id"])
154
+ return result
155
+
156
+
157
+ def failed_executions(connection, room_id):
158
+ """Keep failed attempts readable after a retry, without exposing execution credentials."""
159
+ return [dict(row) for row in connection.execute("""SELECT e.id,e.participant,e.role,e.turn_id,
160
+ e.result,e.created_at,CASE WHEN t.dispatched_at>0 AND t.finished_at>0
161
+ THEN MAX(0,t.finished_at-t.dispatched_at) END AS elapsed_seconds
162
+ FROM discussion_events AS e LEFT JOIN event_timings AS t ON t.event_id=e.id
163
+ WHERE e.room_id=? AND e.status='failed' ORDER BY e.id""", (room_id,))]
164
+
165
+
166
+ def execution_timings(connection, room_id, run_id):
167
+ """Summarize stage durations of the current run so slow stages are visible."""
168
+ now = int(time.time())
169
+ rows = [dict(row) for row in connection.execute(TIMING_SQL, (now, now, now, room_id, run_id))]
170
+ stages = ("queue_seconds", "wake_seconds", "work_seconds")
171
+ totals = {stage: sum(row[stage] or 0 for row in rows) for stage in stages}
172
+ return {"items": rows[:TIMING_ITEMS], "totals": totals, "count": len(rows)}
173
+
174
+
175
+ def participant_executions(connection, room_id):
176
+ """Summarize each participant's latest execution and failure count for the progress panel."""
177
+ rows = connection.execute("""SELECT latest.id,latest.participant,latest.role,latest.status,latest.result,
178
+ latest.turn_id,latest.lease_until,COALESCE((SELECT bound.session_id FROM discussion_events AS bound
179
+ WHERE bound.room_id=latest.room_id AND bound.participant=latest.participant AND bound.session_id<>''
180
+ ORDER BY bound.id DESC LIMIT 1),'') AS session_id,(SELECT COUNT(*) FROM discussion_events AS failed
181
+ WHERE failed.room_id=latest.room_id AND failed.participant=latest.participant
182
+ AND failed.status='failed') AS failures
183
+ FROM discussion_events AS latest WHERE latest.room_id=? AND latest.id=(SELECT MAX(id)
184
+ FROM discussion_events AS other WHERE other.room_id=latest.room_id
185
+ AND other.participant=latest.participant) ORDER BY latest.id""", (room_id,)).fetchall()
186
+ now = int(time.time())
187
+ return [{key: row[key] for key in row.keys() if key != "lease_until"}
188
+ | {"stale": row["status"] == "started" and row["lease_until"] <= now} for row in rows]
189
+
190
+
191
+ def require_control(connection, room_id, token):
192
+ """Validate the room owner capability for control and dispatcher operations."""
193
+ row = connection.execute("SELECT * FROM discussion_runtime WHERE room_id=? AND control_hash=?",
194
+ (room_id, token_hash(token))).fetchone()
195
+ if not row:
196
+ raise PermissionError("需要本房间的运行控制凭据")
197
+ return row
198
+
199
+
200
+ def require_owner(connection, room_id, token):
201
+ """Require the persistent room owner credential, independent of execution mode."""
202
+ hashed = token_hash(token)
203
+ row = connection.execute("SELECT * FROM room_modes WHERE room_id=? AND control_hash=?",
204
+ (room_id, hashed)).fetchone() if hashed else None
205
+ if not row:
206
+ raise PermissionError("需要本房间的控制凭据;没有凭据只能阅读")
207
+ return row
208
+
209
+
210
+ def write_actor(operation, payload):
211
+ """Use the same message attribution defaults as persisted message details."""
212
+ if operation == "messages":
213
+ return payload.get("submitted_by", payload.get("author") if payload.get("source") in ("cli", "api") else "用户")
214
+ return payload.get("actor", "用户")
215
+
216
+
217
+ def authorize_participant(connection, room_id, identity, operation, payload):
218
+ """Limit authenticated participants to their identity and assigned host duties."""
219
+ actor = write_actor(operation, payload)
220
+ if actor != identity["name"]:
221
+ raise PermissionError("凭据与实际参与身份不符")
222
+ if operation == "messages":
223
+ author = payload.get("author")
224
+ online = connection.execute("SELECT 1 FROM room_members WHERE room_id=? AND name=? AND access='online' AND status='active'",
225
+ (room_id, author)).fetchone()
226
+ if author != actor and not (online and identity["role"] in ("owner", "host")):
227
+ raise PermissionError("只能提交本人观点,或由房主、主持人代发在线成员原文")
228
+ elif operation != "attachments" and identity["role"] != "owner":
229
+ if identity["role"] != "host" or operation not in ("topic", "state", "turns", "limits"):
230
+ raise PermissionError("本成员没有此管理权限")
231
+ if operation == "topic":
232
+ if any(field in payload for field in ("host", "experts", "members")):
233
+ raise PermissionError("只有房主可以修改成员和主持人")
234
+
235
+
236
+ def authorize_write(connection, room_id, token, operation, payload):
237
+ """Check a capability under the same writer lock as the following mutation."""
238
+ begin_write(connection)
239
+ run = connection.execute("SELECT * FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
240
+ hashed = token_hash(token)
241
+ owner = connection.execute("SELECT 1 FROM room_modes WHERE room_id=? AND control_hash=?", (room_id, hashed)).fetchone() if hashed else None
242
+ if owner:
243
+ authorize_participant(connection, room_id, {"name": "用户", "role": "owner"}, operation, payload)
244
+ return
245
+ member = connection.execute("SELECT * FROM room_members WHERE room_id=? AND token_hash=? AND status='active' AND access<>'online'",
246
+ (room_id, hashed)).fetchone() if hashed else None
247
+ if member and (not run or run["mode"] == "manual" or member["access"] == "manual"):
248
+ authorize_participant(connection, room_id, member, operation, payload)
249
+ 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
344
+
345
+
346
+ def list_events(connection, room_id, token, after=0):
347
+ """Read a bounded durable inbox after an explicit cursor."""
348
+ require_control(connection, room_id, token)
349
+ rows = connection.execute("SELECT * FROM discussion_events WHERE room_id=? AND id>? ORDER BY id LIMIT 100",
350
+ (room_id, max(0, int(after)))).fetchall()
351
+ events = [{key: row[key] for key in PUBLIC_EVENT_FIELDS} for row in rows]
352
+ return {"events": events, "next_after": events[-1]["id"] if events else int(after)}
353
+
354
+
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)
358
+ event = connection.execute("SELECT * FROM discussion_events WHERE room_id=? AND id=?",
359
+ (room_id, event_id)).fetchone()
360
+ if not event:
361
+ 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"]):
370
+ 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"]))