{"repo":"NousResearch/hermes-agent","public_id":"17139","display_label":"Cluster of 3 items","summary":"Auto-generated cluster label","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":17456,"merged_at":"2026-04-29T13:35:35+00:00"},"resolution":{"state":"resolved","by":"pr-17456"},"freshness":{"state":"stale","last_verified_at":"2026-04-29T13:35:35+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":17139,"pr_number":17456,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-29T13:35:35+00:00"}],"members":[{"kind":"issue","number":17139,"title":"Cron job deliver=['telegram'] fails: no delivery target resolved","url":null,"state":"closed"},{"kind":"pr","number":17222,"title":"fix(cron): accept list-shaped deliver in delivery target resolver (#17139)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#17139","number":17139},{"kind":"pr","value":"#17456","number":17456}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":17456,"title":"fix(cron): accept list-form deliver so deliver=['telegram'] works","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cron/scheduler.py","tests/cron/test_scheduler.py","tests/tools/test_cronjob_tools.py","tools/cronjob_tools.py"],"fix_hunks":[{"file":"cron/scheduler.py","hunks":["@@ -233,12 +233,32 @@ def _resolve_single_delivery_target(job: dict, deliver_value: str) -> Optional[d\n     }\n \n \n+def _normalize_deliver_value(deliver) -> str:\n+    \"\"\"Normalize a stored/submitted ``deliver`` value to its canonical string form.\n+\n+    The contract is that ``deliver`` is a string (``\"local\"``, ``\"origin\"``,\n+    ``\"telegram\"``, ``\"telegram:-1001:17\"``, or comma-separated combinations).\n+    Historically some callers — MCP clients passing an array, direct edits of\n+    ``jobs.json``, or stale code paths — have stored a list/tuple like\n+    ``[\"telegram\"]``.  ``str([\"telegram\"])`` would serialize to the literal\n+    string ``\"['telegram']\"``, which is not a known platform and fails\n+    resolution silently.  Flatten lists/tuples into a comma-separated string\n+    so both forms work.  Returns ``\"local\"`` for anything falsy.\n+    \"\"\"\n+    if deliver is None or deliver == \"\":\n+        return \"local\"\n+    if isinstance(deliver, (list, tuple)):\n+        parts = [str(p).strip() for p in deliver if str(p).strip()]\n+        return \",\".join(parts) if parts else \"local\"\n+    return str(deliver)\n+\n+\n def _resolve_delivery_targets(job: dict) -> List[dict]:\n     \"\"\"Resolve all concrete auto-delivery targets for a cron job (supports comma-separated deliver).\"\"\"\n-    deliver = job.get(\"deliver\", \"local\")\n+    deliver = _normalize_deliver_value(job.get(\"deliver\", \"local\"))\n     if deliver == \"local\":\n         return []\n-    parts = [p.strip() for p in str(deliver).split(\",\") if p.strip()]\n+    parts = [p.strip() for p in deliver.split(\",\") if p.strip()]\n     seen = set()\n     targets = []\n     for part in parts:"]},{"file":"tests/cron/test_scheduler.py","hunks":["@@ -279,6 +279,44 @@ def test_explicit_discord_channel_without_thread(self):\n             \"thread_id\": None,\n         }\n \n+    def test_list_form_deliver_is_normalized(self, monkeypatch):\n+        \"\"\"deliver=['telegram'] (Python list) should resolve like 'telegram' string.\n+\n+        Regression test for #17139: MCP clients / scripts that pass the deliver\n+        field as an array-shaped value used to fail with \"no delivery target\n+        resolved for deliver=['telegram']\" because ``str(['telegram'])`` was\n+        passed through to ``split(',')`` verbatim.\n+        \"\"\"\n+        monkeypatch.setenv(\"TELEGRAM_HOME_CHANNEL\", \"-4004\")\n+        job = {\n+            \"deliver\": [\"telegram\"],\n+            \"origin\": None,\n+        }\n+\n+        assert _resolve_delivery_target(job) == {\n+            \"platform\": \"telegram\",\n+            \"chat_id\": \"-4004\",\n+            \"thread_id\": None,\n+        }\n+\n+    def test_list_form_multiple_platforms_normalized(self, monkeypatch):\n+        \"\"\"deliver=['telegram', 'discord'] resolves to multiple targets.\"\"\"\n+        from cron.scheduler import _resolve_delivery_targets\n+\n+        monkeypatch.setenv(\"TELEGRAM_HOME_CHANNEL\", \"-111\")\n+        monkeypatch.setenv(\"DISCORD_HOME_CHANNEL\", \"-222\")\n+        job = {\"deliver\": [\"telegram\", \"discord\"], \"origin\": None}\n+\n+        targets = _resolve_delivery_targets(job)\n+        platforms = sorted(t[\"platform\"] for t in targets)\n+        assert platforms == [\"discord\", \"telegram\"]\n+\n+    def test_empty_list_form_deliver_resolves_to_local(self):\n+        \"\"\"deliver=[] is treated as local (no delivery).\"\"\"\n+        from cron.scheduler import _resolve_delivery_targets\n+\n+        assert _resolve_delivery_targets({\"deliver\": []}) == []\n+\n \n class TestDeliverResultWrapping:\n     \"\"\"Verify that cron deliveries are wrapped with header/footer and no longer mirrored.\"\"\""]},{"file":"tests/tools/test_cronjob_tools.py","hunks":["@@ -231,3 +231,60 @@ def test_update_can_clear_skills(self):\n         assert updated[\"success\"] is True\n         assert updated[\"job\"][\"skills\"] == []\n         assert updated[\"job\"][\"skill\"] is None\n+\n+    def test_create_normalizes_list_form_deliver(self):\n+        \"\"\"deliver=['telegram'] (list) is stored as the string 'telegram'.\n+\n+        Regression for #17139: MCP clients / scripts sometimes pass ``deliver``\n+        as an array.  Prior to the fix, ``['telegram']`` was written verbatim\n+        to ``jobs.json`` and the scheduler then tried to resolve the literal\n+        string ``\"['telegram']\"`` as a platform, failing with\n+        \"no delivery target resolved\".\n+        \"\"\"\n+        from cron.jobs import get_job\n+\n+        created = json.loads(\n+            cronjob(\n+                action=\"create\",\n+                prompt=\"Daily briefing\",\n+                schedule=\"every 1h\",\n+                deliver=[\"telegram\"],\n+            )\n+        )\n+        assert created[\"success\"] is True\n+        stored = get_job(created[\"job_id\"])\n+        assert stored[\"deliver\"] == \"telegram\"\n+\n+    def test_create_normalizes_multi_element_list_deliver(self):\n+        \"\"\"deliver=['telegram', 'discord'] is stored as 'telegram,discord'.\"\"\"\n+        from cron.jobs import get_job\n+\n+        created = json.loads(\n+            cronjob(\n+                action=\"create\",\n+                prompt=\"Daily briefing\",\n+                schedule=\"every 1h\",\n+                deliver=[\"telegram\", \"discord\"],\n+            )\n+        )\n+        assert created[\"success\"] is True\n+        stored = get_job(created[\"job_id\"])\n+        assert stored[\"deliver\"] == \"telegram,discord\"\n+\n+    def test_update_normalizes_list_form_deliver(self):\n+        \"\"\"update with deliver=['telegram'] stores the canonical string.\"\"\"\n+        from cron.jobs import get_job\n+\n+        created = json.loads(\n+            cronjob(action=\"create\", prompt=\"x\", schedule=\"every 1h\")\n+        )\n+        updated = json.loads(\n+            cronjob(\n+                action=\"update\",\n+                job_id=created[\"job_id\"],\n+                deliver=[\"telegram\"],\n+            )\n+        )\n+        assert updated[\"success\"] is True\n+        stored = get_job(created[\"job_id\"])\n+        assert stored[\"deliver\"] == \"telegram\""]},{"file":"tools/cronjob_tools.py","hunks":["@@ -150,6 +150,27 @@ def _normalize_optional_job_value(value: Optional[Any], *, strip_trailing_slash:\n     return text or None\n \n \n+def _normalize_deliver_param(value: Any) -> Optional[str]:\n+    \"\"\"Normalize a user-supplied ``deliver`` value to the canonical string form.\n+\n+    The cron schema documents ``deliver`` as a string (``\"local\"``, ``\"origin\"``,\n+    ``\"telegram\"``, ``\"telegram:chat_id[:thread_id]\"``, or comma-separated combos).\n+    Some callers — MCP clients passing arrays, scripts building the payload as a\n+    list — supply ``[\"telegram\"]``.  ``create_job``/``update_job`` store it as-is,\n+    and the scheduler's ``str(deliver).split(\",\")`` then serializes the list to\n+    the literal ``\"['telegram']\"`` which is not a known platform.  Flatten lists\n+    / tuples at the API boundary so storage is always a string.  Returns ``None``\n+    for ``None``/empty so callers can treat it as \"not supplied\".\n+    \"\"\"\n+    if value is None:\n+        return None\n+    if isinstance(value, (list, tuple)):\n+        parts = [str(p).strip() for p in value if str(p).strip()]\n+        return \",\".join(parts) if parts else None\n+    text = str(value).strip()\n+    return text or None\n+\n+\n def _validate_cron_script_path(script: Optional[str]) -> Optional[str]:\n     \"\"\"Validate a cron job script path at the API boundary.\n ","@@ -283,7 +304,7 @@ def cronjob(\n                 schedule=schedule,\n                 name=name,\n                 repeat=repeat,\n-                deliver=deliver,\n+                deliver=_normalize_deliver_param(deliver),\n                 origin=_origin_from_env(),\n                 skills=canonical_skills,\n                 model=_normalize_optional_job_value(model),","@@ -364,7 +385,7 @@ def cronjob(\n             if name is not None:\n                 updates[\"name\"] = name\n             if deliver is not None:\n-                updates[\"deliver\"] = deliver\n+                updates[\"deliver\"] = _normalize_deliver_param(deliver)\n             if skills is not None or skill is not None:\n                 canonical_skills = _canonical_skills(skill, skills)\n                 updates[\"skills\"] = canonical_skills\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}