ai_co_discuss 0.2.99 → 0.3.1
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 -3
- package/.agents/skills/discuss-host/references/discussion.md +16 -16
- package/.agents/skills/discuss-host/references/launch.md +1 -3
- package/.agents/skills/discuss-host/references/manual.md +14 -77
- package/.agents/skills/discuss-participant/SKILL.md +3 -3
- package/.agents/skills/discuss-participant/references/discussion.md +29 -27
- package/.agents/skills/discuss-participant/references/free.md +2 -2
- package/.agents/skills/discuss-participant/references/manual.md +8 -10
- package/.agents/skills/discuss-participant/references/writing.md +9 -9
- package/.github/SECURITY.md +1 -1
- package/README.md +3 -3
- package/README.zh-CN.md +3 -3
- package/backend/free_room.py +360 -68
- package/backend/room_import.py +75 -15
- package/backend/runtime.py +81 -169
- package/backend/schema.sql +475 -244
- package/backend/server.py +624 -166
- package/backend/session_room.py +919 -0
- package/docs/architecture.md +3 -2
- package/docs/deployment.md +1 -1
- package/docs/en/architecture.md +4 -3
- package/docs/en/runner.md +3 -1
- package/docs/en/workflow.md +9 -6
- package/docs/runner.md +3 -1
- package/docs/workflow.md +11 -8
- package/package.json +2 -2
- package/runner/api_participant.mjs +47 -44
- package/runner/discuss.py +77 -58
- package/runner/discussion_adapters.mjs +9 -6
- package/runner/discussion_launch.mjs +99 -71
- package/runner/discussion_runner.mjs +39 -497
- package/runner/discussion_service.mjs +77 -76
- package/runner/discussion_sidecar.mjs +4 -13
- package/runner/expert_prompts.mjs +31 -23
- package/runner/free_runner.mjs +96 -19
- package/runner/session_driver.mjs +175 -0
- package/web/app.js +226 -135
- package/web/backup.js +1 -1
- package/web/modern.css +1 -1
- package/web/modern.js +5503 -6647
package/backend/free_room.py
CHANGED
|
@@ -17,7 +17,8 @@ import time
|
|
|
17
17
|
import uuid
|
|
18
18
|
|
|
19
19
|
from .identity import MAX_ROOM_AGENTS, is_ai
|
|
20
|
-
from .runtime import LEASE_SECONDS, MAX_EXECUTION_SEGMENTS, begin_write,
|
|
20
|
+
from .runtime import (LEASE_SECONDS, MAX_EXECUTION_SEGMENTS, begin_write, enforce_session_limits,
|
|
21
|
+
require_owner, save_segments, token_hash)
|
|
21
22
|
|
|
22
23
|
FREE_EXECUTION_MINUTES = 20
|
|
23
24
|
RECENT_REQUESTS = 100
|
|
@@ -79,8 +80,12 @@ MAX_PATH_LENGTH = 500
|
|
|
79
80
|
# Refused operations one execution may report, and the length of each field.
|
|
80
81
|
MAX_DENIALS = 20
|
|
81
82
|
MAX_DENIAL_FIELD = 300
|
|
83
|
+
# A manual member's delivery is retried at most this often before the execution fails.
|
|
84
|
+
MAX_RETRIES = 3
|
|
85
|
+
MAX_RETRY_REASON = 500
|
|
82
86
|
ACTIVE = ("dispatched", "started")
|
|
83
|
-
ACCESS_MODES = ("runner", "manual"
|
|
87
|
+
ACCESS_MODES = ("runner", "manual")
|
|
88
|
+
LEGACY_ACCESS_MODES = ("runner", "manual", "online")
|
|
84
89
|
SCHEDULER_PROVIDERS = ("jev", "decisions")
|
|
85
90
|
RECOMMEND_SECONDS = 60
|
|
86
91
|
# A click sent just before the hint disappears still counts.
|
|
@@ -93,7 +98,8 @@ MAX_CHAIN_ROUNDS = 20
|
|
|
93
98
|
# A host that left gives up the role; the first active runner member (else manual) then takes it.
|
|
94
99
|
RELEASE_HOST = "UPDATE room_members SET role='member' WHERE room_id=? AND role='host' AND status<>'active'"
|
|
95
100
|
ASSIGN_HOST = """UPDATE room_members SET role='host' WHERE id=(SELECT id FROM room_members WHERE room_id=? AND status='active'
|
|
96
|
-
AND
|
|
101
|
+
AND NOT EXISTS(SELECT 1 FROM member_manual_sources source WHERE source.member_id=room_members.id AND source.source='web')
|
|
102
|
+
ORDER BY access='runner' DESC, id LIMIT 1)
|
|
97
103
|
AND NOT EXISTS (SELECT 1 FROM room_members WHERE room_id=? AND role='host' AND status='active')"""
|
|
98
104
|
TERMINAL = ("completed", "cancelled", "failed")
|
|
99
105
|
# A queued member has been asked once its request for that message exists.
|
|
@@ -118,6 +124,13 @@ def migrate_free_tables(connection):
|
|
|
118
124
|
for name, definition in columns.items():
|
|
119
125
|
if name not in present:
|
|
120
126
|
connection.execute(f"ALTER TABLE {table} ADD COLUMN {name} {definition}")
|
|
127
|
+
connection.execute("""CREATE TABLE IF NOT EXISTS member_manual_sources (
|
|
128
|
+
member_id INTEGER PRIMARY KEY REFERENCES room_members(id) ON DELETE CASCADE,
|
|
129
|
+
source TEXT NOT NULL CHECK(source='web'))""")
|
|
130
|
+
connection.execute("""INSERT OR IGNORE INTO member_manual_sources(member_id,source)
|
|
131
|
+
SELECT id,'web' FROM room_members WHERE access='online'""")
|
|
132
|
+
connection.execute("UPDATE room_members SET access='manual' WHERE access='online'")
|
|
133
|
+
connection.execute("UPDATE room_modes SET mode='free' WHERE mode='brainstorm'")
|
|
121
134
|
|
|
122
135
|
|
|
123
136
|
def room_mode(connection, room_id):
|
|
@@ -136,9 +149,15 @@ def member_value(member):
|
|
|
136
149
|
if not isinstance(adapter, str) or len(adapter) > 40:
|
|
137
150
|
raise ValueError("成员适配器名称无效")
|
|
138
151
|
access, session_mode = member.get("access", "runner"), member.get("session_mode", "new")
|
|
139
|
-
if access not in
|
|
152
|
+
if access not in LEGACY_ACCESS_MODES or session_mode not in SESSION_MODES:
|
|
140
153
|
raise ValueError("成员接入方式须为 runner/manual/online,会话方式须为 new/borrowed")
|
|
141
|
-
|
|
154
|
+
manual_source = member.get("manual_source", "")
|
|
155
|
+
if access == "online":
|
|
156
|
+
manual_source, access = "web", "manual"
|
|
157
|
+
if manual_source not in ("", "web") or (manual_source == "web" and access != "manual"):
|
|
158
|
+
raise ValueError("manual_source 只能为 manual 成员配置 web")
|
|
159
|
+
return {"name": member["name"], "adapter": adapter, "token": member["token"],
|
|
160
|
+
"access": access, "session_mode": session_mode, "manual_source": manual_source}
|
|
142
161
|
|
|
143
162
|
|
|
144
163
|
def member_values(members):
|
|
@@ -158,6 +177,10 @@ def insert_member(connection, room_id, member, now):
|
|
|
158
177
|
connection.execute("""INSERT INTO room_members (room_id,name,adapter,token_hash,access,session_mode,created_at)
|
|
159
178
|
VALUES (?,?,?,?,?,?,?)""", (room_id, member["name"], member["adapter"], token_hash(member["token"]),
|
|
160
179
|
member["access"], member["session_mode"], now))
|
|
180
|
+
if member.get("manual_source") == "web" or member.get("access") == "online":
|
|
181
|
+
member_id = connection.execute("SELECT id FROM room_members WHERE room_id=? AND name=? AND token_hash=?",
|
|
182
|
+
(room_id, member["name"], token_hash(member["token"]))).fetchone()[0]
|
|
183
|
+
connection.execute("INSERT OR IGNORE INTO member_manual_sources(member_id,source) VALUES(?,'web')", (member_id,))
|
|
161
184
|
|
|
162
185
|
|
|
163
186
|
def save_room_access(connection, room_id, control_token, members, settings):
|
|
@@ -173,10 +196,12 @@ def save_room_access(connection, room_id, control_token, members, settings):
|
|
|
173
196
|
connection.execute("UPDATE room_members SET role='host' WHERE room_id=? AND name=?", (room_id, settings["host"]))
|
|
174
197
|
|
|
175
198
|
|
|
176
|
-
def discussion_access_ok(access, capability, automatic, is_host):
|
|
177
|
-
"""
|
|
199
|
+
def discussion_access_ok(access, capability, automatic, is_host, manual_source=""):
|
|
200
|
+
"""Require web relays to stay read-only while local participants use runner or manual access."""
|
|
178
201
|
if capability == "online":
|
|
179
|
-
return access == "
|
|
202
|
+
return access == "manual" and manual_source == "web" and not is_host
|
|
203
|
+
if manual_source == "web":
|
|
204
|
+
return False
|
|
180
205
|
if not automatic:
|
|
181
206
|
return access == "manual"
|
|
182
207
|
return access == "runner" or (access == "manual" and not is_host)
|
|
@@ -194,24 +219,28 @@ def discussion_members(payload, settings):
|
|
|
194
219
|
raise ValueError("成员凭证名单须与参与者名单一致")
|
|
195
220
|
for member in members:
|
|
196
221
|
if not discussion_access_ok(member["access"], settings["experts"][member["name"]],
|
|
197
|
-
payload.get("runtime_mode") == "automatic", member["name"] == settings["host"]
|
|
222
|
+
payload.get("runtime_mode") == "automatic", member["name"] == settings["host"],
|
|
223
|
+
member["manual_source"]):
|
|
198
224
|
raise ValueError("成员接入方式与房间参与能力、任务交付方式不符")
|
|
199
225
|
return [mark_manual(member, payload.get("runtime_mode") == "automatic") for member in members]
|
|
200
226
|
|
|
201
227
|
|
|
202
228
|
def sync_discussion_members(connection, room_id, payload, settings):
|
|
203
229
|
"""Keep existing credentials; require fresh credentials for added or changed members."""
|
|
204
|
-
current = {row["name"]: row for row in connection.execute("SELECT
|
|
230
|
+
current = {row["name"]: row for row in connection.execute("""SELECT m.*,
|
|
231
|
+
COALESCE(s.source,'') AS manual_source FROM room_members m
|
|
232
|
+
LEFT JOIN member_manual_sources s ON s.member_id=m.id WHERE m.room_id=? AND m.status='active'""", (room_id,))}
|
|
205
233
|
supplied = {member["name"]: member_value(member) for member in payload.get("members", [])}
|
|
206
234
|
run = connection.execute("SELECT mode FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
|
|
207
235
|
for name, capability in settings["experts"].items():
|
|
208
236
|
automatic = bool(run and run[0] == "automatic")
|
|
209
237
|
old = current.get(name)
|
|
210
|
-
if old and (old["
|
|
238
|
+
if old and (old["manual_source"] == "web") == (capability == "online"):
|
|
211
239
|
if name in supplied and token_hash(supplied[name]["token"]) != old["token_hash"]:
|
|
212
240
|
raise FileExistsError("成员凭证与已登记身份不符;请核对原接入文件")
|
|
213
241
|
continue
|
|
214
|
-
if name not in supplied or not discussion_access_ok(supplied[name]["access"], capability, automatic,
|
|
242
|
+
if name not in supplied or not discussion_access_ok(supplied[name]["access"], capability, automatic,
|
|
243
|
+
name == settings["host"], supplied[name]["manual_source"]):
|
|
215
244
|
raise ValueError(f"新增或改变接入方式的成员 {name} 须提供独立的新凭证")
|
|
216
245
|
member = mark_manual(supplied[name], automatic)
|
|
217
246
|
hashed = token_hash(member["token"])
|
|
@@ -228,8 +257,10 @@ def sync_participants(connection, room_id):
|
|
|
228
257
|
"""Keep one active AI host, then mirror member capabilities and the host into shared room metadata."""
|
|
229
258
|
connection.execute(RELEASE_HOST, (room_id,))
|
|
230
259
|
connection.execute(ASSIGN_HOST, (room_id, room_id))
|
|
231
|
-
members = connection.execute("SELECT name,access,role,status
|
|
232
|
-
|
|
260
|
+
members = connection.execute("""SELECT m.name,m.access,m.role,m.status,COALESCE(s.source,'') AS manual_source
|
|
261
|
+
FROM room_members m LEFT JOIN member_manual_sources s ON s.member_id=m.id
|
|
262
|
+
WHERE m.room_id=? AND m.status<>'removed' ORDER BY m.id""", (room_id,)).fetchall()
|
|
263
|
+
experts = {row["name"]: "online" if row["manual_source"] == "web" else "cli" for row in members}
|
|
233
264
|
host = next((row["name"] for row in members if row["role"] == "host" and row["status"] == "active"), "用户")
|
|
234
265
|
connection.execute("UPDATE topic_settings SET experts=?,host=? WHERE room_id=?",
|
|
235
266
|
(json.dumps(experts, ensure_ascii=False), host, room_id))
|
|
@@ -237,6 +268,12 @@ def sync_participants(connection, room_id):
|
|
|
237
268
|
|
|
238
269
|
def execution_minutes(connection, room_id):
|
|
239
270
|
"""Free rooms always bound one execution; zero in the shared limits means the default."""
|
|
271
|
+
session = connection.execute("""SELECT l.execution_minutes FROM session_limits l
|
|
272
|
+
JOIN discussion_sessions s ON s.id=l.session_id
|
|
273
|
+
WHERE s.room_id=? AND s.status='active' AND EXISTS(SELECT 1 FROM session_state st WHERE st.session_id=s.id)
|
|
274
|
+
ORDER BY s.id DESC LIMIT 1""", (room_id,)).fetchone()
|
|
275
|
+
if session:
|
|
276
|
+
return session[0] or FREE_EXECUTION_MINUTES
|
|
240
277
|
row = connection.execute("SELECT execution_minutes FROM discussion_limits WHERE room_id=?", (room_id,)).fetchone()
|
|
241
278
|
return (row[0] if row and row[0] else FREE_EXECUTION_MINUTES)
|
|
242
279
|
|
|
@@ -246,6 +283,12 @@ def public_execution(row):
|
|
|
246
283
|
return {key: row[key] for key in EXECUTION_FIELDS} if row else None
|
|
247
284
|
|
|
248
285
|
|
|
286
|
+
def execution_retry(connection, execution_id):
|
|
287
|
+
"""What the runner reported about a waiting retry of one execution, or None."""
|
|
288
|
+
row = connection.execute("SELECT attempt,retry_at,reason,stopped FROM execution_retries WHERE execution_id=?", (execution_id,)).fetchone()
|
|
289
|
+
return dict(row) if row else None
|
|
290
|
+
|
|
291
|
+
|
|
249
292
|
def member_descriptions(connection, room_id):
|
|
250
293
|
"""The owner's descriptions of members by name; everyone who can read the room may read them."""
|
|
251
294
|
return {row["name"]: row["description"] for row in connection.execute(
|
|
@@ -302,10 +345,21 @@ def note_count(connection, execution_id):
|
|
|
302
345
|
return connection.execute("SELECT COUNT(*) FROM execution_notes WHERE execution_id=?", (execution_id,)).fetchone()[0]
|
|
303
346
|
|
|
304
347
|
|
|
305
|
-
def execution_notes(connection, room_id, execution_id):
|
|
348
|
+
def execution_notes(connection, room_id, execution_id, token=""):
|
|
306
349
|
"""Read one execution's progress notes in order, with the same access as the room view."""
|
|
307
|
-
|
|
350
|
+
execution = connection.execute("SELECT member_id FROM member_executions WHERE room_id=? AND id=?", (room_id, execution_id)).fetchone()
|
|
351
|
+
if not execution:
|
|
308
352
|
raise LookupError("执行不存在")
|
|
353
|
+
from .session_room import session_message_access
|
|
354
|
+
|
|
355
|
+
boundary = session_message_access(connection, room_id, {}, token)
|
|
356
|
+
if boundary is not None:
|
|
357
|
+
try:
|
|
358
|
+
member = require_member(connection, room_id, token)
|
|
359
|
+
except PermissionError:
|
|
360
|
+
return {"notes": []}
|
|
361
|
+
if member["id"] != execution["member_id"]:
|
|
362
|
+
return {"notes": []}
|
|
309
363
|
return {"notes": [dict(row) for row in connection.execute(
|
|
310
364
|
"SELECT seq,text,created_at FROM execution_notes WHERE execution_id=? ORDER BY seq", (execution_id,))]}
|
|
311
365
|
|
|
@@ -338,17 +392,42 @@ def execution_timeline(connection, room_id, after=0):
|
|
|
338
392
|
|
|
339
393
|
def recent_executions(connection, room_id):
|
|
340
394
|
"""Return bounded public reports, including requests no longer in the recent request list."""
|
|
341
|
-
rows = connection.execute("""SELECT e.*,
|
|
395
|
+
rows = connection.execute("""SELECT e.*,m.name AS member,
|
|
342
396
|
(SELECT json_group_array(id) FROM member_requests WHERE execution_id=e.id) AS request_ids,
|
|
343
397
|
(SELECT MAX(answer_id) FROM member_requests WHERE execution_id=e.id) AS answer_id,
|
|
344
398
|
(SELECT MIN(message_id) FROM member_requests WHERE execution_id=e.id) AS request_message_id,
|
|
345
399
|
(SELECT COUNT(*) FROM execution_notes WHERE execution_id=e.id) AS note_count,
|
|
346
400
|
(SELECT denials FROM execution_denials WHERE execution_id=e.id) AS denials
|
|
347
|
-
FROM member_executions e WHERE room_id=? ORDER BY id DESC LIMIT ?""", (room_id, RECENT_EXECUTIONS))
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
401
|
+
FROM member_executions e JOIN room_members m ON m.id=e.member_id WHERE e.room_id=? ORDER BY e.id DESC LIMIT ?""", (room_id, RECENT_EXECUTIONS))
|
|
402
|
+
executions = []
|
|
403
|
+
for row in rows:
|
|
404
|
+
requests = []
|
|
405
|
+
for request in connection.execute("SELECT id,message_id,purpose,status,answer_id FROM member_requests WHERE execution_id=? ORDER BY id", (row["id"],)):
|
|
406
|
+
requests.append({**dict(request), **session_request_source(connection, request["id"], request["message_id"])})
|
|
407
|
+
executions.append({**public_execution(row), "member": row["member"], "cwd": row["cwd"], "changes": json.loads(row["changes"] or "[]"),
|
|
408
|
+
"changes_recorded": bool(row["changes"]), "request_ids": json.loads(row["request_ids"]),
|
|
409
|
+
"answer_id": row["answer_id"], "request_message_id": row["request_message_id"], "requests": requests,
|
|
410
|
+
"notes": row["note_count"], "denials": json.loads(row["denials"] or "[]"),
|
|
411
|
+
"retry": execution_retry(connection, row["id"])})
|
|
412
|
+
return executions
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def session_request_source(connection, member_request_id, message_id):
|
|
416
|
+
"""Expose session provenance and a visible transcript anchor for a member request."""
|
|
417
|
+
row = connection.execute("""SELECT q.id AS session_request_id,q.session_id AS discussion_session_id,q.round,
|
|
418
|
+
q.question AS source_question,t.cause_kind,t.cause_message_id AS source_message_id,t.cause_request_id,
|
|
419
|
+
CASE WHEN q.id IS NULL THEN r.message_id ELSE COALESCE((SELECT MAX(m.id) FROM messages m
|
|
420
|
+
WHERE m.room_id=r.room_id AND m.id<r.message_id
|
|
421
|
+
AND NOT EXISTS(SELECT 1 FROM task_message_sources hidden WHERE hidden.message_id=m.id)),0) END AS session_anchor
|
|
422
|
+
FROM member_requests r LEFT JOIN session_requests q ON q.member_request_id=r.id
|
|
423
|
+
LEFT JOIN task_message_sources t ON t.session_request_id=q.id WHERE r.id=?""", (member_request_id,)).fetchone()
|
|
424
|
+
if row and row["session_request_id"] is not None:
|
|
425
|
+
return {"session_request_id": row["session_request_id"], "discussion_session_id": row["discussion_session_id"],
|
|
426
|
+
"round": row["round"], "source_question": row["source_question"],
|
|
427
|
+
"source_message_id": row["source_message_id"], "cause_kind": row["cause_kind"],
|
|
428
|
+
"cause_request_id": row["cause_request_id"], "after_message_id": row["session_anchor"]}
|
|
429
|
+
return {"session_request_id": None, "discussion_session_id": None, "round": None, "source_question": "",
|
|
430
|
+
"source_message_id": None, "cause_kind": None, "cause_request_id": None, "after_message_id": message_id}
|
|
352
431
|
|
|
353
432
|
|
|
354
433
|
def possible_conflicts(connection, room_id, names):
|
|
@@ -379,7 +458,8 @@ def possible_conflicts(connection, room_id, names):
|
|
|
379
458
|
|
|
380
459
|
|
|
381
460
|
# An invited manual member that has not registered counts as absent: it receives no request and is never a target.
|
|
382
|
-
REGISTERED_MEMBER = """(room_members.access<>'manual' OR EXISTS(SELECT 1 FROM
|
|
461
|
+
REGISTERED_MEMBER = """(room_members.access<>'manual' OR EXISTS(SELECT 1 FROM member_manual_sources source
|
|
462
|
+
WHERE source.member_id=room_members.id AND source.source='web') OR EXISTS(SELECT 1 FROM member_registrations r
|
|
383
463
|
WHERE r.member_id=room_members.id AND r.token_hash=room_members.token_hash))"""
|
|
384
464
|
|
|
385
465
|
|
|
@@ -389,6 +469,8 @@ def free_view(connection, room_id):
|
|
|
389
469
|
members = []
|
|
390
470
|
for row in connection.execute("SELECT * FROM room_members WHERE room_id=? AND status<>'removed' ORDER BY id", (room_id,)):
|
|
391
471
|
member = {key: row[key] for key in MEMBER_FIELDS}
|
|
472
|
+
source = connection.execute("SELECT source FROM member_manual_sources WHERE member_id=?", (row["id"],)).fetchone()
|
|
473
|
+
member["manual_source"] = source[0] if source else ""
|
|
392
474
|
member["registered_at"] = registered_at(connection, row)
|
|
393
475
|
reported = connection.execute("SELECT session_id,client FROM member_sessions WHERE member_id=?", (row["id"],)).fetchone()
|
|
394
476
|
member["reported_session"], member["reported_client"] = (reported["session_id"], reported["client"]) if reported else ("", "")
|
|
@@ -398,11 +480,13 @@ def free_view(connection, room_id):
|
|
|
398
480
|
if latest and latest["status"] in ACTIVE:
|
|
399
481
|
member["execution"]["stale"] = latest["lease_until"] <= now
|
|
400
482
|
member["execution"]["notes"] = note_count(connection, latest["id"])
|
|
483
|
+
member["execution"]["retry"] = execution_retry(connection, latest["id"])
|
|
401
484
|
member["pending"] = connection.execute("SELECT COUNT(*) FROM member_requests WHERE member_id=? AND status='pending'",
|
|
402
485
|
(row["id"],)).fetchone()[0]
|
|
403
486
|
members.append(member)
|
|
404
487
|
names = {row["id"]: row["name"] for row in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (room_id,))}
|
|
405
|
-
requests = [{**dict(row), "member": names.get(row["member_id"], "")
|
|
488
|
+
requests = [{**dict(row), "member": names.get(row["member_id"], ""),
|
|
489
|
+
**session_request_source(connection, row["id"], row["message_id"])} for row in connection.execute(
|
|
406
490
|
"""SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by,created_at,
|
|
407
491
|
(SELECT reason FROM request_cancel_reasons c WHERE c.request_id=member_requests.id) AS cancel_reason FROM member_requests
|
|
408
492
|
WHERE room_id=? ORDER BY id DESC LIMIT ?""", (room_id, RECENT_REQUESTS))]
|
|
@@ -422,7 +506,9 @@ def require_member(connection, room_id, token, name=None):
|
|
|
422
506
|
(room_id, hashed)).fetchone() if hashed else None
|
|
423
507
|
if not row or (name is not None and row["name"] != name):
|
|
424
508
|
raise PermissionError("成员凭证无效、已停用或与发言身份不符")
|
|
425
|
-
|
|
509
|
+
if connection.execute("SELECT 1 FROM member_manual_sources WHERE member_id=? AND source='web'", (row["id"],)).fetchone():
|
|
510
|
+
raise PermissionError("网页成员只读;由房主凭据代发其回答")
|
|
511
|
+
return dict(row)
|
|
426
512
|
|
|
427
513
|
|
|
428
514
|
def registered_at(connection, member):
|
|
@@ -468,9 +554,14 @@ def authorize_free_write(connection, room_id, token, operation, payload):
|
|
|
468
554
|
if operation not in ("messages", "attachments"):
|
|
469
555
|
raise
|
|
470
556
|
author = payload.get("author")
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
557
|
+
member = require_member(connection, room_id, token)
|
|
558
|
+
if author == member["name"] and payload.get("submitted_by", author) == author:
|
|
559
|
+
return member
|
|
560
|
+
if member["role"] == "host" and author != member["name"] and payload.get("submitted_by") == member["name"]:
|
|
561
|
+
proxy = proxy_member(connection, room_id, author)
|
|
562
|
+
proxy["proxy_by"] = member["name"]
|
|
563
|
+
return proxy
|
|
564
|
+
raise PermissionError("成员只能以本人身份发言;只有主持人可以代发在线成员回答")
|
|
474
565
|
|
|
475
566
|
|
|
476
567
|
def active_members(connection, room_id, registered_only=True):
|
|
@@ -525,11 +616,13 @@ def route_held_messages(connection, room_id, host):
|
|
|
525
616
|
|
|
526
617
|
def proxy_member(connection, room_id, author):
|
|
527
618
|
"""Return the active online member the owner may forward an answer for."""
|
|
528
|
-
row = connection.execute("SELECT
|
|
529
|
-
|
|
619
|
+
row = connection.execute("""SELECT m.*,s.source AS manual_source FROM room_members m
|
|
620
|
+
JOIN member_manual_sources s ON s.member_id=m.id AND s.source='web'
|
|
621
|
+
WHERE m.room_id=? AND m.name=? AND m.status='active' AND m.access='manual'""",
|
|
622
|
+
(room_id, author)).fetchone()
|
|
530
623
|
if not row:
|
|
531
624
|
raise PermissionError("房间控制凭据只能以用户身份发言,或代发在线成员的回答")
|
|
532
|
-
return row
|
|
625
|
+
return dict(row)
|
|
533
626
|
|
|
534
627
|
|
|
535
628
|
def check_answer(connection, room_id, member, payload):
|
|
@@ -548,11 +641,14 @@ def check_answer(connection, room_id, member, payload):
|
|
|
548
641
|
if payload.get("execution_id") is not None or not isinstance(request_ids, list) or any(type(item) is not int for item in request_ids):
|
|
549
642
|
raise ValueError("手动接入成员用 request_ids 关联请求,不使用执行编号")
|
|
550
643
|
purposes = []
|
|
644
|
+
requests = []
|
|
551
645
|
for request_id in request_ids:
|
|
552
646
|
row = connection.execute("SELECT * FROM member_requests WHERE room_id=? AND id=?", (room_id, request_id)).fetchone()
|
|
553
647
|
if not row or row["member_id"] != member["id"] or row["status"] != "pending":
|
|
554
648
|
raise FileExistsError(f"请求 {request_id} 不属于本成员或已处理")
|
|
555
649
|
purposes.append(row["purpose"])
|
|
650
|
+
requests.append(row)
|
|
651
|
+
validate_session_request_answer(connection, room_id, requests)
|
|
556
652
|
check_dispatch(member, payload, purposes)
|
|
557
653
|
return None
|
|
558
654
|
execution_id = payload.get("execution_id")
|
|
@@ -563,8 +659,10 @@ def check_answer(connection, room_id, member, payload):
|
|
|
563
659
|
raise PermissionError("执行编号不属于本成员")
|
|
564
660
|
if row["status"] not in ACTIVE or row["lease_until"] <= int(time.time()):
|
|
565
661
|
raise FileExistsError("本次执行已结束、被打断或已过期;回答不能改作普通发言,请保留草稿")
|
|
566
|
-
|
|
567
|
-
"SELECT purpose FROM member_requests WHERE execution_id=? AND status='delivering'", (execution_id,))]
|
|
662
|
+
requests = [dict(item) for item in connection.execute(
|
|
663
|
+
"SELECT id,purpose FROM member_requests WHERE execution_id=? AND status='delivering'", (execution_id,))]
|
|
664
|
+
validate_session_request_answer(connection, room_id, requests)
|
|
665
|
+
check_dispatch(member, payload, [item["purpose"] for item in requests])
|
|
568
666
|
return row
|
|
569
667
|
|
|
570
668
|
|
|
@@ -574,15 +672,33 @@ def check_dispatch(member, payload, purposes):
|
|
|
574
672
|
raise ValueError("这次是请全体成员各自回应,其他成员已收到请求;主持人只回答自己的部分,不能用 --to 派活")
|
|
575
673
|
|
|
576
674
|
|
|
675
|
+
def validate_session_request_answer(connection, room_id, requests):
|
|
676
|
+
"""Keep a discussion speaker request separate and live when answering it."""
|
|
677
|
+
discussions = [request for request in requests if request["purpose"] == "discuss"]
|
|
678
|
+
if not discussions:
|
|
679
|
+
return
|
|
680
|
+
if len(requests) != 1 or len(discussions) != 1:
|
|
681
|
+
raise ValueError("讨论安排须单独回答,不能与普通请求合并")
|
|
682
|
+
request_id = discussions[0]["id"]
|
|
683
|
+
plan = connection.execute("""SELECT sr.status,s.status AS session_status FROM session_requests sr
|
|
684
|
+
JOIN discussion_sessions s ON s.id=sr.session_id JOIN member_requests r ON r.id=sr.member_request_id
|
|
685
|
+
WHERE s.room_id=? AND r.id=? AND sr.purpose='discuss'""", (room_id, request_id)).fetchone()
|
|
686
|
+
if not plan or plan["session_status"] != "active" or plan["status"] != "open":
|
|
687
|
+
raise FileExistsError("讨论安排已暂停、结束或取消,不能继续回答")
|
|
688
|
+
|
|
689
|
+
|
|
577
690
|
def check_proxy(connection, room_id, member, payload):
|
|
578
691
|
"""The owner forwards an online member's answer and may close that member's open requests."""
|
|
579
692
|
request_ids = payload.get("request_ids", [])
|
|
580
693
|
if not isinstance(request_ids, list) or any(type(item) is not int for item in request_ids):
|
|
581
694
|
raise ValueError("request_ids 须为请求编号列表")
|
|
695
|
+
requests = []
|
|
582
696
|
for request_id in request_ids:
|
|
583
697
|
row = connection.execute("SELECT * FROM member_requests WHERE room_id=? AND id=?", (room_id, request_id)).fetchone()
|
|
584
698
|
if not row or row["member_id"] != member["id"] or row["status"] != "pending":
|
|
585
699
|
raise FileExistsError(f"请求 {request_id} 不属于该在线成员或已处理")
|
|
700
|
+
requests.append(row)
|
|
701
|
+
validate_session_request_answer(connection, room_id, requests)
|
|
586
702
|
|
|
587
703
|
|
|
588
704
|
def insert_request(connection, room_id, member_id, message_id, chain_id, purpose, created_by, batch_id=0):
|
|
@@ -811,16 +927,47 @@ def speaking_order(connection, room):
|
|
|
811
927
|
by_id = {member["id"]: member for member in active}
|
|
812
928
|
ordered = [member["name"] for member in default_order(connection, room["id"], active)]
|
|
813
929
|
items, kind, target, later = [], "default", 0, []
|
|
930
|
+
session_plan_state = []
|
|
814
931
|
if room["mode"] == "free":
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
if
|
|
818
|
-
kind = "
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
932
|
+
from .session_room import current_session
|
|
933
|
+
session = current_session(connection, room["id"], active_only=True)
|
|
934
|
+
if session:
|
|
935
|
+
kind = "turns"
|
|
936
|
+
target = session["id"]
|
|
937
|
+
session_plan_state = [list(row) for row in connection.execute("""SELECT id,round,position,member_id,question,status,submission_id
|
|
938
|
+
FROM session_requests WHERE session_id=? AND purpose='discuss' ORDER BY round,position,id""", (session["id"],))]
|
|
939
|
+
active_round = connection.execute("SELECT MIN(round) FROM session_requests WHERE session_id=? AND purpose='discuss' AND status IN ('open','queued')",
|
|
940
|
+
(session["id"],)).fetchone()[0]
|
|
941
|
+
if active_round is None:
|
|
942
|
+
active_round = connection.execute("SELECT COALESCE(MAX(round),1) FROM session_requests WHERE session_id=? AND purpose='discuss'",
|
|
943
|
+
(session["id"],)).fetchone()[0]
|
|
944
|
+
answered = [row["name"] for row in connection.execute("""SELECT m.name FROM session_requests sr JOIN room_members m ON m.id=sr.member_id
|
|
945
|
+
WHERE sr.session_id=? AND sr.purpose='discuss' AND sr.round=? AND sr.status='answered' ORDER BY sr.position,sr.id""",
|
|
946
|
+
(session["id"], active_round))]
|
|
947
|
+
items.extend({"name": name, "state": "done"} for name in answered)
|
|
948
|
+
open_turn = connection.execute("""SELECT m.name FROM session_requests sr JOIN room_members m ON m.id=sr.member_id
|
|
949
|
+
WHERE sr.session_id=? AND sr.purpose='discuss' AND sr.round=? AND sr.status='open'
|
|
950
|
+
ORDER BY sr.position,sr.id LIMIT 1""", (session["id"], active_round)).fetchone()
|
|
951
|
+
if open_turn:
|
|
952
|
+
items.append({"name": open_turn["name"], "state": "speaking"})
|
|
953
|
+
queued = connection.execute("""SELECT sr.round,m.name FROM session_requests sr JOIN room_members m ON m.id=sr.member_id
|
|
954
|
+
WHERE sr.session_id=? AND sr.purpose='discuss' AND sr.status='queued' ORDER BY sr.round,sr.position,sr.id""",
|
|
955
|
+
(session["id"],)).fetchall()
|
|
956
|
+
for row in queued:
|
|
957
|
+
if row["round"] == active_round:
|
|
958
|
+
items.append({"name": row["name"], "state": "queued"})
|
|
959
|
+
later = [[row["name"] for row in queued if row["round"] == round_number]
|
|
960
|
+
for round_number in sorted({row["round"] for row in queued if row["round"] > active_round})]
|
|
961
|
+
else:
|
|
962
|
+
target = connection.execute("""SELECT MAX(q.message_id) FROM free_queue q LEFT JOIN member_requests r ON r.message_id=q.message_id
|
|
963
|
+
AND r.member_id=q.member_id WHERE q.room_id=? AND (r.id IS NULL OR r.status IN ('pending','delivering'))""", (room["id"],)).fetchone()[0] or 0
|
|
964
|
+
if target:
|
|
965
|
+
kind = "queue"
|
|
966
|
+
for row in connection.execute("""SELECT q.member_id,r.status FROM free_queue q LEFT JOIN member_requests r
|
|
967
|
+
ON r.message_id=q.message_id AND r.member_id=q.member_id WHERE q.message_id=? ORDER BY q.id""", (target,)):
|
|
968
|
+
if row["member_id"] in by_id:
|
|
969
|
+
state = "queued" if row["status"] is None else "speaking" if row["status"] in ("pending", "delivering") else "done"
|
|
970
|
+
items.append({"name": by_id[row["member_id"]]["name"], "state": state})
|
|
824
971
|
else:
|
|
825
972
|
kind = "turns"
|
|
826
973
|
last = room["rounds"][-1] if room["rounds"] else None
|
|
@@ -837,7 +984,7 @@ def speaking_order(connection, room):
|
|
|
837
984
|
listed = {item["name"] for item in items}
|
|
838
985
|
items += [{"name": name, "state": "idle"} for name in dict.fromkeys(ordered) if name not in listed]
|
|
839
986
|
movable = "queued" if kind != "default" else "idle"
|
|
840
|
-
state = json.dumps([kind, target, [[item["name"], item["state"]] for item in items], later], ensure_ascii=False)
|
|
987
|
+
state = json.dumps([kind, target, [[item["name"], item["state"]] for item in items], later, session_plan_state], ensure_ascii=False)
|
|
841
988
|
return {"kind": kind, "message_id": target, "items": items, "later": later, "movable": [item["name"] for item in items if item["state"] == movable],
|
|
842
989
|
"default": [member["name"] for member in default_order(connection, room["id"], active)],
|
|
843
990
|
"revision": hashlib.sha256(state.encode("utf-8")).hexdigest()[:12]}
|
|
@@ -880,7 +1027,24 @@ def reorder_speakers(connection, room, token, payload):
|
|
|
880
1027
|
for name in order:
|
|
881
1028
|
connection.execute("INSERT INTO free_queue (room_id,message_id,member_id) VALUES (?,?,?)", (room["id"], current["message_id"], names[name]))
|
|
882
1029
|
elif current["kind"] == "turns":
|
|
883
|
-
|
|
1030
|
+
from .session_room import current_session
|
|
1031
|
+
session = current_session(connection, room["id"], active_only=True)
|
|
1032
|
+
if session:
|
|
1033
|
+
active_round = connection.execute("SELECT MIN(round) FROM session_requests WHERE session_id=? AND purpose='discuss' AND status IN ('open','queued')",
|
|
1034
|
+
(session["id"],)).fetchone()[0]
|
|
1035
|
+
active_round = active_round or 1
|
|
1036
|
+
open_position = connection.execute("SELECT COALESCE(MAX(position),0) FROM session_requests WHERE session_id=? AND purpose='discuss' AND round=? AND status IN ('answered','open')",
|
|
1037
|
+
(session["id"], active_round)).fetchone()[0]
|
|
1038
|
+
rows = connection.execute("SELECT id FROM session_requests WHERE session_id=? AND purpose='discuss' AND round=? AND status='queued'",
|
|
1039
|
+
(session["id"], active_round)).fetchall()
|
|
1040
|
+
connection.execute("UPDATE session_requests SET position=-id WHERE id IN (%s)" % ",".join("?" for _ in rows),
|
|
1041
|
+
[row["id"] for row in rows]) if rows else None
|
|
1042
|
+
for offset, name in enumerate(order, 1):
|
|
1043
|
+
connection.execute("UPDATE session_requests SET position=? WHERE session_id=? AND purpose='discuss' AND round=? AND status='queued' AND member_id=?",
|
|
1044
|
+
(open_position + offset, session["id"], active_round, names[name]))
|
|
1045
|
+
connection.execute("UPDATE session_state SET revision=revision+1 WHERE session_id=?", (session["id"],))
|
|
1046
|
+
else:
|
|
1047
|
+
reorder_turns(connection, room["id"], order)
|
|
884
1048
|
else:
|
|
885
1049
|
connection.execute("INSERT OR REPLACE INTO speaking_orders (room_id,member_ids) VALUES (?,?)",
|
|
886
1050
|
(room["id"], json.dumps([names[name] for name in order])))
|
|
@@ -919,6 +1083,15 @@ def after_free_message(connection, room_id, message_id, actor, payload):
|
|
|
919
1083
|
"""Create requests for the owner's message, or close what the member's answer covers."""
|
|
920
1084
|
now = int(time.time())
|
|
921
1085
|
if actor == "用户":
|
|
1086
|
+
from .session_room import create_host_request, current_session
|
|
1087
|
+
session = current_session(connection, room_id, active_only=True)
|
|
1088
|
+
if session and session["status"] == "active" and payload.get("to") is None:
|
|
1089
|
+
create_host_request(connection, session["id"],
|
|
1090
|
+
f"用户留言 #{message_id}:请处理并决定是否安排专家。",
|
|
1091
|
+
{"kind": "user_message", "message_id": message_id, "key": f"message-{message_id}"})
|
|
1092
|
+
connection.execute("UPDATE session_state SET held_since=?,revision=revision+1 WHERE session_id=?",
|
|
1093
|
+
(message_id, session["id"]))
|
|
1094
|
+
return
|
|
922
1095
|
pairs = recipients(connection, room_id, payload)
|
|
923
1096
|
if payload.get("sequential") is True and payload.get("to") is not None:
|
|
924
1097
|
queue_in_turn(connection, room_id, message_id, [member_id for member_id, _ in pairs])
|
|
@@ -926,7 +1099,7 @@ def after_free_message(connection, room_id, message_id, actor, payload):
|
|
|
926
1099
|
for member_id, purpose in pairs:
|
|
927
1100
|
insert_request(connection, room_id, member_id, message_id, message_id, purpose, "user")
|
|
928
1101
|
return
|
|
929
|
-
if actor
|
|
1102
|
+
if actor.get("manual_source") == "web":
|
|
930
1103
|
for request_id in payload.get("request_ids", []):
|
|
931
1104
|
connection.execute("UPDATE member_requests SET status='completed',answer_id=? WHERE id=?", (message_id, request_id))
|
|
932
1105
|
settle_batches(connection, room_id)
|
|
@@ -1003,10 +1176,17 @@ def finish_execution(connection, execution_id, status, result, request_status, c
|
|
|
1003
1176
|
"""End one execution and move its delivered requests in the same transaction."""
|
|
1004
1177
|
if request_status == "cancelled" and cancel_reason:
|
|
1005
1178
|
record_cancel_reasons(connection, "execution_id=? AND status='delivering'", (execution_id,), cancel_reason)
|
|
1179
|
+
session_requests = connection.execute("""SELECT r.id FROM member_requests r JOIN session_requests sr
|
|
1180
|
+
ON sr.member_request_id=r.id WHERE r.execution_id=? AND sr.purpose='discuss' AND sr.status='open'""",
|
|
1181
|
+
(execution_id,)).fetchall()
|
|
1006
1182
|
connection.execute("UPDATE member_executions SET status=?,finished_at=?,result=? WHERE id=? AND status IN ('dispatched','started')",
|
|
1007
1183
|
(status, int(time.time()), result[:500], execution_id))
|
|
1008
1184
|
connection.execute("UPDATE member_requests SET status=?,execution_id=CASE WHEN ?='pending' THEN NULL ELSE execution_id END WHERE execution_id=? AND status='delivering'",
|
|
1009
1185
|
(request_status, request_status, execution_id))
|
|
1186
|
+
if status == "failed" and request_status == "failed":
|
|
1187
|
+
from .session_room import fail_session_request
|
|
1188
|
+
for row in session_requests:
|
|
1189
|
+
fail_session_request(connection, row["id"], result)
|
|
1010
1190
|
|
|
1011
1191
|
|
|
1012
1192
|
def open_route_messages(connection, member_id, pending_only=False):
|
|
@@ -1033,6 +1213,26 @@ def stop_member_work(connection, member_id, reason, cancel_reason):
|
|
|
1033
1213
|
finish_execution(connection, row["id"], "cancelled", reason, "cancelled", cancel_reason)
|
|
1034
1214
|
record_cancel_reasons(connection, "member_id=? AND status='pending'", (member_id,), cancel_reason)
|
|
1035
1215
|
connection.execute("UPDATE member_requests SET status='cancelled' WHERE member_id=? AND status='pending'", (member_id,))
|
|
1216
|
+
from .session_room import advance_session, create_host_request, current_session
|
|
1217
|
+
|
|
1218
|
+
member = connection.execute("SELECT room_id,name FROM room_members WHERE id=?", (member_id,)).fetchone()
|
|
1219
|
+
session = current_session(connection, member["room_id"], active_only=True) if member else None
|
|
1220
|
+
if not session:
|
|
1221
|
+
return
|
|
1222
|
+
plans = connection.execute("""SELECT id,member_request_id FROM session_requests
|
|
1223
|
+
WHERE session_id=? AND member_id=? AND purpose='discuss' AND status='open'""",
|
|
1224
|
+
(session["id"], member_id)).fetchall()
|
|
1225
|
+
for plan in plans:
|
|
1226
|
+
connection.execute("UPDATE session_requests SET status='cancelled' WHERE id=?", (plan["id"],))
|
|
1227
|
+
if not plans:
|
|
1228
|
+
return
|
|
1229
|
+
connection.execute("UPDATE session_state SET revision=revision+1 WHERE session_id=?", (session["id"],))
|
|
1230
|
+
if advance_session(connection, session["id"]):
|
|
1231
|
+
return
|
|
1232
|
+
request_id = plans[0]["member_request_id"]
|
|
1233
|
+
create_host_request(connection, session["id"],
|
|
1234
|
+
f"专家 {member['name']} 的讨论发言已被打断,请检查余下安排并决定如何继续。",
|
|
1235
|
+
{"kind": "request_failed", "request_id": request_id, "key": f"interrupted-{request_id or plans[0]['id']}"})
|
|
1036
1236
|
|
|
1037
1237
|
|
|
1038
1238
|
def stop_room_work(connection, room_id, reason, cancel_reason):
|
|
@@ -1049,16 +1249,24 @@ def inbox(connection, room_id, token):
|
|
|
1049
1249
|
"""List what the runner should deliver or reconcile; owner credential only."""
|
|
1050
1250
|
require_owner(connection, room_id, token)
|
|
1051
1251
|
begin_write(connection)
|
|
1252
|
+
enforce_session_limits(connection, room_id)
|
|
1052
1253
|
expire_executions(connection, room_id)
|
|
1053
1254
|
release_recommendations(connection, room_id)
|
|
1054
1255
|
archived = connection.execute("SELECT archived FROM room_directory WHERE room_id=?", (room_id,)).fetchone()[0]
|
|
1055
1256
|
members, observers = [], []
|
|
1056
1257
|
# Manual and online members are never delivered to; the runner only reads them so smart scheduling can route to them too.
|
|
1057
1258
|
for row in connection.execute(f"SELECT * FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER} ORDER BY id", (room_id,)):
|
|
1058
|
-
pending = [dict(item) for item in connection.execute(
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1259
|
+
pending = [dict(item) for item in connection.execute("""SELECT r.id,r.message_id,r.purpose,
|
|
1260
|
+
sr.id AS session_request_id,sr.session_id,sr.round,sr.position,sr.read_until,sr.question AS source_question,
|
|
1261
|
+
t.cause_kind,t.cause_message_id AS source_message_id,t.cause_request_id
|
|
1262
|
+
FROM member_requests r LEFT JOIN session_requests sr ON sr.member_request_id=r.id
|
|
1263
|
+
LEFT JOIN task_message_sources t ON t.session_request_id=sr.id
|
|
1264
|
+
WHERE r.member_id=? AND r.status='pending' ORDER BY r.id""", (row["id"],))]
|
|
1265
|
+
manual_source = connection.execute("SELECT source FROM member_manual_sources WHERE member_id=?", (row["id"],)).fetchone()
|
|
1266
|
+
member_view = {key: row[key] for key in MEMBER_FIELDS}
|
|
1267
|
+
member_view["manual_source"] = manual_source[0] if manual_source else ""
|
|
1268
|
+
(members if row["access"] == "runner" else observers).append({**member_view, "pending": pending})
|
|
1269
|
+
executions = [{**public_execution(row), "member": row["name"], "retry": execution_retry(connection, row["id"])} for row in connection.execute(
|
|
1062
1270
|
"""SELECT member_executions.*,room_members.name FROM member_executions JOIN room_members ON room_members.id=member_id
|
|
1063
1271
|
WHERE member_executions.room_id=? AND member_executions.status IN ('dispatched','started') ORDER BY member_executions.id""", (room_id,))]
|
|
1064
1272
|
return {"room_id": room_id, "archived": bool(archived), "members": members, "observers": observers, "executions": executions,
|
|
@@ -1187,6 +1395,8 @@ def reserve_execution(connection, room_id, token, payload):
|
|
|
1187
1395
|
"""Bundle a member's open requests into one execution; a retried client_id returns the same one."""
|
|
1188
1396
|
require_owner(connection, room_id, token)
|
|
1189
1397
|
begin_write(connection)
|
|
1398
|
+
if enforce_session_limits(connection, room_id):
|
|
1399
|
+
connection.commit()
|
|
1190
1400
|
client_id = str(payload.get("client_id", ""))
|
|
1191
1401
|
if not 8 <= len(client_id) <= 100:
|
|
1192
1402
|
raise ValueError("client_id 须为 8–100 字符的本地投递编号")
|
|
@@ -1203,27 +1413,37 @@ def reserve_execution(connection, room_id, token, payload):
|
|
|
1203
1413
|
if connection.execute("SELECT 1 FROM member_executions WHERE member_id=? AND status IN ('dispatched','started')",
|
|
1204
1414
|
(member["id"],)).fetchone():
|
|
1205
1415
|
raise FileExistsError("该成员已有进行中的执行;同一会话不并发续接")
|
|
1206
|
-
if connection.execute("SELECT 1 FROM discussion_events WHERE room_id=? AND participant=? AND status IN ('dispatched','started')",
|
|
1207
|
-
(room_id, member["name"])).fetchone():
|
|
1208
|
-
raise FileExistsError("该成员正在处理讨论安排;同一会话不并发续接")
|
|
1209
1416
|
# A group request waits for the runner to choose how everyone answers; it is never delivered itself.
|
|
1210
|
-
|
|
1417
|
+
pending = connection.execute("""SELECT purpose FROM member_requests
|
|
1418
|
+
WHERE member_id=? AND status='pending' AND purpose<>'group' ORDER BY id""", (member["id"],)).fetchall()
|
|
1419
|
+
session_purpose = next((row["purpose"] for row in pending if row["purpose"] == "discuss"), None)
|
|
1420
|
+
if not pending:
|
|
1211
1421
|
raise FileExistsError("该成员没有待投递的请求")
|
|
1422
|
+
if session_purpose:
|
|
1423
|
+
request_filter = "purpose=?"
|
|
1424
|
+
request_values = (session_purpose,)
|
|
1425
|
+
else:
|
|
1426
|
+
request_filter = "purpose<>'group'"
|
|
1427
|
+
request_values = ()
|
|
1212
1428
|
now = int(time.time())
|
|
1213
1429
|
cursor = connection.execute("""INSERT INTO member_executions (room_id,member_id,client_id,lease_until,created_at,cwd)
|
|
1214
1430
|
VALUES (?,?,?,?,?,?)""", (room_id, member["id"], client_id, now + LEASE_SECONDS, now, str(payload.get("cwd") or "")[:MAX_PATH_LENGTH]))
|
|
1215
|
-
connection.execute("UPDATE member_requests SET status='delivering',execution_id=? WHERE member_id=? AND status='pending' AND
|
|
1216
|
-
(cursor.lastrowid, member["id"]))
|
|
1431
|
+
connection.execute(f"UPDATE member_requests SET status='delivering',execution_id=? WHERE member_id=? AND status='pending' AND {request_filter}",
|
|
1432
|
+
(cursor.lastrowid, member["id"], *request_values))
|
|
1217
1433
|
return execution_detail(connection, connection.execute("SELECT * FROM member_executions WHERE id=?", (cursor.lastrowid,)).fetchone())
|
|
1218
1434
|
|
|
1219
1435
|
|
|
1220
1436
|
def execution_detail(connection, row):
|
|
1221
1437
|
"""Describe one execution with the requests it carries and the member's read cursor."""
|
|
1222
1438
|
member = connection.execute("SELECT * FROM room_members WHERE id=?", (row["member_id"],)).fetchone()
|
|
1223
|
-
requests = [dict(item) for item in connection.execute(
|
|
1224
|
-
|
|
1439
|
+
requests = [dict(item) for item in connection.execute("""SELECT r.id,r.message_id,r.status,r.purpose,
|
|
1440
|
+
sr.id AS session_request_id,sr.session_id,sr.round,sr.position,sr.read_until,sr.question AS source_question,
|
|
1441
|
+
t.cause_kind,t.cause_message_id AS source_message_id,t.cause_request_id
|
|
1442
|
+
FROM member_requests r LEFT JOIN session_requests sr ON sr.member_request_id=r.id
|
|
1443
|
+
LEFT JOIN task_message_sources t ON t.session_request_id=sr.id
|
|
1444
|
+
WHERE r.execution_id=? ORDER BY r.id""", (row["id"],))]
|
|
1225
1445
|
return {**public_execution(row), "member": member["name"], "read_until": member["read_until"], "requests": requests,
|
|
1226
|
-
"rewound_executions": member_rewinds(connection, member["id"])}
|
|
1446
|
+
"rewound_executions": member_rewinds(connection, member["id"]), "retry": execution_retry(connection, row["id"])}
|
|
1227
1447
|
|
|
1228
1448
|
|
|
1229
1449
|
def view_execution(connection, room_id, execution_id, token):
|
|
@@ -1282,12 +1502,23 @@ def renewed_notes(payload):
|
|
|
1282
1502
|
|
|
1283
1503
|
def execution_action(connection, room_id, execution_id, token, payload):
|
|
1284
1504
|
"""Runner-side lifecycle: renew, fail, release undelivered work, or report observed file changes."""
|
|
1285
|
-
require_owner(connection, room_id, token)
|
|
1286
1505
|
begin_write(connection)
|
|
1506
|
+
action = payload.get("action")
|
|
1507
|
+
member = None
|
|
1508
|
+
if action == "fail":
|
|
1509
|
+
try:
|
|
1510
|
+
require_owner(connection, room_id, token)
|
|
1511
|
+
except PermissionError:
|
|
1512
|
+
member = require_member(connection, room_id, token)
|
|
1513
|
+
else:
|
|
1514
|
+
require_owner(connection, room_id, token)
|
|
1515
|
+
if enforce_session_limits(connection, room_id):
|
|
1516
|
+
connection.commit()
|
|
1287
1517
|
row = connection.execute("SELECT * FROM member_executions WHERE room_id=? AND id=?", (room_id, execution_id)).fetchone()
|
|
1288
1518
|
if not row:
|
|
1289
1519
|
raise LookupError("执行不存在")
|
|
1290
|
-
|
|
1520
|
+
if member and member["id"] != row["member_id"]:
|
|
1521
|
+
raise PermissionError("只能报告自己执行的失败")
|
|
1291
1522
|
now = int(time.time())
|
|
1292
1523
|
if action == "report":
|
|
1293
1524
|
# Observed after the process ends, so the execution may already be complete; recorded once.
|
|
@@ -1320,6 +1551,24 @@ def execution_action(connection, room_id, execution_id, token, payload):
|
|
|
1320
1551
|
(min(now + LEASE_SECONDS, limit), activity, activity_at, execution_id))
|
|
1321
1552
|
connection.executemany("INSERT OR IGNORE INTO execution_notes (execution_id,seq,room_id,text,created_at) VALUES (?,?,?,?,?)",
|
|
1322
1553
|
[(execution_id, note["seq"], room_id, note["text"], now) for note in notes])
|
|
1554
|
+
elif action == "retry_wait":
|
|
1555
|
+
# A manual member's session refused the delivery: the runner keeps the execution and delivers again at retry_at.
|
|
1556
|
+
attempt, retry_at, reason = payload.get("attempt"), payload.get("retry_at"), payload.get("reason")
|
|
1557
|
+
if (type(attempt) is not int or not 1 <= attempt <= MAX_RETRIES or type(retry_at) is not int or retry_at <= 0
|
|
1558
|
+
or not isinstance(reason, str) or not 0 < len(reason) <= MAX_RETRY_REASON):
|
|
1559
|
+
raise ValueError(f"retry_wait 须带 attempt(1–{MAX_RETRIES})、秒级 retry_at 与 reason(最多 {MAX_RETRY_REASON} 字)")
|
|
1560
|
+
connection.execute("""INSERT INTO execution_retries (execution_id,room_id,attempt,retry_at,reason,stopped) VALUES (?,?,?,?,?,0)
|
|
1561
|
+
ON CONFLICT(execution_id) DO UPDATE SET attempt=excluded.attempt,retry_at=excluded.retry_at,reason=excluded.reason,stopped=0""",
|
|
1562
|
+
(execution_id, room_id, attempt, retry_at, reason))
|
|
1563
|
+
elif action == "retry_start":
|
|
1564
|
+
connection.execute("UPDATE execution_retries SET retry_at=0 WHERE execution_id=?", (execution_id,))
|
|
1565
|
+
elif action in ("retry_now", "retry_stop"):
|
|
1566
|
+
# The owner's answer to a waiting retry: deliver now, or stop so the runner fails the execution.
|
|
1567
|
+
change = "retry_at=?" if action == "retry_now" else "stopped=1"
|
|
1568
|
+
updated = connection.execute(f"UPDATE execution_retries SET {change} WHERE execution_id=? AND retry_at>0 AND stopped=0",
|
|
1569
|
+
((now, execution_id) if action == "retry_now" else (execution_id,)))
|
|
1570
|
+
if not updated.rowcount:
|
|
1571
|
+
raise FileExistsError("本次执行没有在等待自动重试")
|
|
1323
1572
|
elif action == "fail":
|
|
1324
1573
|
finish_execution(connection, execution_id, "failed", str(payload.get("reason") or "执行失败"), "failed")
|
|
1325
1574
|
settle_batches(connection, room_id)
|
|
@@ -1327,7 +1576,7 @@ def execution_action(connection, room_id, execution_id, token, payload):
|
|
|
1327
1576
|
# Only for proven non-delivery: the requests return to the queue unchanged.
|
|
1328
1577
|
finish_execution(connection, execution_id, "cancelled", str(payload.get("reason") or "未送达"), "pending")
|
|
1329
1578
|
else:
|
|
1330
|
-
raise ValueError("action 须为 renew、fail、release、report 或
|
|
1579
|
+
raise ValueError("action 须为 renew、fail、release、report、segments、retry_wait、retry_start、retry_now 或 retry_stop")
|
|
1331
1580
|
return execution_detail(connection, connection.execute("SELECT * FROM member_executions WHERE id=?", (execution_id,)).fetchone())
|
|
1332
1581
|
|
|
1333
1582
|
|
|
@@ -1493,6 +1742,7 @@ def add_member(connection, room_id, token, payload, now):
|
|
|
1493
1742
|
raise FileExistsError(f"{member['name']} 已是本房间成员;离开的成员请用请回")
|
|
1494
1743
|
require_fresh_member_token(connection, room_id, member["token"])
|
|
1495
1744
|
insert_member(connection, room_id, member, now)
|
|
1745
|
+
queue_invited_expert(connection, room_id, member["name"], now)
|
|
1496
1746
|
sync_participants(connection, room_id)
|
|
1497
1747
|
return free_view(connection, room_id)
|
|
1498
1748
|
|
|
@@ -1516,6 +1766,32 @@ def queue_invited_expert(connection, room_id, name, now):
|
|
|
1516
1766
|
member = connection.execute("SELECT * FROM room_members WHERE room_id=? AND name=? AND status='active'", (room_id, name)).fetchone()
|
|
1517
1767
|
if not member or (member["adapter"] == "manual" and not registered_at(connection, member)):
|
|
1518
1768
|
return
|
|
1769
|
+
from .session_room import current_session
|
|
1770
|
+
session = current_session(connection, room_id)
|
|
1771
|
+
if session and session["status"] != "concluded":
|
|
1772
|
+
planned = connection.execute("SELECT 1 FROM session_requests WHERE session_id=? AND purpose='discuss' AND status IN ('open','queued') LIMIT 1",
|
|
1773
|
+
(session["id"],)).fetchone()
|
|
1774
|
+
if not planned:
|
|
1775
|
+
return
|
|
1776
|
+
round_number = connection.execute("SELECT MIN(round) FROM session_requests WHERE session_id=? AND purpose='discuss' AND status IN ('open','queued')",
|
|
1777
|
+
(session["id"],)).fetchone()[0]
|
|
1778
|
+
if round_number is None:
|
|
1779
|
+
round_number = 1
|
|
1780
|
+
position = connection.execute("SELECT COALESCE(MAX(position),0)+1 FROM session_requests WHERE session_id=? AND purpose='discuss' AND round=?",
|
|
1781
|
+
(session["id"], round_number)).fetchone()[0]
|
|
1782
|
+
invitation = connection.execute("SELECT COUNT(*) FROM session_requests WHERE session_id=? AND member_id=? AND submission_id LIKE 'invite:%'",
|
|
1783
|
+
(session["id"], member["id"])).fetchone()[0] + 1
|
|
1784
|
+
state = connection.execute("SELECT phase,read_until FROM session_state WHERE session_id=?", (session["id"],)).fetchone()
|
|
1785
|
+
question = ("欢迎加入:请先读取议题,独立给出你的开场观点。" if state and state["phase"] == "initial"
|
|
1786
|
+
else "欢迎加入:请先读取议题和已有回答,给出你的观点。")
|
|
1787
|
+
read_until = state["read_until"] if state and state["phase"] == "initial" and round_number == 1 else None
|
|
1788
|
+
connection.execute("""INSERT OR IGNORE INTO session_requests
|
|
1789
|
+
(session_id,round,position,member_id,purpose,question,status,submission_id,read_until,created_at)
|
|
1790
|
+
VALUES(?,?,?,?,'discuss',?,'queued',?,?,?)""",
|
|
1791
|
+
(session["id"], round_number, position, member["id"], question,
|
|
1792
|
+
f"invite:{member['id']}:{invitation}", read_until,
|
|
1793
|
+
time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())))
|
|
1794
|
+
return
|
|
1519
1795
|
connection.execute("""INSERT OR IGNORE INTO discussion_turns (room_id,expert,question,status,submission_id,created_at)
|
|
1520
1796
|
SELECT settings.room_id,member.name,
|
|
1521
1797
|
CASE WHEN settings.phase='initial' THEN '欢迎加入:请先读取议题,独立给出你的开场观点。'
|
|
@@ -1556,7 +1832,7 @@ def discussion_member_action(connection, room_id, member_id, token, payload):
|
|
|
1556
1832
|
"""Owner sends an expert away (keeping its seat), brings it back with a new credential, or removes it.
|
|
1557
1833
|
|
|
1558
1834
|
Leaving cancels the expert's open assignments; the host keeps its role and cannot leave, and an
|
|
1559
|
-
|
|
1835
|
+
leaving cancels its active plan and asks the host to re-plan without pausing the discussion.
|
|
1560
1836
|
"""
|
|
1561
1837
|
member = owned_member(connection, room_id, member_id, token)
|
|
1562
1838
|
host, experts, phase = discussion_roster(connection, room_id)
|
|
@@ -1564,10 +1840,8 @@ def discussion_member_action(connection, room_id, member_id, token, payload):
|
|
|
1564
1840
|
if action in ("away", "remove"):
|
|
1565
1841
|
if member["name"] == host:
|
|
1566
1842
|
raise ValueError("主持人不能离开或移除;请先更换主持人")
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
if speaking and phase != "paused":
|
|
1570
|
-
raise ValueError("该专家正在发言;请先暂停讨论再离开或移除")
|
|
1843
|
+
from .session_room import session_member_change
|
|
1844
|
+
session_member_change(connection, room_id, member, action)
|
|
1571
1845
|
connection.execute("UPDATE discussion_turns SET status='cancelled' WHERE room_id=? AND expert=? AND status IN ('pending','notified')",
|
|
1572
1846
|
(room_id, member["name"]))
|
|
1573
1847
|
connection.execute("UPDATE discussion_turns SET status='skipped' WHERE room_id=? AND expert=? AND status='queued'", (room_id, member["name"]))
|
|
@@ -1580,9 +1854,13 @@ def discussion_member_action(connection, room_id, member_id, token, payload):
|
|
|
1580
1854
|
if member["status"] != "away" or not hashed or hmac.compare_digest(hashed, member["token_hash"]):
|
|
1581
1855
|
raise ValueError("只有离开的成员可以请回,且须提供新的成员凭证")
|
|
1582
1856
|
require_fresh_member_token(connection, room_id, payload.get("token"))
|
|
1583
|
-
connection.execute("UPDATE discussion_turns SET status='skipped' WHERE room_id=? AND status='queued'", (room_id,))
|
|
1584
1857
|
connection.execute("UPDATE room_members SET status='active',token_hash=? WHERE id=?", (hashed, member_id))
|
|
1585
1858
|
experts[member["name"]] = "online" if member["access"] == "online" else "cli"
|
|
1859
|
+
from .session_room import current_session, session_member_change
|
|
1860
|
+
if current_session(connection, room_id, active_only=True):
|
|
1861
|
+
session_member_change(connection, room_id, member, action)
|
|
1862
|
+
else:
|
|
1863
|
+
queue_invited_expert(connection, room_id, member["name"], int(time.time()))
|
|
1586
1864
|
elif action == "adopt":
|
|
1587
1865
|
adopt_manual_member(connection, room_id, member, payload)
|
|
1588
1866
|
return
|
|
@@ -1628,9 +1906,16 @@ def member_action(connection, room_id, member_id, token, payload):
|
|
|
1628
1906
|
`role` action makes one runner or manual member the AI host, replacing any previous host.
|
|
1629
1907
|
"""
|
|
1630
1908
|
member = owned_member(connection, room_id, member_id, token)
|
|
1909
|
+
manual_source = connection.execute("SELECT source FROM member_manual_sources WHERE member_id=?", (member_id,)).fetchone()
|
|
1910
|
+
is_web_member = bool(manual_source and manual_source["source"] == "web")
|
|
1631
1911
|
action = payload.get("action")
|
|
1632
1912
|
routed = []
|
|
1633
1913
|
if action in ("away", "remove"):
|
|
1914
|
+
from .session_room import pause_session_for_host_change, session_member_change
|
|
1915
|
+
if member["role"] == "host":
|
|
1916
|
+
pause_session_for_host_change(connection, room_id)
|
|
1917
|
+
else:
|
|
1918
|
+
session_member_change(connection, room_id, member, action)
|
|
1634
1919
|
# Unnamed messages still waiting on a departing host move to its successor instead of being dropped.
|
|
1635
1920
|
routed = open_route_messages(connection, member_id)
|
|
1636
1921
|
stop_member_work(connection, member_id, "成员已离开" if action == "away" else "成员已移除",
|
|
@@ -1644,15 +1929,22 @@ def member_action(connection, room_id, member_id, token, payload):
|
|
|
1644
1929
|
raise ValueError("只有离开的成员可以请回,且须提供新的成员凭证")
|
|
1645
1930
|
require_fresh_member_token(connection, room_id, payload.get("token"))
|
|
1646
1931
|
connection.execute("UPDATE room_members SET status='active',token_hash=? WHERE id=?", (hashed, member_id))
|
|
1932
|
+
from .session_room import current_session, session_member_change
|
|
1933
|
+
if current_session(connection, room_id, active_only=True):
|
|
1934
|
+
session_member_change(connection, room_id, member, action)
|
|
1935
|
+
else:
|
|
1936
|
+
queue_invited_expert(connection, room_id, member["name"], int(time.time()))
|
|
1647
1937
|
elif action == "adopt":
|
|
1648
1938
|
adopt_manual_member(connection, room_id, member, payload)
|
|
1649
1939
|
elif action == "role":
|
|
1650
1940
|
role = payload.get("role")
|
|
1651
|
-
if role not in ("host", "member") or (role == "host" and member["access"] == "online"):
|
|
1941
|
+
if role not in ("host", "member") or (role == "host" and (member["access"] == "online" or is_web_member)):
|
|
1652
1942
|
raise ValueError("role 须为 host 或 member;在线成员不能担任主持人")
|
|
1653
1943
|
if role == "member" and member["role"] == "host":
|
|
1654
1944
|
raise ValueError("自由房间须由一位 AI 主持;请指定另一位成员担任主持人")
|
|
1655
1945
|
if role == "host":
|
|
1946
|
+
from .session_room import pause_session_for_host_change
|
|
1947
|
+
pause_session_for_host_change(connection, room_id)
|
|
1656
1948
|
# Unnamed messages the old host has not started move to the new host, which carries the host rules.
|
|
1657
1949
|
old_host = connection.execute("SELECT id FROM room_members WHERE room_id=? AND role='host' AND id<>?", (room_id, member_id)).fetchone()
|
|
1658
1950
|
if old_host:
|