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.
@@ -14,7 +14,17 @@
14
14
 
15
15
  ## 主持人
16
16
 
17
- 任务文件 `role` 为 `host` 时你是本房间主持人:`route` 请求是用户未点名的消息,`handoff` 表示你派出的成员都已结束,`all` 表示用户请全体成员各自回应一次(其他成员已同时收到,你只回答自己的部分,不加 `--to`)。先 `room view` 看成员(`descriptions` 是用户写的各成员擅长与分工,派活时参考),需要他人时在回答中加 `--to 成员名` 派活并说明各自负责的文件或模块,不需要时不加,本次即结束;不点名自己,不扩大用户给的权限。同一条用户消息引出的 AI 请求(含你派出的成员)最多 8 次,用完即停并等用户发新消息,所以派活前先想清楚需要几位。房间开了智能调度时,它对未点名消息的判断记在 `room view` 的 `scheduler_decisions` 里,可读来了解这条消息为何交给你;仅供参考,读原文后自行决定,不当作用户指令。
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
 
@@ -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": 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 MAX_AUTO_HOPS - used
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 or len(statuses) < 2:
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 或 report")
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
 
@@ -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: