| """Always-on monitor that auto-continues the agent when a background job |
| (see src/bg_jobs.py) finishes. |
| |
| Reliability is the whole point: completion → agent re-invocation must never |
| silently no-op. The monitor drains `bg_jobs.pending_followups()` every tick and |
| only calls `mark_followed_up()` AFTER the agent run succeeds — so a transient |
| failure is simply retried on the next tick. A timed-out/dead job still produces |
| a follow-up ("the job failed/timed out"), so the user always hears back. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import asyncio |
| import json |
| import logging |
|
|
| from src import bg_jobs |
|
|
| logger = logging.getLogger(__name__) |
|
|
| _monitor_task = None |
| POLL_INTERVAL_S = 5 |
| |
| |
| _FOLLOWUP_MAX_ROUNDS = 12 |
|
|
|
|
| async def _drain_agent(sess, messages): |
| """Run the agent loop headless against a session. Returns |
| (final_prose, tool_events) — tool_events in the same shape the live chat |
| saves, so the frontend rebuilds them as standard agent-thread tool cards.""" |
| from src.agent_loop import stream_agent_loop |
| full = "" |
| tool_events = [] |
| round_num = 1 |
| async for chunk in stream_agent_loop( |
| sess.endpoint_url, sess.model, messages, |
| headers=getattr(sess, "headers", None), |
| context_length=getattr(sess, "context_length", 0) or 0, |
| session_id=sess.id, |
| max_rounds=_FOLLOWUP_MAX_ROUNDS, |
| owner=getattr(sess, "owner", None), |
| ): |
| if not chunk.startswith("data: "): |
| continue |
| body = chunk[6:].strip() |
| if not body or body == "[DONE]": |
| continue |
| try: |
| d = json.loads(body) |
| except (ValueError, TypeError): |
| continue |
| if not isinstance(d, dict): |
| continue |
| if "delta" in d: |
| delta = d.get("delta") |
| if isinstance(delta, str): |
| full += delta |
| elif d.get("type") == "agent_step": |
| round_num = d.get("round", round_num) |
| elif d.get("type") == "tool_output": |
| |
| tool_events.append({ |
| "round": round_num, |
| "tool": d.get("tool"), |
| "command": d.get("command"), |
| "output": d.get("output"), |
| "exit_code": d.get("exit_code"), |
| }) |
| return full, tool_events |
|
|
|
|
| async def _run_followup(rec: dict) -> bool: |
| """Re-invoke the agent in the job's session with the result. Returns True |
| if the follow-up completed (or there's nothing to do) — i.e. it's safe to |
| mark followed_up. Returns False to retry on the next tick.""" |
| from src.ai_interaction import get_session_manager |
| from core.models import ChatMessage |
|
|
| sm = get_session_manager() |
| if not sm: |
| return False |
| sess = sm.get_session(rec["session_id"]) |
| if not sess: |
| |
| |
| logger.info("bg-followup: session %s gone for job %s — skipping", rec.get("session_id"), rec.get("id")) |
| return True |
|
|
| |
| |
| |
| |
| try: |
| from src import agent_runs |
| if agent_runs.is_active(sess.id): |
| logger.info("bg-followup: session %s busy (live turn) — deferring job %s", sess.id, rec.get("id")) |
| return False |
| except Exception: |
| pass |
|
|
| inject = ( |
| f"[Background job {rec['id']} finished]\n\n" |
| f"{bg_jobs.result_text(rec)}\n\n" |
| "Continue the task using this output. Don't repeat work that's already done. " |
| "If the task is now complete, give the user the final result." |
| ) |
| context = sess.get_context_messages() |
| context.append({"role": "user", "content": inject}) |
|
|
| full, tool_events = await _drain_agent(sess, context) |
|
|
| |
| |
| |
| |
| |
| sm.add_message(sess.id, ChatMessage( |
| "assistant", full, |
| metadata={ |
| "tool_events": tool_events, |
| "model": sess.model, |
| "bg_job_id": rec["id"], |
| "bg_result": bg_jobs.result_text(rec)[:4000], |
| }, |
| )) |
| sm.save_sessions() |
| logger.info("bg-followup: auto-continued session %s for job %s (%d chars, %d tools)", |
| sess.id, rec["id"], len(full), len(tool_events)) |
| return True |
|
|
|
|
| async def _loop(): |
| while True: |
| try: |
| for rec in bg_jobs.pending_followups(): |
| try: |
| if await _run_followup(rec): |
| bg_jobs.mark_followed_up(rec["id"]) |
| except Exception as e: |
| |
| logger.warning("bg-followup failed for %s (will retry): %s", rec.get("id"), e) |
| except Exception as e: |
| logger.warning("bg-monitor tick error: %s", e) |
| await asyncio.sleep(POLL_INTERVAL_S) |
|
|
|
|
| def start_bg_monitor(): |
| """Idempotent — start the always-on background-job monitor.""" |
| global _monitor_task |
| if _monitor_task and not _monitor_task.done(): |
| return _monitor_task |
| _monitor_task = asyncio.create_task(_loop()) |
| logger.info("Background-job monitor started (poll %ds)", POLL_INTERVAL_S) |
| return _monitor_task |
|
|