275 lines
12 KiB
Python
275 lines
12 KiB
Python
"""Honesty of the manual-run delivery notice (issue #83993).
|
|
|
|
A manual ``cronjob(action='run')`` finishes with a completion summary line
|
|
|
|
Delivery target: <target> (output was delivered there by the job itself)
|
|
|
|
that was appended UNCONDITIONALLY for non-local targets — even when
|
|
``run_one_job`` had just written ``last_delivery_error`` onto the refreshed
|
|
job record because the post-run delivery (telegram/discord/…) failed. The
|
|
calling agent then relayed "all good" over a failed delivery.
|
|
|
|
The note must follow the refreshed job record: a set ``last_delivery_error``
|
|
means delivery FAILED with the error text surfaced; an empty/missing error
|
|
keeps the legacy wording byte-for-byte (zero regression), and local jobs
|
|
always say saved-locally.
|
|
"""
|
|
|
|
import contextlib
|
|
import time
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from tools.cronjob_tools import _manual_run_delivery_note
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_state():
|
|
"""Reset the shared async-delegation world around each test.
|
|
|
|
The dispatch tests below submit real workers onto the process-wide
|
|
daemon executor in ``tools.async_delegation``. A finished worker parks
|
|
idle holding an ``_idle_semaphore`` token, so the NEXT dispatch in this
|
|
process REUSES that thread instead of spawning a fresh one — and only
|
|
the fresh-spawn path keeps upstream's dispatch-and-return test winning
|
|
its patch-visibility race: ``Thread.start()`` blocks the dispatching
|
|
thread until the worker has bootstrapped, so the worker performs
|
|
``_run_claimed_job``'s lazy ``from cron.scheduler import run_one_job``
|
|
while the test's patches are still active. On the idle-reuse path
|
|
``submit`` returns with the GIL still held, the patch block unwinds
|
|
first, and the worker binds the REAL ``run_one_job`` — which then runs
|
|
the fake job for real ("no model configured") and the mock never fires.
|
|
Without this reset, test_cronjob_run_background.py's
|
|
``test_dispatches_and_returns_handle_immediately`` fails
|
|
deterministically whenever this file runs before it. Mirrors
|
|
``tests/tools/test_async_delegation.py::_clean_state``.
|
|
"""
|
|
from tools import async_delegation as ad
|
|
from tools.process_registry import process_registry
|
|
|
|
ad._reset_for_tests()
|
|
while not process_registry.completion_queue.empty():
|
|
process_registry.completion_queue.get_nowait()
|
|
yield
|
|
# Give just-drained workers a beat to finalize BEFORE resetting, so
|
|
# their completion events land now instead of leaking into the next
|
|
# test's queue (mirrors test_async_delegation.py).
|
|
deadline = time.monotonic() + 2.0
|
|
while ad.active_count() and time.monotonic() < deadline:
|
|
time.sleep(0.02)
|
|
ad._reset_for_tests()
|
|
while not process_registry.completion_queue.empty():
|
|
process_registry.completion_queue.get_nowait()
|
|
|
|
|
|
def _job(job_id, deliver):
|
|
"""Per-test job dict with a UNIQUE id.
|
|
|
|
Background workers outlive their test (daemon executor) and hold the id
|
|
in the scheduler's shared running set until the run finishes; reusing an
|
|
id across tests trips the in-flight dedupe guard on a straggler.
|
|
"""
|
|
return {
|
|
"id": job_id,
|
|
"name": f"dn run {job_id}",
|
|
"prompt": "hi",
|
|
"schedule": {"kind": "cron", "expr": "0 9 * * *"},
|
|
"deliver": deliver,
|
|
}
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _bound_session_key(key):
|
|
"""Bind the approval session key contextvar (background dispatch gate)."""
|
|
from tools.approval import _approval_session_key
|
|
|
|
token = _approval_session_key.set(key)
|
|
try:
|
|
yield
|
|
finally:
|
|
_approval_session_key.reset(token)
|
|
|
|
|
|
def _dispatch_diag(res) -> str:
|
|
"""Failure renderer for the wiring tests' dispatch asserts: the result
|
|
dict plus the scheduler running set, so a broken assert names the return
|
|
path that was actually taken instead of a bare KeyError."""
|
|
try:
|
|
from cron.scheduler import get_running_job_ids
|
|
|
|
running = sorted(get_running_job_ids())
|
|
except Exception as e: # pragma: no cover - diagnostic only
|
|
running = f"<unavailable: {e}>"
|
|
return f"dispatch result: {res!r}; running: {running}"
|
|
|
|
|
|
def _drain_completion_event(delegation_id):
|
|
"""Wait (bounded) for this delegation's completion event; requeue others.
|
|
|
|
The runner executes on a daemon thread, so this must be called while the
|
|
test's patches are still active.
|
|
"""
|
|
from tools.process_registry import process_registry
|
|
|
|
for _ in range(100):
|
|
try:
|
|
evt = process_registry.completion_queue.get_nowait()
|
|
except Exception:
|
|
time.sleep(0.05)
|
|
continue
|
|
if evt.get("delegation_id") == delegation_id:
|
|
return evt
|
|
process_registry.completion_queue.put(evt)
|
|
time.sleep(0.05)
|
|
return None
|
|
|
|
|
|
class TestDeliveryNote:
|
|
"""``_manual_run_delivery_note`` — the summary-line wording contract."""
|
|
|
|
def test_local_always_saved_locally_only(self):
|
|
expected = " (output saved locally only)"
|
|
assert _manual_run_delivery_note("local", {}) == expected
|
|
# Local jobs never deliver — a stale delivery error must not leak in.
|
|
assert (
|
|
_manual_run_delivery_note("local", {"last_delivery_error": "telegram 400"})
|
|
== expected
|
|
)
|
|
|
|
def test_remote_without_error_keeps_legacy_wording(self):
|
|
expected = " (output was delivered there by the job itself)"
|
|
assert _manual_run_delivery_note("telegram", {}) == expected
|
|
assert (
|
|
_manual_run_delivery_note("telegram", {"last_delivery_error": None})
|
|
== expected
|
|
)
|
|
assert (
|
|
_manual_run_delivery_note("discord:#ops", {"last_delivery_error": " "})
|
|
== expected
|
|
)
|
|
|
|
def test_empty_or_missing_deliver_reads_saved_locally(self):
|
|
"""Falsy deliver = no target, and the fire-time path treats it as
|
|
"local" (no delivery, no delivery error) — the note must not claim
|
|
"delivered there" for a target that doesn't exist (#83993 class)."""
|
|
expected = " (output saved locally only)"
|
|
assert _manual_run_delivery_note("", {}) == expected
|
|
assert _manual_run_delivery_note(None, {}) == expected
|
|
# Falsy deliver never attempts delivery — a stale error (e.g. from an
|
|
# earlier deliver config) must not flip the wording either.
|
|
assert _manual_run_delivery_note("", {"last_delivery_error": "old"}) == expected
|
|
|
|
def test_whitespace_deliver_defers_to_error_record(self):
|
|
"""Whitespace-only deliver is NOT folded into local: fire time lets it
|
|
through as a target that fails to resolve, so the recorded error must
|
|
stay visible rather than being masked by a saved-locally wording."""
|
|
note = _manual_run_delivery_note(" ", {"last_delivery_error": "no target"})
|
|
assert "delivery FAILED" in note
|
|
assert "no target" in note
|
|
|
|
def test_remote_with_error_says_delivery_failed(self):
|
|
note = _manual_run_delivery_note(
|
|
"telegram", {"last_delivery_error": "send failed: 400 Bad Request"}
|
|
)
|
|
assert "delivery FAILED" in note
|
|
assert "send failed: 400 Bad Request" in note
|
|
|
|
def test_remote_error_text_truncated_to_200_chars(self):
|
|
note = _manual_run_delivery_note("telegram", {"last_delivery_error": "E" * 500})
|
|
assert "E" * 200 in note
|
|
assert "E" * 201 not in note
|
|
|
|
|
|
class TestRunnerSummaryWiring:
|
|
"""The completion event the calling agent actually sees must follow the
|
|
refreshed job record — both directions of issue #83993."""
|
|
|
|
def test_delivery_failure_surfaces_in_completion_summary(self):
|
|
from tools.cronjob_tools import _try_dispatch_background_run
|
|
|
|
job = _job("job-dn-01", "telegram")
|
|
with _bound_session_key("agent:main:telegram:dm:83993"):
|
|
with (
|
|
patch(
|
|
"tools.cronjob_tools.claim_job_for_fire",
|
|
return_value=job, # claimed snapshot (return_job=True API)
|
|
),
|
|
patch("cron.scheduler.run_one_job", return_value=True),
|
|
patch(
|
|
"tools.cronjob_tools.get_job",
|
|
return_value={
|
|
# Post-#83993 record shape: mark_job_run writes
|
|
# delivery_failed (not ok) when only delivery failed.
|
|
"last_status": "delivery_failed",
|
|
"last_error": None,
|
|
"last_delivery_error": "telegram send failed: 400",
|
|
},
|
|
),
|
|
):
|
|
res = _try_dispatch_background_run(job)
|
|
assert res.get("dispatched") is True, _dispatch_diag(res)
|
|
evt = _drain_completion_event(res["delegation_id"])
|
|
assert evt is not None, "completion event never reached the queue"
|
|
summary = evt.get("summary") or ""
|
|
assert "Delivery target: telegram" in summary
|
|
assert "delivery FAILED" in summary
|
|
assert "telegram send failed: 400" in summary
|
|
assert "delivered there by the job itself" not in summary
|
|
# The headline must not read "Result: ok" over an undelivered run.
|
|
assert "Result: FAILED" in summary
|
|
assert "Result: ok" not in summary
|
|
|
|
def test_empty_deliver_summary_states_local_not_phantom_target(self):
|
|
"""End-to-end: an empty stored deliver must render as the local target
|
|
it behaves as at fire time — never a bare "Delivery target: " followed
|
|
by a delivered-there claim."""
|
|
from tools.cronjob_tools import _try_dispatch_background_run
|
|
|
|
job = _job("job-dn-03", "")
|
|
with _bound_session_key("agent:main:telegram:dm:86622"):
|
|
with (
|
|
patch(
|
|
"tools.cronjob_tools.claim_job_for_fire",
|
|
return_value=job, # claimed snapshot (return_job=True API)
|
|
),
|
|
patch("cron.scheduler.run_one_job", return_value=True),
|
|
patch(
|
|
"tools.cronjob_tools.get_job",
|
|
return_value={"last_status": "ok", "last_error": None},
|
|
),
|
|
):
|
|
res = _try_dispatch_background_run(job)
|
|
assert res.get("dispatched") is True, _dispatch_diag(res)
|
|
evt = _drain_completion_event(res["delegation_id"])
|
|
assert evt is not None, "completion event never reached the queue"
|
|
summary = evt.get("summary") or ""
|
|
assert "Delivery target: local (output saved locally only)" in summary
|
|
assert "delivered there by the job itself" not in summary
|
|
|
|
def test_delivery_success_wording_unchanged_in_completion_summary(self):
|
|
from tools.cronjob_tools import _try_dispatch_background_run
|
|
|
|
job = _job("job-dn-02", "telegram")
|
|
with _bound_session_key("agent:main:telegram:dm:83994"):
|
|
with (
|
|
patch(
|
|
"tools.cronjob_tools.claim_job_for_fire",
|
|
return_value=job, # claimed snapshot (return_job=True API)
|
|
),
|
|
patch("cron.scheduler.run_one_job", return_value=True),
|
|
patch(
|
|
"tools.cronjob_tools.get_job",
|
|
return_value={"last_status": "ok", "last_error": None},
|
|
),
|
|
):
|
|
res = _try_dispatch_background_run(job)
|
|
assert res.get("dispatched") is True, _dispatch_diag(res)
|
|
evt = _drain_completion_event(res["delegation_id"])
|
|
assert evt is not None, "completion event never reached the queue"
|
|
summary = evt.get("summary") or ""
|
|
assert (
|
|
"Delivery target: telegram (output was delivered there by the job itself)"
|
|
) in summary
|
|
assert "delivery FAILED" not in summary
|