148148# Vehicles a bounded wake-worker may execute. ``implementation`` is
149149# intentionally excluded — it requires a conductor-approved impl vehicle.
150150WAKE_WORKER_VEHICLES = ("analysis" , "review" )
151+ _SINGLETON_MESSAGE_WAKE_RESULTS = {"implementation_requires_impl_vehicle" }
151152VALID_STATES = ("INIT" , "ACTIVE" , "RESOLVED" , "ARCHIVED" )
152153VALID_BINDING_STATES = ("active" , "retired" , "diagnostic" )
153154VALID_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+
11071247def _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 "
0 commit comments