ai_co_discuss 0.2.13 → 0.2.15
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.
- package/.agents/skills/discuss-host/SKILL.md +3 -2
- package/.agents/skills/discuss-participant/SKILL.md +1 -1
- package/README.md +13 -9
- package/backend/free_room.py +64 -0
- package/backend/identity.py +31 -31
- package/backend/runtime.py +364 -364
- package/backend/schema.sql +599 -580
- package/backend/server.py +1782 -1760
- package/docs/architecture.md +8 -4
- package/docs/cli.md +2 -0
- package/docs/runner.md +37 -4
- package/docs/workflow.md +148 -148
- package/package.json +2 -2
- package/runner/api_participant.mjs +186 -0
- package/runner/discuss.py +946 -946
- package/runner/discussion_adapters.mjs +6 -0
- package/runner/discussion_api.mjs +146 -0
- package/runner/discussion_launch.mjs +728 -664
- package/runner/discussion_runner.mjs +36 -7
- package/runner/discussion_service.mjs +395 -394
- package/runner/free_runner.mjs +6 -2
- package/web/app.js +2913 -2859
- package/web/backup.js +1 -1
- package/web/index.html +1 -1
- package/web/markdown.js +1 -1
- package/web/modern.css +1 -1
- package/web/modern.js +7362 -5077
package/backend/runtime.py
CHANGED
|
@@ -1,364 +1,364 @@
|
|
|
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
|
-
"on_limit": "pause", "summary_minutes": 5}
|
|
12
|
-
PUBLIC_EVENT_FIELDS = ("id", "room_id", "run_id", "generation", "cause", "participant",
|
|
13
|
-
"role", "turn_id", "status", "session_id", "lease_until", "result", "created_at")
|
|
14
|
-
LIMITS_SQL = """SELECT limits.*,COALESCE(pauses.paused_seconds,0)+CASE WHEN COALESCE(pauses.paused_at,0)>0
|
|
15
|
-
THEN MAX(0,CAST(strftime('%s','now') AS INTEGER)-pauses.paused_at) ELSE 0 END AS idle_seconds
|
|
16
|
-
FROM discussion_limits AS limits LEFT JOIN discussion_pauses AS pauses USING(room_id) WHERE limits.room_id=?"""
|
|
17
|
-
TIMING_ITEMS = 20
|
|
18
|
-
# Queue: created -> dispatched; wake: dispatched -> entered; work: entered -> finished. Open stages run to now.
|
|
19
|
-
TIMING_SQL = """SELECT e.id,e.participant,e.role,e.cause,e.status,
|
|
20
|
-
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,
|
|
21
|
-
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,
|
|
22
|
-
CASE WHEN e.started_at>0 THEN MAX(0,COALESCE(NULLIF(t.finished_at,0),?)-e.started_at) END AS work_seconds
|
|
23
|
-
FROM discussion_events AS e LEFT JOIN event_timings AS t ON t.event_id=e.id
|
|
24
|
-
WHERE e.room_id=? AND e.run_id=? AND (t.dispatched_at>0 OR e.status='pending') ORDER BY e.id DESC"""
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def token_hash(token):
|
|
28
|
-
"""Hash a bounded opaque capability without storing its original value."""
|
|
29
|
-
if not isinstance(token, str) or not 32 <= len(token) <= 256:
|
|
30
|
-
return ""
|
|
31
|
-
return hashlib.sha256(token.encode()).hexdigest()
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def begin_write(connection):
|
|
35
|
-
"""Acquire the writer lock once, including when routing already acquired it."""
|
|
36
|
-
if not connection.in_transaction:
|
|
37
|
-
connection.execute("BEGIN IMMEDIATE")
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def limit_values(payload, current=None):
|
|
41
|
-
"""Validate bounded discussion settings; zero means no round/time preset."""
|
|
42
|
-
if not isinstance(payload, dict) or set(payload) - set(LIMIT_DEFAULTS):
|
|
43
|
-
raise ValueError("limits 仅接受讨论上限配置字段")
|
|
44
|
-
result = {key: (current or {}).get(key, value) for key, value in LIMIT_DEFAULTS.items()}
|
|
45
|
-
result.update(payload)
|
|
46
|
-
for key, minimum, maximum in (("max_rounds", 0, 100), ("max_minutes", 0, 1440),
|
|
47
|
-
("execution_minutes", 0, 60), ("summary_minutes", 1, 60)):
|
|
48
|
-
if type(result[key]) is not int or not minimum <= result[key] <= maximum:
|
|
49
|
-
raise ValueError(f"{key} 须为 {minimum}–{maximum} 的整数")
|
|
50
|
-
if result["on_limit"] not in ("pause", "summarize"):
|
|
51
|
-
raise ValueError("on_limit 须为 pause 或 summarize")
|
|
52
|
-
if result["on_limit"] == "summarize" and result["max_minutes"] and result["summary_minutes"] >= result["max_minutes"]:
|
|
53
|
-
raise ValueError("收束预留时间必须小于讨论总时长")
|
|
54
|
-
return result
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def limits_view(row, room):
|
|
58
|
-
"""Derive progress without changing state during reads."""
|
|
59
|
-
result = dict(row)
|
|
60
|
-
now = int(time.time())
|
|
61
|
-
idle = result.get("idle_seconds") or 0
|
|
62
|
-
deadline = result["started_at"] + result["max_minutes"] * 60 + idle if result["started_at"] and result["max_minutes"] else 0
|
|
63
|
-
if room.get("runtime", {}).get("deadline") and (room["runtime"]["status"] == "running" or result["stop_reason"]):
|
|
64
|
-
runtime_deadline = room["runtime"]["deadline"]
|
|
65
|
-
deadline = min(deadline or runtime_deadline, runtime_deadline)
|
|
66
|
-
if result["closing_at"]:
|
|
67
|
-
closing_deadline = result["closing_at"] + result["summary_minutes"] * 60
|
|
68
|
-
deadline = min(deadline or closing_deadline, closing_deadline)
|
|
69
|
-
cutoff = deadline - result["summary_minutes"] * 60 if deadline and result["on_limit"] == "summarize" and not result["closing_at"] else deadline
|
|
70
|
-
reason = ""
|
|
71
|
-
if deadline and now >= deadline:
|
|
72
|
-
reason = "time_limit"
|
|
73
|
-
elif result["closing_at"]:
|
|
74
|
-
reason = result["stop_reason"]
|
|
75
|
-
elif result["max_rounds"] and room["completed_rounds"] >= result["max_rounds"]:
|
|
76
|
-
reason = "round_limit"
|
|
77
|
-
elif cutoff and now >= cutoff:
|
|
78
|
-
reason = "summary_reserved" if result["on_limit"] == "summarize" else "time_limit"
|
|
79
|
-
result.update(deadline=deadline, cutoff=cutoff, reached=bool(reason), reason=reason,
|
|
80
|
-
closing=bool(result["closing_at"]) and room["phase"] not in ("paused", "concluded"), completed_rounds=room["completed_rounds"],
|
|
81
|
-
remaining_seconds=max(0, deadline - now) if deadline else None,
|
|
82
|
-
elapsed_seconds=max(0, now - result["started_at"] - idle) if result["started_at"] else 0)
|
|
83
|
-
return result
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def require_within_limits(room):
|
|
87
|
-
"""Stop new expert work at the configured boundary, preserving user notes."""
|
|
88
|
-
if room["limits"]["reached"]:
|
|
89
|
-
raise FileExistsError("已达到讨论上限;请检查上限并暂停或由主持人总结")
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def execution_seconds(connection, room_id):
|
|
93
|
-
"""Read the per-execution budget shared by entry, renewal and writes."""
|
|
94
|
-
row = connection.execute("SELECT execution_minutes FROM discussion_limits WHERE room_id=?", (room_id,)).fetchone()
|
|
95
|
-
return (row[0] if row else LIMIT_DEFAULTS["execution_minutes"]) * 60
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def runtime_view(connection, room_id):
|
|
99
|
-
"""Return public execution state, never capability hashes."""
|
|
100
|
-
row = connection.execute("SELECT * FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
|
|
101
|
-
if not row:
|
|
102
|
-
return {"mode": "manual", "status": "ready", "revision": 0, "protected": False}
|
|
103
|
-
result = {key: row[key] for key in row.keys() if key != "control_hash"}
|
|
104
|
-
result["protected"] = True
|
|
105
|
-
result["expired"] = row["status"] == "running" and 0 < row["deadline"] <= int(time.time())
|
|
106
|
-
event = connection.execute("SELECT id,participant,role,status,result FROM discussion_events WHERE room_id=? ORDER BY id DESC LIMIT 1", (room_id,)).fetchone()
|
|
107
|
-
result["latest_execution"] = dict(event) if event else None
|
|
108
|
-
result["participants"] = participant_executions(connection, room_id)
|
|
109
|
-
result["timings"] = execution_timings(connection, room_id, row["run_id"])
|
|
110
|
-
return result
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def execution_timings(connection, room_id, run_id):
|
|
114
|
-
"""Summarize stage durations of the current run so slow stages are visible."""
|
|
115
|
-
now = int(time.time())
|
|
116
|
-
rows = [dict(row) for row in connection.execute(TIMING_SQL, (now, now, now, room_id, run_id))]
|
|
117
|
-
stages = ("queue_seconds", "wake_seconds", "work_seconds")
|
|
118
|
-
totals = {stage: sum(row[stage] or 0 for row in rows) for stage in stages}
|
|
119
|
-
return {"items": rows[:TIMING_ITEMS], "totals": totals, "count": len(rows)}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
def participant_executions(connection, room_id):
|
|
123
|
-
"""Summarize each participant's latest execution and failure count for the progress panel."""
|
|
124
|
-
rows = connection.execute("""SELECT latest.id,latest.participant,latest.role,latest.status,latest.result,
|
|
125
|
-
latest.turn_id,latest.lease_until,COALESCE((SELECT bound.session_id FROM discussion_events AS bound
|
|
126
|
-
WHERE bound.room_id=latest.room_id AND bound.participant=latest.participant AND bound.session_id<>''
|
|
127
|
-
ORDER BY bound.id DESC LIMIT 1),'') AS session_id,(SELECT COUNT(*) FROM discussion_events AS failed
|
|
128
|
-
WHERE failed.room_id=latest.room_id AND failed.participant=latest.participant
|
|
129
|
-
AND failed.status='failed') AS failures
|
|
130
|
-
FROM discussion_events AS latest WHERE latest.room_id=? AND latest.id=(SELECT MAX(id)
|
|
131
|
-
FROM discussion_events AS other WHERE other.room_id=latest.room_id
|
|
132
|
-
AND other.participant=latest.participant) ORDER BY latest.id""", (room_id,)).fetchall()
|
|
133
|
-
now = int(time.time())
|
|
134
|
-
return [{key: row[key] for key in row.keys() if key != "lease_until"}
|
|
135
|
-
| {"stale": row["status"] == "started" and row["lease_until"] <= now} for row in rows]
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def require_control(connection, room_id, token):
|
|
139
|
-
"""Validate the room owner capability for control and dispatcher operations."""
|
|
140
|
-
row = connection.execute("SELECT * FROM discussion_runtime WHERE room_id=? AND control_hash=?",
|
|
141
|
-
(room_id, token_hash(token))).fetchone()
|
|
142
|
-
if not row:
|
|
143
|
-
raise PermissionError("需要本房间的运行控制凭据")
|
|
144
|
-
return row
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
def require_owner(connection, room_id, token):
|
|
148
|
-
"""Require the persistent room owner credential, independent of execution mode."""
|
|
149
|
-
hashed = token_hash(token)
|
|
150
|
-
row = connection.execute("SELECT * FROM room_modes WHERE room_id=? AND control_hash=?",
|
|
151
|
-
(room_id, hashed)).fetchone() if hashed else None
|
|
152
|
-
if not row:
|
|
153
|
-
raise PermissionError("需要本房间的控制凭据;没有凭据只能阅读")
|
|
154
|
-
return row
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
def write_actor(operation, payload):
|
|
158
|
-
"""Use the same message attribution defaults as persisted message details."""
|
|
159
|
-
if operation == "messages":
|
|
160
|
-
return payload.get("submitted_by", payload.get("author") if payload.get("source")
|
|
161
|
-
return payload.get("actor", "用户")
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
def authorize_participant(connection, room_id, identity, operation, payload):
|
|
165
|
-
"""Limit authenticated participants to their identity and assigned host duties."""
|
|
166
|
-
actor = write_actor(operation, payload)
|
|
167
|
-
if actor != identity["name"]:
|
|
168
|
-
raise PermissionError("凭据与实际参与身份不符")
|
|
169
|
-
if operation == "messages":
|
|
170
|
-
author = payload.get("author")
|
|
171
|
-
online = connection.execute("SELECT 1 FROM room_members WHERE room_id=? AND name=? AND access='online' AND status='active'",
|
|
172
|
-
(room_id, author)).fetchone()
|
|
173
|
-
if author != actor and not (online and identity["role"] in ("owner", "host")):
|
|
174
|
-
raise PermissionError("只能提交本人观点,或由房主、主持人代发在线成员原文")
|
|
175
|
-
elif operation == "floor":
|
|
176
|
-
speaker = payload.get("speaker", actor)
|
|
177
|
-
online = connection.execute("SELECT 1 FROM room_members WHERE room_id=? AND name=? AND access='online' AND status='active'",
|
|
178
|
-
(room_id, speaker)).fetchone()
|
|
179
|
-
if identity["role"] != "owner" and speaker != actor and not (identity["role"] == "host" and online):
|
|
180
|
-
raise PermissionError("只能操作本人发言权")
|
|
181
|
-
elif operation != "attachments" and identity["role"] != "owner":
|
|
182
|
-
if identity["role"] != "host" or operation not in ("topic", "state", "turns", "limits"):
|
|
183
|
-
raise PermissionError("本成员没有此管理权限")
|
|
184
|
-
if operation == "topic":
|
|
185
|
-
if any(field in payload for field in ("host", "experts", "members")):
|
|
186
|
-
raise PermissionError("只有房主可以修改成员和主持人")
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
def authorize_write(connection, room_id, token, operation, payload):
|
|
190
|
-
"""Check a capability under the same writer lock as the following mutation."""
|
|
191
|
-
begin_write(connection)
|
|
192
|
-
run = connection.execute("SELECT * FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
|
|
193
|
-
hashed = token_hash(token)
|
|
194
|
-
owner = connection.execute("SELECT 1 FROM room_modes WHERE room_id=? AND control_hash=?", (room_id, hashed)).fetchone() if hashed else None
|
|
195
|
-
if owner:
|
|
196
|
-
authorize_participant(connection, room_id, {"name": "用户", "role": "owner"}, operation, payload)
|
|
197
|
-
return
|
|
198
|
-
member = connection.execute("SELECT * FROM room_members WHERE room_id=? AND token_hash=? AND status='active' AND access<>'online'",
|
|
199
|
-
(room_id, hashed)).fetchone() if hashed else None
|
|
200
|
-
if member and (not run or run["mode"] == "manual"):
|
|
201
|
-
authorize_participant(connection, room_id, member, operation, payload)
|
|
202
|
-
return
|
|
203
|
-
if not run or not hashed:
|
|
204
|
-
raise PermissionError("需要房主或本成员的有效凭据;没有凭据只能阅读")
|
|
205
|
-
actor = write_actor(operation, payload)
|
|
206
|
-
work_seconds = execution_seconds(connection, room_id)
|
|
207
|
-
event = connection.execute("""SELECT * FROM discussion_events WHERE room_id=? AND token_hash=?
|
|
208
|
-
AND run_id=? AND generation=? AND status='started' AND lease_until>?
|
|
209
|
-
AND (?=0 OR started_at+?>?)""", (room_id, hashed, run["run_id"], run["generation"],
|
|
210
|
-
int(time.time()), work_seconds, work_seconds, int(time.time()))).fetchone()
|
|
211
|
-
if not hashed or not event or run["status"] != "running" or 0 < run["deadline"] <= int(time.time()):
|
|
212
|
-
raise PermissionError("执行资格无效、已结束或已到期;请重新读取运行状态")
|
|
213
|
-
if actor != event["participant"]:
|
|
214
|
-
raise PermissionError("执行凭据与实际参与身份不符")
|
|
215
|
-
authorize_participant(connection, room_id, {"name": event["participant"], "role": event["role"]}, operation, payload)
|
|
216
|
-
if operation == "messages":
|
|
217
|
-
if payload.get("author") != actor:
|
|
218
|
-
raise PermissionError("自动执行不能代发其他参与者")
|
|
219
|
-
if event["role"] == "expert" and payload.get("turn_id") != event["turn_id"]:
|
|
220
|
-
raise PermissionError("回答必须关联本次安排")
|
|
221
|
-
elif operation == "floor":
|
|
222
|
-
if payload.get("speaker", actor) != actor:
|
|
223
|
-
raise PermissionError("自动执行只能操作本人发言权")
|
|
224
|
-
elif operation == "attachments":
|
|
225
|
-
pass
|
|
226
|
-
elif event["role"] != "host" or operation not in ("topic", "state", "turns", "limits"):
|
|
227
|
-
raise PermissionError("本次执行不能进行此操作")
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
def configure_runtime(connection, room_id, token, payload):
|
|
231
|
-
"""Apply one versioned, explicit mode or lifecycle action."""
|
|
232
|
-
begin_write(connection)
|
|
233
|
-
require_owner(connection, room_id, token)
|
|
234
|
-
current = runtime_view(connection, room_id)
|
|
235
|
-
if payload.get("revision") != current["revision"]:
|
|
236
|
-
raise FileExistsError("运行状态已变化,请重新读取 revision")
|
|
237
|
-
hashed = token_hash(token)
|
|
238
|
-
if not current["protected"] and not hashed:
|
|
239
|
-
raise ValueError("运行凭据须为 32–256 字符的随机值")
|
|
240
|
-
action = payload.get("action", "configure")
|
|
241
|
-
mode = payload.get("mode", "automatic" if action == "start" else current["mode"])
|
|
242
|
-
if mode not in ("manual", "automatic") or action not in ("configure", "start", "pause", "stop"):
|
|
243
|
-
raise ValueError("请选择 manual/automatic 和 configure/start/pause/stop")
|
|
244
|
-
if action == "start" and mode != "automatic":
|
|
245
|
-
raise ValueError("start 只用于全自动运行;手动方式无需运行启动")
|
|
246
|
-
if action in ("configure", "start") and current["status"] == "running":
|
|
247
|
-
raise FileExistsError("讨论仍在运行;请先暂停,再切换模式或继续")
|
|
248
|
-
if action in ("configure", "start") and connection.execute(
|
|
249
|
-
"SELECT 1 FROM discussion_events WHERE room_id=? AND status IN ('dispatched','started')", (room_id,)).fetchone():
|
|
250
|
-
raise FileExistsError("存在在途执行;先暂停并核对原会话已停止,再切换或继续")
|
|
251
|
-
status = {"configure": "ready", "start": "running", "pause": "paused", "stop": "stopped"}[action]
|
|
252
|
-
start_payload = {"phase": current.get("resume_phase", "initial"), **payload}
|
|
253
|
-
deadline = validate_start(connection, room_id, start_payload) if action == "start" else current.get("deadline", 0)
|
|
254
|
-
connection.execute("""INSERT INTO discussion_runtime
|
|
255
|
-
(room_id,mode,run_id,status,control_hash,deadline,updated_at) VALUES (?,?,?,?,?,?,datetime('now'))
|
|
256
|
-
ON CONFLICT(room_id) DO UPDATE SET mode=excluded.mode,status=excluded.status,
|
|
257
|
-
generation=generation+1,revision=revision+1,deadline=excluded.deadline,updated_at=excluded.updated_at""",
|
|
258
|
-
(room_id, mode, str(uuid.uuid4()), "ready", hashed, deadline))
|
|
259
|
-
if action in ("pause", "stop"):
|
|
260
|
-
connection.execute("UPDATE topic_settings SET phase='paused' WHERE room_id=? AND phase<>'concluded'", (room_id,))
|
|
261
|
-
connection.execute("UPDATE discussion_runtime SET status=? WHERE room_id=?", (status, room_id))
|
|
262
|
-
connection.execute("UPDATE room_members SET access=? WHERE room_id=? AND access<>'online'",
|
|
263
|
-
("runner" if mode == "automatic" else "manual", room_id))
|
|
264
|
-
return runtime_view(connection, room_id)
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
def validate_start(connection, room_id, payload):
|
|
268
|
-
"""Validate an AI-hosted run; zero deadline leaves only the configured limits."""
|
|
269
|
-
row = connection.execute("SELECT * FROM topic_settings JOIN room_directory USING(room_id) WHERE room_id=?",
|
|
270
|
-
(room_id,)).fetchone()
|
|
271
|
-
limits = connection.execute(LIMITS_SQL, (room_id,)).fetchone()
|
|
272
|
-
configured = ((limits["started_at"] or int(time.time())) + limits["max_minutes"] * 60 + limits["idle_seconds"]
|
|
273
|
-
if limits["max_minutes"] else 0)
|
|
274
|
-
if limits["closing_at"]:
|
|
275
|
-
closing_deadline = limits["closing_at"] + limits["summary_minutes"] * 60
|
|
276
|
-
previous_deadline = runtime_view(connection, room_id).get("deadline", 0)
|
|
277
|
-
configured = min(value for value in (configured, closing_deadline, previous_deadline) if value)
|
|
278
|
-
deadline = payload.get("deadline", configured)
|
|
279
|
-
if type(deadline) is int and configured:
|
|
280
|
-
deadline = min(deadline or configured, configured)
|
|
281
|
-
if type(deadline) is not int or (deadline != 0 and not int(time.time()) < deadline <= int(time.time()) + MAX_RUN_SECONDS):
|
|
282
|
-
raise ValueError("deadline 须为 0(不限时长)或未来 24 小时内的 Unix 秒数")
|
|
283
|
-
experts = json.loads(row["experts"])
|
|
284
|
-
if row["archived"] or row["phase"] == "concluded" or row["host"] == "用户":
|
|
285
|
-
raise ValueError("自动运行需要未归档、未收束的话题及 AI 主持人")
|
|
286
|
-
if not experts or any(mode != "cli" for mode in experts.values()):
|
|
287
|
-
raise ValueError("自动运行的参与者须均具有本地 CLI 读写能力")
|
|
288
|
-
phase = payload.get("phase", "initial" if row["phase"] == "preparing" else "discussing")
|
|
289
|
-
if phase not in ("initial", "discussing", "reviewing"):
|
|
290
|
-
raise ValueError("恢复阶段必须为 initial/discussing/reviewing")
|
|
291
|
-
if row["phase"] in ("preparing", "paused"):
|
|
292
|
-
connection.execute("UPDATE topic_settings SET phase=? WHERE room_id=?", (phase, room_id))
|
|
293
|
-
connection.execute("UPDATE discussion_limits SET started_at=? WHERE room_id=? AND started_at=0",
|
|
294
|
-
(int(time.time()), room_id))
|
|
295
|
-
return deadline
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
def list_events(connection, room_id, token, after=0):
|
|
299
|
-
"""Read a bounded durable inbox after an explicit cursor."""
|
|
300
|
-
require_control(connection, room_id, token)
|
|
301
|
-
rows = connection.execute("SELECT * FROM discussion_events WHERE room_id=? AND id>? ORDER BY id LIMIT 100",
|
|
302
|
-
(room_id, max(0, int(after)))).fetchall()
|
|
303
|
-
events = [{key: row[key] for key in PUBLIC_EVENT_FIELDS} for row in rows]
|
|
304
|
-
return {"events": events, "next_after": events[-1]["id"] if events else int(after)}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
def event_action(connection, room_id, event_id, token, payload):
|
|
308
|
-
"""Reserve, enter or renew one persisted execution, with no implicit replay."""
|
|
309
|
-
begin_write(connection)
|
|
310
|
-
event = connection.execute("SELECT * FROM discussion_events WHERE room_id=? AND id=?",
|
|
311
|
-
(room_id, event_id)).fetchone()
|
|
312
|
-
if not event:
|
|
313
|
-
raise LookupError("执行不存在")
|
|
314
|
-
run = connection.execute("SELECT * FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
|
|
315
|
-
now = int(time.time())
|
|
316
|
-
work_seconds = execution_seconds(connection, room_id)
|
|
317
|
-
action = payload.get("action")
|
|
318
|
-
work_deadline = (now if action == "enter" else event["started_at"]) + work_seconds if work_seconds else 0
|
|
319
|
-
lease_until = min(now + LEASE_SECONDS, work_deadline or now + LEASE_SECONDS, run["deadline"] or now + LEASE_SECONDS)
|
|
320
|
-
controller = token_hash(token) == run["control_hash"]
|
|
321
|
-
if not controller and (not token_hash(token) or token_hash(token) != event["token_hash"]):
|
|
322
|
-
raise PermissionError("执行凭据无效")
|
|
323
|
-
if action == "view":
|
|
324
|
-
return {key: event[key] for key in PUBLIC_EVENT_FIELDS}
|
|
325
|
-
if run["status"] != "running" or 0 < run["deadline"] <= now or event["generation"] != run["generation"]:
|
|
326
|
-
raise FileExistsError("本次运行已停止或执行已过期")
|
|
327
|
-
if action == "reserve" and controller:
|
|
328
|
-
reserve_event(connection, event, payload)
|
|
329
|
-
elif action == "enter" and not controller:
|
|
330
|
-
if event["status"] != "dispatched" or not payload.get("session_id"):
|
|
331
|
-
raise FileExistsError("本次任务已进入或不可进入;不要重复执行")
|
|
332
|
-
connection.execute("UPDATE discussion_events SET status='started',session_id=?,started_at=?,lease_until=? WHERE id=?",
|
|
333
|
-
(str(payload["session_id"])[:200], now, lease_until, event_id))
|
|
334
|
-
elif action == "renew" and controller and event["status"] == "started":
|
|
335
|
-
if event["lease_until"] <= now or 0 < work_deadline <= now:
|
|
336
|
-
raise FileExistsError("执行租约已到期,不能自动重新激活")
|
|
337
|
-
connection.execute("UPDATE discussion_events SET lease_until=? WHERE id=?",
|
|
338
|
-
(lease_until, event_id))
|
|
339
|
-
elif action == "fail" and event["status"] in ("dispatched", "started"):
|
|
340
|
-
connection.execute("UPDATE discussion_events SET status='failed',result=? WHERE id=?",
|
|
341
|
-
(str(payload.get("reason", "需要用户处理"))[:500], event_id))
|
|
342
|
-
connection.execute("UPDATE topic_settings SET phase='paused' WHERE room_id=?", (room_id,))
|
|
343
|
-
else:
|
|
344
|
-
raise FileExistsError("执行状态不允许此操作")
|
|
345
|
-
result = connection.execute("SELECT * FROM discussion_events WHERE id=?", (event_id,)).fetchone()
|
|
346
|
-
return {key: result[key] for key in PUBLIC_EVENT_FIELDS}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
def reserve_event(connection, event, payload):
|
|
350
|
-
"""Reserve only current work and merge pending host wakeups before dispatch."""
|
|
351
|
-
hashed = token_hash(payload.get("execution_token"))
|
|
352
|
-
if event["status"] != "pending" or not hashed:
|
|
353
|
-
raise FileExistsError("执行已被领取或缺少有效的执行凭据")
|
|
354
|
-
turn = connection.execute("SELECT * FROM discussion_turns WHERE room_id=? AND status IN ('pending','notified')",
|
|
355
|
-
(event["room_id"],)).fetchone()
|
|
356
|
-
if (event["role"] == "host" and turn) or (event["role"] == "expert" and (not turn or turn["id"] != event["turn_id"])):
|
|
357
|
-
raise FileExistsError("当前安排与执行不符,请重新读取")
|
|
358
|
-
if connection.execute("SELECT 1 FROM discussion_events WHERE room_id=? AND status IN ('dispatched','started')",
|
|
359
|
-
(event["room_id"],)).fetchone():
|
|
360
|
-
raise FileExistsError("已有在途执行")
|
|
361
|
-
connection.execute("UPDATE discussion_events SET status='dispatched',token_hash=? WHERE id=?", (hashed, event["id"]))
|
|
362
|
-
if event["role"] == "host":
|
|
363
|
-
connection.execute("UPDATE discussion_events SET status='completed',result=? WHERE room_id=? AND role='host' AND status='pending' AND id<?",
|
|
364
|
-
(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 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
|
+
"on_limit": "pause", "summary_minutes": 5}
|
|
12
|
+
PUBLIC_EVENT_FIELDS = ("id", "room_id", "run_id", "generation", "cause", "participant",
|
|
13
|
+
"role", "turn_id", "status", "session_id", "lease_until", "result", "created_at")
|
|
14
|
+
LIMITS_SQL = """SELECT limits.*,COALESCE(pauses.paused_seconds,0)+CASE WHEN COALESCE(pauses.paused_at,0)>0
|
|
15
|
+
THEN MAX(0,CAST(strftime('%s','now') AS INTEGER)-pauses.paused_at) ELSE 0 END AS idle_seconds
|
|
16
|
+
FROM discussion_limits AS limits LEFT JOIN discussion_pauses AS pauses USING(room_id) WHERE limits.room_id=?"""
|
|
17
|
+
TIMING_ITEMS = 20
|
|
18
|
+
# Queue: created -> dispatched; wake: dispatched -> entered; work: entered -> finished. Open stages run to now.
|
|
19
|
+
TIMING_SQL = """SELECT e.id,e.participant,e.role,e.cause,e.status,
|
|
20
|
+
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,
|
|
21
|
+
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,
|
|
22
|
+
CASE WHEN e.started_at>0 THEN MAX(0,COALESCE(NULLIF(t.finished_at,0),?)-e.started_at) END AS work_seconds
|
|
23
|
+
FROM discussion_events AS e LEFT JOIN event_timings AS t ON t.event_id=e.id
|
|
24
|
+
WHERE e.room_id=? AND e.run_id=? AND (t.dispatched_at>0 OR e.status='pending') ORDER BY e.id DESC"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def token_hash(token):
|
|
28
|
+
"""Hash a bounded opaque capability without storing its original value."""
|
|
29
|
+
if not isinstance(token, str) or not 32 <= len(token) <= 256:
|
|
30
|
+
return ""
|
|
31
|
+
return hashlib.sha256(token.encode()).hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def begin_write(connection):
|
|
35
|
+
"""Acquire the writer lock once, including when routing already acquired it."""
|
|
36
|
+
if not connection.in_transaction:
|
|
37
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def limit_values(payload, current=None):
|
|
41
|
+
"""Validate bounded discussion settings; zero means no round/time preset."""
|
|
42
|
+
if not isinstance(payload, dict) or set(payload) - set(LIMIT_DEFAULTS):
|
|
43
|
+
raise ValueError("limits 仅接受讨论上限配置字段")
|
|
44
|
+
result = {key: (current or {}).get(key, value) for key, value in LIMIT_DEFAULTS.items()}
|
|
45
|
+
result.update(payload)
|
|
46
|
+
for key, minimum, maximum in (("max_rounds", 0, 100), ("max_minutes", 0, 1440),
|
|
47
|
+
("execution_minutes", 0, 60), ("summary_minutes", 1, 60)):
|
|
48
|
+
if type(result[key]) is not int or not minimum <= result[key] <= maximum:
|
|
49
|
+
raise ValueError(f"{key} 须为 {minimum}–{maximum} 的整数")
|
|
50
|
+
if result["on_limit"] not in ("pause", "summarize"):
|
|
51
|
+
raise ValueError("on_limit 须为 pause 或 summarize")
|
|
52
|
+
if result["on_limit"] == "summarize" and result["max_minutes"] and result["summary_minutes"] >= result["max_minutes"]:
|
|
53
|
+
raise ValueError("收束预留时间必须小于讨论总时长")
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def limits_view(row, room):
|
|
58
|
+
"""Derive progress without changing state during reads."""
|
|
59
|
+
result = dict(row)
|
|
60
|
+
now = int(time.time())
|
|
61
|
+
idle = result.get("idle_seconds") or 0
|
|
62
|
+
deadline = result["started_at"] + result["max_minutes"] * 60 + idle if result["started_at"] and result["max_minutes"] else 0
|
|
63
|
+
if room.get("runtime", {}).get("deadline") and (room["runtime"]["status"] == "running" or result["stop_reason"]):
|
|
64
|
+
runtime_deadline = room["runtime"]["deadline"]
|
|
65
|
+
deadline = min(deadline or runtime_deadline, runtime_deadline)
|
|
66
|
+
if result["closing_at"]:
|
|
67
|
+
closing_deadline = result["closing_at"] + result["summary_minutes"] * 60
|
|
68
|
+
deadline = min(deadline or closing_deadline, closing_deadline)
|
|
69
|
+
cutoff = deadline - result["summary_minutes"] * 60 if deadline and result["on_limit"] == "summarize" and not result["closing_at"] else deadline
|
|
70
|
+
reason = ""
|
|
71
|
+
if deadline and now >= deadline:
|
|
72
|
+
reason = "time_limit"
|
|
73
|
+
elif result["closing_at"]:
|
|
74
|
+
reason = result["stop_reason"]
|
|
75
|
+
elif result["max_rounds"] and room["completed_rounds"] >= result["max_rounds"]:
|
|
76
|
+
reason = "round_limit"
|
|
77
|
+
elif cutoff and now >= cutoff:
|
|
78
|
+
reason = "summary_reserved" if result["on_limit"] == "summarize" else "time_limit"
|
|
79
|
+
result.update(deadline=deadline, cutoff=cutoff, reached=bool(reason), reason=reason,
|
|
80
|
+
closing=bool(result["closing_at"]) and room["phase"] not in ("paused", "concluded"), completed_rounds=room["completed_rounds"],
|
|
81
|
+
remaining_seconds=max(0, deadline - now) if deadline else None,
|
|
82
|
+
elapsed_seconds=max(0, now - result["started_at"] - idle) if result["started_at"] else 0)
|
|
83
|
+
return result
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def require_within_limits(room):
|
|
87
|
+
"""Stop new expert work at the configured boundary, preserving user notes."""
|
|
88
|
+
if room["limits"]["reached"]:
|
|
89
|
+
raise FileExistsError("已达到讨论上限;请检查上限并暂停或由主持人总结")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def execution_seconds(connection, room_id):
|
|
93
|
+
"""Read the per-execution budget shared by entry, renewal and writes."""
|
|
94
|
+
row = connection.execute("SELECT execution_minutes FROM discussion_limits WHERE room_id=?", (room_id,)).fetchone()
|
|
95
|
+
return (row[0] if row else LIMIT_DEFAULTS["execution_minutes"]) * 60
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def runtime_view(connection, room_id):
|
|
99
|
+
"""Return public execution state, never capability hashes."""
|
|
100
|
+
row = connection.execute("SELECT * FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
|
|
101
|
+
if not row:
|
|
102
|
+
return {"mode": "manual", "status": "ready", "revision": 0, "protected": False}
|
|
103
|
+
result = {key: row[key] for key in row.keys() if key != "control_hash"}
|
|
104
|
+
result["protected"] = True
|
|
105
|
+
result["expired"] = row["status"] == "running" and 0 < row["deadline"] <= int(time.time())
|
|
106
|
+
event = connection.execute("SELECT id,participant,role,status,result FROM discussion_events WHERE room_id=? ORDER BY id DESC LIMIT 1", (room_id,)).fetchone()
|
|
107
|
+
result["latest_execution"] = dict(event) if event else None
|
|
108
|
+
result["participants"] = participant_executions(connection, room_id)
|
|
109
|
+
result["timings"] = execution_timings(connection, room_id, row["run_id"])
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def execution_timings(connection, room_id, run_id):
|
|
114
|
+
"""Summarize stage durations of the current run so slow stages are visible."""
|
|
115
|
+
now = int(time.time())
|
|
116
|
+
rows = [dict(row) for row in connection.execute(TIMING_SQL, (now, now, now, room_id, run_id))]
|
|
117
|
+
stages = ("queue_seconds", "wake_seconds", "work_seconds")
|
|
118
|
+
totals = {stage: sum(row[stage] or 0 for row in rows) for stage in stages}
|
|
119
|
+
return {"items": rows[:TIMING_ITEMS], "totals": totals, "count": len(rows)}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def participant_executions(connection, room_id):
|
|
123
|
+
"""Summarize each participant's latest execution and failure count for the progress panel."""
|
|
124
|
+
rows = connection.execute("""SELECT latest.id,latest.participant,latest.role,latest.status,latest.result,
|
|
125
|
+
latest.turn_id,latest.lease_until,COALESCE((SELECT bound.session_id FROM discussion_events AS bound
|
|
126
|
+
WHERE bound.room_id=latest.room_id AND bound.participant=latest.participant AND bound.session_id<>''
|
|
127
|
+
ORDER BY bound.id DESC LIMIT 1),'') AS session_id,(SELECT COUNT(*) FROM discussion_events AS failed
|
|
128
|
+
WHERE failed.room_id=latest.room_id AND failed.participant=latest.participant
|
|
129
|
+
AND failed.status='failed') AS failures
|
|
130
|
+
FROM discussion_events AS latest WHERE latest.room_id=? AND latest.id=(SELECT MAX(id)
|
|
131
|
+
FROM discussion_events AS other WHERE other.room_id=latest.room_id
|
|
132
|
+
AND other.participant=latest.participant) ORDER BY latest.id""", (room_id,)).fetchall()
|
|
133
|
+
now = int(time.time())
|
|
134
|
+
return [{key: row[key] for key in row.keys() if key != "lease_until"}
|
|
135
|
+
| {"stale": row["status"] == "started" and row["lease_until"] <= now} for row in rows]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def require_control(connection, room_id, token):
|
|
139
|
+
"""Validate the room owner capability for control and dispatcher operations."""
|
|
140
|
+
row = connection.execute("SELECT * FROM discussion_runtime WHERE room_id=? AND control_hash=?",
|
|
141
|
+
(room_id, token_hash(token))).fetchone()
|
|
142
|
+
if not row:
|
|
143
|
+
raise PermissionError("需要本房间的运行控制凭据")
|
|
144
|
+
return row
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def require_owner(connection, room_id, token):
|
|
148
|
+
"""Require the persistent room owner credential, independent of execution mode."""
|
|
149
|
+
hashed = token_hash(token)
|
|
150
|
+
row = connection.execute("SELECT * FROM room_modes WHERE room_id=? AND control_hash=?",
|
|
151
|
+
(room_id, hashed)).fetchone() if hashed else None
|
|
152
|
+
if not row:
|
|
153
|
+
raise PermissionError("需要本房间的控制凭据;没有凭据只能阅读")
|
|
154
|
+
return row
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def write_actor(operation, payload):
|
|
158
|
+
"""Use the same message attribution defaults as persisted message details."""
|
|
159
|
+
if operation == "messages":
|
|
160
|
+
return payload.get("submitted_by", payload.get("author") if payload.get("source") in ("cli", "api") else "用户")
|
|
161
|
+
return payload.get("actor", "用户")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def authorize_participant(connection, room_id, identity, operation, payload):
|
|
165
|
+
"""Limit authenticated participants to their identity and assigned host duties."""
|
|
166
|
+
actor = write_actor(operation, payload)
|
|
167
|
+
if actor != identity["name"]:
|
|
168
|
+
raise PermissionError("凭据与实际参与身份不符")
|
|
169
|
+
if operation == "messages":
|
|
170
|
+
author = payload.get("author")
|
|
171
|
+
online = connection.execute("SELECT 1 FROM room_members WHERE room_id=? AND name=? AND access='online' AND status='active'",
|
|
172
|
+
(room_id, author)).fetchone()
|
|
173
|
+
if author != actor and not (online and identity["role"] in ("owner", "host")):
|
|
174
|
+
raise PermissionError("只能提交本人观点,或由房主、主持人代发在线成员原文")
|
|
175
|
+
elif operation == "floor":
|
|
176
|
+
speaker = payload.get("speaker", actor)
|
|
177
|
+
online = connection.execute("SELECT 1 FROM room_members WHERE room_id=? AND name=? AND access='online' AND status='active'",
|
|
178
|
+
(room_id, speaker)).fetchone()
|
|
179
|
+
if identity["role"] != "owner" and speaker != actor and not (identity["role"] == "host" and online):
|
|
180
|
+
raise PermissionError("只能操作本人发言权")
|
|
181
|
+
elif operation != "attachments" and identity["role"] != "owner":
|
|
182
|
+
if identity["role"] != "host" or operation not in ("topic", "state", "turns", "limits"):
|
|
183
|
+
raise PermissionError("本成员没有此管理权限")
|
|
184
|
+
if operation == "topic":
|
|
185
|
+
if any(field in payload for field in ("host", "experts", "members")):
|
|
186
|
+
raise PermissionError("只有房主可以修改成员和主持人")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def authorize_write(connection, room_id, token, operation, payload):
|
|
190
|
+
"""Check a capability under the same writer lock as the following mutation."""
|
|
191
|
+
begin_write(connection)
|
|
192
|
+
run = connection.execute("SELECT * FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
|
|
193
|
+
hashed = token_hash(token)
|
|
194
|
+
owner = connection.execute("SELECT 1 FROM room_modes WHERE room_id=? AND control_hash=?", (room_id, hashed)).fetchone() if hashed else None
|
|
195
|
+
if owner:
|
|
196
|
+
authorize_participant(connection, room_id, {"name": "用户", "role": "owner"}, operation, payload)
|
|
197
|
+
return
|
|
198
|
+
member = connection.execute("SELECT * FROM room_members WHERE room_id=? AND token_hash=? AND status='active' AND access<>'online'",
|
|
199
|
+
(room_id, hashed)).fetchone() if hashed else None
|
|
200
|
+
if member and (not run or run["mode"] == "manual"):
|
|
201
|
+
authorize_participant(connection, room_id, member, operation, payload)
|
|
202
|
+
return
|
|
203
|
+
if not run or not hashed:
|
|
204
|
+
raise PermissionError("需要房主或本成员的有效凭据;没有凭据只能阅读")
|
|
205
|
+
actor = write_actor(operation, payload)
|
|
206
|
+
work_seconds = execution_seconds(connection, room_id)
|
|
207
|
+
event = connection.execute("""SELECT * FROM discussion_events WHERE room_id=? AND token_hash=?
|
|
208
|
+
AND run_id=? AND generation=? AND status='started' AND lease_until>?
|
|
209
|
+
AND (?=0 OR started_at+?>?)""", (room_id, hashed, run["run_id"], run["generation"],
|
|
210
|
+
int(time.time()), work_seconds, work_seconds, int(time.time()))).fetchone()
|
|
211
|
+
if not hashed or not event or run["status"] != "running" or 0 < run["deadline"] <= int(time.time()):
|
|
212
|
+
raise PermissionError("执行资格无效、已结束或已到期;请重新读取运行状态")
|
|
213
|
+
if actor != event["participant"]:
|
|
214
|
+
raise PermissionError("执行凭据与实际参与身份不符")
|
|
215
|
+
authorize_participant(connection, room_id, {"name": event["participant"], "role": event["role"]}, operation, payload)
|
|
216
|
+
if operation == "messages":
|
|
217
|
+
if payload.get("author") != actor:
|
|
218
|
+
raise PermissionError("自动执行不能代发其他参与者")
|
|
219
|
+
if event["role"] == "expert" and payload.get("turn_id") != event["turn_id"]:
|
|
220
|
+
raise PermissionError("回答必须关联本次安排")
|
|
221
|
+
elif operation == "floor":
|
|
222
|
+
if payload.get("speaker", actor) != actor:
|
|
223
|
+
raise PermissionError("自动执行只能操作本人发言权")
|
|
224
|
+
elif operation == "attachments":
|
|
225
|
+
pass
|
|
226
|
+
elif event["role"] != "host" or operation not in ("topic", "state", "turns", "limits"):
|
|
227
|
+
raise PermissionError("本次执行不能进行此操作")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def configure_runtime(connection, room_id, token, payload):
|
|
231
|
+
"""Apply one versioned, explicit mode or lifecycle action."""
|
|
232
|
+
begin_write(connection)
|
|
233
|
+
require_owner(connection, room_id, token)
|
|
234
|
+
current = runtime_view(connection, room_id)
|
|
235
|
+
if payload.get("revision") != current["revision"]:
|
|
236
|
+
raise FileExistsError("运行状态已变化,请重新读取 revision")
|
|
237
|
+
hashed = token_hash(token)
|
|
238
|
+
if not current["protected"] and not hashed:
|
|
239
|
+
raise ValueError("运行凭据须为 32–256 字符的随机值")
|
|
240
|
+
action = payload.get("action", "configure")
|
|
241
|
+
mode = payload.get("mode", "automatic" if action == "start" else current["mode"])
|
|
242
|
+
if mode not in ("manual", "automatic") or action not in ("configure", "start", "pause", "stop"):
|
|
243
|
+
raise ValueError("请选择 manual/automatic 和 configure/start/pause/stop")
|
|
244
|
+
if action == "start" and mode != "automatic":
|
|
245
|
+
raise ValueError("start 只用于全自动运行;手动方式无需运行启动")
|
|
246
|
+
if action in ("configure", "start") and current["status"] == "running":
|
|
247
|
+
raise FileExistsError("讨论仍在运行;请先暂停,再切换模式或继续")
|
|
248
|
+
if action in ("configure", "start") and connection.execute(
|
|
249
|
+
"SELECT 1 FROM discussion_events WHERE room_id=? AND status IN ('dispatched','started')", (room_id,)).fetchone():
|
|
250
|
+
raise FileExistsError("存在在途执行;先暂停并核对原会话已停止,再切换或继续")
|
|
251
|
+
status = {"configure": "ready", "start": "running", "pause": "paused", "stop": "stopped"}[action]
|
|
252
|
+
start_payload = {"phase": current.get("resume_phase", "initial"), **payload}
|
|
253
|
+
deadline = validate_start(connection, room_id, start_payload) if action == "start" else current.get("deadline", 0)
|
|
254
|
+
connection.execute("""INSERT INTO discussion_runtime
|
|
255
|
+
(room_id,mode,run_id,status,control_hash,deadline,updated_at) VALUES (?,?,?,?,?,?,datetime('now'))
|
|
256
|
+
ON CONFLICT(room_id) DO UPDATE SET mode=excluded.mode,status=excluded.status,
|
|
257
|
+
generation=generation+1,revision=revision+1,deadline=excluded.deadline,updated_at=excluded.updated_at""",
|
|
258
|
+
(room_id, mode, str(uuid.uuid4()), "ready", hashed, deadline))
|
|
259
|
+
if action in ("pause", "stop"):
|
|
260
|
+
connection.execute("UPDATE topic_settings SET phase='paused' WHERE room_id=? AND phase<>'concluded'", (room_id,))
|
|
261
|
+
connection.execute("UPDATE discussion_runtime SET status=? WHERE room_id=?", (status, room_id))
|
|
262
|
+
connection.execute("UPDATE room_members SET access=? WHERE room_id=? AND access<>'online'",
|
|
263
|
+
("runner" if mode == "automatic" else "manual", room_id))
|
|
264
|
+
return runtime_view(connection, room_id)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def validate_start(connection, room_id, payload):
|
|
268
|
+
"""Validate an AI-hosted run; zero deadline leaves only the configured limits."""
|
|
269
|
+
row = connection.execute("SELECT * FROM topic_settings JOIN room_directory USING(room_id) WHERE room_id=?",
|
|
270
|
+
(room_id,)).fetchone()
|
|
271
|
+
limits = connection.execute(LIMITS_SQL, (room_id,)).fetchone()
|
|
272
|
+
configured = ((limits["started_at"] or int(time.time())) + limits["max_minutes"] * 60 + limits["idle_seconds"]
|
|
273
|
+
if limits["max_minutes"] else 0)
|
|
274
|
+
if limits["closing_at"]:
|
|
275
|
+
closing_deadline = limits["closing_at"] + limits["summary_minutes"] * 60
|
|
276
|
+
previous_deadline = runtime_view(connection, room_id).get("deadline", 0)
|
|
277
|
+
configured = min(value for value in (configured, closing_deadline, previous_deadline) if value)
|
|
278
|
+
deadline = payload.get("deadline", configured)
|
|
279
|
+
if type(deadline) is int and configured:
|
|
280
|
+
deadline = min(deadline or configured, configured)
|
|
281
|
+
if type(deadline) is not int or (deadline != 0 and not int(time.time()) < deadline <= int(time.time()) + MAX_RUN_SECONDS):
|
|
282
|
+
raise ValueError("deadline 须为 0(不限时长)或未来 24 小时内的 Unix 秒数")
|
|
283
|
+
experts = json.loads(row["experts"])
|
|
284
|
+
if row["archived"] or row["phase"] == "concluded" or row["host"] == "用户":
|
|
285
|
+
raise ValueError("自动运行需要未归档、未收束的话题及 AI 主持人")
|
|
286
|
+
if not experts or any(mode != "cli" for mode in experts.values()):
|
|
287
|
+
raise ValueError("自动运行的参与者须均具有本地 CLI 读写能力")
|
|
288
|
+
phase = payload.get("phase", "initial" if row["phase"] == "preparing" else "discussing")
|
|
289
|
+
if phase not in ("initial", "discussing", "reviewing"):
|
|
290
|
+
raise ValueError("恢复阶段必须为 initial/discussing/reviewing")
|
|
291
|
+
if row["phase"] in ("preparing", "paused"):
|
|
292
|
+
connection.execute("UPDATE topic_settings SET phase=? WHERE room_id=?", (phase, room_id))
|
|
293
|
+
connection.execute("UPDATE discussion_limits SET started_at=? WHERE room_id=? AND started_at=0",
|
|
294
|
+
(int(time.time()), room_id))
|
|
295
|
+
return deadline
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def list_events(connection, room_id, token, after=0):
|
|
299
|
+
"""Read a bounded durable inbox after an explicit cursor."""
|
|
300
|
+
require_control(connection, room_id, token)
|
|
301
|
+
rows = connection.execute("SELECT * FROM discussion_events WHERE room_id=? AND id>? ORDER BY id LIMIT 100",
|
|
302
|
+
(room_id, max(0, int(after)))).fetchall()
|
|
303
|
+
events = [{key: row[key] for key in PUBLIC_EVENT_FIELDS} for row in rows]
|
|
304
|
+
return {"events": events, "next_after": events[-1]["id"] if events else int(after)}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def event_action(connection, room_id, event_id, token, payload):
|
|
308
|
+
"""Reserve, enter or renew one persisted execution, with no implicit replay."""
|
|
309
|
+
begin_write(connection)
|
|
310
|
+
event = connection.execute("SELECT * FROM discussion_events WHERE room_id=? AND id=?",
|
|
311
|
+
(room_id, event_id)).fetchone()
|
|
312
|
+
if not event:
|
|
313
|
+
raise LookupError("执行不存在")
|
|
314
|
+
run = connection.execute("SELECT * FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
|
|
315
|
+
now = int(time.time())
|
|
316
|
+
work_seconds = execution_seconds(connection, room_id)
|
|
317
|
+
action = payload.get("action")
|
|
318
|
+
work_deadline = (now if action == "enter" else event["started_at"]) + work_seconds if work_seconds else 0
|
|
319
|
+
lease_until = min(now + LEASE_SECONDS, work_deadline or now + LEASE_SECONDS, run["deadline"] or now + LEASE_SECONDS)
|
|
320
|
+
controller = token_hash(token) == run["control_hash"]
|
|
321
|
+
if not controller and (not token_hash(token) or token_hash(token) != event["token_hash"]):
|
|
322
|
+
raise PermissionError("执行凭据无效")
|
|
323
|
+
if action == "view":
|
|
324
|
+
return {key: event[key] for key in PUBLIC_EVENT_FIELDS}
|
|
325
|
+
if run["status"] != "running" or 0 < run["deadline"] <= now or event["generation"] != run["generation"]:
|
|
326
|
+
raise FileExistsError("本次运行已停止或执行已过期")
|
|
327
|
+
if action == "reserve" and controller:
|
|
328
|
+
reserve_event(connection, event, payload)
|
|
329
|
+
elif action == "enter" and not controller:
|
|
330
|
+
if event["status"] != "dispatched" or not payload.get("session_id"):
|
|
331
|
+
raise FileExistsError("本次任务已进入或不可进入;不要重复执行")
|
|
332
|
+
connection.execute("UPDATE discussion_events SET status='started',session_id=?,started_at=?,lease_until=? WHERE id=?",
|
|
333
|
+
(str(payload["session_id"])[:200], now, lease_until, event_id))
|
|
334
|
+
elif action == "renew" and controller and event["status"] == "started":
|
|
335
|
+
if event["lease_until"] <= now or 0 < work_deadline <= now:
|
|
336
|
+
raise FileExistsError("执行租约已到期,不能自动重新激活")
|
|
337
|
+
connection.execute("UPDATE discussion_events SET lease_until=? WHERE id=?",
|
|
338
|
+
(lease_until, event_id))
|
|
339
|
+
elif action == "fail" and event["status"] in ("dispatched", "started"):
|
|
340
|
+
connection.execute("UPDATE discussion_events SET status='failed',result=? WHERE id=?",
|
|
341
|
+
(str(payload.get("reason", "需要用户处理"))[:500], event_id))
|
|
342
|
+
connection.execute("UPDATE topic_settings SET phase='paused' WHERE room_id=?", (room_id,))
|
|
343
|
+
else:
|
|
344
|
+
raise FileExistsError("执行状态不允许此操作")
|
|
345
|
+
result = connection.execute("SELECT * FROM discussion_events WHERE id=?", (event_id,)).fetchone()
|
|
346
|
+
return {key: result[key] for key in PUBLIC_EVENT_FIELDS}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def reserve_event(connection, event, payload):
|
|
350
|
+
"""Reserve only current work and merge pending host wakeups before dispatch."""
|
|
351
|
+
hashed = token_hash(payload.get("execution_token"))
|
|
352
|
+
if event["status"] != "pending" or not hashed:
|
|
353
|
+
raise FileExistsError("执行已被领取或缺少有效的执行凭据")
|
|
354
|
+
turn = connection.execute("SELECT * FROM discussion_turns WHERE room_id=? AND status IN ('pending','notified')",
|
|
355
|
+
(event["room_id"],)).fetchone()
|
|
356
|
+
if (event["role"] == "host" and turn) or (event["role"] == "expert" and (not turn or turn["id"] != event["turn_id"])):
|
|
357
|
+
raise FileExistsError("当前安排与执行不符,请重新读取")
|
|
358
|
+
if connection.execute("SELECT 1 FROM discussion_events WHERE room_id=? AND status IN ('dispatched','started')",
|
|
359
|
+
(event["room_id"],)).fetchone():
|
|
360
|
+
raise FileExistsError("已有在途执行")
|
|
361
|
+
connection.execute("UPDATE discussion_events SET status='dispatched',token_hash=? WHERE id=?", (hashed, event["id"]))
|
|
362
|
+
if event["role"] == "host":
|
|
363
|
+
connection.execute("UPDATE discussion_events SET status='completed',result=? WHERE room_id=? AND role='host' AND status='pending' AND id<?",
|
|
364
|
+
(f"merged:{event['id']}", event["room_id"], event["id"]))
|