Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 114 additions & 2 deletions cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,102 @@ def _is_token(line: str) -> bool:
_running_job_ids: set = set()
_running_lock = threading.Lock()

# Job IDs the gateway shutdown path force-killed the tool subprocess of
# while still in ``_running_job_ids`` (see ``mark_running_jobs_interrupted``
# below). ``run_one_job``'s own completion path checks this set before
# writing its own ``last_status`` so a cron agent thread that keeps running
# in-process after its tool was killed out from under it — and produces a
# plausible-looking final response from truncated output — can never
# overwrite the interrupted status with a false "ok" (#60432).
_interrupted_job_ids: set = set()


def get_running_job_ids() -> "frozenset[str]":
"""Thread-safe snapshot of cron job IDs currently executing.

A job ID is a member from the moment ``_submit_with_guard`` dispatches
it onto the parallel/sequential pool until ``_process_job`` returns —
i.e. for the job's *entire* run, tool calls included, not just the
ticker's dispatch instant.

The gateway shutdown path (``gateway/run.py::GatewayRunner.
_drain_active_agents``) reads this to treat in-flight cron work as
active the same way it already treats in-flight chat sessions via
``_running_agents`` — cron jobs run through their own thread pool here,
entirely outside that dict, so without this the drain is structurally
blind to them (#60432).
"""
with _running_lock:
return frozenset(_running_job_ids)


def mark_running_jobs_interrupted(reason: str) -> list:
"""Best-effort: mark every currently in-flight cron job interrupted.

Called by the gateway shutdown path immediately after it force-kills
tool subprocesses (``process_registry.kill_all()``). A job whose tool
subprocess was just killed out from under it must never be allowed to
report success — even though its agent thread is still alive in this
same process and may go on to produce a plausible-looking final
response from the now-truncated tool output.

Records the job IDs in ``_interrupted_job_ids`` BEFORE writing
``last_status`` so ``run_one_job``'s own eventual completion for the
same job (racing in its own thread) sees the flag and skips its normal
write instead of clobbering this one — see the check near the end of
``run_one_job``. This does not attempt to correlate the killed
subprocess PID to a specific job ID (the process registry tracks PIDs,
not cron job IDs); any job still dispatched at the moment of a forced
kill is treated as interrupted, matching the coarser precedent already
set by ``GatewayRunner._interrupt_running_agents``, which interrupts
every entry in ``_running_agents`` on a drain timeout without
per-agent correlation either.

Returns the list of job IDs marked, for the caller to log.
"""
with _running_lock:
job_ids = list(_running_job_ids)
_interrupted_job_ids.update(job_ids)
marked = []
for job_id in job_ids:
try:
mark_job_run(job_id, False, reason)
marked.append(job_id)
except Exception as e:
logger.warning("Failed to mark job %s interrupted: %s", job_id, e)
return marked


def _is_interrupted(job_id: str) -> bool:
"""Non-destructive peek at whether the shutdown path has marked
``job_id`` interrupted (see ``mark_running_jobs_interrupted``).

Called by ``run_one_job`` BEFORE it decides what to deliver — a job
whose tool subprocess was killed mid-flight may still produce a
plausible-looking ``final_response`` from the truncated output, and
that must not go out to the user as if it were a normal result.
Unlike ``_consume_interrupted_flag`` below, this does not clear the
flag: the later, authoritative check (right before ``last_status`` is
written) still needs to see it."""
with _running_lock:
return job_id in _interrupted_job_ids


def _consume_interrupted_flag(job_id: str) -> bool:
"""Return True and clear the flag if the shutdown path already marked
``job_id`` interrupted (see ``mark_running_jobs_interrupted``).

Called by ``run_one_job`` right before it would otherwise write its own
``last_status``. Consuming (discarding) rather than just checking keeps
the flag from leaking across a later, unrelated run of the same job ID
(recurring jobs reuse their ID every fire)."""
with _running_lock:
if job_id in _interrupted_job_ids:
_interrupted_job_ids.discard(job_id)
return True
return False


# Sequential (env-mutating) cron jobs — workdir jobs that touch
# process-global runtime state — must run one at a time, but must NOT block the
# ticker thread. A persistent single-thread executor preserves ordering across
Expand Down Expand Up @@ -3332,6 +3428,20 @@ def run_one_job(job: dict, *, adapters=None, loop=None, verbose: bool = False) -
if verbose:
logger.info("Output saved to: %s", output_file)

# If the gateway shutdown killed this job's tool subprocess
# mid-flight (#60432), the agent may still have produced a
# plausible-looking final_response from the truncated output --
# force the failure path so the delivered message is an honest
# "this run was interrupted" summary instead of that response.
# Peek-only: the flag stays set for the authoritative check
# right before mark_job_run below.
if success and _is_interrupted(job["id"]):
success = False
error = (
"Interrupted by gateway shutdown before the run finished "
"(tool subprocess was killed mid-flight)."
)

# Deliver the final response to the origin/target chat.
# If the agent responded with [SILENT], skip delivery (but
# output is already saved above). Failed jobs always deliver.
Expand Down Expand Up @@ -3370,12 +3480,14 @@ def run_one_job(job: dict, *, adapters=None, loop=None, verbose: bool = False) -
success = False
error = "Agent completed but produced empty response (model error, timeout, or misconfiguration)"

mark_job_run(job["id"], success, error, delivery_error=delivery_error)
if not _consume_interrupted_flag(job["id"]):
mark_job_run(job["id"], success, error, delivery_error=delivery_error)
return True

except Exception as e:
logger.error("Error processing job %s: %s", job['id'], e)
mark_job_run(job["id"], False, str(e))
if not _consume_interrupted_flag(job["id"]):
mark_job_run(job["id"], False, str(e))
return False


Expand Down
77 changes: 70 additions & 7 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -4080,6 +4080,26 @@ def _request_clean_exit(self, reason: str) -> None:
def _running_agent_count(self) -> int:
return len(self._running_agents)

def _active_cron_job_count(self) -> int:
"""Count of cron jobs currently executing, from the cron scheduler's
own in-flight tracking (``cron.scheduler._running_job_ids``).

Cron jobs run through a standalone ``AIAgent`` on the scheduler's own
thread pool (``cron/scheduler.py::run_job``), entirely outside
``self._running_agents`` — the dict every OTHER active-work check on
this class (``_running_agent_count``, ``_drain_active_agents``) reads.
Without this, the shutdown drain is structurally blind to in-flight
cron work: it can report ``active_at_start=0`` and proceed straight
to killing tool subprocesses while a cron job's terminal command is
still running (#60432). Best-effort: returns 0 if the cron module
can't be imported (e.g. a minimal test double for this class).
"""
try:
from cron.scheduler import get_running_job_ids
return len(get_running_job_ids())
except Exception:
return 0

# ── scale-to-zero idle detection / dormant-quiesce (Phase 0) ──────────────
# The gateway-side BEHAVIOUR that consumes the relay scale-to-zero primitives
# (gateway-gateway Phase 5). Pure logic lives in gateway/scale_to_zero.py; the
Expand Down Expand Up @@ -5550,18 +5570,31 @@ async def _handle_active_session_busy_message(self, event: MessageEvent, session
async def _drain_active_agents(self, timeout: float) -> tuple[Dict[str, Any], bool]:
snapshot = self._snapshot_running_agents()
last_active_count = self._running_agent_count()
last_cron_count = self._active_cron_job_count()
last_status_at = 0.0

def _maybe_update_status(force: bool = False) -> None:
nonlocal last_active_count, last_status_at
nonlocal last_active_count, last_cron_count, last_status_at
now = asyncio.get_running_loop().time()
active_count = self._running_agent_count()
if force or active_count != last_active_count or (now - last_status_at) >= 1.0:
cron_count = self._active_cron_job_count()
if (
force
or active_count != last_active_count
or cron_count != last_cron_count
or (now - last_status_at) >= 1.0
):
self._update_runtime_status("draining")
last_active_count = active_count
last_cron_count = cron_count
last_status_at = now

if not self._running_agents:
# Cron jobs run on the scheduler's own thread pool, outside
# ``self._running_agents`` — fold their in-flight count into the
# same wait/timeout this method already applies to chat sessions,
# or a cron job's tool work gets killed with zero warning the
# instant it's the only active thing running (#60432).
if not self._running_agents and last_cron_count == 0:
_maybe_update_status(force=True)
return snapshot, False

Expand All @@ -5570,10 +5603,13 @@ def _maybe_update_status(force: bool = False) -> None:
return snapshot, True

deadline = asyncio.get_running_loop().time() + timeout
while self._running_agents and asyncio.get_running_loop().time() < deadline:
while (
(self._running_agents or self._active_cron_job_count())
and asyncio.get_running_loop().time() < deadline
):
_maybe_update_status()
await asyncio.sleep(0.1)
timed_out = bool(self._running_agents)
timed_out = bool(self._running_agents) or bool(self._active_cron_job_count())
_maybe_update_status(force=True)
return snapshot, timed_out

Expand Down Expand Up @@ -7944,6 +7980,27 @@ def _kill_tool_subprocesses(phase: str) -> None:
)
except Exception as _e:
logger.debug("process_registry.kill_all (%s) error: %s", phase, _e)
try:
# Any cron job still dispatched at this instant just had
# its tool subprocess killed above (kill_all() has no
# per-job-ID targeting — it's a global sweep). Its agent
# thread is still alive in this process and may go on to
# produce a plausible-looking final response from the
# now-truncated tool output; mark the run interrupted so
# the scheduler can never report that as success (#60432).
# No-op when no cron job is in flight.
from cron.scheduler import mark_running_jobs_interrupted
_interrupted = mark_running_jobs_interrupted(
f"Gateway shutdown ({phase}) killed the job's tool "
"subprocess before the run finished."
)
if _interrupted:
logger.warning(
"Shutdown (%s): marked %d in-flight cron job(s) interrupted: %s",
phase, len(_interrupted), ", ".join(_interrupted),
)
except Exception as _e:
logger.debug("mark_running_jobs_interrupted (%s) error: %s", phase, _e)
try:
from tools.async_delegation import interrupt_all as _interrupt_async
_async_n = _interrupt_async(reason=f"gateway shutdown ({phase})")
Expand Down Expand Up @@ -8004,16 +8061,20 @@ def _phase_elapsed() -> float:
except Exception as _e:
logger.debug("pre-drain mark_resume_pending failed for %s: %s", _sk, _e)

_cron_at_start = self._active_cron_job_count()
_drain_started_at = time.monotonic()
active_agents, timed_out = await self._drain_active_agents(timeout)
logger.info(
"Shutdown phase: drain done at +%.2fs (drain took %.2fs, "
"timed_out=%s, active_at_start=%d, active_now=%d)",
"timed_out=%s, active_at_start=%d, active_now=%d, "
"cron_at_start=%d, cron_now=%d)",
_phase_elapsed(),
time.monotonic() - _drain_started_at,
timed_out,
len(active_agents),
self._running_agent_count(),
_cron_at_start,
self._active_cron_job_count(),
)

if not timed_out:
Expand All @@ -8032,9 +8093,11 @@ def _phase_elapsed() -> float:

if timed_out:
logger.warning(
"Gateway drain timed out after %.1fs with %d active agent(s); interrupting remaining work.",
"Gateway drain timed out after %.1fs with %d active agent(s) "
"and %d in-flight cron job(s); interrupting remaining work.",
timeout,
self._running_agent_count(),
self._active_cron_job_count(),
)
# Mark forcibly-interrupted sessions as resume_pending BEFORE
# interrupting the agents. This preserves each session's
Expand Down
Loading
Loading