ai_co_discuss 0.2.98 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +4 -4
- package/.agents/skills/discuss-participant/references/discussion.md +29 -27
- package/.agents/skills/discuss-participant/references/free.md +3 -3
- 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 +542 -69
- package/backend/room_import.py +77 -15
- package/backend/runtime.py +81 -169
- package/backend/schema.sql +167 -244
- package/backend/server.py +639 -175
- package/backend/session_room.py +919 -0
- package/docs/architecture.md +3 -2
- package/docs/en/architecture.md +4 -3
- package/docs/en/runner.md +3 -1
- package/docs/en/workflow.md +101 -96
- package/docs/runner.md +3 -1
- package/docs/workflow.md +101 -96
- package/package.json +2 -2
- package/runner/api_participant.mjs +47 -44
- package/runner/codex_app_client.mjs +13 -0
- package/runner/discuss.py +77 -58
- package/runner/discussion_adapters.mjs +210 -28
- package/runner/discussion_launch.mjs +234 -85
- package/runner/discussion_models.mjs +89 -1
- package/runner/discussion_runner.mjs +49 -504
- package/runner/discussion_service.mjs +119 -93
- package/runner/discussion_sidecar.mjs +4 -13
- package/runner/expert_prompts.mjs +100 -8
- package/runner/free_runner.mjs +118 -35
- package/runner/scheduler.mjs +4 -4
- package/runner/session_cut.mjs +167 -0
- package/runner/session_driver.mjs +175 -0
- package/web/app.js +421 -154
- package/web/backup.js +1 -1
- package/web/markdown.css +1 -1
- package/web/modern.css +1 -1
- package/web/modern.js +6544 -7299
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
|
|
@@ -72,10 +73,19 @@ MAX_NOTE_LENGTH = 500
|
|
|
72
73
|
MAX_NOTES_PER_RENEW = 10
|
|
73
74
|
# A member description is a short note for the scheduler and the host, keyed by the member's name.
|
|
74
75
|
MAX_MEMBER_DESCRIPTION = 500
|
|
76
|
+
# A display label replaces the member's name on screen only; the name stays the member's identity everywhere else.
|
|
77
|
+
MAX_MEMBER_LABEL = 32
|
|
75
78
|
MAX_MEMBER_NAME = 80
|
|
76
79
|
MAX_PATH_LENGTH = 500
|
|
80
|
+
# Refused operations one execution may report, and the length of each field.
|
|
81
|
+
MAX_DENIALS = 20
|
|
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
|
|
77
86
|
ACTIVE = ("dispatched", "started")
|
|
78
|
-
ACCESS_MODES = ("runner", "manual"
|
|
87
|
+
ACCESS_MODES = ("runner", "manual")
|
|
88
|
+
LEGACY_ACCESS_MODES = ("runner", "manual", "online")
|
|
79
89
|
SCHEDULER_PROVIDERS = ("jev", "decisions")
|
|
80
90
|
RECOMMEND_SECONDS = 60
|
|
81
91
|
# A click sent just before the hint disappears still counts.
|
|
@@ -88,7 +98,8 @@ MAX_CHAIN_ROUNDS = 20
|
|
|
88
98
|
# A host that left gives up the role; the first active runner member (else manual) then takes it.
|
|
89
99
|
RELEASE_HOST = "UPDATE room_members SET role='member' WHERE room_id=? AND role='host' AND status<>'active'"
|
|
90
100
|
ASSIGN_HOST = """UPDATE room_members SET role='host' WHERE id=(SELECT id FROM room_members WHERE room_id=? AND status='active'
|
|
91
|
-
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)
|
|
92
103
|
AND NOT EXISTS (SELECT 1 FROM room_members WHERE room_id=? AND role='host' AND status='active')"""
|
|
93
104
|
TERMINAL = ("completed", "cancelled", "failed")
|
|
94
105
|
# A queued member has been asked once its request for that message exists.
|
|
@@ -113,6 +124,13 @@ def migrate_free_tables(connection):
|
|
|
113
124
|
for name, definition in columns.items():
|
|
114
125
|
if name not in present:
|
|
115
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'")
|
|
116
134
|
|
|
117
135
|
|
|
118
136
|
def room_mode(connection, room_id):
|
|
@@ -131,9 +149,15 @@ def member_value(member):
|
|
|
131
149
|
if not isinstance(adapter, str) or len(adapter) > 40:
|
|
132
150
|
raise ValueError("成员适配器名称无效")
|
|
133
151
|
access, session_mode = member.get("access", "runner"), member.get("session_mode", "new")
|
|
134
|
-
if access not in
|
|
152
|
+
if access not in LEGACY_ACCESS_MODES or session_mode not in SESSION_MODES:
|
|
135
153
|
raise ValueError("成员接入方式须为 runner/manual/online,会话方式须为 new/borrowed")
|
|
136
|
-
|
|
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}
|
|
137
161
|
|
|
138
162
|
|
|
139
163
|
def member_values(members):
|
|
@@ -153,6 +177,10 @@ def insert_member(connection, room_id, member, now):
|
|
|
153
177
|
connection.execute("""INSERT INTO room_members (room_id,name,adapter,token_hash,access,session_mode,created_at)
|
|
154
178
|
VALUES (?,?,?,?,?,?,?)""", (room_id, member["name"], member["adapter"], token_hash(member["token"]),
|
|
155
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,))
|
|
156
184
|
|
|
157
185
|
|
|
158
186
|
def save_room_access(connection, room_id, control_token, members, settings):
|
|
@@ -168,10 +196,12 @@ def save_room_access(connection, room_id, control_token, members, settings):
|
|
|
168
196
|
connection.execute("UPDATE room_members SET role='host' WHERE room_id=? AND name=?", (room_id, settings["host"]))
|
|
169
197
|
|
|
170
198
|
|
|
171
|
-
def discussion_access_ok(access, capability, automatic, is_host):
|
|
172
|
-
"""
|
|
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."""
|
|
173
201
|
if capability == "online":
|
|
174
|
-
return access == "
|
|
202
|
+
return access == "manual" and manual_source == "web" and not is_host
|
|
203
|
+
if manual_source == "web":
|
|
204
|
+
return False
|
|
175
205
|
if not automatic:
|
|
176
206
|
return access == "manual"
|
|
177
207
|
return access == "runner" or (access == "manual" and not is_host)
|
|
@@ -189,24 +219,28 @@ def discussion_members(payload, settings):
|
|
|
189
219
|
raise ValueError("成员凭证名单须与参与者名单一致")
|
|
190
220
|
for member in members:
|
|
191
221
|
if not discussion_access_ok(member["access"], settings["experts"][member["name"]],
|
|
192
|
-
payload.get("runtime_mode") == "automatic", member["name"] == settings["host"]
|
|
222
|
+
payload.get("runtime_mode") == "automatic", member["name"] == settings["host"],
|
|
223
|
+
member["manual_source"]):
|
|
193
224
|
raise ValueError("成员接入方式与房间参与能力、任务交付方式不符")
|
|
194
225
|
return [mark_manual(member, payload.get("runtime_mode") == "automatic") for member in members]
|
|
195
226
|
|
|
196
227
|
|
|
197
228
|
def sync_discussion_members(connection, room_id, payload, settings):
|
|
198
229
|
"""Keep existing credentials; require fresh credentials for added or changed members."""
|
|
199
|
-
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,))}
|
|
200
233
|
supplied = {member["name"]: member_value(member) for member in payload.get("members", [])}
|
|
201
234
|
run = connection.execute("SELECT mode FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
|
|
202
235
|
for name, capability in settings["experts"].items():
|
|
203
236
|
automatic = bool(run and run[0] == "automatic")
|
|
204
237
|
old = current.get(name)
|
|
205
|
-
if old and (old["
|
|
238
|
+
if old and (old["manual_source"] == "web") == (capability == "online"):
|
|
206
239
|
if name in supplied and token_hash(supplied[name]["token"]) != old["token_hash"]:
|
|
207
240
|
raise FileExistsError("成员凭证与已登记身份不符;请核对原接入文件")
|
|
208
241
|
continue
|
|
209
|
-
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"]):
|
|
210
244
|
raise ValueError(f"新增或改变接入方式的成员 {name} 须提供独立的新凭证")
|
|
211
245
|
member = mark_manual(supplied[name], automatic)
|
|
212
246
|
hashed = token_hash(member["token"])
|
|
@@ -223,8 +257,10 @@ def sync_participants(connection, room_id):
|
|
|
223
257
|
"""Keep one active AI host, then mirror member capabilities and the host into shared room metadata."""
|
|
224
258
|
connection.execute(RELEASE_HOST, (room_id,))
|
|
225
259
|
connection.execute(ASSIGN_HOST, (room_id, room_id))
|
|
226
|
-
members = connection.execute("SELECT name,access,role,status
|
|
227
|
-
|
|
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}
|
|
228
264
|
host = next((row["name"] for row in members if row["role"] == "host" and row["status"] == "active"), "用户")
|
|
229
265
|
connection.execute("UPDATE topic_settings SET experts=?,host=? WHERE room_id=?",
|
|
230
266
|
(json.dumps(experts, ensure_ascii=False), host, room_id))
|
|
@@ -232,6 +268,12 @@ def sync_participants(connection, room_id):
|
|
|
232
268
|
|
|
233
269
|
def execution_minutes(connection, room_id):
|
|
234
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
|
|
235
277
|
row = connection.execute("SELECT execution_minutes FROM discussion_limits WHERE room_id=?", (room_id,)).fetchone()
|
|
236
278
|
return (row[0] if row and row[0] else FREE_EXECUTION_MINUTES)
|
|
237
279
|
|
|
@@ -241,6 +283,12 @@ def public_execution(row):
|
|
|
241
283
|
return {key: row[key] for key in EXECUTION_FIELDS} if row else None
|
|
242
284
|
|
|
243
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
|
+
|
|
244
292
|
def member_descriptions(connection, room_id):
|
|
245
293
|
"""The owner's descriptions of members by name; everyone who can read the room may read them."""
|
|
246
294
|
return {row["name"]: row["description"] for row in connection.execute(
|
|
@@ -267,15 +315,51 @@ def describe_members(connection, room_id, token, payload):
|
|
|
267
315
|
return {"descriptions": member_descriptions(connection, room_id)}
|
|
268
316
|
|
|
269
317
|
|
|
318
|
+
def member_labels(connection, room_id):
|
|
319
|
+
"""The owner's display labels of members by name; only screens use them, never addressing or credentials."""
|
|
320
|
+
return {row["name"]: row["label"] for row in connection.execute(
|
|
321
|
+
"SELECT name,label FROM member_labels WHERE room_id=? ORDER BY name", (room_id,))}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def label_members(connection, room_id, token, payload):
|
|
325
|
+
"""Owner sets members' display labels by name; an empty text removes that member's label."""
|
|
326
|
+
require_owner(connection, room_id, token)
|
|
327
|
+
labels = payload.get("labels")
|
|
328
|
+
if (not isinstance(labels, dict) or not labels or len(labels) > MAX_ROOM_AGENTS
|
|
329
|
+
or any(not isinstance(name, str) or not 0 < len(name) <= MAX_MEMBER_NAME or not isinstance(text, str)
|
|
330
|
+
or len(text.strip()) > MAX_MEMBER_LABEL for name, text in labels.items())):
|
|
331
|
+
raise ValueError(f"labels 须为 {{成员名: 显示名}},最多 {MAX_ROOM_AGENTS} 位,每个最多 {MAX_MEMBER_LABEL} 字")
|
|
332
|
+
begin_write(connection)
|
|
333
|
+
now = int(time.time())
|
|
334
|
+
for name, text in labels.items():
|
|
335
|
+
if text.strip():
|
|
336
|
+
connection.execute("""INSERT INTO member_labels (room_id,name,label,updated_at) VALUES (?,?,?,?)
|
|
337
|
+
ON CONFLICT(room_id,name) DO UPDATE SET label=excluded.label,updated_at=excluded.updated_at""",
|
|
338
|
+
(room_id, name, text.strip(), now))
|
|
339
|
+
else:
|
|
340
|
+
connection.execute("DELETE FROM member_labels WHERE room_id=? AND name=?", (room_id, name))
|
|
341
|
+
return {"labels": member_labels(connection, room_id)}
|
|
342
|
+
|
|
270
343
|
def note_count(connection, execution_id):
|
|
271
344
|
"""How many progress notes an execution has; the texts are read separately so room views stay small."""
|
|
272
345
|
return connection.execute("SELECT COUNT(*) FROM execution_notes WHERE execution_id=?", (execution_id,)).fetchone()[0]
|
|
273
346
|
|
|
274
347
|
|
|
275
|
-
def execution_notes(connection, room_id, execution_id):
|
|
348
|
+
def execution_notes(connection, room_id, execution_id, token=""):
|
|
276
349
|
"""Read one execution's progress notes in order, with the same access as the room view."""
|
|
277
|
-
|
|
350
|
+
execution = connection.execute("SELECT member_id FROM member_executions WHERE room_id=? AND id=?", (room_id, execution_id)).fetchone()
|
|
351
|
+
if not execution:
|
|
278
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": []}
|
|
279
363
|
return {"notes": [dict(row) for row in connection.execute(
|
|
280
364
|
"SELECT seq,text,created_at FROM execution_notes WHERE execution_id=? ORDER BY seq", (execution_id,))]}
|
|
281
365
|
|
|
@@ -308,15 +392,42 @@ def execution_timeline(connection, room_id, after=0):
|
|
|
308
392
|
|
|
309
393
|
def recent_executions(connection, room_id):
|
|
310
394
|
"""Return bounded public reports, including requests no longer in the recent request list."""
|
|
311
|
-
rows = connection.execute("""SELECT e.*,
|
|
395
|
+
rows = connection.execute("""SELECT e.*,m.name AS member,
|
|
312
396
|
(SELECT json_group_array(id) FROM member_requests WHERE execution_id=e.id) AS request_ids,
|
|
313
397
|
(SELECT MAX(answer_id) FROM member_requests WHERE execution_id=e.id) AS answer_id,
|
|
314
398
|
(SELECT MIN(message_id) FROM member_requests WHERE execution_id=e.id) AS request_message_id,
|
|
315
|
-
(SELECT COUNT(*) FROM execution_notes WHERE execution_id=e.id) AS note_count
|
|
316
|
-
FROM
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
399
|
+
(SELECT COUNT(*) FROM execution_notes WHERE execution_id=e.id) AS note_count,
|
|
400
|
+
(SELECT denials FROM execution_denials WHERE execution_id=e.id) AS denials
|
|
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}
|
|
320
431
|
|
|
321
432
|
|
|
322
433
|
def possible_conflicts(connection, room_id, names):
|
|
@@ -347,7 +458,8 @@ def possible_conflicts(connection, room_id, names):
|
|
|
347
458
|
|
|
348
459
|
|
|
349
460
|
# An invited manual member that has not registered counts as absent: it receives no request and is never a target.
|
|
350
|
-
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
|
|
351
463
|
WHERE r.member_id=room_members.id AND r.token_hash=room_members.token_hash))"""
|
|
352
464
|
|
|
353
465
|
|
|
@@ -357,6 +469,8 @@ def free_view(connection, room_id):
|
|
|
357
469
|
members = []
|
|
358
470
|
for row in connection.execute("SELECT * FROM room_members WHERE room_id=? AND status<>'removed' ORDER BY id", (room_id,)):
|
|
359
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 ""
|
|
360
474
|
member["registered_at"] = registered_at(connection, row)
|
|
361
475
|
reported = connection.execute("SELECT session_id,client FROM member_sessions WHERE member_id=?", (row["id"],)).fetchone()
|
|
362
476
|
member["reported_session"], member["reported_client"] = (reported["session_id"], reported["client"]) if reported else ("", "")
|
|
@@ -366,11 +480,13 @@ def free_view(connection, room_id):
|
|
|
366
480
|
if latest and latest["status"] in ACTIVE:
|
|
367
481
|
member["execution"]["stale"] = latest["lease_until"] <= now
|
|
368
482
|
member["execution"]["notes"] = note_count(connection, latest["id"])
|
|
483
|
+
member["execution"]["retry"] = execution_retry(connection, latest["id"])
|
|
369
484
|
member["pending"] = connection.execute("SELECT COUNT(*) FROM member_requests WHERE member_id=? AND status='pending'",
|
|
370
485
|
(row["id"],)).fetchone()[0]
|
|
371
486
|
members.append(member)
|
|
372
487
|
names = {row["id"]: row["name"] for row in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (room_id,))}
|
|
373
|
-
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(
|
|
374
490
|
"""SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by,created_at,
|
|
375
491
|
(SELECT reason FROM request_cancel_reasons c WHERE c.request_id=member_requests.id) AS cancel_reason FROM member_requests
|
|
376
492
|
WHERE room_id=? ORDER BY id DESC LIMIT ?""", (room_id, RECENT_REQUESTS))]
|
|
@@ -390,7 +506,9 @@ def require_member(connection, room_id, token, name=None):
|
|
|
390
506
|
(room_id, hashed)).fetchone() if hashed else None
|
|
391
507
|
if not row or (name is not None and row["name"] != name):
|
|
392
508
|
raise PermissionError("成员凭证无效、已停用或与发言身份不符")
|
|
393
|
-
|
|
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)
|
|
394
512
|
|
|
395
513
|
|
|
396
514
|
def registered_at(connection, member):
|
|
@@ -436,9 +554,14 @@ def authorize_free_write(connection, room_id, token, operation, payload):
|
|
|
436
554
|
if operation not in ("messages", "attachments"):
|
|
437
555
|
raise
|
|
438
556
|
author = payload.get("author")
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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("成员只能以本人身份发言;只有主持人可以代发在线成员回答")
|
|
442
565
|
|
|
443
566
|
|
|
444
567
|
def active_members(connection, room_id, registered_only=True):
|
|
@@ -493,11 +616,13 @@ def route_held_messages(connection, room_id, host):
|
|
|
493
616
|
|
|
494
617
|
def proxy_member(connection, room_id, author):
|
|
495
618
|
"""Return the active online member the owner may forward an answer for."""
|
|
496
|
-
row = connection.execute("SELECT
|
|
497
|
-
|
|
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()
|
|
498
623
|
if not row:
|
|
499
624
|
raise PermissionError("房间控制凭据只能以用户身份发言,或代发在线成员的回答")
|
|
500
|
-
return row
|
|
625
|
+
return dict(row)
|
|
501
626
|
|
|
502
627
|
|
|
503
628
|
def check_answer(connection, room_id, member, payload):
|
|
@@ -516,11 +641,14 @@ def check_answer(connection, room_id, member, payload):
|
|
|
516
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):
|
|
517
642
|
raise ValueError("手动接入成员用 request_ids 关联请求,不使用执行编号")
|
|
518
643
|
purposes = []
|
|
644
|
+
requests = []
|
|
519
645
|
for request_id in request_ids:
|
|
520
646
|
row = connection.execute("SELECT * FROM member_requests WHERE room_id=? AND id=?", (room_id, request_id)).fetchone()
|
|
521
647
|
if not row or row["member_id"] != member["id"] or row["status"] != "pending":
|
|
522
648
|
raise FileExistsError(f"请求 {request_id} 不属于本成员或已处理")
|
|
523
649
|
purposes.append(row["purpose"])
|
|
650
|
+
requests.append(row)
|
|
651
|
+
validate_session_request_answer(connection, room_id, requests)
|
|
524
652
|
check_dispatch(member, payload, purposes)
|
|
525
653
|
return None
|
|
526
654
|
execution_id = payload.get("execution_id")
|
|
@@ -531,8 +659,10 @@ def check_answer(connection, room_id, member, payload):
|
|
|
531
659
|
raise PermissionError("执行编号不属于本成员")
|
|
532
660
|
if row["status"] not in ACTIVE or row["lease_until"] <= int(time.time()):
|
|
533
661
|
raise FileExistsError("本次执行已结束、被打断或已过期;回答不能改作普通发言,请保留草稿")
|
|
534
|
-
|
|
535
|
-
"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])
|
|
536
666
|
return row
|
|
537
667
|
|
|
538
668
|
|
|
@@ -542,15 +672,33 @@ def check_dispatch(member, payload, purposes):
|
|
|
542
672
|
raise ValueError("这次是请全体成员各自回应,其他成员已收到请求;主持人只回答自己的部分,不能用 --to 派活")
|
|
543
673
|
|
|
544
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
|
+
|
|
545
690
|
def check_proxy(connection, room_id, member, payload):
|
|
546
691
|
"""The owner forwards an online member's answer and may close that member's open requests."""
|
|
547
692
|
request_ids = payload.get("request_ids", [])
|
|
548
693
|
if not isinstance(request_ids, list) or any(type(item) is not int for item in request_ids):
|
|
549
694
|
raise ValueError("request_ids 须为请求编号列表")
|
|
695
|
+
requests = []
|
|
550
696
|
for request_id in request_ids:
|
|
551
697
|
row = connection.execute("SELECT * FROM member_requests WHERE room_id=? AND id=?", (room_id, request_id)).fetchone()
|
|
552
698
|
if not row or row["member_id"] != member["id"] or row["status"] != "pending":
|
|
553
699
|
raise FileExistsError(f"请求 {request_id} 不属于该在线成员或已处理")
|
|
700
|
+
requests.append(row)
|
|
701
|
+
validate_session_request_answer(connection, room_id, requests)
|
|
554
702
|
|
|
555
703
|
|
|
556
704
|
def insert_request(connection, room_id, member_id, message_id, chain_id, purpose, created_by, batch_id=0):
|
|
@@ -779,16 +927,47 @@ def speaking_order(connection, room):
|
|
|
779
927
|
by_id = {member["id"]: member for member in active}
|
|
780
928
|
ordered = [member["name"] for member in default_order(connection, room["id"], active)]
|
|
781
929
|
items, kind, target, later = [], "default", 0, []
|
|
930
|
+
session_plan_state = []
|
|
782
931
|
if room["mode"] == "free":
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
if
|
|
786
|
-
kind = "
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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})
|
|
792
971
|
else:
|
|
793
972
|
kind = "turns"
|
|
794
973
|
last = room["rounds"][-1] if room["rounds"] else None
|
|
@@ -805,7 +984,7 @@ def speaking_order(connection, room):
|
|
|
805
984
|
listed = {item["name"] for item in items}
|
|
806
985
|
items += [{"name": name, "state": "idle"} for name in dict.fromkeys(ordered) if name not in listed]
|
|
807
986
|
movable = "queued" if kind != "default" else "idle"
|
|
808
|
-
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)
|
|
809
988
|
return {"kind": kind, "message_id": target, "items": items, "later": later, "movable": [item["name"] for item in items if item["state"] == movable],
|
|
810
989
|
"default": [member["name"] for member in default_order(connection, room["id"], active)],
|
|
811
990
|
"revision": hashlib.sha256(state.encode("utf-8")).hexdigest()[:12]}
|
|
@@ -848,7 +1027,24 @@ def reorder_speakers(connection, room, token, payload):
|
|
|
848
1027
|
for name in order:
|
|
849
1028
|
connection.execute("INSERT INTO free_queue (room_id,message_id,member_id) VALUES (?,?,?)", (room["id"], current["message_id"], names[name]))
|
|
850
1029
|
elif current["kind"] == "turns":
|
|
851
|
-
|
|
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)
|
|
852
1048
|
else:
|
|
853
1049
|
connection.execute("INSERT OR REPLACE INTO speaking_orders (room_id,member_ids) VALUES (?,?)",
|
|
854
1050
|
(room["id"], json.dumps([names[name] for name in order])))
|
|
@@ -887,6 +1083,15 @@ def after_free_message(connection, room_id, message_id, actor, payload):
|
|
|
887
1083
|
"""Create requests for the owner's message, or close what the member's answer covers."""
|
|
888
1084
|
now = int(time.time())
|
|
889
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
|
|
890
1095
|
pairs = recipients(connection, room_id, payload)
|
|
891
1096
|
if payload.get("sequential") is True and payload.get("to") is not None:
|
|
892
1097
|
queue_in_turn(connection, room_id, message_id, [member_id for member_id, _ in pairs])
|
|
@@ -894,7 +1099,7 @@ def after_free_message(connection, room_id, message_id, actor, payload):
|
|
|
894
1099
|
for member_id, purpose in pairs:
|
|
895
1100
|
insert_request(connection, room_id, member_id, message_id, message_id, purpose, "user")
|
|
896
1101
|
return
|
|
897
|
-
if actor
|
|
1102
|
+
if actor.get("manual_source") == "web":
|
|
898
1103
|
for request_id in payload.get("request_ids", []):
|
|
899
1104
|
connection.execute("UPDATE member_requests SET status='completed',answer_id=? WHERE id=?", (message_id, request_id))
|
|
900
1105
|
settle_batches(connection, room_id)
|
|
@@ -971,10 +1176,17 @@ def finish_execution(connection, execution_id, status, result, request_status, c
|
|
|
971
1176
|
"""End one execution and move its delivered requests in the same transaction."""
|
|
972
1177
|
if request_status == "cancelled" and cancel_reason:
|
|
973
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()
|
|
974
1182
|
connection.execute("UPDATE member_executions SET status=?,finished_at=?,result=? WHERE id=? AND status IN ('dispatched','started')",
|
|
975
1183
|
(status, int(time.time()), result[:500], execution_id))
|
|
976
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'",
|
|
977
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)
|
|
978
1190
|
|
|
979
1191
|
|
|
980
1192
|
def open_route_messages(connection, member_id, pending_only=False):
|
|
@@ -1001,6 +1213,26 @@ def stop_member_work(connection, member_id, reason, cancel_reason):
|
|
|
1001
1213
|
finish_execution(connection, row["id"], "cancelled", reason, "cancelled", cancel_reason)
|
|
1002
1214
|
record_cancel_reasons(connection, "member_id=? AND status='pending'", (member_id,), cancel_reason)
|
|
1003
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']}"})
|
|
1004
1236
|
|
|
1005
1237
|
|
|
1006
1238
|
def stop_room_work(connection, room_id, reason, cancel_reason):
|
|
@@ -1017,16 +1249,24 @@ def inbox(connection, room_id, token):
|
|
|
1017
1249
|
"""List what the runner should deliver or reconcile; owner credential only."""
|
|
1018
1250
|
require_owner(connection, room_id, token)
|
|
1019
1251
|
begin_write(connection)
|
|
1252
|
+
enforce_session_limits(connection, room_id)
|
|
1020
1253
|
expire_executions(connection, room_id)
|
|
1021
1254
|
release_recommendations(connection, room_id)
|
|
1022
1255
|
archived = connection.execute("SELECT archived FROM room_directory WHERE room_id=?", (room_id,)).fetchone()[0]
|
|
1023
1256
|
members, observers = [], []
|
|
1024
1257
|
# Manual and online members are never delivered to; the runner only reads them so smart scheduling can route to them too.
|
|
1025
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,)):
|
|
1026
|
-
pending = [dict(item) for item in connection.execute(
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
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(
|
|
1030
1270
|
"""SELECT member_executions.*,room_members.name FROM member_executions JOIN room_members ON room_members.id=member_id
|
|
1031
1271
|
WHERE member_executions.room_id=? AND member_executions.status IN ('dispatched','started') ORDER BY member_executions.id""", (room_id,))]
|
|
1032
1272
|
return {"room_id": room_id, "archived": bool(archived), "members": members, "observers": observers, "executions": executions,
|
|
@@ -1155,6 +1395,8 @@ def reserve_execution(connection, room_id, token, payload):
|
|
|
1155
1395
|
"""Bundle a member's open requests into one execution; a retried client_id returns the same one."""
|
|
1156
1396
|
require_owner(connection, room_id, token)
|
|
1157
1397
|
begin_write(connection)
|
|
1398
|
+
if enforce_session_limits(connection, room_id):
|
|
1399
|
+
connection.commit()
|
|
1158
1400
|
client_id = str(payload.get("client_id", ""))
|
|
1159
1401
|
if not 8 <= len(client_id) <= 100:
|
|
1160
1402
|
raise ValueError("client_id 须为 8–100 字符的本地投递编号")
|
|
@@ -1171,26 +1413,37 @@ def reserve_execution(connection, room_id, token, payload):
|
|
|
1171
1413
|
if connection.execute("SELECT 1 FROM member_executions WHERE member_id=? AND status IN ('dispatched','started')",
|
|
1172
1414
|
(member["id"],)).fetchone():
|
|
1173
1415
|
raise FileExistsError("该成员已有进行中的执行;同一会话不并发续接")
|
|
1174
|
-
if connection.execute("SELECT 1 FROM discussion_events WHERE room_id=? AND participant=? AND status IN ('dispatched','started')",
|
|
1175
|
-
(room_id, member["name"])).fetchone():
|
|
1176
|
-
raise FileExistsError("该成员正在处理讨论安排;同一会话不并发续接")
|
|
1177
1416
|
# A group request waits for the runner to choose how everyone answers; it is never delivered itself.
|
|
1178
|
-
|
|
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:
|
|
1179
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 = ()
|
|
1180
1428
|
now = int(time.time())
|
|
1181
1429
|
cursor = connection.execute("""INSERT INTO member_executions (room_id,member_id,client_id,lease_until,created_at,cwd)
|
|
1182
1430
|
VALUES (?,?,?,?,?,?)""", (room_id, member["id"], client_id, now + LEASE_SECONDS, now, str(payload.get("cwd") or "")[:MAX_PATH_LENGTH]))
|
|
1183
|
-
connection.execute("UPDATE member_requests SET status='delivering',execution_id=? WHERE member_id=? AND status='pending' AND
|
|
1184
|
-
(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))
|
|
1185
1433
|
return execution_detail(connection, connection.execute("SELECT * FROM member_executions WHERE id=?", (cursor.lastrowid,)).fetchone())
|
|
1186
1434
|
|
|
1187
1435
|
|
|
1188
1436
|
def execution_detail(connection, row):
|
|
1189
1437
|
"""Describe one execution with the requests it carries and the member's read cursor."""
|
|
1190
1438
|
member = connection.execute("SELECT * FROM room_members WHERE id=?", (row["member_id"],)).fetchone()
|
|
1191
|
-
requests = [dict(item) for item in connection.execute(
|
|
1192
|
-
|
|
1193
|
-
|
|
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"],))]
|
|
1445
|
+
return {**public_execution(row), "member": member["name"], "read_until": member["read_until"], "requests": requests,
|
|
1446
|
+
"rewound_executions": member_rewinds(connection, member["id"]), "retry": execution_retry(connection, row["id"])}
|
|
1194
1447
|
|
|
1195
1448
|
|
|
1196
1449
|
def view_execution(connection, room_id, execution_id, token):
|
|
@@ -1216,6 +1469,17 @@ def changed_files(payload):
|
|
|
1216
1469
|
return sorted(set(files))
|
|
1217
1470
|
|
|
1218
1471
|
|
|
1472
|
+
def denied_actions(payload):
|
|
1473
|
+
"""Validate the operations a member's software refused: the tool, what it tried, and the rule that would allow it."""
|
|
1474
|
+
denials = payload.get("denials")
|
|
1475
|
+
def valid(item):
|
|
1476
|
+
return (isinstance(item, dict) and isinstance(item.get("tool"), str) and 0 < len(item["tool"]) <= 40
|
|
1477
|
+
and all(isinstance(item.get(key, ""), str) and len(item.get(key, "")) <= MAX_DENIAL_FIELD for key in ("detail", "rule")))
|
|
1478
|
+
if not isinstance(denials, list) or not 0 < len(denials) <= MAX_DENIALS or not all(map(valid, denials)):
|
|
1479
|
+
raise ValueError(f"denials 须为 1–{MAX_DENIALS} 条 {{tool, detail, rule}}")
|
|
1480
|
+
return [{key: item.get(key, "") for key in ("tool", "detail", "rule")} for item in denials]
|
|
1481
|
+
|
|
1482
|
+
|
|
1219
1483
|
def renewed_activity(payload, row, now):
|
|
1220
1484
|
"""Validate the runner's reported phase; its output time is kept within the execution, since the runner's clock may differ."""
|
|
1221
1485
|
activity, activity_at = payload.get("activity"), payload.get("activity_at")
|
|
@@ -1238,18 +1502,35 @@ def renewed_notes(payload):
|
|
|
1238
1502
|
|
|
1239
1503
|
def execution_action(connection, room_id, execution_id, token, payload):
|
|
1240
1504
|
"""Runner-side lifecycle: renew, fail, release undelivered work, or report observed file changes."""
|
|
1241
|
-
require_owner(connection, room_id, token)
|
|
1242
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()
|
|
1243
1517
|
row = connection.execute("SELECT * FROM member_executions WHERE room_id=? AND id=?", (room_id, execution_id)).fetchone()
|
|
1244
1518
|
if not row:
|
|
1245
1519
|
raise LookupError("执行不存在")
|
|
1246
|
-
|
|
1520
|
+
if member and member["id"] != row["member_id"]:
|
|
1521
|
+
raise PermissionError("只能报告自己执行的失败")
|
|
1247
1522
|
now = int(time.time())
|
|
1248
1523
|
if action == "report":
|
|
1249
1524
|
# Observed after the process ends, so the execution may already be complete; recorded once.
|
|
1250
1525
|
if row["changes"]:
|
|
1251
1526
|
raise FileExistsError("本次执行的改动已记录")
|
|
1252
1527
|
connection.execute("UPDATE member_executions SET changes=? WHERE id=?", (json.dumps(changed_files(payload), ensure_ascii=False), execution_id))
|
|
1528
|
+
elif action == "denials":
|
|
1529
|
+
# Like file changes, read from the software's output once its process ends; recorded once.
|
|
1530
|
+
saved = connection.execute("INSERT OR IGNORE INTO execution_denials (execution_id,room_id,denials) VALUES (?,?,?)",
|
|
1531
|
+
(execution_id, room_id, json.dumps(denied_actions(payload), ensure_ascii=False)))
|
|
1532
|
+
if not saved.rowcount:
|
|
1533
|
+
raise FileExistsError("本次执行的拒绝记录已记录")
|
|
1253
1534
|
elif action == "segments":
|
|
1254
1535
|
# The last segments and notes arrive after the member's answer ended the execution; such notes are dated at its end.
|
|
1255
1536
|
finished = 0 if row["status"] in ACTIVE else (row["finished_at"] or 1)
|
|
@@ -1270,6 +1551,24 @@ def execution_action(connection, room_id, execution_id, token, payload):
|
|
|
1270
1551
|
(min(now + LEASE_SECONDS, limit), activity, activity_at, execution_id))
|
|
1271
1552
|
connection.executemany("INSERT OR IGNORE INTO execution_notes (execution_id,seq,room_id,text,created_at) VALUES (?,?,?,?,?)",
|
|
1272
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("本次执行没有在等待自动重试")
|
|
1273
1572
|
elif action == "fail":
|
|
1274
1573
|
finish_execution(connection, execution_id, "failed", str(payload.get("reason") or "执行失败"), "failed")
|
|
1275
1574
|
settle_batches(connection, room_id)
|
|
@@ -1277,7 +1576,7 @@ def execution_action(connection, room_id, execution_id, token, payload):
|
|
|
1277
1576
|
# Only for proven non-delivery: the requests return to the queue unchanged.
|
|
1278
1577
|
finish_execution(connection, execution_id, "cancelled", str(payload.get("reason") or "未送达"), "pending")
|
|
1279
1578
|
else:
|
|
1280
|
-
raise ValueError("action 须为 renew、fail、release、report 或
|
|
1579
|
+
raise ValueError("action 须为 renew、fail、release、report、segments、retry_wait、retry_start、retry_now 或 retry_stop")
|
|
1281
1580
|
return execution_detail(connection, connection.execute("SELECT * FROM member_executions WHERE id=?", (execution_id,)).fetchone())
|
|
1282
1581
|
|
|
1283
1582
|
|
|
@@ -1299,6 +1598,137 @@ def interrupt_member(connection, room_id, member_id, token):
|
|
|
1299
1598
|
return free_view(connection, room_id)
|
|
1300
1599
|
|
|
1301
1600
|
|
|
1601
|
+
def withdrawals(connection, room_id):
|
|
1602
|
+
"""The room's taken-back messages by id, so the page keeps them in place while AI readers skip them."""
|
|
1603
|
+
return [dict(row) for row in connection.execute("""SELECT w.message_id,w.kind FROM message_withdrawals w
|
|
1604
|
+
JOIN messages m ON m.id=w.message_id WHERE m.room_id=? ORDER BY w.message_id""", (room_id,))]
|
|
1605
|
+
|
|
1606
|
+
|
|
1607
|
+
def owner_message(connection, room_id, message_id):
|
|
1608
|
+
"""One visible message of the user in a free room without an open discussion, which a retract or rewind may take back."""
|
|
1609
|
+
row = connection.execute("SELECT * FROM messages WHERE room_id=? AND id=?", (room_id, message_id)).fetchone()
|
|
1610
|
+
if not row:
|
|
1611
|
+
raise LookupError("消息不存在")
|
|
1612
|
+
if row["author"] != "用户":
|
|
1613
|
+
raise ValueError("只能撤回或编辑用户自己的消息")
|
|
1614
|
+
if room_mode(connection, room_id) != "free":
|
|
1615
|
+
raise ValueError("只有协作房间的消息可以撤回或编辑")
|
|
1616
|
+
if connection.execute("SELECT 1 FROM room_directory WHERE room_id=? AND archived=1", (room_id,)).fetchone():
|
|
1617
|
+
raise ValueError("房间已存档;恢复后才能撤回或编辑")
|
|
1618
|
+
# A discussion's host reads the user's messages itself, with no request that shows whether it began.
|
|
1619
|
+
if connection.execute("SELECT 1 FROM discussion_sessions WHERE room_id=? AND (status IN ('active','paused') OR started_message_id>=?)",
|
|
1620
|
+
(room_id, message_id)).fetchone():
|
|
1621
|
+
raise FileExistsError("讨论进行中,或这条消息之后开启过讨论,不能撤回或编辑")
|
|
1622
|
+
return row
|
|
1623
|
+
|
|
1624
|
+
|
|
1625
|
+
def retract_message(connection, room_id, message_id, token):
|
|
1626
|
+
"""Owner takes back a message no member began to handle: its queued requests end and AI readers no longer see it.
|
|
1627
|
+
|
|
1628
|
+
Bound to one known message, so it takes no revision; a message any member started, answered or failed is refused.
|
|
1629
|
+
A member working on an earlier message may still have read it, which the room cannot know.
|
|
1630
|
+
|
|
1631
|
+
Returns:
|
|
1632
|
+
`{"message_id", "text", "duplicate"}`; the text lets the page put it back in the input for editing.
|
|
1633
|
+
"""
|
|
1634
|
+
require_owner(connection, room_id, token)
|
|
1635
|
+
begin_write(connection)
|
|
1636
|
+
message = owner_message(connection, room_id, message_id)
|
|
1637
|
+
if connection.execute("SELECT 1 FROM message_withdrawals WHERE message_id=?", (message_id,)).fetchone():
|
|
1638
|
+
return {"message_id": message_id, "text": message["text"], "duplicate": True}
|
|
1639
|
+
if connection.execute("SELECT 1 FROM member_requests WHERE message_id=? AND status IN ('delivering','completed','failed')", (message_id,)).fetchone():
|
|
1640
|
+
raise FileExistsError("已有成员开始处理这条消息,不能撤回;可以编辑这条消息回退到它之前")
|
|
1641
|
+
connection.execute("UPDATE member_requests SET status='cancelled' WHERE message_id=? AND status='pending'", (message_id,))
|
|
1642
|
+
connection.execute("DELETE FROM free_queue WHERE message_id=?", (message_id,))
|
|
1643
|
+
connection.execute("INSERT INTO message_withdrawals (message_id,kind,created_at) VALUES (?,'retracted',?)", (message_id, int(time.time())))
|
|
1644
|
+
settle_batches(connection, room_id)
|
|
1645
|
+
return {"message_id": message_id, "text": message["text"], "duplicate": False}
|
|
1646
|
+
|
|
1647
|
+
|
|
1648
|
+
# Software whose session the runner can cut before a taken-back execution; an API member rebuilds its context each time.
|
|
1649
|
+
CUTTABLE_ADAPTERS = ("claude", "codex", "codex_cli", "antigravity_cli")
|
|
1650
|
+
# Executions a rewind takes back: any touching a message from the rewind point on, and any still running.
|
|
1651
|
+
REWOUND_EXECUTIONS = """SELECT e.id,e.member_id FROM member_executions e WHERE e.room_id=? AND (e.status IN ('dispatched','started')
|
|
1652
|
+
OR EXISTS(SELECT 1 FROM member_requests r WHERE r.execution_id=e.id AND (r.message_id>=? OR r.answer_id>=?))) ORDER BY e.id"""
|
|
1653
|
+
|
|
1654
|
+
|
|
1655
|
+
def rewind_plan(connection, room_id, message_id):
|
|
1656
|
+
"""What rewinding to just before a message takes back, and a revision of the state it was read from.
|
|
1657
|
+
|
|
1658
|
+
Each member reports what happens to its session: `cut` (the runner cuts it before the first taken-back execution),
|
|
1659
|
+
`fresh` (an API member, which rebuilds its context), `unchanged` (no taken-back execution) or `kept` (manual, online or
|
|
1660
|
+
App software whose own window keeps what it saw).
|
|
1661
|
+
"""
|
|
1662
|
+
owner_message(connection, room_id, message_id)
|
|
1663
|
+
if connection.execute("SELECT 1 FROM message_withdrawals WHERE message_id=?", (message_id,)).fetchone():
|
|
1664
|
+
raise FileExistsError("这条消息已被撤回或回退")
|
|
1665
|
+
executions = {}
|
|
1666
|
+
for row in connection.execute(REWOUND_EXECUTIONS, (room_id, message_id, message_id)):
|
|
1667
|
+
executions.setdefault(row["member_id"], []).append(row["id"])
|
|
1668
|
+
members = []
|
|
1669
|
+
for row in connection.execute("SELECT id,name,access,adapter FROM room_members WHERE room_id=? AND status<>'removed' ORDER BY id", (room_id,)):
|
|
1670
|
+
taken = executions.get(row["id"], [])
|
|
1671
|
+
session = ("kept" if row["access"] != "runner" or row["adapter"] not in (*CUTTABLE_ADAPTERS, "api")
|
|
1672
|
+
else "unchanged" if not taken else "fresh" if row["adapter"] == "api" else "cut")
|
|
1673
|
+
members.append({"id": row["id"], "name": row["name"], "executions": taken, "session": session})
|
|
1674
|
+
count = connection.execute("""SELECT COUNT(*) FROM messages m WHERE m.room_id=? AND m.id>=?
|
|
1675
|
+
AND NOT EXISTS(SELECT 1 FROM message_withdrawals w WHERE w.message_id=m.id)""", (room_id, message_id)).fetchone()[0]
|
|
1676
|
+
latest = connection.execute("SELECT COALESCE(MAX(id),0) FROM messages WHERE room_id=?", (room_id,)).fetchone()[0]
|
|
1677
|
+
state = json.dumps([message_id, latest, count, members])
|
|
1678
|
+
return {"message_id": message_id, "messages": count, "members": members,
|
|
1679
|
+
"revision": hashlib.sha256(state.encode("utf-8")).hexdigest()[:12]}
|
|
1680
|
+
|
|
1681
|
+
|
|
1682
|
+
def rewind_room(connection, room_id, token, payload):
|
|
1683
|
+
"""Owner returns the room to just before one of its messages, as editing that message does.
|
|
1684
|
+
|
|
1685
|
+
Read the plan first (`GET rewind?message_id=`) and send its revision: a missing revision is refused and a changed
|
|
1686
|
+
one conflicts. The message and everything after it stay on the page but leave AI reads; running work stops, open
|
|
1687
|
+
requests on them end, members read again from before the message, and each runner member's session is cut before
|
|
1688
|
+
its first taken-back execution when it next works. Files the members changed are not restored.
|
|
1689
|
+
"""
|
|
1690
|
+
require_owner(connection, room_id, token)
|
|
1691
|
+
begin_write(connection)
|
|
1692
|
+
message_id = payload.get("message_id")
|
|
1693
|
+
if type(message_id) is not int:
|
|
1694
|
+
raise ValueError("须提供要回退到其之前的消息编号 message_id")
|
|
1695
|
+
if payload.get("revision") is None:
|
|
1696
|
+
raise ValueError("回退前须先读取回退计划(GET rewind?message_id=),并带上其 revision")
|
|
1697
|
+
plan = rewind_plan(connection, room_id, message_id)
|
|
1698
|
+
if payload["revision"] != plan["revision"]:
|
|
1699
|
+
raise FileExistsError("房间已有新的消息或执行,请重新读取回退计划后再确认")
|
|
1700
|
+
now = int(time.time())
|
|
1701
|
+
for row in connection.execute("SELECT id FROM member_executions WHERE room_id=? AND status IN ('dispatched','started')", (room_id,)).fetchall():
|
|
1702
|
+
finish_execution(connection, row["id"], "cancelled", "用户回退了对话", "cancelled", "user_stop")
|
|
1703
|
+
record_cancel_reasons(connection, "room_id=? AND message_id>=? AND status='pending'", (room_id, message_id), "user_stop")
|
|
1704
|
+
connection.execute("UPDATE member_requests SET status='cancelled' WHERE room_id=? AND message_id>=? AND status='pending'", (room_id, message_id))
|
|
1705
|
+
connection.execute("DELETE FROM free_queue WHERE room_id=? AND message_id>=?", (room_id, message_id))
|
|
1706
|
+
# Batches of taken-back dispatches never hand back, and a dispatch the round limit held from them is never sent.
|
|
1707
|
+
connection.execute("UPDATE free_batches SET settled=1 WHERE room_id=? AND batch_id>=?", (room_id, message_id))
|
|
1708
|
+
connection.execute("UPDATE free_chain_holds SET message_id=0,host_id=0,names='[]',handoff=0 WHERE room_id=? AND message_id>=?", (room_id, message_id))
|
|
1709
|
+
connection.execute("""INSERT INTO message_withdrawals (message_id,kind,created_at) SELECT id,'rewound',? FROM messages m
|
|
1710
|
+
WHERE m.room_id=? AND m.id>=? AND NOT EXISTS(SELECT 1 FROM message_withdrawals w WHERE w.message_id=m.id)""", (now, room_id, message_id))
|
|
1711
|
+
connection.execute("UPDATE room_members SET read_until=MIN(read_until,?) WHERE room_id=?", (message_id - 1, room_id))
|
|
1712
|
+
cuts = {str(member["id"]): member["executions"] for member in plan["members"] if member["session"] == "cut"}
|
|
1713
|
+
cursor = connection.execute("INSERT INTO room_rewinds (room_id,message_id,cuts,created_at) VALUES (?,?,?,?)",
|
|
1714
|
+
(room_id, message_id, json.dumps(cuts), now))
|
|
1715
|
+
settle_batches(connection, room_id)
|
|
1716
|
+
return {"rewind_id": cursor.lastrowid, **plan}
|
|
1717
|
+
|
|
1718
|
+
|
|
1719
|
+
# The runner checks this many of a member's latest rewinds before each execution.
|
|
1720
|
+
RECENT_REWINDS = 50
|
|
1721
|
+
|
|
1722
|
+
|
|
1723
|
+
def member_rewinds(connection, member_id):
|
|
1724
|
+
"""Executions taken back from one member by recent rewinds, oldest first; the runner cuts before the earliest it finds."""
|
|
1725
|
+
taken = set()
|
|
1726
|
+
for row in connection.execute("SELECT cuts FROM room_rewinds WHERE room_id=(SELECT room_id FROM room_members WHERE id=?) ORDER BY id DESC LIMIT ?",
|
|
1727
|
+
(member_id, RECENT_REWINDS)):
|
|
1728
|
+
taken.update(json.loads(row["cuts"]).get(str(member_id), []))
|
|
1729
|
+
return sorted(taken)
|
|
1730
|
+
|
|
1731
|
+
|
|
1302
1732
|
def add_member(connection, room_id, token, payload, now):
|
|
1303
1733
|
"""Owner adds a member while the room keeps running; other members are not interrupted."""
|
|
1304
1734
|
require_owner(connection, room_id, token)
|
|
@@ -1312,6 +1742,7 @@ def add_member(connection, room_id, token, payload, now):
|
|
|
1312
1742
|
raise FileExistsError(f"{member['name']} 已是本房间成员;离开的成员请用请回")
|
|
1313
1743
|
require_fresh_member_token(connection, room_id, member["token"])
|
|
1314
1744
|
insert_member(connection, room_id, member, now)
|
|
1745
|
+
queue_invited_expert(connection, room_id, member["name"], now)
|
|
1315
1746
|
sync_participants(connection, room_id)
|
|
1316
1747
|
return free_view(connection, room_id)
|
|
1317
1748
|
|
|
@@ -1335,6 +1766,32 @@ def queue_invited_expert(connection, room_id, name, now):
|
|
|
1335
1766
|
member = connection.execute("SELECT * FROM room_members WHERE room_id=? AND name=? AND status='active'", (room_id, name)).fetchone()
|
|
1336
1767
|
if not member or (member["adapter"] == "manual" and not registered_at(connection, member)):
|
|
1337
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
|
|
1338
1795
|
connection.execute("""INSERT OR IGNORE INTO discussion_turns (room_id,expert,question,status,submission_id,created_at)
|
|
1339
1796
|
SELECT settings.room_id,member.name,
|
|
1340
1797
|
CASE WHEN settings.phase='initial' THEN '欢迎加入:请先读取议题,独立给出你的开场观点。'
|
|
@@ -1375,7 +1832,7 @@ def discussion_member_action(connection, room_id, member_id, token, payload):
|
|
|
1375
1832
|
"""Owner sends an expert away (keeping its seat), brings it back with a new credential, or removes it.
|
|
1376
1833
|
|
|
1377
1834
|
Leaving cancels the expert's open assignments; the host keeps its role and cannot leave, and an
|
|
1378
|
-
|
|
1835
|
+
leaving cancels its active plan and asks the host to re-plan without pausing the discussion.
|
|
1379
1836
|
"""
|
|
1380
1837
|
member = owned_member(connection, room_id, member_id, token)
|
|
1381
1838
|
host, experts, phase = discussion_roster(connection, room_id)
|
|
@@ -1383,10 +1840,8 @@ def discussion_member_action(connection, room_id, member_id, token, payload):
|
|
|
1383
1840
|
if action in ("away", "remove"):
|
|
1384
1841
|
if member["name"] == host:
|
|
1385
1842
|
raise ValueError("主持人不能离开或移除;请先更换主持人")
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
if speaking and phase != "paused":
|
|
1389
|
-
raise ValueError("该专家正在发言;请先暂停讨论再离开或移除")
|
|
1843
|
+
from .session_room import session_member_change
|
|
1844
|
+
session_member_change(connection, room_id, member, action)
|
|
1390
1845
|
connection.execute("UPDATE discussion_turns SET status='cancelled' WHERE room_id=? AND expert=? AND status IN ('pending','notified')",
|
|
1391
1846
|
(room_id, member["name"]))
|
|
1392
1847
|
connection.execute("UPDATE discussion_turns SET status='skipped' WHERE room_id=? AND expert=? AND status='queued'", (room_id, member["name"]))
|
|
@@ -1399,9 +1854,13 @@ def discussion_member_action(connection, room_id, member_id, token, payload):
|
|
|
1399
1854
|
if member["status"] != "away" or not hashed or hmac.compare_digest(hashed, member["token_hash"]):
|
|
1400
1855
|
raise ValueError("只有离开的成员可以请回,且须提供新的成员凭证")
|
|
1401
1856
|
require_fresh_member_token(connection, room_id, payload.get("token"))
|
|
1402
|
-
connection.execute("UPDATE discussion_turns SET status='skipped' WHERE room_id=? AND status='queued'", (room_id,))
|
|
1403
1857
|
connection.execute("UPDATE room_members SET status='active',token_hash=? WHERE id=?", (hashed, member_id))
|
|
1404
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()))
|
|
1405
1864
|
elif action == "adopt":
|
|
1406
1865
|
adopt_manual_member(connection, room_id, member, payload)
|
|
1407
1866
|
return
|
|
@@ -1410,7 +1869,7 @@ def discussion_member_action(connection, room_id, member_id, token, payload):
|
|
|
1410
1869
|
save_discussion_experts(connection, room_id, experts)
|
|
1411
1870
|
|
|
1412
1871
|
|
|
1413
|
-
ADOPTABLE_ADAPTERS = ("claude", "codex", "codex_cli", "antigravity_cli")
|
|
1872
|
+
ADOPTABLE_ADAPTERS = ("claude", "codex", "codex_cli", "antigravity", "antigravity_cli")
|
|
1414
1873
|
|
|
1415
1874
|
|
|
1416
1875
|
def adopt_manual_member(connection, room_id, member, payload):
|
|
@@ -1447,9 +1906,16 @@ def member_action(connection, room_id, member_id, token, payload):
|
|
|
1447
1906
|
`role` action makes one runner or manual member the AI host, replacing any previous host.
|
|
1448
1907
|
"""
|
|
1449
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")
|
|
1450
1911
|
action = payload.get("action")
|
|
1451
1912
|
routed = []
|
|
1452
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)
|
|
1453
1919
|
# Unnamed messages still waiting on a departing host move to its successor instead of being dropped.
|
|
1454
1920
|
routed = open_route_messages(connection, member_id)
|
|
1455
1921
|
stop_member_work(connection, member_id, "成员已离开" if action == "away" else "成员已移除",
|
|
@@ -1463,15 +1929,22 @@ def member_action(connection, room_id, member_id, token, payload):
|
|
|
1463
1929
|
raise ValueError("只有离开的成员可以请回,且须提供新的成员凭证")
|
|
1464
1930
|
require_fresh_member_token(connection, room_id, payload.get("token"))
|
|
1465
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()))
|
|
1466
1937
|
elif action == "adopt":
|
|
1467
1938
|
adopt_manual_member(connection, room_id, member, payload)
|
|
1468
1939
|
elif action == "role":
|
|
1469
1940
|
role = payload.get("role")
|
|
1470
|
-
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)):
|
|
1471
1942
|
raise ValueError("role 须为 host 或 member;在线成员不能担任主持人")
|
|
1472
1943
|
if role == "member" and member["role"] == "host":
|
|
1473
1944
|
raise ValueError("自由房间须由一位 AI 主持;请指定另一位成员担任主持人")
|
|
1474
1945
|
if role == "host":
|
|
1946
|
+
from .session_room import pause_session_for_host_change
|
|
1947
|
+
pause_session_for_host_change(connection, room_id)
|
|
1475
1948
|
# Unnamed messages the old host has not started move to the new host, which carries the host rules.
|
|
1476
1949
|
old_host = connection.execute("SELECT id FROM room_members WHERE room_id=? AND role='host' AND id<>?", (room_id, member_id)).fetchone()
|
|
1477
1950
|
if old_host:
|