Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,631 @@
|
||||
"""Tests for #60432: cron jobs must not be silently invisible to gateway
|
||||
shutdown, and a job whose tool subprocess got killed by shutdown must
|
||||
never be reported as a successful run.
|
||||
|
||||
Covers the cron/scheduler.py primitives directly:
|
||||
- get_running_job_ids() -- thread-safe snapshot the gateway drain reads
|
||||
- mark_running_jobs_interrupted() -- called by the gateway right after
|
||||
it force-kills tool subprocesses
|
||||
- the interrupted-flag race guard in run_one_job(), which must win over
|
||||
the job's own thread finishing normally with a plausible-looking
|
||||
result AFTER its tool was already killed out from under it
|
||||
"""
|
||||
|
||||
import threading
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_scheduler_state():
|
||||
"""Every test starts from a clean slate and leaves one behind, since
|
||||
these sets are module-level globals shared across the test process."""
|
||||
import cron.scheduler as sched
|
||||
|
||||
sched._running_job_ids.clear()
|
||||
sched._running_fire_owners.clear()
|
||||
sched._interrupted_job_ids.clear()
|
||||
yield
|
||||
sched._running_job_ids.clear()
|
||||
sched._running_fire_owners.clear()
|
||||
sched._interrupted_job_ids.clear()
|
||||
|
||||
|
||||
class TestGetRunningJobIds:
|
||||
def test_empty_when_nothing_running(self):
|
||||
import cron.scheduler as sched
|
||||
|
||||
assert sched.get_running_job_ids() == frozenset()
|
||||
|
||||
def test_reflects_in_flight_jobs(self):
|
||||
import cron.scheduler as sched
|
||||
|
||||
sched._running_job_ids.add("job-1")
|
||||
sched._running_job_ids.add("job-2")
|
||||
|
||||
result = sched.get_running_job_ids()
|
||||
|
||||
assert result == frozenset({"job-1", "job-2"})
|
||||
|
||||
def test_snapshot_is_immutable_and_independent(self):
|
||||
"""Mutating _running_job_ids after the call must not change the
|
||||
already-returned snapshot -- callers (the gateway drain loop) rely
|
||||
on this to safely count in a tight polling loop."""
|
||||
import cron.scheduler as sched
|
||||
|
||||
sched._running_job_ids.add("job-1")
|
||||
snapshot = sched.get_running_job_ids()
|
||||
sched._running_job_ids.add("job-2")
|
||||
|
||||
assert snapshot == frozenset({"job-1"})
|
||||
|
||||
|
||||
class TestMarkRunningJobsInterrupted:
|
||||
def test_no_op_when_nothing_running(self):
|
||||
import cron.scheduler as sched
|
||||
|
||||
with patch("cron.scheduler.mark_job_run") as mock_mark:
|
||||
marked = sched.mark_running_jobs_interrupted("shutdown")
|
||||
|
||||
assert marked == []
|
||||
mock_mark.assert_not_called()
|
||||
|
||||
def test_marks_every_in_flight_job(self):
|
||||
import cron.scheduler as sched
|
||||
|
||||
sched._running_job_ids.update({"job-1", "job-2"})
|
||||
profile_home = sched._get_hermes_home().resolve()
|
||||
sched._running_fire_owners.update(
|
||||
{
|
||||
"job-1": {object(): ("owner-1", profile_home)},
|
||||
"job-2": {object(): ("owner-2", profile_home)},
|
||||
}
|
||||
)
|
||||
|
||||
with patch("cron.scheduler.mark_job_run", return_value=True) as mock_mark:
|
||||
marked = sched.mark_running_jobs_interrupted("gateway shutdown (final-cleanup)")
|
||||
|
||||
assert sorted(marked) == ["job-1", "job-2"]
|
||||
assert mock_mark.call_count == 2
|
||||
called_ids = {c.args[0] for c in mock_mark.call_args_list}
|
||||
assert called_ids == {"job-1", "job-2"}
|
||||
for c in mock_mark.call_args_list:
|
||||
# success must be False -- an interrupted run is never "ok".
|
||||
assert c.args[1] is False
|
||||
assert "gateway shutdown" in c.args[2]
|
||||
assert c.kwargs["expected_fire_owner"] in {"owner-1", "owner-2"}
|
||||
|
||||
def test_sets_interrupted_flag_for_consumption_by_run_one_job(self):
|
||||
import cron.scheduler as sched
|
||||
|
||||
sched._running_job_ids.add("job-1")
|
||||
|
||||
with patch("cron.scheduler.mark_job_run"):
|
||||
sched.mark_running_jobs_interrupted("shutdown")
|
||||
|
||||
assert "job-1" in sched._interrupted_job_ids
|
||||
|
||||
def test_one_job_marking_failure_does_not_block_the_others(self):
|
||||
"""mark_job_run raising for one job (e.g. a jobs.json write race)
|
||||
must not prevent the rest from being marked -- this runs during
|
||||
shutdown, there's no retry window."""
|
||||
import cron.scheduler as sched
|
||||
|
||||
sched._running_job_ids.update({"job-1", "job-2"})
|
||||
profile_home = sched._get_hermes_home().resolve()
|
||||
sched._running_fire_owners.update(
|
||||
{
|
||||
"job-1": {object(): ("owner-1", profile_home)},
|
||||
"job-2": {object(): ("owner-2", profile_home)},
|
||||
}
|
||||
)
|
||||
|
||||
def _side_effect(job_id, success, reason, **kwargs):
|
||||
if job_id == "job-1":
|
||||
raise OSError("disk full")
|
||||
return True
|
||||
|
||||
with patch("cron.scheduler.mark_job_run", side_effect=_side_effect):
|
||||
marked = sched.mark_running_jobs_interrupted("shutdown")
|
||||
|
||||
assert marked == ["job-2"]
|
||||
|
||||
def test_stale_shutdown_cannot_clear_replacement_owner(self, tmp_path):
|
||||
import cron.jobs as jobs
|
||||
import cron.scheduler as sched
|
||||
|
||||
profile_home = tmp_path / "profile"
|
||||
profile_home.mkdir()
|
||||
with jobs.use_cron_store(profile_home):
|
||||
created = jobs.create_job(prompt="x", schedule="every 5m", name="owned")
|
||||
claimed = jobs.claim_job_for_fire(created["id"], force=True, return_job=True)
|
||||
assert isinstance(claimed, dict)
|
||||
stale_owner = claimed["fire_claim"]["by"]
|
||||
original_status = claimed["last_status"]
|
||||
replacement_claim = {
|
||||
"at": "2026-07-12T12:30:00+00:00",
|
||||
"by": "replacement-owner",
|
||||
}
|
||||
replacement = {**claimed, "fire_claim": replacement_claim}
|
||||
jobs.save_jobs([replacement])
|
||||
|
||||
sched._running_job_ids.add(created["id"])
|
||||
sched._running_fire_owners[created["id"]] = {
|
||||
object(): (stale_owner, profile_home)
|
||||
}
|
||||
marked = sched.mark_running_jobs_interrupted("shutdown")
|
||||
refreshed = jobs.get_job(created["id"])
|
||||
|
||||
assert marked == []
|
||||
assert isinstance(refreshed, dict)
|
||||
assert refreshed["fire_claim"] == replacement_claim
|
||||
assert refreshed["last_status"] == original_status
|
||||
|
||||
|
||||
class TestRunningFireOwnerRegistry:
|
||||
def test_run_one_job_registers_owner_only_while_active(self):
|
||||
import cron.scheduler as sched
|
||||
|
||||
job = {
|
||||
"id": "owned-job",
|
||||
"fire_claim": {"at": "2026-07-12T12:00:00+00:00", "by": "owner-1"},
|
||||
}
|
||||
|
||||
def _observe_registry(current_job, run):
|
||||
assert list(sched._running_fire_owners[current_job["id"]].values()) == [
|
||||
("owner-1", sched._get_hermes_home().resolve())
|
||||
]
|
||||
return True
|
||||
|
||||
with patch("cron.scheduler._run_with_fire_claim_heartbeat", side_effect=_observe_registry):
|
||||
assert sched.run_one_job(job) is True
|
||||
|
||||
assert job["id"] not in sched._running_fire_owners
|
||||
|
||||
def test_shutdown_sees_all_concurrent_direct_fire_owners(self, monkeypatch):
|
||||
"""Direct entry points and replacement owners share one token registry."""
|
||||
import cron.scheduler as sched
|
||||
|
||||
entered = threading.Barrier(3)
|
||||
release = threading.Event()
|
||||
marked_owners: list[str] = []
|
||||
|
||||
def hold_run(_job, _run):
|
||||
entered.wait(timeout=2)
|
||||
release.wait(timeout=2)
|
||||
return True
|
||||
|
||||
def mark(_job_id, _success, _reason, *, expected_fire_owner):
|
||||
marked_owners.append(expected_fire_owner)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(sched, "_run_with_fire_claim_heartbeat", hold_run)
|
||||
monkeypatch.setattr(sched, "mark_job_run", mark)
|
||||
|
||||
jobs = [
|
||||
{"id": "same-job", "fire_claim": {"by": "old-owner"}},
|
||||
{"id": "same-job", "fire_claim": {"by": "replacement-owner"}},
|
||||
]
|
||||
threads = [threading.Thread(target=sched.run_one_job, args=(job,)) for job in jobs]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
entered.wait(timeout=2)
|
||||
|
||||
assert sched.get_running_job_ids() == frozenset({"same-job"})
|
||||
assert sched.mark_running_jobs_interrupted("shutdown") == ["same-job", "same-job"]
|
||||
assert set(marked_owners) == {"old-owner", "replacement-owner"}
|
||||
|
||||
release.set()
|
||||
for thread in threads:
|
||||
thread.join(timeout=2)
|
||||
assert not thread.is_alive()
|
||||
assert "same-job" not in sched.get_running_job_ids()
|
||||
|
||||
def test_shutdown_marks_each_owner_in_its_profile_store(self, monkeypatch, tmp_path):
|
||||
import cron.jobs as cron_jobs
|
||||
import cron.scheduler as sched
|
||||
|
||||
profile_a = tmp_path / "a"
|
||||
profile_b = tmp_path / "b"
|
||||
observed = []
|
||||
sched._running_fire_owners["same-job"] = {
|
||||
object(): ("owner-a", profile_a),
|
||||
object(): ("owner-b", profile_b),
|
||||
}
|
||||
|
||||
def mark(job_id, success, reason, *, expected_fire_owner):
|
||||
observed.append(
|
||||
(
|
||||
job_id,
|
||||
success,
|
||||
expected_fire_owner,
|
||||
cron_jobs._current_cron_store().jobs_file,
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(sched, "mark_job_run", mark)
|
||||
|
||||
assert sched.mark_running_jobs_interrupted("shutdown") == ["same-job", "same-job"]
|
||||
assert set(observed) == {
|
||||
("same-job", False, "owner-a", profile_a / "cron" / "jobs.json"),
|
||||
("same-job", False, "owner-b", profile_b / "cron" / "jobs.json"),
|
||||
}
|
||||
|
||||
|
||||
class TestIsInterrupted:
|
||||
"""Peek-only check used at the delivery gate -- must NOT clear the
|
||||
flag, unlike _consume_interrupted_flag."""
|
||||
|
||||
def test_false_when_not_marked(self):
|
||||
import cron.scheduler as sched
|
||||
|
||||
assert sched._is_interrupted("job-1") is False
|
||||
|
||||
def test_true_when_marked(self):
|
||||
import cron.scheduler as sched
|
||||
|
||||
sched._interrupted_job_ids.add("job-1")
|
||||
|
||||
assert sched._is_interrupted("job-1") is True
|
||||
|
||||
def test_does_not_clear_the_flag(self):
|
||||
import cron.scheduler as sched
|
||||
|
||||
sched._interrupted_job_ids.add("job-1")
|
||||
|
||||
sched._is_interrupted("job-1")
|
||||
|
||||
# Still set -- the later, authoritative check before mark_job_run
|
||||
# must still see it.
|
||||
assert "job-1" in sched._interrupted_job_ids
|
||||
assert sched._is_interrupted("job-1") is True
|
||||
|
||||
|
||||
class TestConsumeInterruptedFlag:
|
||||
|
||||
def test_true_and_clears_when_marked(self):
|
||||
import cron.scheduler as sched
|
||||
|
||||
sched._interrupted_job_ids.add("job-1")
|
||||
|
||||
assert sched._consume_interrupted_flag("job-1") is True
|
||||
# Consumed -- a second check (e.g. a later, unrelated fire of the
|
||||
# same recurring job ID) must not still read as interrupted.
|
||||
assert sched._consume_interrupted_flag("job-1") is False
|
||||
|
||||
|
||||
class TestExecutionScopedInterruption:
|
||||
"""Interruption flags must target ONE execution, not the job ID.
|
||||
|
||||
Owner-registered executions are recorded by their unique execution
|
||||
token, so a fresh run that reuses the same job ID (recurring fire,
|
||||
replacement claim owner) never consumes a flag that targeted its
|
||||
dead predecessor.
|
||||
"""
|
||||
|
||||
def test_interruption_targets_only_the_interrupted_execution(self):
|
||||
import cron.scheduler as sched
|
||||
|
||||
profile_home = sched._get_hermes_home().resolve()
|
||||
old_token = object()
|
||||
sched._running_fire_owners["job-1"] = {
|
||||
old_token: ("owner-1", profile_home),
|
||||
}
|
||||
|
||||
with patch("cron.scheduler.mark_job_run", return_value=True):
|
||||
sched.mark_running_jobs_interrupted("shutdown")
|
||||
|
||||
assert sched._is_interrupted("job-1", old_token) is True
|
||||
new_token = object()
|
||||
assert sched._is_interrupted("job-1", new_token) is False
|
||||
# A new execution must not steal (and thereby clear) the old flag.
|
||||
assert sched._consume_interrupted_flag("job-1", new_token) is False
|
||||
assert sched._consume_interrupted_flag("job-1", old_token) is True
|
||||
assert sched._is_interrupted("job-1", old_token) is False
|
||||
|
||||
def test_only_owners_marks_only_targeted_executions(self):
|
||||
import cron.scheduler as sched
|
||||
|
||||
profile_home = sched._get_hermes_home().resolve()
|
||||
token_a, token_b = object(), object()
|
||||
sched._running_fire_owners["job-a"] = {token_a: ("owner-a", profile_home)}
|
||||
sched._running_fire_owners["job-b"] = {token_b: ("owner-b", profile_home)}
|
||||
|
||||
with patch("cron.scheduler.mark_job_run", return_value=True) as mock_mark:
|
||||
marked = sched.mark_running_jobs_interrupted(
|
||||
"dashboard shutdown",
|
||||
only_owners={("job-a", "owner-a")},
|
||||
)
|
||||
|
||||
assert marked == ["job-a"]
|
||||
assert mock_mark.call_count == 1
|
||||
assert mock_mark.call_args.kwargs["expected_fire_owner"] == "owner-a"
|
||||
assert sched._is_interrupted("job-a", token_a) is True
|
||||
assert sched._is_interrupted("job-b", token_b) is False
|
||||
|
||||
def test_replacement_execution_of_same_job_is_not_poisoned(self):
|
||||
"""A replacement owner starting while the stale flag exists must
|
||||
complete through the normal mark path, not the interrupted one."""
|
||||
import cron.scheduler as sched
|
||||
|
||||
profile_home = sched._get_hermes_home().resolve()
|
||||
stale_token = object()
|
||||
sched._running_fire_owners["job-1"] = {
|
||||
stale_token: ("stale-owner", profile_home),
|
||||
}
|
||||
with patch("cron.scheduler.mark_job_run", return_value=True):
|
||||
sched.mark_running_jobs_interrupted("shutdown")
|
||||
sched._running_fire_owners.clear()
|
||||
|
||||
job = {
|
||||
"id": "job-1",
|
||||
"name": "test job",
|
||||
"prompt": "do work",
|
||||
"fire_claim": {"by": "replacement-owner"},
|
||||
}
|
||||
with patch("cron.scheduler.claim_dispatch", return_value=True), \
|
||||
patch("agent.secret_scope.set_secret_scope", return_value=None), \
|
||||
patch("agent.secret_scope.build_profile_secret_scope", return_value=None), \
|
||||
patch("agent.secret_scope.reset_secret_scope"), \
|
||||
patch(
|
||||
"cron.scheduler.run_job",
|
||||
return_value=(True, "full output", "final response", None),
|
||||
), \
|
||||
patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \
|
||||
patch("cron.scheduler._is_cron_silence_response", return_value=False), \
|
||||
patch("cron.scheduler._deliver_result", return_value=None), \
|
||||
patch("cron.scheduler.fire_claim_fence"), \
|
||||
patch("cron.scheduler.heartbeat_fire_claim", return_value=True), \
|
||||
patch("cron.scheduler.mark_job_run", return_value=True) as mock_mark:
|
||||
result = sched.run_one_job(job)
|
||||
|
||||
assert result is True
|
||||
mock_mark.assert_called_once()
|
||||
|
||||
|
||||
class TestCombinedCancelEvent:
|
||||
def test_or_semantics(self):
|
||||
import cron.scheduler as sched
|
||||
|
||||
a, b = threading.Event(), threading.Event()
|
||||
combined = sched._CombinedCancelEvent(a, b)
|
||||
assert combined.is_set() is False
|
||||
b.set()
|
||||
assert combined.is_set() is True
|
||||
|
||||
def test_set_propagates_to_all(self):
|
||||
import cron.scheduler as sched
|
||||
|
||||
a, b = threading.Event(), threading.Event()
|
||||
combined = sched._CombinedCancelEvent(a, b)
|
||||
combined.set()
|
||||
assert a.is_set() and b.is_set()
|
||||
|
||||
def test_run_one_job_forwards_external_cancel_event(self):
|
||||
import cron.scheduler as sched
|
||||
|
||||
external = threading.Event()
|
||||
job = {"id": "job-x", "name": "x", "prompt": "p"}
|
||||
|
||||
with patch.object(
|
||||
sched,
|
||||
"_run_with_fire_claim_heartbeat",
|
||||
side_effect=lambda job_arg, run: run(threading.Event()),
|
||||
), patch.object(sched, "_run_one_job_body", return_value=True) as body:
|
||||
assert sched.run_one_job(job, cancel_event=external) is True
|
||||
|
||||
combined = body.call_args.kwargs["fire_claim_lost"]
|
||||
assert combined.is_set() is False
|
||||
external.set()
|
||||
assert combined.is_set() is True
|
||||
|
||||
|
||||
class TestBaseExceptionThroughOwnerFencedFlow:
|
||||
"""#73973 (sweeper review on #70638): a BaseException escaping run_job
|
||||
must still record a failed run through the owner-fenced terminal path —
|
||||
and a stale worker must not record over a replacement claim owner."""
|
||||
|
||||
def _job(self):
|
||||
return {
|
||||
"id": "job-be",
|
||||
"name": "base exc",
|
||||
"prompt": "p",
|
||||
"fire_claim": {"by": "owner-be"},
|
||||
}
|
||||
|
||||
def _patches(self, run_side_effect):
|
||||
return (
|
||||
patch("cron.scheduler.claim_dispatch", return_value=True),
|
||||
patch("agent.secret_scope.set_secret_scope", return_value=None),
|
||||
patch("agent.secret_scope.build_profile_secret_scope", return_value=None),
|
||||
patch("agent.secret_scope.reset_secret_scope"),
|
||||
patch("cron.scheduler.run_job", side_effect=run_side_effect),
|
||||
patch("cron.scheduler.heartbeat_fire_claim", return_value=True),
|
||||
)
|
||||
|
||||
def test_cancelled_error_records_failure_and_reraises(self):
|
||||
import asyncio
|
||||
|
||||
import cron.scheduler as sched
|
||||
|
||||
p1, p2, p3, p4, p5, p6 = self._patches(asyncio.CancelledError())
|
||||
with p1, p2, p3, p4, p5, p6, \
|
||||
patch("cron.scheduler.mark_job_run", return_value=True) as mock_mark, \
|
||||
patch("cron.scheduler.finish_execution") as mock_finish:
|
||||
try:
|
||||
sched.run_one_job(self._job())
|
||||
raised = False
|
||||
except asyncio.CancelledError:
|
||||
raised = True
|
||||
|
||||
assert raised, "non-Exception BaseException must propagate"
|
||||
mock_mark.assert_called_once()
|
||||
assert mock_mark.call_args.args[:3] == ("job-be", False, "CancelledError")
|
||||
assert mock_mark.call_args.kwargs["expected_fire_owner"] == "owner-be"
|
||||
assert mock_finish.call_args.kwargs["success"] is False
|
||||
|
||||
def test_keyboard_interrupt_records_failure_and_reraises(self):
|
||||
import cron.scheduler as sched
|
||||
|
||||
p1, p2, p3, p4, p5, p6 = self._patches(KeyboardInterrupt())
|
||||
with p1, p2, p3, p4, p5, p6, \
|
||||
patch("cron.scheduler.mark_job_run", return_value=True) as mock_mark, \
|
||||
patch("cron.scheduler.finish_execution"):
|
||||
try:
|
||||
sched.run_one_job(self._job())
|
||||
raised = False
|
||||
except KeyboardInterrupt:
|
||||
raised = True
|
||||
|
||||
assert raised
|
||||
mock_mark.assert_called_once()
|
||||
assert mock_mark.call_args.kwargs["expected_fire_owner"] == "owner-be"
|
||||
|
||||
def test_base_exception_from_stale_owner_is_fenced_out(self):
|
||||
"""A replacement owner reclaimed the job: the stale worker's
|
||||
BaseException path must NOT write terminal state over it."""
|
||||
import asyncio
|
||||
|
||||
import cron.scheduler as sched
|
||||
|
||||
p1, p2, p3, p4, p5, p6 = self._patches(asyncio.CancelledError())
|
||||
with p1, p2, p3, p4, p5, p6, \
|
||||
patch("cron.scheduler.mark_job_run", return_value=False) as mock_mark, \
|
||||
patch("cron.scheduler.finish_execution"):
|
||||
try:
|
||||
sched.run_one_job(self._job())
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
mock_mark.assert_called_once()
|
||||
# fenced write was attempted with the stale owner and discarded by
|
||||
# the store (return False) — and the code accepted that verdict
|
||||
# without retrying or writing anything else.
|
||||
assert mock_mark.call_args.kwargs["expected_fire_owner"] == "owner-be"
|
||||
|
||||
|
||||
class TestCallerLossAfterClaimAcquisition:
|
||||
"""cirwel's integration assertion on #70638: if the HTTP/CLI caller is
|
||||
lost AFTER the claim was acquired, the gateway owner must produce at
|
||||
most one terminal ledger/artifact/delivery, clear only its own claim,
|
||||
and block retries while that ownership is live."""
|
||||
|
||||
def test_second_fire_cannot_claim_while_first_ownership_live(self, tmp_path):
|
||||
import cron.jobs as jobs
|
||||
|
||||
with jobs.use_cron_store(tmp_path):
|
||||
job = jobs.create_job(prompt="x", schedule="every 5m", name="owned")
|
||||
claimed = jobs.claim_job_for_fire(job["id"], force=True, return_job=True)
|
||||
assert isinstance(claimed, dict)
|
||||
|
||||
# Caller died here — the claim outlives it. A retry (NAS/webhook
|
||||
# or manual) must be refused while the lease is fresh.
|
||||
retry = jobs.claim_job_for_fire(job["id"], return_job=True)
|
||||
assert retry is False or not isinstance(retry, dict)
|
||||
|
||||
# The live owner still heartbeats and terminally marks — exactly
|
||||
# one terminal write, and only its own claim is cleared.
|
||||
owner = claimed["fire_claim"]["by"]
|
||||
assert jobs.heartbeat_fire_claim(job["id"], expected_owner=owner) is True
|
||||
assert jobs.mark_job_run(
|
||||
job["id"], True, expected_fire_owner=owner,
|
||||
) is True
|
||||
refreshed = jobs.get_job(job["id"])
|
||||
assert refreshed["fire_claim"] is None
|
||||
assert refreshed["last_status"] == "ok"
|
||||
|
||||
|
||||
class TestRunOneJobHonoursInterruptedFlag:
|
||||
"""run_one_job() must not let a job's own completion overwrite a
|
||||
status the shutdown path already wrote for the same run."""
|
||||
|
||||
def _make_job(self, job_id="job-1"):
|
||||
return {"id": job_id, "name": "test job", "prompt": "do work"}
|
||||
|
||||
def test_success_path_skipped_when_interrupted(self):
|
||||
import cron.scheduler as sched
|
||||
|
||||
job = self._make_job()
|
||||
sched._interrupted_job_ids.add(job["id"])
|
||||
|
||||
with patch("cron.scheduler.claim_dispatch", return_value=True), \
|
||||
patch("agent.secret_scope.set_secret_scope", return_value=None), \
|
||||
patch("agent.secret_scope.build_profile_secret_scope", return_value=None), \
|
||||
patch("agent.secret_scope.reset_secret_scope"), \
|
||||
patch(
|
||||
"cron.scheduler.run_job",
|
||||
return_value=(True, "full output", "final response", None),
|
||||
), \
|
||||
patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \
|
||||
patch("cron.scheduler._is_cron_silence_response", return_value=False), \
|
||||
patch("cron.scheduler._deliver_result", return_value=None), \
|
||||
patch("cron.scheduler.mark_job_run") as mock_mark:
|
||||
result = sched.run_one_job(job)
|
||||
|
||||
assert result is True
|
||||
# The would-be "success" write must NOT happen -- the shutdown
|
||||
# path already wrote the authoritative interrupted status.
|
||||
mock_mark.assert_not_called()
|
||||
# Flag is consumed so a later, unrelated fire of the same job ID
|
||||
# isn't permanently silenced.
|
||||
assert job["id"] not in sched._interrupted_job_ids
|
||||
|
||||
def test_interrupted_job_delivers_failure_summary_not_raw_response(self):
|
||||
"""The status-write guard alone isn't enough: delivery happens
|
||||
BEFORE mark_job_run in run_one_job's own flow, so a job that kept
|
||||
running post-kill and produced a plausible-looking final_response
|
||||
must not have that response sent to the user just because the
|
||||
eventual status write gets suppressed. Interrupted jobs must route
|
||||
through the same failure-summary delivery path a real failure
|
||||
would."""
|
||||
import cron.scheduler as sched
|
||||
|
||||
job = self._make_job()
|
||||
sched._interrupted_job_ids.add(job["id"])
|
||||
|
||||
with patch("cron.scheduler.claim_dispatch", return_value=True), \
|
||||
patch("agent.secret_scope.set_secret_scope", return_value=None), \
|
||||
patch("agent.secret_scope.build_profile_secret_scope", return_value=None), \
|
||||
patch("agent.secret_scope.reset_secret_scope"), \
|
||||
patch(
|
||||
"cron.scheduler.run_job",
|
||||
return_value=(True, "full output", "a plausible final response", None),
|
||||
), \
|
||||
patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \
|
||||
patch(
|
||||
"cron.scheduler._summarize_cron_failure_for_delivery",
|
||||
return_value="This run was interrupted.",
|
||||
) as mock_summarize, \
|
||||
patch("cron.scheduler._is_cron_silence_response", return_value=False), \
|
||||
patch("cron.scheduler._deliver_result", return_value=None) as mock_deliver, \
|
||||
patch("cron.scheduler.mark_job_run"):
|
||||
result = sched.run_one_job(job)
|
||||
|
||||
assert result is True
|
||||
mock_summarize.assert_called_once()
|
||||
# The summarizer's error argument must mention the interruption,
|
||||
# not be silently None / the agent's own (possibly absent) error.
|
||||
assert "interrupt" in mock_summarize.call_args.args[1].lower()
|
||||
delivered_content = mock_deliver.call_args.args[1]
|
||||
assert delivered_content == "This run was interrupted."
|
||||
assert "plausible final response" not in delivered_content
|
||||
|
||||
|
||||
def test_exception_path_also_honours_interrupted_flag(self):
|
||||
import cron.scheduler as sched
|
||||
|
||||
job = self._make_job()
|
||||
sched._interrupted_job_ids.add(job["id"])
|
||||
|
||||
with patch("cron.scheduler.claim_dispatch", return_value=True), \
|
||||
patch("agent.secret_scope.set_secret_scope", return_value=None), \
|
||||
patch("agent.secret_scope.build_profile_secret_scope", return_value=None), \
|
||||
patch("agent.secret_scope.reset_secret_scope"), \
|
||||
patch("cron.scheduler.run_job", side_effect=RuntimeError("boom")), \
|
||||
patch("cron.scheduler.mark_job_run") as mock_mark:
|
||||
result = sched.run_one_job(job)
|
||||
|
||||
assert result is False
|
||||
mock_mark.assert_not_called()
|
||||
Reference in New Issue
Block a user