| """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
|
|
|