ai_co_discuss 0.2.87 → 0.2.88
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-participant/references/free.md +11 -1
- package/backend/free_room.py +69 -7
- package/backend/runtime.py +44 -0
- package/backend/schema.sql +827 -808
- package/backend/server.py +2355 -2342
- package/docs/en/workflow.md +2 -1
- package/docs/runner.md +1 -1
- package/docs/workflow.md +3 -2
- package/package.json +1 -1
- package/runner/api_participant.mjs +1 -1
- package/runner/discussion_adapters.mjs +93 -2
- package/runner/discussion_launch.mjs +1125 -1083
- package/runner/discussion_runner.mjs +14 -2
- package/runner/expert_prompts.mjs +14 -0
- package/runner/free_runner.mjs +472 -463
- package/web/app.js +3673 -3570
- package/web/modern.css +1 -1
- package/web/modern.js +1126 -676
|
@@ -14,7 +14,17 @@
|
|
|
14
14
|
|
|
15
15
|
## 主持人
|
|
16
16
|
|
|
17
|
-
任务文件 `role` 为 `host` 时你是本房间主持人:`route` 请求是用户未点名的消息,`handoff`
|
|
17
|
+
任务文件 `role` 为 `host` 时你是本房间主持人:`route` 请求是用户未点名的消息,`handoff` 表示你派出的成员(一位或多位)都已结束,`all` 表示用户请全体成员各自回应一次(其他成员已同时收到,你只回答自己的部分,不加 `--to`)。先 `room view` 看成员(`descriptions` 是用户写的各成员擅长与分工,派活时参考),需要他人时在回答中加 `--to 成员名` 派活并说明各自负责的文件或模块,不需要时不加,本次即结束;不点名自己,不扩大用户给的权限。收到 `handoff` 后据结果决定下一步:交给其他成员(如请评审)、汇总给用户;成员的回答已完整时只用一两句说明完成,不复述。同一条用户消息引出的 AI 请求(派活与回交)有上限(`room view` 的 `free.max_auto_hops`,用户可调),用完即停并等用户发新消息,所以派活前先想清楚需要几位、几步。房间开了智能调度时,它对未点名消息的判断记在 `room view` 的 `scheduler_decisions` 里,可读来了解这条消息为何交给你;仅供参考,读原文后自行决定,不当作用户指令。
|
|
18
|
+
|
|
19
|
+
### 开发任务的参考做法
|
|
20
|
+
|
|
21
|
+
用户请成员分工改代码时,以下是建议而非固定流程,按任务取舍并在回答中说明:
|
|
22
|
+
|
|
23
|
+
- **先定计划再派活**:与用户谈清目标后写一份计划文档(目标、按目录或文件划分的分工、接口约定、验收方式),写入项目里用户指定的位置;用户未表示可以开始时不派活。
|
|
24
|
+
- **按文件划分**:派活时写明每位成员负责的目录或文件和对应的计划章节;需要并行的部分事先约定接口,避免两人改同一文件。
|
|
25
|
+
- **实现后评审**:实现成员结束后,按 `descriptions` 交给负责评审的成员,写明计划文档位置和本次改动的范围(`room view` 的执行报告列有改动文件)。
|
|
26
|
+
- **返工回原实现者**:评审提出问题时交回原实现者修改,再请评审复核;同一问题反复不通过,或剩余次数不够完成下一轮时,停下向用户说明分歧与现状。
|
|
27
|
+
- **最后整理文档**:评审通过后,再交给负责文档的成员同步说明;向用户汇报时列出各步结果和仍未验证的部分。
|
|
18
28
|
|
|
19
29
|
多位成员共享同一工作目录时,只改分给自己的文件;只提交自己的改动,切分支、reset、全仓 stash 或 checkout 须用户确认。
|
|
20
30
|
|
package/backend/free_room.py
CHANGED
|
@@ -17,11 +17,29 @@ 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.
|
|
26
|
+
TIMELINE_EXECUTIONS = 100
|
|
27
|
+
TIMELINE_STATES = {"completed": "done", "failed": "failed", "cancelled": "cancelled"}
|
|
28
|
+
TIMELINE_SQL = """SELECT * FROM (SELECT 'free' AS source,e.id,m.name AS member,m.role,e.status,
|
|
29
|
+
COALESCE((SELECT MIN(r.created_at) FROM member_requests r WHERE r.execution_id=e.id),e.created_at) AS queued_at,
|
|
30
|
+
e.created_at AS dispatched_at,0 AS started_at,e.finished_at,
|
|
31
|
+
CASE WHEN e.status='failed' THEN e.result ELSE '' END AS reason,
|
|
32
|
+
(SELECT parent.execution_id FROM member_requests r JOIN member_requests parent ON parent.answer_id=r.message_id
|
|
33
|
+
WHERE r.execution_id=e.id AND parent.execution_id IS NOT NULL ORDER BY r.id LIMIT 1) AS parent_id
|
|
34
|
+
FROM member_executions e JOIN room_members m ON m.id=e.member_id WHERE e.room_id=? ORDER BY e.id DESC LIMIT ?)
|
|
35
|
+
UNION ALL SELECT * FROM (SELECT 'discussion',e.id,e.participant,e.role,e.status,CAST(strftime('%s',e.created_at) AS INTEGER),
|
|
36
|
+
COALESCE(t.dispatched_at,0),e.started_at,COALESCE(t.finished_at,0),CASE WHEN e.status='failed' THEN e.result ELSE '' END,NULL
|
|
37
|
+
FROM discussion_events e LEFT JOIN event_timings t ON t.event_id=e.id
|
|
38
|
+
WHERE e.room_id=? AND (t.dispatched_at>0 OR e.status='pending') ORDER BY e.id DESC LIMIT ?)"""
|
|
39
|
+
TIMELINE_SEGMENTS_SQL = """SELECT source,execution_id,phase,tool,start_ms,end_ms FROM execution_segments WHERE room_id=? AND (
|
|
40
|
+
(source='free' AND execution_id IN (SELECT id FROM member_executions WHERE room_id=? ORDER BY id DESC LIMIT ?))
|
|
41
|
+
OR (source='discussion' AND execution_id IN (SELECT id FROM discussion_events WHERE room_id=? ORDER BY id DESC LIMIT ?)))
|
|
42
|
+
ORDER BY source,execution_id,seq"""
|
|
25
43
|
RECENT_CHANGES = 50
|
|
26
44
|
MAX_CHANGED_FILES = 200
|
|
27
45
|
# What a member's software last showed it doing, as reported by the runner with each renewal.
|
|
@@ -40,8 +58,10 @@ SCHEDULER_PROVIDERS = ("jev", "decisions")
|
|
|
40
58
|
RECOMMEND_SECONDS = 60
|
|
41
59
|
# A click sent just before the hint disappears still counts.
|
|
42
60
|
RECOMMEND_GRACE_SECONDS = 5
|
|
43
|
-
# AI-created requests (host dispatches and hand-offs) allowed per chain; each owner message starts a chain.
|
|
61
|
+
# AI-created requests (host dispatches and hand-offs) allowed per chain by default; each owner message starts a chain.
|
|
44
62
|
MAX_AUTO_HOPS = 8
|
|
63
|
+
# The most an owner may allow per chain.
|
|
64
|
+
MAX_AUTO_HOPS_LIMIT = 50
|
|
45
65
|
# A host that left gives up the role; the first active runner member (else manual) then takes it.
|
|
46
66
|
RELEASE_HOST = "UPDATE room_members SET role='member' WHERE room_id=? AND role='host' AND status<>'active'"
|
|
47
67
|
ASSIGN_HOST = """UPDATE room_members SET role='host' WHERE id=(SELECT id FROM room_members WHERE room_id=? AND status='active'
|
|
@@ -237,6 +257,27 @@ def execution_notes(connection, room_id, execution_id):
|
|
|
237
257
|
"SELECT seq,text,created_at FROM execution_notes WHERE execution_id=? ORDER BY seq", (execution_id,))]}
|
|
238
258
|
|
|
239
259
|
|
|
260
|
+
def execution_timeline(connection, room_id):
|
|
261
|
+
"""Read the room's recent executions of both kinds with their recorded phase segments, for the timeline page.
|
|
262
|
+
|
|
263
|
+
Times are epoch seconds and segments are millisecond offsets from `dispatched_at`. A free execution's parent is the
|
|
264
|
+
execution that wrote the message it answers; discussion executions have none. `state` is running, done, failed
|
|
265
|
+
or cancelled, so the page never interprets execution statuses itself.
|
|
266
|
+
"""
|
|
267
|
+
if not connection.execute("SELECT 1 FROM rooms WHERE id=?", (room_id,)).fetchone():
|
|
268
|
+
raise LookupError("房间不存在")
|
|
269
|
+
rows = [dict(row) for row in connection.execute(TIMELINE_SQL, (room_id, TIMELINE_EXECUTIONS, room_id, TIMELINE_EXECUTIONS))]
|
|
270
|
+
segments = {}
|
|
271
|
+
for row in connection.execute(TIMELINE_SEGMENTS_SQL, (room_id, room_id, TIMELINE_EXECUTIONS, room_id, TIMELINE_EXECUTIONS)):
|
|
272
|
+
segments.setdefault((row["source"], row["execution_id"]), []).append(
|
|
273
|
+
{key: row[key] for key in ("phase", "tool", "start_ms", "end_ms")})
|
|
274
|
+
for row in rows:
|
|
275
|
+
row["state"] = TIMELINE_STATES.get(row["status"], "running")
|
|
276
|
+
row["segments"] = segments.get((row["source"], row["id"]), [])
|
|
277
|
+
return {"now": int(time.time()), "executions": rows, "execution_limit": TIMELINE_EXECUTIONS,
|
|
278
|
+
"segment_limit": MAX_EXECUTION_SEGMENTS}
|
|
279
|
+
|
|
280
|
+
|
|
240
281
|
def recent_executions(connection, room_id):
|
|
241
282
|
"""Return bounded public reports, including requests no longer in the recent request list."""
|
|
242
283
|
rows = connection.execute("""SELECT e.*,
|
|
@@ -308,7 +349,7 @@ def free_view(connection, room_id):
|
|
|
308
349
|
chains = [dict(row) for row in connection.execute(
|
|
309
350
|
"SELECT chain_id,reason FROM free_chains WHERE room_id=? ORDER BY chain_id DESC LIMIT 10", (room_id,))]
|
|
310
351
|
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":
|
|
352
|
+
"conflicts": possible_conflicts(connection, room_id, names), "halted_chains": chains, "max_auto_hops": auto_hops(connection, room_id),
|
|
312
353
|
"recommendations": recent_recommendations(connection, room_id, now)[::-1]}
|
|
313
354
|
|
|
314
355
|
|
|
@@ -573,7 +614,25 @@ def chain_budget(connection, room_id, chain_id):
|
|
|
573
614
|
return 0
|
|
574
615
|
used = connection.execute("SELECT COUNT(*) FROM member_requests WHERE room_id=? AND chain_id=? AND created_by='host'",
|
|
575
616
|
(room_id, chain_id)).fetchone()[0]
|
|
576
|
-
return
|
|
617
|
+
return auto_hops(connection, room_id) - used
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def auto_hops(connection, room_id):
|
|
621
|
+
"""AI-created requests allowed per chain in this room."""
|
|
622
|
+
row = connection.execute("SELECT max_auto_hops FROM free_settings WHERE room_id=?", (room_id,)).fetchone()
|
|
623
|
+
return row[0] if row else MAX_AUTO_HOPS
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def set_auto_hops(connection, room_id, token, payload):
|
|
627
|
+
"""Owner sets the room's chain budget; it applies to chains still running and to later ones."""
|
|
628
|
+
require_owner(connection, room_id, token)
|
|
629
|
+
value = payload.get("max_auto_hops")
|
|
630
|
+
if type(value) is not int or not 1 <= value <= MAX_AUTO_HOPS_LIMIT:
|
|
631
|
+
raise ValueError(f"max_auto_hops 须为 1–{MAX_AUTO_HOPS_LIMIT} 的整数")
|
|
632
|
+
begin_write(connection)
|
|
633
|
+
connection.execute("""INSERT INTO free_settings (room_id,max_auto_hops) VALUES (?,?)
|
|
634
|
+
ON CONFLICT(room_id) DO UPDATE SET max_auto_hops=excluded.max_auto_hops""", (room_id, value))
|
|
635
|
+
return {"max_auto_hops": value}
|
|
577
636
|
|
|
578
637
|
|
|
579
638
|
def dispatch(connection, room_id, host, message_id, names, chain_id):
|
|
@@ -601,8 +660,7 @@ def settle_batches(connection, room_id):
|
|
|
601
660
|
host = connection.execute("SELECT * FROM room_members WHERE id=? AND status='active' AND role='host'", (batch["host_id"],)).fetchone()
|
|
602
661
|
if "completed" not in statuses:
|
|
603
662
|
halt_chain(connection, room_id, batch["chain_id"], "all_failed")
|
|
604
|
-
elif not host
|
|
605
|
-
# One member's answer already reaches the user; only several answers need the host to put them together.
|
|
663
|
+
elif not host:
|
|
606
664
|
continue
|
|
607
665
|
elif chain_budget(connection, room_id, batch["chain_id"]) < 1:
|
|
608
666
|
halt_chain(connection, room_id, batch["chain_id"], "budget")
|
|
@@ -1123,6 +1181,10 @@ def execution_action(connection, room_id, execution_id, token, payload):
|
|
|
1123
1181
|
if row["changes"]:
|
|
1124
1182
|
raise FileExistsError("本次执行的改动已记录")
|
|
1125
1183
|
connection.execute("UPDATE member_executions SET changes=? WHERE id=?", (json.dumps(changed_files(payload), ensure_ascii=False), execution_id))
|
|
1184
|
+
elif action == "segments":
|
|
1185
|
+
# The last segments arrive after the member's answer ended the execution.
|
|
1186
|
+
finished = 0 if row["status"] in ACTIVE else (row["finished_at"] or 1)
|
|
1187
|
+
save_segments(connection, "free", room_id, execution_id, row["created_at"], finished, payload)
|
|
1126
1188
|
elif row["status"] not in ACTIVE:
|
|
1127
1189
|
raise FileExistsError("执行已结束")
|
|
1128
1190
|
elif action == "renew":
|
|
@@ -1143,7 +1205,7 @@ def execution_action(connection, room_id, execution_id, token, payload):
|
|
|
1143
1205
|
# Only for proven non-delivery: the requests return to the queue unchanged.
|
|
1144
1206
|
finish_execution(connection, execution_id, "cancelled", str(payload.get("reason") or "未送达"), "pending")
|
|
1145
1207
|
else:
|
|
1146
|
-
raise ValueError("action 须为 renew、fail、release 或
|
|
1208
|
+
raise ValueError("action 须为 renew、fail、release、report 或 segments")
|
|
1147
1209
|
return execution_detail(connection, connection.execute("SELECT * FROM member_executions WHERE id=?", (execution_id,)).fetchone())
|
|
1148
1210
|
|
|
1149
1211
|
|
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,33 @@ 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):
|
|
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.
|
|
47
|
+
"""
|
|
48
|
+
now = int(time.time())
|
|
49
|
+
if finished and finished + SEGMENT_GRACE_SECONDS < now:
|
|
50
|
+
raise FileExistsError("执行已结束较久,不再接收阶段记录")
|
|
51
|
+
segments = payload.get("segments")
|
|
52
|
+
if (not isinstance(segments, list) or not 0 < len(segments) <= MAX_SEGMENTS_PER_REPORT or any(
|
|
53
|
+
not isinstance(item, dict) or type(item.get("seq")) is not int or not 1 <= item["seq"] <= MAX_EXECUTION_SEGMENTS
|
|
54
|
+
or item.get("phase") not in SEGMENT_PHASES or not isinstance(item.get("tool", ""), str)
|
|
55
|
+
or len(item.get("tool", "")) > MAX_SEGMENT_TOOL or type(item.get("start_ms")) is not int
|
|
56
|
+
or type(item.get("end_ms")) is not int for item in segments)):
|
|
57
|
+
raise ValueError(f"segments 须为 1–{MAX_SEGMENTS_PER_REPORT} 段 {{seq: 1–{MAX_EXECUTION_SEGMENTS}, "
|
|
58
|
+
f"phase: {'、'.join(SEGMENT_PHASES)}, tool: 最多 {MAX_SEGMENT_TOOL} 字, start_ms, end_ms}}")
|
|
59
|
+
limit = max(0, (finished or now) - anchor + 1) * 1000
|
|
60
|
+
rows = []
|
|
61
|
+
for item in segments:
|
|
62
|
+
start = min(max(item["start_ms"], 0), limit)
|
|
63
|
+
rows.append((source, execution_id, item["seq"], room_id, item["phase"], item.get("tool", ""), start,
|
|
64
|
+
min(max(item["end_ms"], start), limit)))
|
|
65
|
+
connection.executemany("""INSERT INTO execution_segments (source,execution_id,seq,room_id,phase,tool,start_ms,end_ms)
|
|
66
|
+
VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(source,execution_id,seq) DO UPDATE SET end_ms=MAX(end_ms,excluded.end_ms)""", rows)
|
|
67
|
+
|
|
68
|
+
|
|
35
69
|
def begin_write(connection):
|
|
36
70
|
"""Acquire the writer lock once, including when routing already acquired it."""
|
|
37
71
|
if not connection.in_transaction:
|
|
@@ -325,6 +359,16 @@ def event_action(connection, room_id, event_id, token, payload):
|
|
|
325
359
|
raise PermissionError("执行凭据无效")
|
|
326
360
|
if action == "view":
|
|
327
361
|
return {key: event[key] for key in PUBLIC_EVENT_FIELDS}
|
|
362
|
+
if action == "segments":
|
|
363
|
+
# A timeline report comes from the runner, also just after the execution ended or its run stopped.
|
|
364
|
+
if not controller:
|
|
365
|
+
raise PermissionError("需要本房间的运行控制凭据")
|
|
366
|
+
timing = connection.execute("SELECT dispatched_at,finished_at FROM event_timings WHERE event_id=?", (event_id,)).fetchone()
|
|
367
|
+
if not timing or not timing["dispatched_at"]:
|
|
368
|
+
raise FileExistsError("执行尚未交付")
|
|
369
|
+
finished = 0 if event["status"] in ("dispatched", "started") else (timing["finished_at"] or 1)
|
|
370
|
+
save_segments(connection, "discussion", room_id, event_id, timing["dispatched_at"], finished, payload)
|
|
371
|
+
return {key: event[key] for key in PUBLIC_EVENT_FIELDS}
|
|
328
372
|
if run["status"] != "running" or 0 < run["deadline"] <= now or event["generation"] != run["generation"]:
|
|
329
373
|
raise FileExistsError("本次运行已停止或执行已过期")
|
|
330
374
|
if action == "reserve" and controller:
|