feat(cron): expose structured cron context via adapter send metadata (#26004) - #26012
feat(cron): expose structured cron context via adapter send metadata (#26004)#26012briandevans wants to merge 4 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds a structured cron side-channel to the metadata kwarg passed to live platform adapters in _deliver_result, so plugin adapters can recover cron job context (id/name/schedule/deliver/origin) without regex-parsing the wrapped envelope text.
Changes:
- Build a
cron_metadict with job context and merge it intosend_metadataalongsidethread_id. - Add a new
TestDeliverResultCronMetadatatest class covering presence, name fallback, coexistence withthread_id, and omission of empty fields.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| cron/scheduler.py | Constructs cron metadata dict and merges into send_metadata for live adapter sends. |
| tests/cron/test_scheduler.py | Adds tests verifying cron metadata is forwarded to live adapter send calls. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| "deliver": job.get("deliver"), | ||
| "origin": origin or None, | ||
| } | ||
| cron_meta = {k: v for k, v in cron_meta.items() if v} |
| cron_meta = { | ||
| "job_id": job.get("id", ""), | ||
| "job_name": job.get("name") or job.get("id", ""), | ||
| "schedule": job.get("schedule"), | ||
| "deliver": job.get("deliver"), | ||
| "origin": origin or None, | ||
| } | ||
| cron_meta = {k: v for k, v in cron_meta.items() if v} | ||
|
|
||
| send_metadata: Optional[dict] = None | ||
| if thread_id: | ||
| send_metadata = {"thread_id": thread_id} | ||
| if cron_meta: | ||
| send_metadata = {**(send_metadata or {}), "cron": cron_meta} |
|
@copilot Both findings addressed in 362ab6d49:
|
362ab6d to
9cd8e93
Compare
|
CI audit — all 7 test failures (3 in Branch is now rebased onto current
Reproduced both clusters locally on clean |
9cd8e93 to
16fc509
Compare
|
Hi @briandevans 👋 — I opened #29291 yesterday without spotting this PR; just closed mine and pointed at yours. After re-reading both, your structured-side-channel is the right shape for #26004 (centralized field set, fixed contract adapter authors can rely on, easier to extend to Step 2 — A few extensions the failure-route / audit-logging / inbox-UI use cases would benefit from, in case any of these are in scope for this PR (happy to defer to a follow-up if not): 1. cron_meta_full = {
...
"status": "ok", # or "error" when run_job returned success=False
}
2. cron_meta_full = {
...
"ran_at": _hermes_now().isoformat(),
}The execution timestamp is invaluable for any adapter routing cron output to a UI that displays "when did this fire?". Cheap to add at the same call site as the existing fields. 3. Failure-path regression test Your current I have a working implementation of all three in a branch (extracted from my closed #29291) if you'd like me to send a follow-up PR against your branch, or you'd rather fold them into this one — your call. Either way, thanks for picking this up — your hoisting fix on the per-target loop and the |
|
Cool, here's what those would look like in code so you can copy-paste any/all/none. Each one's independent. 1. Thread # signature
def _deliver_result(
job: dict,
content: str,
adapters=None,
loop=None,
status_hint: str = "ok", # NEW
) -> Optional[str]:
# inside _deliver_result, add to cron_meta_full where you build it now:
"status": status_hint,
# tick() callsite (~line 1913)
delivery_error = _deliver_result(
job, deliver_content, adapters=adapters, loop=loop,
status_hint="ok" if success else "error",
)2.
"ran_at": _hermes_now().isoformat(),3. Failure-path test (~30 lines) A clone of one of your existing def test_cron_metadata_attached_on_failed_run_with_wrap_disabled(self):
from gateway.config import Platform
adapter, loop, fake_run_coro = self._build_adapter_send_mock()
pconfig = MagicMock()
pconfig.enabled = True
mock_cfg = MagicMock()
mock_cfg.platforms = {Platform.TELEGRAM: pconfig}
job = {
"id": "fail-job",
"name": "nightly-report",
"deliver": "origin",
"origin": {"platform": "telegram", "chat_id": "999"},
}
with patch("gateway.config.load_gateway_config", return_value=mock_cfg), \
patch("cron.scheduler.load_config", return_value={"cron": {"wrap_response": False}}), \
patch("asyncio.run_coroutine_threadsafe", side_effect=fake_run_coro):
_deliver_result(
job,
"Job hit an exception",
adapters={Platform.TELEGRAM: adapter},
loop=loop,
status_hint="error",
)
cron_ctx = adapter.send.call_args.kwargs["metadata"]["cron"]
assert cron_ctx["status"] == "error"
assert cron_ctx["job_id"] == "fail-job"If you'd rather cherry-pick than retype, happy to push a branch on my fork — just say. Otherwise this is just for reference, no obligation. |
|
Thanks @deestax — pulled all three into f758e5496:
Also updated the existing The defaulting of |
f758e54 to
49cb370
Compare
|
CI audit — the only failure in the touched scope was Pushed 49cb370f6:
The other two |
49cb370 to
06a8840
Compare
06a8840 to
b7b1beb
Compare
b7b1beb to
7508221
Compare
7508221 to
e6e566d
Compare
Plugin platform adapters that route cron output to external inboxes need job_id/job_name/schedule/deliver/origin to render proper source labels and chain follow-up turns. Today the only structured side-channel on adapter.send() is thread_id; adapters recover the rest by regex-parsing the "Cronjob Response: <name>\n(job_id: <id>)" envelope, which is brittle and breaks entirely when cron.wrap_response=false (issue NousResearch#26004). Enrich the live-adapter send_metadata with a nested "cron" dict carrying the fields already available in _deliver_result: job_id, job_name (falls back to job_id), schedule, deliver, and the already-computed origin. Adapters that ignore unknown metadata keys are unaffected — the dict already received {"thread_id": ...} today. Scoped to the issue's "Step 1 (minimal, fully backwards-compatible)". The response_id / session_id fields the issue lists are deferred: today they would require widening run_job's return tuple, which is out of scope for a minimal patch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s not None Addresses Copilot inline review on NousResearch#26012: 1. cron_meta and origin are invariant across `for target in targets:` — every field reads `job.*` and a per-job-resolved origin. Hoist the construction once above the loop so we don't rebuild it on every target. 2. Switch the strip-empty filter from truthiness (`if v`) to explicit `if v is not None` so falsy-but-meaningful values (e.g. an explicitly-empty schedule string a future job type might set) still reach adapters. Only true absence (None) is dropped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Incorporates @deestax's suggested extensions from NousResearch#26012 review: - `status`: "ok" | "error" — set by `tick()` based on the run's `success` bool, threaded through `_deliver_result` as a `status_hint` kwarg (defaults to "ok" for non-tick call sites). Lets plugin adapters distinguish failed-run deliveries from successful ones without re-parsing the wrap envelope, which also doesn't work when `cron.wrap_response: false`. - `ran_at`: ISO timestamp from `_hermes_now()`. Trivial to compute and lets adapters annotate cron deliveries with a verifiable run time independent of message-receive time. Both are unconditional fields (the truthy-only filter only strips `None`s, so both reach adapters without the falsy-filter problem fixed in 16fc509d). Tests: - New failure-path test asserts `status == "error"` and full context survives when `wrap_response=false` (the brittle path Step 2 of NousResearch#26004 cares about). - Updated the omits-empty-fields exact-match assertion to pop `ran_at` (varies by clock) and expect `status: "ok"` on the default path.
…data The test added in d81b888 asserts the exact metadata payload sent to the live adapter. After this PR enriches send_metadata with a "cron" context block, the exact-equality assertion no longer holds — but the load-bearing invariant (thread_id is preserved on fallback) still does. Rewrite the assertion to check call count, positional args, and the thread_id field directly, plus a couple of cron context fields to keep the structured-metadata path covered.
e6e566d to
2f1560d
Compare
|
Closing to focus the queue on security/file-safety work where civilian merges are landing. Happy to reopen if maintainers want this picked up. |
What does this PR do?
cron/scheduler.py's_deliver_resultcallsadapter.send(chat_id, content, metadata=...)with onlythread_idinmetadata. Plugin adapters that want the cron'sjob_id,job_name,schedule,deliver, ororigintoday have to regex-parse the"Cronjob Response: <name>\n(job_id: <id>)"envelope — which is brittle and breaks entirely whencron.wrap_response: falseis configured.This PR adds those fields under
metadata["cron"]as a structured side-channel, keeping the existingmetadata["thread_id"]key. Implements Step 1 (minimal, fully backwards-compatible) of #26004. Adapters that ignore unknown metadata keys are unaffected — they already received{"thread_id": ...}today and continue to.Related Issue
Fixes #26004 (Step 1)
Type of Change
Changes Made
cron/scheduler.py— buildcron_meta = {"job_id", "job_name", "schedule", "deliver", "origin"}(filtered to non-empty values) from data already in scope in_deliver_result, then merge assend_metadata["cron"] = cron_metaalongside the existingthread_idkey. Norun_jobsignature change.tests/cron/test_scheduler.py—TestDeliverResultCronMetadatacovering populated fields, name fallback to job_id, coexistence with thread_id, omission of empty optional fields (4 cases).How to Test
uv run --with pytest --with pytest-xdist --with pytest-asyncio python3 -m pytest tests/cron/test_scheduler.py::TestDeliverResultCronMetadata -vuv run --with pytest --with pytest-xdist --with pytest-asyncio python3 -m pytest tests/cron/ -v— 341 passed.TypeError: 'NoneType' object is not subscriptableonmetadata["cron"]. Restore — green.Checklist
Code
fix(scope):,feat(scope):, etc.)Documentation & Housekeeping
docs/, docstrings) — N/A, side-channel metadata is plugin-facingcli-config.yaml.exampleif I added/changed config keys — N/A, no config changeCONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/ARelated / Positioning
response_idandsession_id) would require wideningrun_job's(success, output, final_response, error)return tuple — out of scope for a minimal patch. Happy to follow up if the maintainer wants that wired through.