ai_co_discuss 0.2.87 → 0.2.89
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 +37 -131
- package/.agents/skills/discuss-host/references/dev-flow.md +10 -0
- package/.agents/skills/discuss-host/references/discussion.md +107 -0
- package/.agents/skills/discuss-host/references/free.md +22 -0
- package/.agents/skills/discuss-host/references/manual.md +6 -6
- package/.agents/skills/discuss-participant/SKILL.md +21 -53
- package/.agents/skills/discuss-participant/references/discussion.md +41 -0
- package/.agents/skills/discuss-participant/references/free.md +7 -13
- package/.agents/skills/discuss-participant/references/manual.md +1 -1
- package/.agents/skills/discuss-participant/references/writing.md +1 -3
- package/backend/free_room.py +104 -10
- package/backend/runtime.py +45 -0
- package/backend/schema.sql +833 -808
- package/backend/server.py +2355 -2342
- package/docs/en/workflow.md +2 -1
- package/docs/runner.md +2 -2
- package/docs/workflow.md +4 -3
- package/package.json +1 -1
- package/runner/api_participant.mjs +11 -8
- package/runner/codiscuss.mjs +1 -0
- package/runner/discuss.py +4 -0
- package/runner/discussion_adapters.mjs +111 -2
- package/runner/discussion_launch.mjs +1125 -1083
- package/runner/discussion_runner.mjs +18 -4
- package/runner/discussion_service.mjs +81 -4
- package/runner/expert_prompts.mjs +14 -0
- package/runner/free_runner.mjs +469 -463
- package/web/app.js +3692 -3570
- package/web/modern.css +1 -1
- package/web/modern.js +1595 -749
package/backend/free_room.py
CHANGED
|
@@ -17,11 +17,47 @@ import time
|
|
|
17
17
|
import uuid
|
|
18
18
|
|
|
19
19
|
from .identity import MAX_ROOM_AGENTS, is_ai
|
|
20
|
-
from .runtime import LEASE_SECONDS, begin_write, require_owner, token_hash
|
|
20
|
+
from .runtime import LEASE_SECONDS, MAX_EXECUTION_SEGMENTS, begin_write, require_owner, save_segments, token_hash
|
|
21
21
|
|
|
22
22
|
FREE_EXECUTION_MINUTES = 20
|
|
23
23
|
RECENT_REQUESTS = 100
|
|
24
24
|
RECENT_EXECUTIONS = 100
|
|
25
|
+
# The timeline page reads this many recent executions of each kind; with `after` (?3) only those running or ended since.
|
|
26
|
+
# Parameters: ?1 room, ?2 limit, ?3 after (0 reads all). Shared verbatim with the Worker.
|
|
27
|
+
TIMELINE_EXECUTIONS = 100
|
|
28
|
+
TIMELINE_STATES = {"completed": "done", "failed": "failed", "cancelled": "cancelled"}
|
|
29
|
+
TIMELINE_FREE_IDS = "SELECT id FROM member_executions WHERE room_id=?1 AND (?3=0 OR finished_at=0 OR finished_at>=?3) ORDER BY id DESC LIMIT ?2"
|
|
30
|
+
TIMELINE_DISCUSSION_IDS = """SELECT e.id FROM discussion_events e LEFT JOIN event_timings t ON t.event_id=e.id
|
|
31
|
+
WHERE e.room_id=?1 AND (t.dispatched_at>0 OR e.status='pending') AND (?3=0 OR COALESCE(t.finished_at,0)=0 OR t.finished_at>=?3)
|
|
32
|
+
ORDER BY e.id DESC LIMIT ?2"""
|
|
33
|
+
# Besides times: the task an execution took on (its request message, or the discussion turn's question), its result
|
|
34
|
+
# (the answer's summary, else the answer's opening), its latest progress note, how many files it changed (NULL when
|
|
35
|
+
# unobserved) and its last output time (free executions only).
|
|
36
|
+
TIMELINE_SQL = f"""SELECT x.*,(SELECT COALESCE(NULLIF(s.text,''),substr(a.text,1,200)) FROM messages a
|
|
37
|
+
LEFT JOIN message_summaries s ON s.message_id=a.id WHERE a.id=x.answer_id) AS result FROM (
|
|
38
|
+
SELECT 'free' AS source,e.id,m.name AS member,m.role,e.status,
|
|
39
|
+
COALESCE((SELECT MIN(r.created_at) FROM member_requests r WHERE r.execution_id=e.id),e.created_at) AS queued_at,
|
|
40
|
+
e.created_at AS dispatched_at,0 AS started_at,e.finished_at,
|
|
41
|
+
CASE WHEN e.status='failed' THEN e.result ELSE '' END AS reason,
|
|
42
|
+
(SELECT parent.execution_id FROM member_requests r JOIN member_requests parent ON parent.answer_id=r.message_id
|
|
43
|
+
WHERE r.execution_id=e.id AND parent.execution_id IS NOT NULL ORDER BY r.id LIMIT 1) AS parent_id,
|
|
44
|
+
e.activity_at,(SELECT MIN(r.message_id) FROM member_requests r WHERE r.execution_id=e.id) AS task_id,
|
|
45
|
+
(SELECT substr(q.text,1,160) FROM messages q WHERE q.id=(SELECT MIN(r.message_id) FROM member_requests r WHERE r.execution_id=e.id)) AS task,
|
|
46
|
+
(SELECT MAX(r.answer_id) FROM member_requests r WHERE r.execution_id=e.id) AS answer_id,
|
|
47
|
+
(SELECT n.text FROM execution_notes n WHERE n.execution_id=e.id ORDER BY n.seq DESC LIMIT 1) AS last_note,
|
|
48
|
+
CASE WHEN e.changes='' THEN NULL ELSE json_array_length(e.changes) END AS changes
|
|
49
|
+
FROM member_executions e JOIN room_members m ON m.id=e.member_id WHERE e.id IN ({TIMELINE_FREE_IDS})
|
|
50
|
+
UNION ALL SELECT 'discussion',e.id,e.participant,e.role,e.status,CAST(strftime('%s',e.created_at) AS INTEGER),
|
|
51
|
+
COALESCE(t.dispatched_at,0),e.started_at,COALESCE(t.finished_at,0),CASE WHEN e.status='failed' THEN e.result ELSE '' END,NULL,
|
|
52
|
+
0,NULL,(SELECT substr(q.question,1,160) FROM discussion_turns q WHERE q.id=e.turn_id),
|
|
53
|
+
CASE WHEN e.result LIKE 'message:%' THEN CAST(substr(e.result,9) AS INTEGER) END,NULL,NULL
|
|
54
|
+
FROM discussion_events e LEFT JOIN event_timings t ON t.event_id=e.id WHERE e.id IN ({TIMELINE_DISCUSSION_IDS})) x"""
|
|
55
|
+
TIMELINE_SEGMENTS_SQL = f"""SELECT source,execution_id,phase,tool,start_ms,end_ms FROM execution_segments WHERE room_id=?1 AND (
|
|
56
|
+
(source='free' AND execution_id IN ({TIMELINE_FREE_IDS})) OR (source='discussion' AND execution_id IN ({TIMELINE_DISCUSSION_IDS})))
|
|
57
|
+
ORDER BY source,execution_id,seq"""
|
|
58
|
+
# Only the times of progress notes: the texts are read when the reader hovers one.
|
|
59
|
+
TIMELINE_NOTES_SQL = f"""SELECT execution_id,created_at FROM execution_notes
|
|
60
|
+
WHERE room_id=?1 AND execution_id IN ({TIMELINE_FREE_IDS}) ORDER BY execution_id,seq"""
|
|
25
61
|
RECENT_CHANGES = 50
|
|
26
62
|
MAX_CHANGED_FILES = 200
|
|
27
63
|
# What a member's software last showed it doing, as reported by the runner with each renewal.
|
|
@@ -40,8 +76,10 @@ SCHEDULER_PROVIDERS = ("jev", "decisions")
|
|
|
40
76
|
RECOMMEND_SECONDS = 60
|
|
41
77
|
# A click sent just before the hint disappears still counts.
|
|
42
78
|
RECOMMEND_GRACE_SECONDS = 5
|
|
43
|
-
# AI-created requests (host dispatches and hand-offs) allowed per chain; each owner message starts a chain.
|
|
79
|
+
# AI-created requests (host dispatches and hand-offs) allowed per chain by default; each owner message starts a chain.
|
|
44
80
|
MAX_AUTO_HOPS = 8
|
|
81
|
+
# The most an owner may allow per chain.
|
|
82
|
+
MAX_AUTO_HOPS_LIMIT = 50
|
|
45
83
|
# A host that left gives up the role; the first active runner member (else manual) then takes it.
|
|
46
84
|
RELEASE_HOST = "UPDATE room_members SET role='member' WHERE room_id=? AND role='host' AND status<>'active'"
|
|
47
85
|
ASSIGN_HOST = """UPDATE room_members SET role='host' WHERE id=(SELECT id FROM room_members WHERE room_id=? AND status='active'
|
|
@@ -237,6 +275,32 @@ def execution_notes(connection, room_id, execution_id):
|
|
|
237
275
|
"SELECT seq,text,created_at FROM execution_notes WHERE execution_id=? ORDER BY seq", (execution_id,))]}
|
|
238
276
|
|
|
239
277
|
|
|
278
|
+
def execution_timeline(connection, room_id, after=0):
|
|
279
|
+
"""Read the room's recent executions of both kinds with their phase segments and note times, for the timeline page.
|
|
280
|
+
|
|
281
|
+
Times are epoch seconds and segments are millisecond offsets from `dispatched_at`. A free execution's parent is the
|
|
282
|
+
execution that wrote the message it answers; discussion executions have none. `state` is running, done, failed
|
|
283
|
+
or cancelled, so the page never interprets execution statuses itself. A positive `after` reads only executions
|
|
284
|
+
still running or ended since then, so a page following live work rereads little.
|
|
285
|
+
"""
|
|
286
|
+
if not connection.execute("SELECT 1 FROM rooms WHERE id=?", (room_id,)).fetchone():
|
|
287
|
+
raise LookupError("房间不存在")
|
|
288
|
+
params = (room_id, TIMELINE_EXECUTIONS, max(0, int(after)))
|
|
289
|
+
rows = [dict(row) for row in connection.execute(TIMELINE_SQL, params)]
|
|
290
|
+
segments, notes = {}, {}
|
|
291
|
+
for row in connection.execute(TIMELINE_SEGMENTS_SQL, params):
|
|
292
|
+
segments.setdefault((row["source"], row["execution_id"]), []).append(
|
|
293
|
+
{key: row[key] for key in ("phase", "tool", "start_ms", "end_ms")})
|
|
294
|
+
for row in connection.execute(TIMELINE_NOTES_SQL, params):
|
|
295
|
+
notes.setdefault(row["execution_id"], []).append(row["created_at"])
|
|
296
|
+
for row in rows:
|
|
297
|
+
row["state"] = TIMELINE_STATES.get(row["status"], "running")
|
|
298
|
+
row["segments"] = segments.get((row["source"], row["id"]), [])
|
|
299
|
+
row["notes"] = notes.get(row["id"], []) if row["source"] == "free" else []
|
|
300
|
+
return {"now": int(time.time()), "after": params[2], "executions": rows, "execution_limit": TIMELINE_EXECUTIONS,
|
|
301
|
+
"segment_limit": MAX_EXECUTION_SEGMENTS}
|
|
302
|
+
|
|
303
|
+
|
|
240
304
|
def recent_executions(connection, room_id):
|
|
241
305
|
"""Return bounded public reports, including requests no longer in the recent request list."""
|
|
242
306
|
rows = connection.execute("""SELECT e.*,
|
|
@@ -308,7 +372,7 @@ def free_view(connection, room_id):
|
|
|
308
372
|
chains = [dict(row) for row in connection.execute(
|
|
309
373
|
"SELECT chain_id,reason FROM free_chains WHERE room_id=? ORDER BY chain_id DESC LIMIT 10", (room_id,))]
|
|
310
374
|
return {"members": members, "requests": requests[::-1], "executions": recent_executions(connection, room_id), "execution_minutes": execution_minutes(connection, room_id),
|
|
311
|
-
"conflicts": possible_conflicts(connection, room_id, names), "halted_chains": chains, "max_auto_hops":
|
|
375
|
+
"conflicts": possible_conflicts(connection, room_id, names), "halted_chains": chains, "max_auto_hops": auto_hops(connection, room_id),
|
|
312
376
|
"recommendations": recent_recommendations(connection, room_id, now)[::-1]}
|
|
313
377
|
|
|
314
378
|
|
|
@@ -436,6 +500,8 @@ def check_answer(connection, room_id, member, payload):
|
|
|
436
500
|
"""
|
|
437
501
|
if payload.get("to") is not None:
|
|
438
502
|
named_members(active_members(connection, room_id), payload["to"], exclude=member["name"])
|
|
503
|
+
if payload.get("handoff") not in (None, False) and (payload["handoff"] is not True or member["role"] != "host" or not payload.get("to")):
|
|
504
|
+
raise ValueError("handoff 只能由主持人与 to 一起使用,值为 true")
|
|
439
505
|
if member["access"] == "online":
|
|
440
506
|
raise PermissionError("在线成员只读,回答由用户代发")
|
|
441
507
|
if member["access"] == "manual":
|
|
@@ -573,11 +639,29 @@ def chain_budget(connection, room_id, chain_id):
|
|
|
573
639
|
return 0
|
|
574
640
|
used = connection.execute("SELECT COUNT(*) FROM member_requests WHERE room_id=? AND chain_id=? AND created_by='host'",
|
|
575
641
|
(room_id, chain_id)).fetchone()[0]
|
|
576
|
-
return
|
|
642
|
+
return auto_hops(connection, room_id) - used
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def auto_hops(connection, room_id):
|
|
646
|
+
"""AI-created requests allowed per chain in this room."""
|
|
647
|
+
row = connection.execute("SELECT max_auto_hops FROM free_settings WHERE room_id=?", (room_id,)).fetchone()
|
|
648
|
+
return row[0] if row else MAX_AUTO_HOPS
|
|
577
649
|
|
|
578
650
|
|
|
579
|
-
def
|
|
580
|
-
"""
|
|
651
|
+
def set_auto_hops(connection, room_id, token, payload):
|
|
652
|
+
"""Owner sets the room's chain budget; it applies to chains still running and to later ones."""
|
|
653
|
+
require_owner(connection, room_id, token)
|
|
654
|
+
value = payload.get("max_auto_hops")
|
|
655
|
+
if type(value) is not int or not 1 <= value <= MAX_AUTO_HOPS_LIMIT:
|
|
656
|
+
raise ValueError(f"max_auto_hops 须为 1–{MAX_AUTO_HOPS_LIMIT} 的整数")
|
|
657
|
+
begin_write(connection)
|
|
658
|
+
connection.execute("""INSERT INTO free_settings (room_id,max_auto_hops) VALUES (?,?)
|
|
659
|
+
ON CONFLICT(room_id) DO UPDATE SET max_auto_hops=excluded.max_auto_hops""", (room_id, value))
|
|
660
|
+
return {"max_auto_hops": value}
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def dispatch(connection, room_id, host, message_id, names, chain_id, handoff=False):
|
|
664
|
+
"""Queue the host's named members as one batch; several members, or one the host asked back, hand back when it ends."""
|
|
581
665
|
member_ids = named_members(active_members(connection, room_id), names, exclude=host["name"])
|
|
582
666
|
if not member_ids or not chain_id:
|
|
583
667
|
return
|
|
@@ -588,6 +672,8 @@ def dispatch(connection, room_id, host, message_id, names, chain_id):
|
|
|
588
672
|
insert_request(connection, room_id, member_id, message_id, chain_id, "ask", "host", batch_id=message_id)
|
|
589
673
|
connection.execute("INSERT INTO free_batches (room_id,batch_id,chain_id,host_id) VALUES (?,?,?,?)",
|
|
590
674
|
(room_id, message_id, chain_id, host["id"]))
|
|
675
|
+
if handoff:
|
|
676
|
+
connection.execute("INSERT INTO free_handoffs (room_id,batch_id) VALUES (?,?)", (room_id, message_id))
|
|
591
677
|
|
|
592
678
|
|
|
593
679
|
def settle_batches(connection, room_id):
|
|
@@ -601,8 +687,9 @@ def settle_batches(connection, room_id):
|
|
|
601
687
|
host = connection.execute("SELECT * FROM room_members WHERE id=? AND status='active' AND role='host'", (batch["host_id"],)).fetchone()
|
|
602
688
|
if "completed" not in statuses:
|
|
603
689
|
halt_chain(connection, room_id, batch["chain_id"], "all_failed")
|
|
604
|
-
elif not host or len(statuses) < 2
|
|
605
|
-
|
|
690
|
+
elif not host or len(statuses) < 2 and not connection.execute(
|
|
691
|
+
"SELECT 1 FROM free_handoffs WHERE room_id=? AND batch_id=?", (room_id, batch["batch_id"])).fetchone():
|
|
692
|
+
# One member's answer reaches the user directly unless the host asked for it back, e.g. to send it to a reviewer.
|
|
606
693
|
continue
|
|
607
694
|
elif chain_budget(connection, room_id, batch["chain_id"]) < 1:
|
|
608
695
|
halt_chain(connection, room_id, batch["chain_id"], "budget")
|
|
@@ -784,7 +871,7 @@ def after_free_message(connection, room_id, message_id, actor, payload):
|
|
|
784
871
|
(message_id, execution_id))
|
|
785
872
|
connection.execute("UPDATE room_members SET read_until=MAX(read_until,?) WHERE id=?", (message_id, actor["id"]))
|
|
786
873
|
if actor["role"] == "host" and payload.get("to"):
|
|
787
|
-
dispatch(connection, room_id, actor, message_id, payload["to"], chain_id)
|
|
874
|
+
dispatch(connection, room_id, actor, message_id, payload["to"], chain_id, payload.get("handoff") is True)
|
|
788
875
|
settle_batches(connection, room_id)
|
|
789
876
|
|
|
790
877
|
|
|
@@ -1123,6 +1210,13 @@ def execution_action(connection, room_id, execution_id, token, payload):
|
|
|
1123
1210
|
if row["changes"]:
|
|
1124
1211
|
raise FileExistsError("本次执行的改动已记录")
|
|
1125
1212
|
connection.execute("UPDATE member_executions SET changes=? WHERE id=?", (json.dumps(changed_files(payload), ensure_ascii=False), execution_id))
|
|
1213
|
+
elif action == "segments":
|
|
1214
|
+
# The last segments and notes arrive after the member's answer ended the execution; such notes are dated at its end.
|
|
1215
|
+
finished = 0 if row["status"] in ACTIVE else (row["finished_at"] or 1)
|
|
1216
|
+
notes = renewed_notes(payload)
|
|
1217
|
+
save_segments(connection, "free", room_id, execution_id, row["created_at"], finished, payload, optional=bool(notes))
|
|
1218
|
+
connection.executemany("INSERT OR IGNORE INTO execution_notes (execution_id,seq,room_id,text,created_at) VALUES (?,?,?,?,?)",
|
|
1219
|
+
[(execution_id, note["seq"], room_id, note["text"], min(now, finished) if finished else now) for note in notes])
|
|
1126
1220
|
elif row["status"] not in ACTIVE:
|
|
1127
1221
|
raise FileExistsError("执行已结束")
|
|
1128
1222
|
elif action == "renew":
|
|
@@ -1143,7 +1237,7 @@ def execution_action(connection, room_id, execution_id, token, payload):
|
|
|
1143
1237
|
# Only for proven non-delivery: the requests return to the queue unchanged.
|
|
1144
1238
|
finish_execution(connection, execution_id, "cancelled", str(payload.get("reason") or "未送达"), "pending")
|
|
1145
1239
|
else:
|
|
1146
|
-
raise ValueError("action 须为 renew、fail、release 或
|
|
1240
|
+
raise ValueError("action 须为 renew、fail、release、report 或 segments")
|
|
1147
1241
|
return execution_detail(connection, connection.execute("SELECT * FROM member_executions WHERE id=?", (execution_id,)).fetchone())
|
|
1148
1242
|
|
|
1149
1243
|
|
package/backend/runtime.py
CHANGED
|
@@ -16,6 +16,13 @@ LIMITS_SQL = """SELECT limits.*,COALESCE(pauses.paused_seconds,0)+CASE WHEN COAL
|
|
|
16
16
|
THEN MAX(0,CAST(strftime('%s','now') AS INTEGER)-pauses.paused_at) ELSE 0 END AS idle_seconds
|
|
17
17
|
FROM discussion_limits AS limits LEFT JOIN discussion_pauses AS pauses USING(room_id) WHERE limits.room_id=?"""
|
|
18
18
|
TIMING_ITEMS = 20
|
|
19
|
+
# Phase segments the runner reports for the execution timeline: the phases, how many one execution keeps and one report
|
|
20
|
+
# carries, a tool name's length, and how long after its end an execution still takes its last report.
|
|
21
|
+
SEGMENT_PHASES = ("thinking", "command", "tool", "editing", "writing")
|
|
22
|
+
MAX_EXECUTION_SEGMENTS = 400
|
|
23
|
+
MAX_SEGMENTS_PER_REPORT = 40
|
|
24
|
+
MAX_SEGMENT_TOOL = 60
|
|
25
|
+
SEGMENT_GRACE_SECONDS = 600
|
|
19
26
|
# Queue: created -> dispatched; wake: dispatched -> entered; work: entered -> finished. Open stages run to now.
|
|
20
27
|
TIMING_SQL = """SELECT e.id,e.participant,e.role,e.cause,e.status,
|
|
21
28
|
MAX(0,CASE WHEN t.dispatched_at>0 THEN t.dispatched_at ELSE ? END-CAST(strftime('%s',e.created_at) AS INTEGER)) AS queue_seconds,
|
|
@@ -32,6 +39,34 @@ def token_hash(token):
|
|
|
32
39
|
return hashlib.sha256(token.encode()).hexdigest()
|
|
33
40
|
|
|
34
41
|
|
|
42
|
+
def save_segments(connection, source, room_id, execution_id, anchor, finished, payload, optional=False):
|
|
43
|
+
"""Record an execution's phase segments as millisecond offsets from its delivery at `anchor`.
|
|
44
|
+
|
|
45
|
+
The runner measures them on its own clock, so they are kept within the execution's own span; `finished` is 0 while
|
|
46
|
+
it runs. A resent segment keeps the later end, so the open segment can be reported again as it runs on. `optional`
|
|
47
|
+
allows a report with no segments, when it carries progress notes instead.
|
|
48
|
+
"""
|
|
49
|
+
now = int(time.time())
|
|
50
|
+
if finished and finished + SEGMENT_GRACE_SECONDS < now:
|
|
51
|
+
raise FileExistsError("执行已结束较久,不再接收阶段记录")
|
|
52
|
+
segments = payload.get("segments", [])
|
|
53
|
+
if (not isinstance(segments, list) or len(segments) > MAX_SEGMENTS_PER_REPORT or (not segments and not optional) or any(
|
|
54
|
+
not isinstance(item, dict) or type(item.get("seq")) is not int or not 1 <= item["seq"] <= MAX_EXECUTION_SEGMENTS
|
|
55
|
+
or item.get("phase") not in SEGMENT_PHASES or not isinstance(item.get("tool", ""), str)
|
|
56
|
+
or len(item.get("tool", "")) > MAX_SEGMENT_TOOL or type(item.get("start_ms")) is not int
|
|
57
|
+
or type(item.get("end_ms")) is not int for item in segments)):
|
|
58
|
+
raise ValueError(f"segments 须为 1–{MAX_SEGMENTS_PER_REPORT} 段 {{seq: 1–{MAX_EXECUTION_SEGMENTS}, "
|
|
59
|
+
f"phase: {'、'.join(SEGMENT_PHASES)}, tool: 最多 {MAX_SEGMENT_TOOL} 字, start_ms, end_ms}}")
|
|
60
|
+
limit = max(0, (finished or now) - anchor + 1) * 1000
|
|
61
|
+
rows = []
|
|
62
|
+
for item in segments:
|
|
63
|
+
start = min(max(item["start_ms"], 0), limit)
|
|
64
|
+
rows.append((source, execution_id, item["seq"], room_id, item["phase"], item.get("tool", ""), start,
|
|
65
|
+
min(max(item["end_ms"], start), limit)))
|
|
66
|
+
connection.executemany("""INSERT INTO execution_segments (source,execution_id,seq,room_id,phase,tool,start_ms,end_ms)
|
|
67
|
+
VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(source,execution_id,seq) DO UPDATE SET end_ms=MAX(end_ms,excluded.end_ms)""", rows)
|
|
68
|
+
|
|
69
|
+
|
|
35
70
|
def begin_write(connection):
|
|
36
71
|
"""Acquire the writer lock once, including when routing already acquired it."""
|
|
37
72
|
if not connection.in_transaction:
|
|
@@ -325,6 +360,16 @@ def event_action(connection, room_id, event_id, token, payload):
|
|
|
325
360
|
raise PermissionError("执行凭据无效")
|
|
326
361
|
if action == "view":
|
|
327
362
|
return {key: event[key] for key in PUBLIC_EVENT_FIELDS}
|
|
363
|
+
if action == "segments":
|
|
364
|
+
# A timeline report comes from the runner, also just after the execution ended or its run stopped.
|
|
365
|
+
if not controller:
|
|
366
|
+
raise PermissionError("需要本房间的运行控制凭据")
|
|
367
|
+
timing = connection.execute("SELECT dispatched_at,finished_at FROM event_timings WHERE event_id=?", (event_id,)).fetchone()
|
|
368
|
+
if not timing or not timing["dispatched_at"]:
|
|
369
|
+
raise FileExistsError("执行尚未交付")
|
|
370
|
+
finished = 0 if event["status"] in ("dispatched", "started") else (timing["finished_at"] or 1)
|
|
371
|
+
save_segments(connection, "discussion", room_id, event_id, timing["dispatched_at"], finished, payload)
|
|
372
|
+
return {key: event[key] for key in PUBLIC_EVENT_FIELDS}
|
|
328
373
|
if run["status"] != "running" or 0 < run["deadline"] <= now or event["generation"] != run["generation"]:
|
|
329
374
|
raise FileExistsError("本次运行已停止或执行已过期")
|
|
330
375
|
if action == "reserve" and controller:
|