Skip to content

Commit 7475e29

Browse files
authored
Merge pull request #4 from RMANOV/agent/debate-mechanism-recovery
Recover debate exchange and add ranked memory search
2 parents 10e987d + 351196f commit 7475e29

18 files changed

Lines changed: 1769 additions & 15 deletions

debate.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@
148148
# Vehicles a bounded wake-worker may execute. ``implementation`` is
149149
# intentionally excluded — it requires a conductor-approved impl vehicle.
150150
WAKE_WORKER_VEHICLES = ("analysis", "review")
151+
_SINGLETON_MESSAGE_WAKE_RESULTS = {"implementation_requires_impl_vehicle"}
151152
VALID_STATES = ("INIT", "ACTIVE", "RESOLVED", "ARCHIVED")
152153
VALID_BINDING_STATES = ("active", "retired", "diagnostic")
153154
VALID_CURSOR_MODES = ("head", "copy", "replay")
@@ -1104,6 +1105,145 @@ def worker_no_action(
11041105
return out
11051106

11061107

1108+
def recover_stale_worker_claims(
1109+
conn: sqlite3.Connection,
1110+
*,
1111+
topic_id: str,
1112+
older_than_ts: str,
1113+
minimum_age_seconds: int = 120,
1114+
live_worker_session_ids: set[str] | None = None,
1115+
) -> dict[str, Any]:
1116+
"""Reconcile active worker claims whose launcher process is gone.
1117+
1118+
A spawned Claude/Codex worker can exit before it calls
1119+
``debate_worker_no_action`` (quota/session limit, crash, killed process).
1120+
The old pump reaped the OS child but left the DB claim active forever.
1121+
This recovery path is conservative:
1122+
1123+
* a live worker session is skipped;
1124+
* a terminal same-role A/STATUS completes the claim with its ack;
1125+
* otherwise the orphan is retired without advancing either cursor, so the
1126+
parent session still sees the addressed trigger as pending.
1127+
1128+
Every transition is recorded in ``debate_worker_recovery_log``.
1129+
"""
1130+
validate_topic_id(topic_id)
1131+
_validate_reclaim_cutoff(older_than_ts, minimum_age_seconds)
1132+
debate = get_debate(conn, topic_id)
1133+
if debate is None:
1134+
raise DebateError(
1135+
f"unknown_topic: {topic_id}",
1136+
error_type="topic_not_found",
1137+
)
1138+
live = set(live_worker_session_ids or set())
1139+
rows = conn.execute(
1140+
"SELECT * FROM debate_worker_claims "
1141+
"WHERE topic_id = ? AND state = 'active' AND heartbeat_at < ? "
1142+
"ORDER BY heartbeat_at ASC, worker_session_id ASC",
1143+
(topic_id, older_than_ts),
1144+
).fetchall()
1145+
now = now_iso()
1146+
completed: list[dict[str, Any]] = []
1147+
retired: list[dict[str, Any]] = []
1148+
skipped_live: list[str] = []
1149+
for row in rows:
1150+
worker_session_id = str(row["worker_session_id"])
1151+
if worker_session_id in live:
1152+
skipped_live.append(worker_session_id)
1153+
continue
1154+
ack = _terminal_reply_for_trigger(
1155+
conn,
1156+
topic_id=topic_id,
1157+
role=row["role"],
1158+
trigger_msg_id=row["trigger_msg_id"],
1159+
)
1160+
details = _claim_details_dict(row)
1161+
recovery = {
1162+
"recovered_at": now,
1163+
"older_than_ts": older_than_ts,
1164+
"minimum_age_seconds": minimum_age_seconds,
1165+
"previous_heartbeat_at": row["heartbeat_at"],
1166+
"launcher_process_live": False,
1167+
}
1168+
if ack is not None:
1169+
result = "completed_from_terminal"
1170+
new_state = "completed"
1171+
ack_msg_id = ack["msg_id"]
1172+
recovery["ack_msg_id"] = ack_msg_id
1173+
completed.append(
1174+
{
1175+
"worker_session_id": worker_session_id,
1176+
"trigger_msg_id": row["trigger_msg_id"],
1177+
"ack_msg_id": ack_msg_id,
1178+
}
1179+
)
1180+
else:
1181+
result = "retired_orphan_no_terminal"
1182+
new_state = "retired"
1183+
ack_msg_id = None
1184+
recovery["parent_trigger_still_pending"] = True
1185+
retired.append(
1186+
{
1187+
"worker_session_id": worker_session_id,
1188+
"trigger_msg_id": row["trigger_msg_id"],
1189+
}
1190+
)
1191+
details["stale_worker_recovery"] = recovery
1192+
conn.execute(
1193+
"UPDATE debate_worker_claims SET state = ?, heartbeat_at = ?, "
1194+
"completed_at = ?, ack_msg_id = ?, details_json = ? "
1195+
"WHERE topic_id = ? AND role = ? AND worker_session_id = ? "
1196+
"AND state = 'active'",
1197+
(
1198+
new_state,
1199+
now,
1200+
now,
1201+
ack_msg_id,
1202+
json_dumps(details),
1203+
topic_id,
1204+
row["role"],
1205+
worker_session_id,
1206+
),
1207+
)
1208+
recovery_id = new_msg_id()
1209+
while conn.execute(
1210+
"SELECT 1 FROM debate_worker_recovery_log "
1211+
"WHERE recovery_id = ? LIMIT 1",
1212+
(recovery_id,),
1213+
).fetchone():
1214+
recovery_id = new_msg_id()
1215+
conn.execute(
1216+
"INSERT INTO debate_worker_recovery_log "
1217+
"(recovery_id, topic_id, role, parent_session_id, "
1218+
" worker_session_id, trigger_msg_id, previous_state, result, "
1219+
" details_json, created_at) "
1220+
"VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)",
1221+
(
1222+
recovery_id,
1223+
topic_id,
1224+
row["role"],
1225+
row["parent_session_id"],
1226+
worker_session_id,
1227+
row["trigger_msg_id"],
1228+
result,
1229+
json_dumps(recovery),
1230+
now,
1231+
),
1232+
)
1233+
return {
1234+
"topic_id": topic_id,
1235+
"topic_state": debate["state"],
1236+
"older_than_ts": older_than_ts,
1237+
"minimum_age_seconds": minimum_age_seconds,
1238+
"completed": completed,
1239+
"retired": retired,
1240+
"skipped_live": skipped_live,
1241+
"completed_count": len(completed),
1242+
"retired_count": len(retired),
1243+
"skipped_live_count": len(skipped_live),
1244+
}
1245+
1246+
11071247
def _complete_worker_claim_if_terminal(
11081248
conn: sqlite3.Connection,
11091249
*,
@@ -3609,6 +3749,23 @@ def _insert_wake_log(
36093749
binding_generation: int | None = None,
36103750
details: dict[str, Any] | None = None,
36113751
) -> dict[str, Any]:
3752+
# Message-level terminal routing decisions do not have a target session,
3753+
# so the historical partial unique index could not dedupe them. A pump
3754+
# rescan consequently wrote tens of thousands of identical refusal rows.
3755+
# Return the first durable receipt instead of growing an audit hot-loop.
3756+
if target_session_id is None and result in _SINGLETON_MESSAGE_WAKE_RESULTS:
3757+
existing = conn.execute(
3758+
"SELECT * FROM debate_wake_log "
3759+
"WHERE trigger_msg_id = ? AND topic_id = ? AND recipient = ? "
3760+
"AND action = ? AND result = ? AND target_session_id IS NULL "
3761+
"ORDER BY created_at ASC, wake_id ASC LIMIT 1",
3762+
(trigger_msg_id, topic_id, recipient, action, result),
3763+
).fetchone()
3764+
if existing is not None:
3765+
out = dict(existing)
3766+
out["details"] = json_loads(out.pop("details_json") or "{}")
3767+
out["duplicate"] = True
3768+
return out
36123769
wake_id = new_msg_id()
36133770
while conn.execute(
36143771
"SELECT 1 FROM debate_wake_log WHERE wake_id = ? LIMIT 1",
@@ -3630,6 +3787,7 @@ def _insert_wake_log(
36303787
"schema_version": DEBATE_WAKE_SCHEMA_VERSION,
36313788
"details": details or {},
36323789
"created_at": now,
3790+
"duplicate": False,
36333791
}
36343792
conn.execute(
36353793
"INSERT INTO debate_wake_log "

debate_prompt_context.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Safe prompt adapter for ranked debate inbox context.
2+
3+
Addressing/unread status is established by the signal DAO first. This module
4+
then ranks only those proven candidate message ids through the two-path
5+
``debate_retrieval`` engine and replaces full bodies with bounded snippets.
6+
The production memory database is opened ``mode=ro`` with ``query_only=ON``.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from pathlib import Path
12+
import sqlite3
13+
from typing import Any, Sequence
14+
15+
from debate_retrieval import search_debate_context
16+
17+
18+
def rank_pending_from_memory_db(
19+
*,
20+
db_path: Path | str,
21+
pending: Sequence[dict[str, Any]],
22+
query: str,
23+
role: str,
24+
session_id: str,
25+
limit: int = 8,
26+
snippet_bytes: int = 480,
27+
max_query_ms: int = 750,
28+
) -> list[dict[str, Any]]:
29+
"""Rank an authoritative pending set without exposing full message bodies."""
30+
if not pending:
31+
return []
32+
by_id = {str(item["msg_id"]): dict(item) for item in pending}
33+
topic_ids = sorted(
34+
{str(item.get("topic_id") or "") for item in pending if item.get("topic_id")}
35+
)
36+
uri = f"file:{Path(db_path).expanduser().resolve()}?mode=ro"
37+
con = sqlite3.connect(uri, uri=True, timeout=1.0)
38+
con.row_factory = sqlite3.Row
39+
try:
40+
con.execute("PRAGMA query_only=ON")
41+
con.execute("PRAGMA busy_timeout=1000")
42+
ranked = search_debate_context(
43+
con,
44+
query=query,
45+
topic_ids=topic_ids,
46+
candidate_msg_ids=list(by_id),
47+
target_role=role,
48+
target_session_id=session_id,
49+
limit=limit,
50+
snippet_bytes=snippet_bytes,
51+
max_query_ms=max_query_ms,
52+
)
53+
finally:
54+
con.close()
55+
56+
out: list[dict[str, Any]] = []
57+
for hit in ranked["results"]:
58+
item = by_id[str(hit["msg_id"])]
59+
item["body"] = hit["snippet"]
60+
item["retrieval"] = {
61+
"rank": hit["rank"],
62+
"score": hit["score"],
63+
"source_ranks": hit["source_ranks"],
64+
"body_bytes": hit["body_bytes"],
65+
"snippet_bytes": len(hit["snippet"].encode("utf-8")),
66+
}
67+
out.append(item)
68+
return out

0 commit comments

Comments
 (0)