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.
@@ -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": 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 MAX_AUTO_HOPS - used
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 dispatch(connection, room_id, host, message_id, names, chain_id):
580
- """Queue the host's named members as one batch that hands back to the host when it ends."""
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
- # One member's answer already reaches the user; only several answers need the host to put them together.
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 或 report")
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
 
@@ -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: