diff --git a/.claude/rules/runner-development.md b/.claude/rules/runner-development.md index ac14fa92..4edb8528 100644 --- a/.claude/rules/runner-development.md +++ b/.claude/rules/runner-development.md @@ -34,6 +34,10 @@ factory.completed_ok(answer=..., resume=token, usage=...) Do NOT construct `StartedEvent`, `ActionEvent`, `CompletedEvent` dataclasses directly. +## RunContext trigger_source (#271) + +`RunContext` has a `trigger_source: str | None` field. Dispatchers set it to `"cron:"` or `"webhook:"`; `runner_bridge.handle_message` seeds `progress_tracker.meta["trigger"] = " "`. Engine `StartedEvent.meta` merges over (not replaces) the trigger key via `ProgressTracker.note_event`. Runners themselves should NOT set `meta["trigger"]`; that's reserved for dispatchers. + ## Session locking - `SessionLockMixin` provides `lock_for(token) -> anyio.Semaphore` diff --git a/.claude/rules/telegram-transport.md b/.claude/rules/telegram-transport.md index 736313a6..90483c14 100644 --- a/.claude/rules/telegram-transport.md +++ b/.claude/rules/telegram-transport.md @@ -59,6 +59,22 @@ Agents write files to `.untether-outbox/` during a run. On completion, `outbox_d `progress_persistence.py` tracks active progress messages in `active_progress.json`. On startup, orphan messages from a prior instance are edited to "⚠️ interrupted by restart" with keyboard removed. +## Telegram update_id persistence (#287) + +`offset_persistence.py` persists the last confirmed Telegram `update_id` to `last_update_id.json` (sibling to config). On startup, `poll_updates` loads the saved offset and passes `offset=saved+1` to `getUpdates` so restarts don't drop or re-process updates within Telegram's 24h retention window. Writes are debounced (5s interval, 100-update cap) via `DebouncedOffsetWriter` — see its docstring for the crash/replay tradeoff. Flush happens automatically in the `poll_updates` finally block. + +## TelegramBridgeConfig hot-reload (#286) + +`TelegramBridgeConfig` is unfrozen (slots preserved) as of rc4. `update_from(settings)` applies a reloaded `TelegramTransportSettings` to the live config; `handle_reload()` in `loop.py` calls it and refreshes the two cached copies in `TelegramLoopState`. `route_update()` reads `cfg.allowed_user_ids` live so allowlist changes take effect on the next message. Restart-only keys (`bot_token`, `chat_id`, `session_mode`, `topics`, `message_overflow`) still warn with `restart_required=true`. + +## sd_notify (#287) + +`untether.sdnotify.notify(message)` sends `READY=1`/`STOPPING=1` to systemd's notify socket (stdlib only — no dependency). `NOTIFY_SOCKET` absent → no-op False. `poll_updates` sends `READY=1` after `_send_startup` succeeds; `_drain_and_exit` sends `STOPPING=1` at drain start. Requires `Type=notify` + `NotifyAccess=main` in the systemd unit (see `contrib/untether.service`). + +## /at command (#288) + +`telegram/at_scheduler.py` is a module-level holder for the task group + `run_job` closure; `install()` is called from `run_main_loop` once both are available. `AtCommand.handle` calls `schedule_delayed_run(chat_id, thread_id, delay_s, prompt)` which starts an anyio task that sleeps then dispatches. Pending delays tracked in `_PENDING`; `/cancel` drops them via `cancel_pending_for_chat(chat_id)`. Drain integration via `at_scheduler.active_count()`. No persistence — restart cancels all pending delays (documented in issue body). + ## Plan outline rendering Plan outlines render as formatted Telegram text via `render_markdown()` + `split_markdown_body()`. Approval buttons (✅/❌/📋) appear on the last outline message. Outline and notification messages are cleaned up on approve/deny via `_OUTLINE_REGISTRY`. diff --git a/.claude/rules/testing-conventions.md b/.claude/rules/testing-conventions.md index 931e9f34..800cd086 100644 --- a/.claude/rules/testing-conventions.md +++ b/.claude/rules/testing-conventions.md @@ -60,14 +60,14 @@ Integration tests are automated via Telegram MCP tools by Claude Code during the ### Test chats -| Chat | Chat ID | -|------|---------| -| `ut-dev-hf: claude` | 5171122044 | -| `ut-dev-hf: codex` | 5116709786 | -| `ut-dev-hf: opencode` | 5020138767 | -| `ut-dev-hf: pi` | 5276373372 | -| `ut-dev-hf: gemini` | 5152406011 | -| `ut-dev-hf: amp` | 5064468679 | +| Chat | Chat ID | Bot API chat_id | +|------|---------|-----------------| +| Claude Code | `5284581592` | `-5284581592` | +| Codex CLI | `4929463515` | `-4929463515` | +| OpenCode | `5200822877` | `-5200822877` | +| Pi | `5156256333` | `-5156256333` | +| Gemini CLI | `5207762142` | `-5207762142` | +| AMP CLI | `5230875989` | `-5230875989` | ### Pattern diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b84099e..a09b6df8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,11 @@ # changelog -## v0.35.1 (2026-04-03) +## v0.35.1 (2026-04-14) ### fixes +- diff preview approval gate no longer blocks edits after a plan is approved — the `_discuss_approved` flag now short-circuits diff preview as well as `ExitPlanMode`, so once the user approves a plan outline the next `Edit`/`Write` runs without a second approval prompt [#283](https://github.com/littlebearapps/untether/issues/283) + - fix multipart webhooks returning HTTP 500 — `_process_webhook` pre-read the request body for size/auth/rate-limit checks, leaving the stream empty when `_parse_multipart` called `request.multipart()`. Now the multipart reader is constructed from the cached raw body, so multipart uploads work end-to-end; also short-circuits the post-parse raw-body write so the MIME envelope isn't duplicated at `file_path` alongside the extracted file at `file_destination` [#280](https://github.com/littlebearapps/untether/issues/280) - fix webhook rate limiter never returning 429 — `_process_webhook` awaited the downstream dispatch (Telegram outbox send, `http_forward` network call, etc.) before returning 202, which capped request throughput at the dispatch rate (~1/sec for private Telegram chats) and meant the `TokenBucketLimiter` never saw a real burst. Dispatch is now fire-and-forget with exception logging, so the rate limiter drains the bucket correctly and a burst of 80 requests against `rate_limit = 60` now yields 60 × 202 + 20 × 429 [#281](https://github.com/littlebearapps/untether/issues/281) - **security:** validate callback query sender in group chats — reject button presses from unauthorised users; prevents malicious group members from approving/denying other users' tool requests [#192](https://github.com/littlebearapps/untether/issues/192) @@ -13,6 +15,7 @@ - reduce stall warning false positives during Agent subagent work — tree CPU tracking across process descendants, child-aware 15 min threshold when child processes or elevated TCP detected, early diagnostic collection for CPU baseline, total stall warning counter that persists through recovery, improved "Waiting for child processes" notification messages [#264](https://github.com/littlebearapps/untether/issues/264) - `/ping` uptime now resets on service restart — previously the module-level start time was cached across `/restart` commands; now `reset_uptime()` is called on each service start [#234](https://github.com/littlebearapps/untether/issues/234) +- **systemd:** stop Untether being the preferred OOM victim — systemd user services inherit `OOMScoreAdjust=200` and `OOMPolicy=stop` defaults, which made Untether's engine subprocesses preferred earlyoom/kernel OOM killer targets ahead of CLI `claude` (`oom_score_adj=0`) and orphaned grandchildren actually consuming the RAM. `contrib/untether.service` now sets `OOMScoreAdjust=-100` (documents intent; the kernel clamps to the parent baseline for unprivileged users, typically 100) and `OOMPolicy=continue` (a single OOM-killed child no longer tears down the whole unit cgroup, which previously broke every live chat at once). Docs in `docs/reference/dev-instance.md` updated. Existing installs need to copy the unit file and `systemctl --user daemon-reload`; staging picks up the change on the next `scripts/staging.sh install` cycle [#275](https://github.com/littlebearapps/untether/issues/275) ### changes @@ -48,6 +51,39 @@ - `on_failure = "abort"` (default) sends failure notification; `"run_with_error"` injects error into prompt - all fetched data prefixed with untrusted-data marker +- **hot-reload for trigger configuration** — editing `untether.toml` `[triggers]` applies changes immediately without restarting Untether or killing active runs [#269](https://github.com/littlebearapps/untether/issues/269) ([#285](https://github.com/littlebearapps/untether/pull/285)) + - new `TriggerManager` class holds cron and webhook config; scheduler reads `manager.crons` each tick; webhook server resolves routes per-request via `manager.webhook_for_path()` + - supports add/remove/modify of crons and webhooks, auth/secret changes, action type, multipart/file settings, cron fetch, and timezones + - `last_fired` dict preserved across swaps to prevent double-firing within the same minute + - unauthenticated webhooks logged at `WARNING` on reload (previously only at startup) + - 13 new tests in `test_trigger_manager.py`; 2038 existing tests still pass + +- **hot-reload for Telegram bridge settings** — `voice_transcription`, file transfer, `allowed_user_ids`, `show_resume_line`, and message-timing settings now reload without a restart [#286](https://github.com/littlebearapps/untether/issues/286) + - `TelegramBridgeConfig` unfrozen (keeps `slots=True`) and gains an `update_from(settings)` method + - `handle_reload()` now applies changes in-place and refreshes cached loop-state copies; restart-only keys (`bot_token`, `chat_id`, `session_mode`, `topics`, `message_overflow`) still warn with `restart_required=true` + - `route_update()` reads `cfg.allowed_user_ids` live so allowlist changes take effect on the next message + +- **`/at` command for one-shot delayed runs** — schedule a prompt to run between 60s and 24h in the future with `/at 30m Check the build`; accepts `Ns`/`Nm`/`Nh` suffixes [#288](https://github.com/littlebearapps/untether/issues/288) + - pending delays tracked in-memory (lost on restart — acceptable for one-shot use) + - `/cancel` drops pending `/at` timers before they fire + - per-chat cap of 20 pending delays; graceful drain cancels pending scopes on shutdown + - new module `telegram/at_scheduler.py`; command registered as `at` entry point + +- **`run_once` cron flag** — `[[triggers.crons]]` entries can set `run_once = true` to fire once then auto-disable; the cron stays in the TOML and re-activates on the next config reload or restart [#288](https://github.com/littlebearapps/untether/issues/288) + +- **trigger visibility improvements (Tier 1)** — surface configured triggers in the Telegram UI [#271](https://github.com/littlebearapps/untether/issues/271) + - `/ping` in a chat with active triggers appends `⏰ triggers: 1 cron (daily-review, 9:00 AM daily (Melbourne))` + - trigger-initiated runs show provenance in the meta footer: `🏷 opus 4.6 · plan · ⏰ cron:daily-review` + - new `describe_cron(schedule, timezone)` utility renders common cron patterns in plain English; falls back to the raw expression for complex schedules + - `RunContext` gains `trigger_source` field; `ProgressTracker.note_event` merges engine meta over the dispatcher-seeded trigger so it survives + - `TriggerManager` exposes `crons_for_chat()`, `webhooks_for_chat()`, `cron_ids()`, `webhook_ids()` helpers + +- **faster, cleaner restarts (Tier 1)** — restart gap reduced from ~15-30s to ~5s with no lost messages [#287](https://github.com/littlebearapps/untether/issues/287) + - persist last Telegram `update_id` to `last_update_id.json` and resume polling from the saved offset on startup; Telegram retains undelivered updates for 24h, so the polling gap no longer drops or re-processes messages + - `Type=notify` systemd integration via stdlib `sd_notify` (`socket.AF_UNIX`, no dependency) — `READY=1` is sent after the first `getUpdates` succeeds, `STOPPING=1` at the start of drain + - `RestartSec=2` in `contrib/untether.service` (was `10`) — faster restart after drain completes + - `contrib/untether.service` also adds `NotifyAccess=main`; existing installs must copy the unit file and `systemctl --user daemon-reload` + ## v0.35.0 (2026-03-31) ### fixes diff --git a/CLAUDE.md b/CLAUDE.md index 64685de0..74f972a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,12 @@ Untether adds interactive permission control, plan mode support, and several UX - **`/continue`** — cross-environment resume; pick up the most recent CLI session from Telegram using each engine's native continue flag (`--continue`, `resume --last`, `--resume latest`); supported for Claude, Codex, OpenCode, Pi, Gemini (not AMP) - **Timezone-aware cron triggers** — per-cron `timezone` or global `default_timezone` with IANA names (e.g. `Australia/Melbourne`); DST-aware via `zoneinfo`; invalid names rejected at config parse time - **Hot-reload trigger configuration** — editing `untether.toml` applies cron/webhook changes immediately without restart; `TriggerManager` holds mutable state that the cron scheduler and webhook server reference at runtime; `handle_reload()` re-parses `[triggers]` on config file change +- **Hot-reload Telegram bridge settings** — `voice_transcription`, file transfer, `allowed_user_ids`, timing, and `show_resume_line` settings reload without restart; `TelegramBridgeConfig` unfrozen (slots kept) with `update_from()` wired into `handle_reload()`; restart-only keys (`bot_token`, `chat_id`, `session_mode`, `topics`, `message_overflow`) still warn +- **`/at` command** — one-shot delayed runs: `/at 30m ` schedules a prompt to run in 60s–24h; `/cancel` drops pending delays before firing; lost on restart (documented) with a per-chat cap of 20 pending delays; `telegram/at_scheduler.py` holds task-group + run_job refs +- **`run_once` cron flag** — `[[triggers.crons]]` entries can set `run_once = true` to fire once then auto-disable; cron stays in TOML and re-activates on config reload or restart +- **Trigger visibility (Tier 1)** — `/ping` shows per-chat trigger summary (`⏰ triggers: 1 cron (id, 9:00 AM daily (Melbourne))`); run footer shows `⏰ cron:` / `⚡ webhook:` for trigger-initiated runs; new `describe_cron()` utility renders common patterns in plain English +- **Graceful restart improvements (Tier 1)** — persists Telegram `update_id` to `last_update_id.json` so restarts don't drop/duplicate messages; `Type=notify` systemd integration via stdlib `sd_notify` (`READY=1` + `STOPPING=1`); `RestartSec=2` +- **`diff_preview` plan bypass (#283)** — after user approves a plan outline via "Pause & Outline Plan", the `_discuss_approved` flag short-circuits diff preview for subsequent Edit/Write tools so no second approval is needed See `.claude/skills/claude-stream-json/` and `.claude/rules/control-channel.md` for implementation details. @@ -86,7 +92,12 @@ Telegram <-> TelegramPresenter <-> RunnerBridge <-> Runner (claude/codex/opencod | `commands.py` | Command result types | | `scripts/validate_release.py` | Release validation (changelog format, issue links, version match) | | `scripts/healthcheck.sh` | Post-deploy health check (systemd, version, logs, Bot API) | -| `triggers/manager.py` | TriggerManager: mutable cron/webhook holder for hot-reload; atomic config swap on TOML change | +| `triggers/manager.py` | TriggerManager: mutable cron/webhook holder for hot-reload; atomic config swap on TOML change; `crons_for_chat`, `webhooks_for_chat`, `remove_cron` helpers | +| `triggers/describe.py` | `describe_cron(schedule, timezone)` utility for human-friendly cron rendering | +| `telegram/at_scheduler.py` | `/at` command state: pending one-shot delays with cancel scopes, install/uninstall, cancel per chat | +| `telegram/commands/at.py` | `/at` command backend — parses Ns/Nm/Nh, schedules delayed run | +| `telegram/offset_persistence.py` | Persist Telegram `update_id` across restarts; `DebouncedOffsetWriter` | +| `sdnotify.py` | Stdlib `sd_notify` client for `READY=1`/`STOPPING=1` systemd signals | | `triggers/server.py` | Webhook HTTP server (aiohttp); multipart parsing from cached body, fire-and-forget dispatch | | `triggers/dispatcher.py` | Routes webhooks/crons to `run_job()` or non-agent action handlers | | `triggers/cron.py` | Cron expression parser, timezone-aware scheduler loop | @@ -205,7 +216,13 @@ Key test files: - `test_trigger_fetch.py` — 12 tests: HTTP GET/POST, file read, parse modes, failure handling, prompt building - `test_trigger_auth.py` — 12 tests: bearer token, HMAC-SHA256/SHA1, timing-safe comparison - `test_trigger_rate_limit.py` — 5 tests: token bucket fill/drain, per-key isolation, refill timing -- `test_trigger_manager.py` — 13 tests: TriggerManager init/update/clear, webhook server hot-reload (add/remove/update routes, secret changes, health count), cron schedule swapping, timezone updates +- `test_trigger_manager.py` — 23 tests: TriggerManager init/update/clear, webhook server hot-reload (add/remove/update routes, secret changes, health count), cron schedule swapping, timezone updates; rc4 helpers (crons_for_chat, webhooks_for_chat, cron_ids, webhook_ids, remove_cron, atomic iteration) +- `test_describe_cron.py` — 31 tests: human-friendly cron rendering (daily, weekday ranges, weekday lists, single day, timezone suffix, fallback to raw, AM/PM boundaries) +- `test_trigger_meta_line.py` — 6 tests: trigger source rendering in `format_meta_line()`, ordering relative to model/effort/permission +- `test_bridge_config_reload.py` — 11 tests: TelegramBridgeConfig unfrozen (slots preserved), `update_from()` copies all 11 fields, files swap, chat_ids/voice_transcription_api_key edge cases, trigger_manager field default +- `test_at_command.py` — 34 tests: `/at` parse (valid/invalid suffixes, bounds, case-insensitive), `_format_delay`, schedule/cancel, per-chat cap, scheduler install/uninstall +- `test_offset_persistence.py` — 15 tests: Telegram update_id round-trip, corrupt JSON handling, atomic write, `DebouncedOffsetWriter` interval/max-pending semantics, explicit flush +- `test_sdnotify.py` — 7 tests: NOTIFY_SOCKET handling (absent/empty/filesystem/abstract-namespace), send error swallowing, UTF-8 encoding ## Development diff --git a/README.md b/README.md index e5163c06..c0d0860f 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ The wizard offers three **workflow modes** — pick the one that fits: - 🔄 **Cross-environment resume** — start a session in your terminal, pick it up from Telegram with `/continue`; works with Claude Code, Codex, OpenCode, Pi, and Gemini ([guide](docs/how-to/cross-environment-resume.md)) - 📎 **File transfer** — upload files to your repo with `/file put`, download with `/file get`; agents can also deliver files automatically by writing to `.untether-outbox/` during a run — sent as Telegram documents on completion - 🛡️ **Graceful recovery** — orphan progress messages cleaned up on restart; stall detection with CPU-aware diagnostics; auto-continue for Claude Code sessions that exit prematurely -- ⏰ **Scheduled tasks** — cron expressions with timezone support, webhook triggers, and hot-reload configuration (no restart required) +- ⏰ **Scheduled tasks** — cron expressions with timezone support, webhook triggers, one-shot delays (`/at 30m `), `run_once` crons, and hot-reload configuration (no restart required). `/ping` shows per-chat trigger summary; trigger-initiated runs show provenance in the footer - 💬 **Forum topics** — map Telegram topics to projects and branches - 📤 **Session export** — `/export` for markdown or JSON transcripts - 🗂️ **File browser** — `/browse` to navigate project files with inline buttons @@ -179,7 +179,8 @@ The wizard offers three **workflow modes** — pick the one that fits: | `/trigger` | Set group chat trigger mode | | `/stats` | Per-engine session statistics (today/week/all-time) | | `/auth` | Codex device re-authentication | -| `/ping` | Health check / uptime | +| `/at 30m ` | Schedule a one-shot delayed run (60s–24h; `/cancel` to drop) | +| `/ping` | Health check / uptime (shows per-chat trigger summary if any) | Prefix any message with `/` to pick an engine for that task, or `/` to target a repo: diff --git a/contrib/untether.service b/contrib/untether.service index 23e5e045..4e279c3b 100644 --- a/contrib/untether.service +++ b/contrib/untether.service @@ -6,11 +6,31 @@ # systemctl --user enable --now untether # # Key settings: +# Type=notify — Untether sends READY=1 via sd_notify after the +# first getUpdates succeeds, so systemd knows the +# bot is actually healthy (not just "PID exists"). +# STOPPING=1 is sent during drain. See #287. +# NotifyAccess=main — only the main process can send sd_notify messages +# (defence in depth). # KillMode=mixed — SIGTERM only the main process first (drain logic # waits for active runs); then SIGKILL all remaining # cgroup processes (orphaned MCP servers, containers) # TimeoutStopSec=150 — give the 120s drain timeout room to complete # before systemd sends SIGKILL +# RestartSec=2 — resume quickly after drain completes; Telegram +# update_id persistence (#287) means no lost +# messages across the restart gap. +# OOMScoreAdjust=-100 — lower than CLI/tmux processes (oom_score_adj=0); +# prevents earlyoom/kernel OOM killer from picking +# Untether's Claude subprocesses first under memory +# pressure. Silently clamped by the kernel to the +# parent's baseline for unprivileged users (typically +# user@UID.service's OOMScoreAdjust, often 100), but +# the -100 request future-proofs the unit. See #275. +# OOMPolicy=continue — do NOT tear down the whole unit when one child +# process is OOM-killed. Default is `stop`, which +# cascades SIGTERM to all active engine subprocesses +# and breaks every live chat at once. [Unit] Description=Untether - Telegram bridge for Claude Code/OpenCode @@ -18,10 +38,11 @@ After=network-online.target Wants=network-online.target [Service] -Type=simple +Type=notify +NotifyAccess=main ExecStart=%h/.local/bin/untether Restart=always -RestartSec=10 +RestartSec=2 # Graceful shutdown: SIGTERM the main process first, then SIGKILL the rest. # - process: SIGTERM main only, but orphaned children (MCP servers, @@ -31,6 +52,18 @@ RestartSec=10 KillMode=mixed TimeoutStopSec=150 +# OOM victim ordering — see littlebearapps/untether#275 (and closed #222). +# Without these, systemd defaults (OOMScoreAdjust=200 inherited via +# user@UID.service, OOMPolicy=stop) make Untether's Claude subprocesses +# preferred OOM victims over CLI claude (oom_score_adj=0) and the +# orphaned workerd grandchildren that are actually consuming the RAM. +# -100 brings Untether below typical CLI/tmux processes (subject to the +# kernel's clamp at the parent baseline for unprivileged users); +# `continue` prevents tearing down the whole unit when a single child +# (e.g. an MCP server) gets killed. +OOMScoreAdjust=-100 +OOMPolicy=continue + Environment=HOME=%h Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin EnvironmentFile=%h/.untether/.env diff --git a/docs/how-to/operations.md b/docs/how-to/operations.md index cd0dcb2f..fa4ae54a 100644 --- a/docs/how-to/operations.md +++ b/docs/how-to/operations.md @@ -11,6 +11,12 @@ Send `/ping` in Telegram to verify the bot is running: The response includes the bot's uptime since last restart. Use this as a quick liveness check. +If triggers (crons or webhooks) target the current chat, `/ping` also shows a trigger summary: + +!!! untether "Untether" + pong — up 3d 14h 22m + ⏰ triggers: 1 cron (daily-review, 9:00 AM daily (Melbourne)), 1 webhook + If [webhooks and cron](webhooks-and-cron.md) are enabled, the webhook server also exposes a health endpoint: ``` @@ -41,6 +47,10 @@ Sending SIGTERM to the Untether process triggers the same graceful drain as `/re This means `systemctl --user stop untether` (Linux) also drains gracefully, as systemd sends SIGTERM first. Pressing Ctrl+C in a terminal sends SIGINT, which triggers the same graceful drain. +### Message continuity across restarts + +Untether persists the last Telegram `update_id` to `last_update_id.json` in the config directory. On startup, polling resumes from the saved offset — no messages are dropped or re-processed within Telegram's 24-hour retention window. Pending `/at` delays are cancelled during drain and not persisted (they are lost on restart). + !!! note "Drain timeout" The default drain timeout is 120 seconds. If active runs don't complete within this window, they are cancelled and a timeout notification is sent to Telegram. @@ -59,6 +69,29 @@ The cleanup happens before the startup message is sent, so by the time you see " +## Systemd service (Linux) + +The recommended systemd unit file is provided at `contrib/untether.service`. Key settings: + +| Setting | Value | Purpose | +|---------|-------|---------| +| `Type=notify` | — | Untether sends `READY=1` after startup completes; systemd knows the service is ready | +| `NotifyAccess=main` | — | Only the main process can send sd_notify signals | +| `RestartSec=2` | — | Wait 2 seconds before auto-restarting on failure | +| `OOMScoreAdjust=-100` | — | Makes Untether less likely to be OOM-killed than default processes | +| `OOMPolicy=continue` | — | Don't stop the service if a child process is OOM-killed | +| `KillMode=mixed` | — | Sends SIGTERM to main process, SIGKILL to remaining children after timeout | + +Copy the unit file and reload: + +```bash +cp contrib/untether.service ~/.config/systemd/user/untether.service +systemctl --user daemon-reload +systemctl --user enable --now untether +``` + +See the [dev instance reference](../reference/dev-instance.md) for full service file documentation. + ## Auto-continue (Claude Code) When Claude Code exits after receiving tool results without processing them (an upstream bug), Untether detects the premature exit and automatically resumes the session. You'll see a "⚠️ Auto-continuing" notification in the chat. @@ -127,7 +160,19 @@ Enable config watching so Untether picks up changes without a restart: watch_config = true ``` -When enabled, Untether watches the config file for changes and reloads most settings automatically. Transport settings (bot token, chat ID) are excluded — those require a full restart. +When enabled, Untether watches the config file for changes and reloads most settings automatically. + +**Hot-reloadable** (applied immediately): + +- Trigger system: `triggers.enabled`, crons, webhooks, auth, rate limits, timezones +- Telegram bridge: `voice_transcription`, `[files]`, `allowed_user_ids`, `show_resume_line`, timing +- Engine defaults, budget, cost/usage display flags + +**Restart-only** (require `/restart` or `systemctl restart`): + +- `bot_token`, `chat_id` (Telegram connectivity) +- `session_mode`, `topics.enabled` (structural) +- `message_overflow` (message splitting strategy) ## Process management diff --git a/docs/how-to/schedule-tasks.md b/docs/how-to/schedule-tasks.md index 123a2a7e..e555c778 100644 --- a/docs/how-to/schedule-tasks.md +++ b/docs/how-to/schedule-tasks.md @@ -1,6 +1,29 @@ # Schedule tasks -There are two ways to run tasks on a schedule: Telegram's built-in message scheduling (no config needed) and Untether's trigger system (webhooks and cron). +There are several ways to run tasks on a schedule: the `/at` command for quick one-shot delays, Telegram's built-in message scheduling, and Untether's trigger system (webhooks and cron). + +## One-shot delays with /at + +The `/at` command schedules a prompt to run after a delay — useful for reminders, follow-ups, or "run this in 30 minutes": + +``` +/at 30m Check the build +/at 2h Review the PR feedback +/at 60s Say hello +``` + +**Duration format:** `Ns` (seconds), `Nm` (minutes), or `Nh` (hours). Minimum 60 seconds, maximum 24 hours. + +After scheduling, you'll see a confirmation: + +!!! untether "Untether" + ⏳ Scheduled: will run in 30m + Cancel with /cancel. + +When the delay expires, the prompt runs as a normal agent session. Use `/cancel` to cancel all pending delays in the current chat. + +!!! note "Not persistent" + Pending `/at` delays are held in memory. They are lost if Untether restarts. For persistent scheduled tasks, use [cron triggers](#cron-triggers) instead. ## Telegram scheduling @@ -48,6 +71,8 @@ Common schedules: | `*/30 * * * *` | Every 30 minutes | | `0 */4 * * *` | Every 4 hours | +Add `run_once = true` to fire a cron exactly once, then auto-disable. It re-activates on config reload or restart — useful for one-off tasks that shouldn't repeat. + ## Webhook triggers Webhooks let external services (GitHub, Slack, PagerDuty) trigger agent runs via HTTP POST. diff --git a/docs/how-to/security.md b/docs/how-to/security.md index 12517292..d789c507 100644 --- a/docs/how-to/security.md +++ b/docs/how-to/security.md @@ -134,6 +134,10 @@ DNS resolution is checked after hostname lookup to prevent DNS rebinding attacks If you need triggers to reach local services, you can configure an allowlist (see the [triggers reference](../reference/triggers/triggers.md)). +## Untrusted payload marking + +All webhook payloads and cron-fetched data are automatically prefixed with `#-- EXTERNAL WEBHOOK PAYLOAD --#` before being injected into the agent prompt. This signals to AI agents that the content is untrusted external input and should not be treated as instructions. The same prefix is applied to fetched cron data (`#-- EXTERNAL FETCHED DATA --#`). + ## Run untether doctor After any configuration change, run the built-in preflight check: diff --git a/docs/how-to/troubleshooting.md b/docs/how-to/troubleshooting.md index bde1487f..64402045 100644 --- a/docs/how-to/troubleshooting.md +++ b/docs/how-to/troubleshooting.md @@ -231,6 +231,24 @@ Run `untether doctor` to validate voice configuration. 5. Check firewall rules if the webhook server is behind NAT 6. Look at `debug.log` for incoming request logs +## Config change didn't take effect + +**Symptoms:** You edited `untether.toml` but the change doesn't seem to apply. + +1. **Check `watch_config`:** Hot-reload requires `watch_config = true` in the top-level config. Without it, changes only apply on restart. +2. **Hot-reloadable settings** apply immediately: `voice_transcription`, `[files]`, `allowed_user_ids`, `show_resume_line`, trigger crons/webhooks/auth/timezones. +3. **Restart-only settings** require `/restart` or `systemctl restart`: `bot_token`, `chat_id`, `session_mode`, `topics.enabled`, `message_overflow`, `triggers.server.host`/`port`. +4. Check the log for `config.reload.applied` (success) or `config.reload.transport_config_changed restart_required=True` (restart needed). + +## /at delay not firing + +**Symptoms:** You scheduled `/at 30m Check the build` but the prompt never runs. + +- Pending `/at` delays are held in memory — they are **lost on restart**. If Untether restarted after you scheduled, the delay was cancelled. +- Use `/cancel` to see how many pending delays exist. If it says "nothing running", there are no pending delays. +- Minimum duration: 60 seconds. Maximum: 24 hours. Values outside this range are rejected. +- Per-chat cap: 20 pending delays. The 21st is rejected with an error message. + ## Session not resuming **Symptoms:** Sending a follow-up message starts a new session instead of continuing. diff --git a/docs/how-to/voice-notes.md b/docs/how-to/voice-notes.md index 3c7e252e..8071e134 100644 --- a/docs/how-to/voice-notes.md +++ b/docs/how-to/voice-notes.md @@ -33,6 +33,9 @@ requests on their own base URL without relying on `OPENAI_BASE_URL`. If your ser requires a specific model name, set `voice_transcription_model` (for example, `whisper-1`). +!!! tip "Hot-reload" + Voice transcription settings (`voice_transcription`, model, base URL, API key) can be toggled by editing `untether.toml` — changes take effect immediately without restarting (requires `watch_config = true`). + ## Behavior When you send a voice note, Untether transcribes it and runs the result as a normal text message. diff --git a/docs/how-to/webhooks-and-cron.md b/docs/how-to/webhooks-and-cron.md index 80643bb9..4341ce91 100644 --- a/docs/how-to/webhooks-and-cron.md +++ b/docs/how-to/webhooks-and-cron.md @@ -268,6 +268,49 @@ accessible immediately, and removed webhooks start returning 404. require a restart. See the [Triggers reference — Hot-reload](../reference/triggers/triggers.md#hot-reload) for the full list. +## One-shot crons with `run_once` + +Set `run_once = true` on a cron to fire once then auto-disable. The cron stays in the TOML but is skipped until the next reload or restart: + +```toml +[[triggers.crons]] +id = "deploy-check" +schedule = "0 15 * * *" +prompt = "Check today's deployment status" +run_once = true +``` + +After the cron fires, the `triggers.cron.run_once_completed` log line confirms the removal. To re-enable, save the TOML again (triggers a reload) or restart the service. + +## Delayed runs with `/at` + +For ad-hoc one-shot delays, use the `/at` command directly in Telegram — no TOML edit required: + +``` +/at 30m Check the build status +/at 2h Review open PRs +/at 90s Run the test suite +``` + +Duration supports `Ns` / `Nm` / `Nh` with a 60s minimum and 24h maximum. Pending delays are cancelled via `/cancel` and lost on restart. Per-chat cap of 20 pending delays. + +## Discovering configured triggers + +Once triggers are configured, `/ping` in the targeted chat shows a summary: + +``` +🏓 pong — up 2d 4h 12m 3s +⏰ triggers: 1 cron (daily-review, 9:00 AM daily (Melbourne)) +``` + +Runs initiated by a trigger show their provenance in the meta footer: + +``` +🏷 opus 4.6 · plan · ⏰ cron:daily-review +``` + +See the [Triggers reference — Trigger visibility](../reference/triggers/triggers.md#trigger-visibility) for details. + ## Security notes - The server binds to localhost by default. Use a reverse proxy (nginx, Caddy) with TLS to expose it to the internet. diff --git a/docs/reference/commands-and-directives.md b/docs/reference/commands-and-directives.md index 14098ee9..e22ef2f0 100644 --- a/docs/reference/commands-and-directives.md +++ b/docs/reference/commands-and-directives.md @@ -57,6 +57,7 @@ This line is parsed from replies and takes precedence over new directives. For b | `/auth` | Headless device re-authentication for Codex — runs `codex login --device-auth` and sends the verification URL + device code. `/auth status` checks CLI availability. Codex-only. | | `/new` | Cancel any running task and clear stored sessions for the current scope (topic/chat). | | `/continue [prompt]` | Resume the most recent session in the project directory. Picks up CLI-started sessions from Telegram. Optional prompt appended. Not supported for AMP. | +| `/at ` | Schedule a one-shot delayed run. Duration: `Ns` (60-9999s), `Nm`, or `Nh` (up to 24h). Pending delays are cancelled via `/cancel` and lost on restart. Per-chat cap of 20 pending delays. | Notes: diff --git a/docs/reference/config.md b/docs/reference/config.md index 157af652..d0121e83 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -20,7 +20,7 @@ If you expect to edit config while Untether is running, set: | Key | Type | Default | Notes | |-----|------|---------|-------| -| `watch_config` | bool | `false` | Hot-reload config changes (transport excluded). | +| `watch_config` | bool | `false` | Watch config file for changes; applies most settings immediately. Restart-only: `bot_token`, `chat_id`, `session_mode`, `topics`, `message_overflow`. | | `default_engine` | string | `"codex"` | Default engine id for new threads. | | `default_project` | string\|null | `null` | Default project alias. | | `transport` | string | `"telegram"` | Transport backend id. | @@ -492,3 +492,4 @@ routing details. | `chat_id` | int\|null | `null` | Telegram chat. Falls back to transport default. | | `prompt` | string | (required) | Prompt sent to the engine. | | `timezone` | string\|null | `null` | IANA timezone (e.g. `"Australia/Melbourne"`). Overrides `default_timezone`. | +| `run_once` | bool | `false` | Fire once then auto-disable in-memory. Re-activates on config reload or restart. | diff --git a/docs/reference/dev-instance.md b/docs/reference/dev-instance.md index b972bbe6..36daaf59 100644 --- a/docs/reference/dev-instance.md +++ b/docs/reference/dev-instance.md @@ -173,14 +173,47 @@ To add another test route: ## Systemd service configuration -An example service file lives at `contrib/untether.service`. Two settings are -critical for graceful shutdown: +An example service file lives at `contrib/untether.service`. Seven settings are +critical — two for systemd readiness notification, two for graceful shutdown, +two for OOM (out-of-memory) behaviour, plus `RestartSec`: ```ini +Type=notify # Untether sends READY=1 after first getUpdates succeeds +NotifyAccess=main # Only the main process can send sd_notify messages KillMode=mixed # SIGTERM main process first, then SIGKILL remaining cgroup TimeoutStopSec=150 # Give the 120s drain timeout room to complete +RestartSec=2 # Restart quickly after drain completes +OOMScoreAdjust=-100 # Don't be earlyoom's preferred victim +OOMPolicy=continue # Don't tear down the whole unit on a single OOM kill ``` +### Readiness (`Type=notify`) + +!!! info "New in v0.35.1" + +`Type=notify` tells systemd the bot is "activating" until Untether sends a +`READY=1` datagram to `$NOTIFY_SOCKET` — which only happens after the first +`getUpdates` call succeeds. This prevents the previous race where `systemctl +start` returned "active" before the bot was actually polling. On shutdown, +Untether sends `STOPPING=1` at the start of drain so `systemctl status` shows +"Deactivating" rather than "Active" during the drain window. + +The `sd_notify` integration uses the standard library only (no external +dependency). Missing `NOTIFY_SOCKET` (e.g. running outside systemd) is a +silent no-op. See `src/untether/sdnotify.py` and issue #287. + +### Restart timing + +!!! info "New in v0.35.1" + +`RestartSec=2` (down from systemd's default) lets Untether resume polling +within a few seconds of drain completion. The Telegram `update_id` offset is +persisted to `last_update_id.json` on shutdown, so no messages are dropped +or re-processed across the restart window (Telegram retains undelivered +updates for 24 hours). See issue #287. + +### Graceful shutdown + `KillMode=mixed` sends SIGTERM only to the main Untether process first, allowing the drain mechanism to gracefully finish active runs. After the main process exits, systemd sends SIGKILL to all remaining processes in the cgroup — cleaning @@ -194,7 +227,46 @@ Other modes have drawbacks: Without `TimeoutStopSec=150`, systemd's default 90s timeout may kill the process before the 120s drain finishes. -To apply: +### OOM (out-of-memory) behaviour + +By default, systemd user services inherit `OOMScoreAdjust=100` or `200` from +`user@UID.service` and use `OOMPolicy=stop`. Without overrides, this makes +Untether's Claude subprocesses **preferred victims** for earlyoom and the +kernel OOM killer — ahead of CLI `claude` running in tmux (`oom_score_adj=0`) +and any orphaned grandchildren the user has spawned from a shell session. When +RAM exhaustion hits, the result is that live Telegram chats die with rc=143 +(SIGTERM) while the processes actually eating the RAM survive. + +`OOMScoreAdjust=-100` lowers Untether's OOM priority. Unprivileged user +processes can only raise their own `oom_score_adj`, not lower it below the +parent's baseline — so the kernel silently clamps the effective value at the +parent's setting (typically 100 on default installs). The `-100` request is +still worth keeping: it documents intent and takes effect if the parent +`user@UID.service` is ever overridden to a lower baseline. See `#275` and +`#222` for the full diagnosis. + +`OOMPolicy=continue` tells systemd **not** to tear down the entire unit when +a single child process is OOM-killed. The default (`stop`) cascades SIGTERM +to all active engine subprocesses, breaking every live chat at once. With +`continue`, a single dead MCP server or a single killed engine subprocess is +reported as a clean failure on that one run; the bridge and other active +chats keep running. + +Optional system-wide companion override (requires root) — lowers the baseline +for *all* user services to `-200`, which lets Untether's `-100` actually take +effect. Only apply if you want Untether's children to live *longer* than +other unprivileged user processes, including CLI claude: + +```bash +sudo systemctl edit user@1000.service # adjust UID for your host +# add: +[Service] +OOMScoreAdjust=-200 +``` + +This affects every user service on the host — use judgment. + +### To apply: ```bash cp contrib/untether.service ~/.config/systemd/user/untether.service diff --git a/docs/reference/integration-testing.md b/docs/reference/integration-testing.md index 6fcb6bbc..99a254ea 100644 --- a/docs/reference/integration-testing.md +++ b/docs/reference/integration-testing.md @@ -23,16 +23,21 @@ All integration test tiers are fully automated by Claude Code using Telegram MCP ### Test chats -Tests are sent to 6 dedicated `ut-dev-hf:` engine chats via `@untether_dev_bot`: - -| Chat | Chat ID | -|------|---------| -| `ut-dev-hf: claude` | 5171122044 | -| `ut-dev-hf: codex` | 5116709786 | -| `ut-dev-hf: opencode` | 5020138767 | -| `ut-dev-hf: pi` | 5276373372 | -| `ut-dev-hf: gemini` | 5152406011 | -| `ut-dev-hf: amp` | 5064468679 | +Tests are sent to 6 dedicated engine chats via `@untether_dev_bot` (bot ID `8678330610`). +For DM-only tests (commands, `/at`, `/cancel`), use the Nathan DM chat ID `8678330610`. + +| Chat | Chat ID | Bot API chat_id | +|------|---------|-----------------| +| Claude Code | `5284581592` | `-5284581592` | +| Codex CLI | `4929463515` | `-4929463515` | +| OpenCode | `5200822877` | `-5200822877` | +| Pi | `5156256333` | `-5156256333` | +| Gemini CLI | `5207762142` | `-5207762142` | +| AMP CLI | `5230875989` | `-5230875989` | + +> **Note:** The Telegram MCP (Telethon) accepts both positive and negative chat IDs. +> If a positive ID fails with `GEN-ERR-582` (PeerUser lookup), use the negative Bot API form. +> A local fix in `resolve_entity()` auto-retries with the negative form (applied 2026-04-14). ### Workflow @@ -194,6 +199,28 @@ Run quickly to verify all commands respond. | Q11 | `/agent` | Current engine override or default | 1s | | Q12 | `/trigger` | Current trigger mode | 1s | | Q13 | `/file` | Usage help or file browser | 1s | +| Q14 | `/at 60s smoke test` | "⏳ Scheduled" confirmation; run fires after ~60s | 70s | +| Q15 | `/at 5m test` then `/cancel` | Scheduling confirmation; cancel drops pending; no run after 5m | 10s (skip 5m wait) | +| Q16 | `/ping` in chat with cron | Pong + `⏰ triggers: ... cron (...)` line appears | 1s | + +--- + +## rc4 scenarios (v0.35.1rc4) + +Run these in addition to the standard tiers for rc4. + +| # | Scenario | Expected | +|---|----------|----------| +| R1 | **Hot-reload cron add** | Edit `~/.untether-dev/untether.toml` to add a `* * * * *` cron; no restart; wait 60s | New cron fires at next minute; `triggers.manager.updated` log line present | +| R2 | **Hot-reload webhook add** | Add a new `[[triggers.webhooks]]` entry; curl the new path | Returns 202; run dispatched to the configured chat | +| R3 | **Hot-reload webhook secret change** | Change `secret` on existing webhook; curl with old secret | 401; new secret returns 202 | +| R4 | **`run_once` cron** | Add `run_once = true` cron with `* * * * *` | Fires once, skips next minute, `triggers.cron.run_once_completed` log line | +| R5 | **Trigger source in footer** | Trigger a cron run | Final message footer shows `⏰ cron:` next to model | +| R6 | **Bridge voice hot-reload** | Toggle `voice_transcription = false` in TOML; send a voice note | Not transcribed; `config.reload.transport_config_hot_reloaded` log line with `keys=['voice_transcription']` | +| R7 | **Bridge allowed_user_ids hot-reload** | Add a new user id to `allowed_user_ids`; have that user send a message | Message routed on the next message (no restart) | +| R8 | **update_id persistence** | `systemctl --user restart untether-dev` mid-conversation | Startup log `startup.offset.resumed`; no duplicate processing of pre-restart messages | +| R9 | **sd_notify READY=1** | `systemctl --user status untether-dev` after start | "Active: active (running)" only appears after READY=1 | +| R10 | **sd_notify STOPPING=1 during drain** | `systemctl --user restart untether-dev` while a run is active | journalctl shows `sdnotify.stopping` before `shutdown.draining` | --- diff --git a/docs/reference/specification.md b/docs/reference/specification.md index cef89845..843ed856 100644 --- a/docs/reference/specification.md +++ b/docs/reference/specification.md @@ -1,4 +1,4 @@ -# Untether Specification v0.35.0 [2026-03-18] +# Untether Specification v0.35.1 [2026-04-14] This document is **normative**. The words **MUST**, **SHOULD**, and **MAY** express requirements. diff --git a/docs/reference/triggers/triggers.md b/docs/reference/triggers/triggers.md index 1bc7d2b8..34aa3fbb 100644 --- a/docs/reference/triggers/triggers.md +++ b/docs/reference/triggers/triggers.md @@ -146,6 +146,7 @@ Webhook IDs must be unique across all configured webhooks. | `prompt_template` | string\|null | `null` | Template prompt with `{{field}}` substitution (used with fetch data). | | `timezone` | string\|null | `null` | IANA timezone name (e.g. `"Australia/Melbourne"`). Overrides `default_timezone`. | | `fetch` | object\|null | `null` | Pre-fetch step configuration (see [Data-fetch crons](#data-fetch-crons)). | +| `run_once` | bool | `false` | Fire once then auto-disable in-memory. The cron stays in the TOML; it re-enters the active list on the next config reload or restart. Useful for scheduled one-off tasks. | Either `prompt` or `prompt_template` is required. Cron IDs must be unique across all configured crons. @@ -488,6 +489,51 @@ the filesystem context. against blocked IP ranges (loopback, RFC 1918, link-local, CGN, multicast) and DNS resolution is checked to prevent rebinding attacks. See `triggers/ssrf.py`. +## Trigger visibility + +!!! info "New in v0.35.1" + +### Per-chat `/ping` indicator + +Running `/ping` in a chat with configured triggers appends a summary line: + +``` +🏓 pong — up 2d 4h 12m 3s +⏰ triggers: 1 cron (daily-review, 9:00 AM daily (Melbourne)) +``` + +If multiple triggers target the chat, the indicator shows counts instead of the single-cron detail: + +``` +⏰ triggers: 2 crons, 1 webhook +``` + +The indicator is per-chat — only triggers whose `chat_id` matches the current chat appear. Triggers that omit `chat_id` (and therefore fall back to the transport's default `chat_id`) show for that chat only. + +### Meta footer + +Runs initiated by a cron or webhook show provenance in the meta footer alongside model and mode: + +``` +🏷 opus 4.6 · plan · ⏰ cron:daily-review +``` + +- `⏰ cron:` for cron-initiated runs +- `⚡ webhook:` for webhook-initiated runs + +### Human-friendly cron descriptions + +Common patterns render in plain English via `describe_cron(schedule, timezone)`: + +| Schedule | Timezone | Rendered | +|----------|----------|----------| +| `0 9 * * *` | `Australia/Melbourne` | `9:00 AM daily (Melbourne)` | +| `0 8 * * 1-5` | `Australia/Melbourne` | `8:00 AM Mon-Fri (Melbourne)` | +| `30 14 * * 0,6` | — | `2:30 PM Sat,Sun` | +| `*/15 * * * *` | — | `*/15 * * * *` (raw, fallback) | + +Complex patterns (stepped fields, specific day-of-month, multi-month) fall back to the raw expression. + ## Startup message When triggers are enabled, the startup message includes a triggers line: @@ -582,12 +628,17 @@ within seconds, and active runs are not interrupted. ### How it works +Requires `watch_config = true` in the top-level config. + A `TriggerManager` holds the current cron list and webhook lookup table. The cron scheduler reads `manager.crons` on each tick, and the webhook server calls `manager.webhook_for_path()` on each request. When the config file changes, `handle_reload()` re-parses the `[triggers]` TOML section and calls `manager.update()`, which atomically swaps the configuration. In-flight iterations over the old cron list are unaffected because `update()` creates new container objects. +The `triggers.manager.updated` log line lists added/removed crons and webhooks after each reload. +`last_fired` state is preserved across reloads so the same cron won't fire twice in the same minute. + ## Key files | File | Purpose | diff --git a/docs/tutorials/first-run.md b/docs/tutorials/first-run.md index a14d87e6..4938f8a9 100644 --- a/docs/tutorials/first-run.md +++ b/docs/tutorials/first-run.md @@ -16,7 +16,7 @@ untether Untether keeps running in your terminal. In Telegram, your bot will post a startup message like: !!! untether "Untether" - 🐕 untether (v0.35.0) + 🐕 untether (v0.35.1) *default engine:* `codex`
*installed engines:* claude, codex, opencode
diff --git a/docs/tutorials/install.md b/docs/tutorials/install.md index 501522c6..0f6101b3 100644 --- a/docs/tutorials/install.md +++ b/docs/tutorials/install.md @@ -30,7 +30,7 @@ Verify it's installed: untether --version ``` -You should see the installed version number (e.g. `0.35.0`). +You should see the installed version number (e.g. `0.35.1`). ## 3. Install agent CLIs @@ -314,7 +314,7 @@ Press **y** or **Enter** to save. You'll see: Untether is now running and listening for messages! !!! untether "Untether" - 🐕 untether is ready (v0.35.0) + 🐕 untether is ready (v0.35.1) *default engine:* `codex`
*installed engines:* codex
diff --git a/incoming/v0.35.1rc4-integration-test-plan.md b/incoming/v0.35.1rc4-integration-test-plan.md new file mode 100644 index 00000000..cfb092d5 --- /dev/null +++ b/incoming/v0.35.1rc4-integration-test-plan.md @@ -0,0 +1,276 @@ +# v0.35.1rc4 Integration Test Plan + +**Date:** 2026-04-14 +**PR:** #292 (`feature/v0.35.1rc4` → `dev`) +**Release type:** Minor (new features) — requires Tier 7 + Tier 1 (all engines) + Tier 2 (Claude) + Tier 3 (transport, touched) + Tier 4 (overrides, if touched) + Tier 6 (stress) + upgrade path + +## Infrastructure + +| Item | Value | +|------|-------| +| **Service** | `untether-dev.service` (PID varies on restart) | +| **Bot** | `@untether_dev_bot` (ID: `8678330610`) | +| **Config** | `~/.untether-dev/untether.toml` | +| **Source** | Local editable at `/home/nathan/untether/src/` | +| **Version** | `0.35.1rc4` | + +### Correct Chat IDs (Bot API → Telethon MCP) + +> **IMPORTANT:** The `ut-dev-hf:` chat IDs in `docs/reference/integration-testing.md` are STALE — they point to a different bot (ID 8485467124). The correct dev bot chats use these IDs: + +| Engine | Bot API chat_id | Telethon MCP chat_id | Name | +|--------|-----------------|---------------------|------| +| Nathan DM | `8351408485` | `8678330610` (bot ID) | Nathan ↔ @untether_dev_bot | +| Claude | `-5284581592` | `5284581592` | Claude Code | +| Codex | `-4929463515` | `4929463515` | Codex CLI | +| OpenCode | `-5200822877` | `5200822877` | OpenCode | +| Pi | `-5156256333` | `5156256333` | Pi | +| Gemini | `-5207762142` | `5207762142` | Gemini CLI | +| AMP | `-5230875989` | `5230875989` | AMP CLI | + +For DM-only tests (commands, `/at`, `/cancel`), use the Nathan DM: `send_message(chat_id=8678330610, ...)`. +For engine-specific tests, use the engine group's Telethon ID. + +--- + +## Pre-test setup + +### 1. Enable triggers for testing + +Add the following to `~/.untether-dev/untether.toml`: + +```toml +[triggers] +enabled = true +default_timezone = "Australia/Melbourne" + +[triggers.server] +host = "127.0.0.1" +port = 19876 + +[[triggers.webhooks]] +id = "test-wh" +path = "/hooks/test" +auth = "bearer" +secret = "test-token-rc4" +prompt_template = "Webhook test: {{text}}" + +[[triggers.crons]] +id = "rc4-test-cron" +schedule = "* * * * *" +prompt = "say 'cron test ok' — one sentence only, no tools" +run_once = true +``` + +### 2. Restart dev service + +```bash +systemctl --user restart untether-dev +journalctl --user -u untether-dev --since "10 seconds ago" --no-pager | head -30 +``` + +Verify in startup logs: +- `at.installed` present +- `triggers.enabled` with webhooks=1, crons=1 +- No errors + +--- + +## Phase 1: Tier 7 — Command Smoke Tests (~5 min) + +All commands via **Nathan DM** (`chat_id=8678330610`). + +| # | Command | Send | Verify | Status | +|---|---------|------|--------|--------| +| Q1 | `/ping` | `/ping` | "🏓 pong — up Ns" | | +| Q2 | `/config` | `/config` | Settings menu with buttons renders | | +| Q3 | `/cancel` | `/cancel` | "nothing running" | | +| Q4 | `/verbose` | `/verbose` | Toggle confirmation | | +| Q5 | `/stats` | `/stats` | Statistics or empty | | +| Q6 | `/ctx` | `/ctx` | Context or "none set" | | +| Q7 | `/agent` | `/agent` | Engine default shown | | +| Q8 | `/trigger` | `/trigger` | Trigger mode shown | | +| Q9 | `/file` | `/file` | Usage help | | +| Q10 | `/at` (no args) | `/at` | Usage text with examples | | +| Q11 | `/at` (invalid) | `/at 30x hello` | "❌ couldn't parse" + usage | | +| Q12 | `/at` (below min) | `/at 10s hello` | "❌ couldn't parse" (10s < 60s minimum) | | + +--- + +## Phase 2: rc4 Feature Tests (~30 min) + +### 2a. `/at` command (#288) + +| # | Test | Steps | Verify | Status | +|---|------|-------|--------|--------| +| AT1 | **Schedule + fire** | `send_message(8678330610, "/at 60s say hello — /at test")` | "⏳ Scheduled: will run in 1m" appears; after ~60s "⏰ Running scheduled prompt" appears + engine run completes | | +| AT2 | **Schedule + cancel** | `send_message(8678330610, "/at 5m cancel test")` then `send_message(8678330610, "/cancel")` | "⏳ Scheduled" then "❌ cancelled 1 pending /at run" — no run fires after 5 minutes | | +| AT3 | **Multiple + cancel** | Schedule 3x `/at` (60s, 2m, 3m), then `/cancel` | "❌ cancelled 3 pending /at runs" | | +| AT4 | **Per-chat cap** | Schedule 21x `/at 5m test` (exceeds cap of 20) | 20 succeed, 21st returns "❌ per-chat limit of 20 pending /at delays reached" | | + +**Log check after AT1:** +```bash +journalctl --user -u untether-dev --since "2 minutes ago" | grep "at\." +``` +Expected: `at.scheduled`, `at.firing` with correct token and delay_s. + +### 2b. `run_once` cron flag (#288) + +| # | Test | Steps | Verify | Status | +|---|------|-------|--------|--------| +| RO1 | **Fire once** | Trigger config from pre-test setup has `run_once = true` cron with `* * * * *`; wait up to 120s | Cron fires exactly once (check Telegram + logs); `triggers.cron.run_once_completed` in logs; next minute: no second fire | | +| RO2 | **Reload re-enables** | After RO1, save the TOML (touch or edit+save) to trigger hot-reload | `triggers.manager.updated` log; cron fires again on next minute (re-entered the active list) | | + +**Log check:** +```bash +journalctl --user -u untether-dev --since "3 minutes ago" | grep "run_once\|cron.firing\|manager.updated" +``` + +### 2c. Hot-reload trigger config (#269/#285) + +| # | Test | Steps | Verify | Status | +|---|------|-------|--------|--------| +| HR1 | **Add webhook via reload** | Edit TOML: add a second `[[triggers.webhooks]]` with `id="test-wh2"`, `path="/hooks/test2"`, `auth="none"`, `prompt_template="test: {{text}}"` | `triggers.manager.updated` log with `webhooks_added=['test-wh2']`; `triggers.webhook.no_auth` warning | | +| HR2 | **Curl new webhook** | `curl -X POST http://127.0.0.1:19876/hooks/test2 -H "Content-Type: application/json" -d '{"text":"hot-reload works"}'` | Returns 202; agent run dispatched | | +| HR3 | **Remove webhook via reload** | Remove `test-wh2` from TOML, save | `triggers.manager.updated` with `webhooks_removed=['test-wh2']`; curl to `/hooks/test2` returns 404 | | +| HR4 | **Webhook secret change** | Change `secret` on `test-wh` from `test-token-rc4` to `new-secret-rc4`, save | Old token → 401; new token → 202 | | +| HR5 | **Health endpoint** | `curl http://127.0.0.1:19876/health` | `{"status":"ok","webhooks":1}` (after removing test-wh2) | | + +### 2d. Hot-reload bridge config (#286) + +| # | Test | Steps | Verify | Status | +|---|------|-------|--------|--------| +| BR1 | **Voice hot-reload** | Set `voice_transcription = false` in TOML, save; send a voice note | `config.reload.transport_config_hot_reloaded` log with `keys=['voice_transcription']`; voice note NOT transcribed | | +| BR2 | **Voice re-enable** | Set `voice_transcription = true`, save; send another voice note | Transcription appears ("🎙 ...") | | +| BR3 | **Restart-only key warning** | Change `session_mode = "stateless"` (or `message_overflow = "trim"`), save | `config.reload.transport_config_changed` log with `restart_required=true` | | + +### 2e. Trigger visibility (#271) + +| # | Test | Steps | Verify | Status | +|---|------|-------|--------|--------| +| TV1 | **`/ping` with triggers** | `/ping` in the chat that has a cron targeting it | Response includes `⏰ triggers: 1 cron (rc4-test-cron, ...)` line | | +| TV2 | **`/ping` without triggers** | `/ping` in a different engine chat with no triggers | No `⏰ triggers` line (just pong + uptime) | | +| TV3 | **Trigger footer on cron run** | Wait for a cron to fire (or re-enable `run_once` cron) | Footer shows `⏰ cron:rc4-test-cron` alongside model name | | +| TV4 | **Trigger footer on webhook run** | `curl -X POST http://127.0.0.1:19876/hooks/test -H "Authorization: Bearer test-token-rc4" -H "Content-Type: application/json" -d '{"text":"visibility test"}'` | Footer shows `⚡ webhook:test-wh` | | + +### 2f. Graceful restart Tier 1 (#287) + +| # | Test | Steps | Verify | Status | +|---|------|-------|--------|--------| +| GR1 | **update_id persistence** | Send a message, `systemctl --user restart untether-dev`, send another message | Startup log shows `startup.offset.resumed`; no duplicate "pong" from the pre-restart `/ping`; second message processes normally | | +| GR2 | **sd_notify READY=1** | After restart: `systemctl --user status untether-dev` | Status shows "Active: active (running)" (not "activating"); Note: this only works if unit file has `Type=notify` — dev unit may still be `Type=simple` | | +| GR3 | **sd_notify STOPPING=1** | Start a `/at 5m test` then `systemctl --user restart untether-dev` | journalctl shows `sdnotify.stopping` → `shutdown.draining` → `at.cancelled` in order | | +| GR4 | **RestartSec** | `systemctl show untether-dev.service -p RestartUSec` | Shows the configured restart interval | | + +**Dev unit upgrade for GR2 (optional — do this to test Type=notify):** +```bash +# Backup current dev unit +cp ~/.config/systemd/user/untether-dev.service /tmp/untether-dev-backup.service + +# Update dev unit with Type=notify + NotifyAccess=main + RestartSec=2 +# (edit the file manually or copy from contrib/untether.service and adjust ExecStart) +sed -i 's/Type=simple/Type=notify/' ~/.config/systemd/user/untether-dev.service +sed -i '/Type=notify/a NotifyAccess=main' ~/.config/systemd/user/untether-dev.service +sed -i 's/RestartSec=10/RestartSec=2/' ~/.config/systemd/user/untether-dev.service +systemctl --user daemon-reload +systemctl --user restart untether-dev +``` + +### 2g. OOM fix (#275) + diff_preview gate (#283) + +| # | Test | Steps | Verify | Status | +|---|------|-------|--------|--------| +| OOM1 | **Service file has OOM settings** | `grep OOMScoreAdjust contrib/untether.service` | `-100` present | | +| DP1 | **diff_preview after plan approve** | (Claude chat, plan mode on) Send a prompt → Pause & Outline → Approve → next Edit tool should NOT gate again | Edit proceeds without a second approval dialog | | + +--- + +## Phase 3: Tier 1 — Engine Smoke Tests (~20 min) + +Run U1 (basic prompt) and U6 (cancel) across each engine to verify no regressions. + +| Engine | Telethon chat_id | U1 prompt | U6 prompt | +|--------|-----------------|-----------|-----------| +| Claude | `5284581592` | `create hello.txt with "rc4 test"` | `write a 500-word essay` then `/cancel` | +| Codex | `4929463515` | `create hello.txt with "rc4 test"` | `write a 500-word essay` then `/cancel` | +| OpenCode | `5200822877` | `create hello.txt with "rc4 test"` | `write a 500-word essay` then `/cancel` | +| Pi | `5156256333` | `create hello.txt with "rc4 test"` | `write a 500-word essay` then `/cancel` | +| Gemini | `5207762142` | `create hello.txt with "rc4 test"` | `write a 500-word essay` then `/cancel` | +| AMP | `5230875989` | `create hello.txt with "rc4 test"` | `write a 500-word essay` then `/cancel` | + +**Verify for each:** +- Progress messages appear (starting → working → done) +- Final answer renders with footer (model name visible) +- Cancel stops the run cleanly (U6) +- No orphan processes: `ps aux | grep "claude\|codex\|opencode\|pi\|gemini\|amp" | grep -v grep` + +--- + +## Phase 4: Tier 6 — Stress + Edge Cases (~15 min) + +| # | Test | Steps | Verify | Status | +|---|------|-------|--------|--------| +| S2 | **Concurrent sessions** | Send prompts to Claude + Codex chats simultaneously | Both run independently, both complete | | +| S3 | **Restart mid-run** | Start a Claude run, then `/restart` | Drain message appears, run completes, bot restarts | | +| S7 | **Rapid-fire prompts** | Send 5 messages rapidly to Claude chat | Only one run starts, no crash | | + +--- + +## Phase 5: Log Inspection (~5 min) + +After all tests complete: + +```bash +# Check for errors +journalctl --user -u untether-dev --since "1 hour ago" | grep -E "ERROR|error" | grep -v "project.skipped\|telegram.http_error.*chat not found" + +# Check for warnings (excluding expected ones) +journalctl --user -u untether-dev --since "1 hour ago" | grep -E "WARNING|warning" | grep -v "projects.config.skipped\|transport.send.failed.*chat_id=123\|webhook.no_auth" + +# Check for zombies +ps aux | grep defunct | grep -v grep + +# Check FD count +ls /proc/$(pgrep -f '.venv/bin/untether')/fd | wc -l +``` + +--- + +## Phase 6: Cleanup + +1. **Remove test trigger config** — edit `~/.untether-dev/untether.toml` to remove `[triggers]` section (or set `enabled = false`) +2. **Restart dev service** — `systemctl --user restart untether-dev` +3. **Restore dev unit file** (if modified for GR2) — `cp /tmp/untether-dev-backup.service ~/.config/systemd/user/untether-dev.service && systemctl --user daemon-reload` + +--- + +## Results Summary Template + +| Phase | Tests | Pass | Fail | Skip | Notes | +|-------|-------|------|------|------|-------| +| Tier 7 (commands) | 12 | | | | | +| rc4: /at (#288) | 4 | | | | | +| rc4: run_once (#288) | 2 | | | | | +| rc4: hot-reload triggers (#269) | 5 | | | | | +| rc4: hot-reload bridge (#286) | 3 | | | | | +| rc4: trigger visibility (#271) | 4 | | | | | +| rc4: restart Tier 1 (#287) | 4 | | | | | +| rc4: OOM + diff_preview | 2 | | | | | +| Tier 1 (all engines) | 12 | | | | | +| Tier 6 (stress) | 3 | | | | | +| Log inspection | 1 | | | | | +| **Total** | **52** | | | | | + +**Estimated time:** ~75 minutes + +--- + +## Known Issues / Caveats + +1. **Stale chat IDs** — `docs/reference/integration-testing.md` lists `ut-dev-hf:` chat IDs (5171122044 etc.) that belong to a different bot (ID 8485467124). The correct IDs are in the table above. Should be updated in the integration-testing doc. + +2. **Primary chat_id = 123** — the dev config uses a dummy `chat_id = 123` as the transport primary. Startup fails to send the greeting (400 "chat not found") — this is expected and harmless. The `/ping` trigger indicator test (TV1) requires that the cron's `chat_id` field matches one of the real project chat IDs, or that the DM chat is used (which equals the bot's user ID, not 123). + +3. **Type=notify in dev** — the dev unit file is `Type=simple` by default. To test sd_notify end-to-end (GR2), the unit must be temporarily changed to `Type=notify`. If sd_notify has an issue, the service will hang at "activating" for 90s before timing out. Restore the backup if this happens. + +4. **CancelScope race (fixed)** — a race where cancelled `/at` timers still fired was found and fixed (commit `11963d3`). The fix checks `cancelled_caught` after the scope exits. This was the only integration bug found during initial testing. diff --git a/pyproject.toml b/pyproject.toml index 9bab017f..9312f829 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "untether" authors = [{name = "Little Bear Apps", email = "hello@littlebearapps.com"}] maintainers = [{name = "Little Bear Apps", email = "hello@littlebearapps.com"}] -version = "0.35.1rc3" +version = "0.35.1rc4" keywords = ["telegram", "claude-code", "codex", "opencode", "pi", "gemini-cli", "amp", "ai-agents", "coding-assistant", "remote-control", "cli-bridge"] description = "Run AI coding agents from your phone. Bridges Claude Code, Codex, OpenCode, Pi, Gemini CLI, and Amp to Telegram with interactive permissions, voice input, cost tracking, and live progress." readme = {file = "README.md", content-type = "text/markdown"} @@ -78,6 +78,7 @@ aq = "untether.telegram.commands.ask_question:BACKEND" stats = "untether.telegram.commands.stats:BACKEND" auth = "untether.telegram.commands.auth:BACKEND" threads = "untether.telegram.commands.threads:BACKEND" +at = "untether.telegram.commands.at:BACKEND" [build-system] requires = ["uv_build>=0.9.18,<0.11.0"] diff --git a/src/untether/commands.py b/src/untether/commands.py index 840590ce..a23a03f9 100644 --- a/src/untether/commands.py +++ b/src/untether/commands.py @@ -3,7 +3,7 @@ from collections.abc import Iterable, Sequence from dataclasses import dataclass from pathlib import Path -from typing import Any, Literal, Protocol, overload, runtime_checkable +from typing import TYPE_CHECKING, Any, Literal, Protocol, overload, runtime_checkable from .config import ConfigError from .context import RunContext @@ -13,6 +13,9 @@ from .transport import MessageRef, RenderedMessage from .transport_runtime import TransportRuntime +if TYPE_CHECKING: + from .triggers.manager import TriggerManager + RunMode = Literal["emit", "capture"] @@ -70,6 +73,12 @@ class CommandContext: plugin_config: dict[str, Any] runtime: TransportRuntime executor: CommandExecutor + # rc4 (#271): exposed to commands so /ping can render per-chat trigger + # indicators. Transports without triggers pass None. + trigger_manager: TriggerManager | None = None + # rc4 (#271): the default chat_id that unscoped triggers fall back to + # (Telegram transport: cfg.chat_id). + default_chat_id: int | None = None @dataclass(frozen=True, slots=True) diff --git a/src/untether/context.py b/src/untether/context.py index a4efe074..df6c32e4 100644 --- a/src/untether/context.py +++ b/src/untether/context.py @@ -7,3 +7,7 @@ class RunContext: project: str | None = None branch: str | None = None + # rc4 (#271): trigger_source is set when a run is initiated by a cron + # or webhook (e.g. "cron:daily-review", "webhook:github-push") so the + # Telegram meta footer can show the provenance. + trigger_source: str | None = None diff --git a/src/untether/markdown.py b/src/untether/markdown.py index 3905daf5..65527352 100644 --- a/src/untether/markdown.py +++ b/src/untether/markdown.py @@ -310,7 +310,7 @@ def _short_model_name(model: str) -> str: def format_meta_line(meta: dict[str, Any]) -> str | None: - """Format model + effort + permission mode into a compact footer line.""" + """Format model + effort + permission mode (+ trigger source) as a footer line.""" parts: list[str] = [] model = meta.get("model") if isinstance(model, str) and model: @@ -321,6 +321,10 @@ def format_meta_line(meta: dict[str, Any]) -> str | None: perm = meta.get("permissionMode") if isinstance(perm, str) and perm: parts.append(perm) + # rc4 (#271): show trigger provenance when set by the dispatcher. + trigger = meta.get("trigger") + if isinstance(trigger, str) and trigger: + parts.append(trigger) return HEADER_SEP.join(parts) if parts else None diff --git a/src/untether/progress.py b/src/untether/progress.py index 380d7b06..ae6ced5b 100644 --- a/src/untether/progress.py +++ b/src/untether/progress.py @@ -43,7 +43,13 @@ def note_event(self, event: UntetherEvent) -> bool: case StartedEvent(resume=resume, meta=meta): self.resume = resume if meta: - self.meta = meta + # Merge rather than replace so that dispatcher-seeded + # keys (e.g. "trigger" from RunContext, #271) survive + # the engine's own StartedEvent.meta. + if self.meta is None: + self.meta = dict(meta) + else: + self.meta = {**self.meta, **meta} return True case ActionEvent(action=action, phase=phase, ok=ok): if action.kind == "turn": diff --git a/src/untether/runner_bridge.py b/src/untether/runner_bridge.py index d2adb0a4..7f1ef90d 100644 --- a/src/untether/runner_bridge.py +++ b/src/untether/runner_bridge.py @@ -1810,6 +1810,15 @@ async def handle_message( runner_text = _apply_preamble(runner_text) progress_tracker = ProgressTracker(engine=runner.engine) + # rc4 (#271): seed trigger source into meta so the footer renders it. + # The engine's own StartedEvent.meta merges onto this via note_event. + if context is not None and context.trigger_source: + icon = ( + "\N{ALARM CLOCK}" + if context.trigger_source.startswith("cron:") + else "\N{HIGH VOLTAGE SIGN}" + ) + progress_tracker.meta = {"trigger": f"{icon} {context.trigger_source}"} # Resolve effective presenter: check for per-chat verbose override effective_presenter = _resolve_presenter(cfg.presenter, incoming.channel_id) diff --git a/src/untether/sdnotify.py b/src/untether/sdnotify.py new file mode 100644 index 00000000..643ce880 --- /dev/null +++ b/src/untether/sdnotify.py @@ -0,0 +1,60 @@ +"""Minimal sd_notify client (stdlib only). + +systemd's ``Type=notify`` services use the ``$NOTIFY_SOCKET`` environment +variable to signal readiness and state changes. This module sends datagrams +to that socket with no external dependency. + +Messages of interest: +- ``READY=1`` — sent after the bot has finished startup and is serving + updates. Until this is sent, systemd keeps the unit in "activating". +- ``STOPPING=1`` — sent at the start of the drain sequence so that + ``systemctl status`` shows "Deactivating" rather than "Active". + +When ``NOTIFY_SOCKET`` is absent (non-systemd runs, dev containers, +pytest), ``notify()`` is a no-op returning ``False`` and does not raise. +""" + +from __future__ import annotations + +import os +import socket + +from .logging import get_logger + +logger = get_logger(__name__) + +__all__ = ["notify"] + + +def notify(message: str) -> bool: + """Send ``message`` to the systemd notify socket. + + Returns ``True`` when the datagram was sent, ``False`` otherwise + (no socket configured, send failed). Never raises — a failure to + notify systemd must not break the bot. + """ + sock_path = os.environ.get("NOTIFY_SOCKET") + if not sock_path: + return False + + # Abstract sockets on Linux use a leading null byte — systemd + # encodes this as a leading '@' in the NOTIFY_SOCKET env var. + addr: str | bytes + if sock_path.startswith("@"): + addr = b"\0" + sock_path[1:].encode("utf-8") + else: + addr = sock_path + + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as sock: + sock.sendto(message.encode("utf-8"), addr) + except OSError as exc: + logger.debug( + "sdnotify.send_failed", + message=message, + error=str(exc), + error_type=exc.__class__.__name__, + ) + return False + + return True diff --git a/src/untether/telegram/at_scheduler.py b/src/untether/telegram/at_scheduler.py new file mode 100644 index 00000000..b7f45d5f --- /dev/null +++ b/src/untether/telegram/at_scheduler.py @@ -0,0 +1,259 @@ +"""One-shot delayed-run scheduler for the ``/at`` command (#288). + +Users send ``/at 30m `` in Telegram; ``AtCommand.handle`` calls +:func:`schedule_delayed_run` which spawns an anyio task that sleeps for +the requested duration, then dispatches a run via the ``run_job`` closure +registered via :func:`install`. + +State is process-local and not persisted — a restart cancels all pending +delays. This is explicitly documented and matches the "fire-and-forget" +intent of the feature (the issue body calls this acceptable). The /cancel +command can drop pending /at timers via :func:`cancel_pending_for_chat`. +""" + +from __future__ import annotations + +import secrets +import time +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field + +import anyio +from anyio.abc import TaskGroup + +from ..logging import get_logger +from ..transport import ChannelId, RenderedMessage, SendOptions, Transport + +logger = get_logger(__name__) + +__all__ = [ + "MAX_DELAY_SECONDS", + "MIN_DELAY_SECONDS", + "PER_CHAT_LIMIT", + "active_count", + "cancel_pending_for_chat", + "install", + "pending_for_chat", + "schedule_delayed_run", + "uninstall", +] + +# 60s minimum mirrors ScheduleWakeup / Untether cron granularity. +MIN_DELAY_SECONDS = 60 +# 24h maximum — beyond this users probably want a cron. +MAX_DELAY_SECONDS = 86_400 +# Per-chat cap to prevent runaway scheduling. +PER_CHAT_LIMIT = 20 + +RunJobFn = Callable[..., Awaitable[None]] + + +@dataclass(slots=True) +class _PendingAt: + token: str + chat_id: int + thread_id: int | None + prompt: str + delay_s: int + scheduled_at: float # monotonic time when user called /at + fire_at: float # monotonic time when the run will fire + cancel_scope: anyio.CancelScope + fired: bool = field(default=False) + + +_TASK_GROUP: TaskGroup | None = None +_RUN_JOB: RunJobFn | None = None +_TRANSPORT: Transport | None = None +_DEFAULT_CHAT_ID: int | None = None +_PENDING: dict[str, _PendingAt] = {} + + +def install( + task_group: TaskGroup, + run_job: RunJobFn, + transport: Transport, + default_chat_id: int, +) -> None: + """Register the task group and run_job closure used by the scheduler. + + Called from ``telegram.loop.run_main_loop`` once the task group is + open and ``run_job`` has been defined. + """ + global _TASK_GROUP, _RUN_JOB, _TRANSPORT, _DEFAULT_CHAT_ID + _TASK_GROUP = task_group + _RUN_JOB = run_job + _TRANSPORT = transport + _DEFAULT_CHAT_ID = int(default_chat_id) + logger.info("at.installed", default_chat_id=default_chat_id) + + +def uninstall() -> None: + """Clear installed references — tests and graceful shutdown use this.""" + global _TASK_GROUP, _RUN_JOB, _TRANSPORT, _DEFAULT_CHAT_ID + _TASK_GROUP = None + _RUN_JOB = None + _TRANSPORT = None + _DEFAULT_CHAT_ID = None + _PENDING.clear() + + +class AtSchedulerError(Exception): + """Raised when /at scheduling cannot proceed.""" + + +def schedule_delayed_run( + chat_id: int, + thread_id: int | None, + delay_s: int, + prompt: str, +) -> str: + """Start a background task that fires a run after ``delay_s`` seconds. + + Returns a token identifying the pending delay so callers can record or + cancel it. Raises :class:`AtSchedulerError` if the scheduler is not + installed, the delay is out of range, or the per-chat cap is reached. + """ + if _TASK_GROUP is None or _RUN_JOB is None or _TRANSPORT is None: + logger.error( + "at.schedule.not_installed", + task_group=_TASK_GROUP is not None, + run_job=_RUN_JOB is not None, + transport=_TRANSPORT is not None, + module_id=id(__import__("untether.telegram.at_scheduler", fromlist=[""])), + ) + raise AtSchedulerError("/at scheduler not installed") + if delay_s < MIN_DELAY_SECONDS or delay_s > MAX_DELAY_SECONDS: + raise AtSchedulerError( + f"delay must be between {MIN_DELAY_SECONDS}s and {MAX_DELAY_SECONDS}s" + ) + if sum(1 for p in _PENDING.values() if p.chat_id == chat_id) >= PER_CHAT_LIMIT: + raise AtSchedulerError( + f"per-chat limit of {PER_CHAT_LIMIT} pending /at delays reached" + ) + token = secrets.token_hex(6) + now = time.monotonic() + scope = anyio.CancelScope() + entry = _PendingAt( + token=token, + chat_id=chat_id, + thread_id=thread_id, + prompt=prompt, + delay_s=delay_s, + scheduled_at=now, + fire_at=now + delay_s, + cancel_scope=scope, + ) + _PENDING[token] = entry + _TASK_GROUP.start_soon(_run_delayed, token) + logger.info("at.scheduled", chat_id=chat_id, token=token, delay_s=delay_s) + return token + + +async def _run_delayed(token: str) -> None: + """Sleep until fire_at, then dispatch a run via run_job.""" + entry = _PENDING.get(token) + if entry is None: + return + with entry.cancel_scope: + try: + await anyio.sleep(entry.delay_s) + except anyio.get_cancelled_exc_class(): + logger.info("at.cancelled", chat_id=entry.chat_id, token=token) + _PENDING.pop(token, None) + raise + entry.fired = True + # Pop before firing so /cancel can no longer see it as pending. + _PENDING.pop(token, None) + + # CancelScope.__exit__ swallows the Cancelled exception when the scope + # itself was the source of the cancellation. Check cancelled_caught to + # avoid firing after /cancel. + if entry.cancel_scope.cancelled_caught: + _PENDING.pop(token, None) + return + + assert _RUN_JOB is not None and _TRANSPORT is not None + # Send a notification so run_job has a message_id to reply to, + # mirroring TriggerDispatcher._dispatch. + label = f"\N{ALARM CLOCK} Running scheduled prompt ({entry.delay_s}s after /at)" + try: + notify_ref = await _TRANSPORT.send( + channel_id=_as_channel_id(entry.chat_id), + message=RenderedMessage(text=label), + options=SendOptions(notify=False), + ) + except Exception as exc: # noqa: BLE001 + logger.error( + "at.notify_failed", + chat_id=entry.chat_id, + token=token, + error=str(exc), + error_type=exc.__class__.__name__, + ) + return + if notify_ref is None: + logger.error("at.notify_failed", chat_id=entry.chat_id, token=token) + return + + logger.info( + "at.firing", + chat_id=entry.chat_id, + token=token, + delay_s=entry.delay_s, + ) + try: + await _RUN_JOB( + entry.chat_id, + notify_ref.message_id, + entry.prompt, + None, # resume_token + None, # context + entry.thread_id, + None, # chat_session_key + None, # reply_ref + None, # on_thread_known + None, # engine_override + None, # progress_ref + ) + except Exception as exc: # noqa: BLE001 + logger.error( + "at.run_failed", + chat_id=entry.chat_id, + token=token, + error=str(exc), + error_type=exc.__class__.__name__, + ) + + +def _as_channel_id(chat_id: int) -> ChannelId: + return chat_id + + +def cancel_pending_for_chat(chat_id: int) -> int: + """Cancel all pending /at delays for ``chat_id``. + + Returns the number of delays cancelled. Delays that have already + fired (``fired=True``) run as part of the normal running_tasks set + and are unaffected. + """ + cancelled = 0 + for token in list(_PENDING): + entry = _PENDING.get(token) + if entry is None or entry.chat_id != chat_id or entry.fired: + continue + entry.cancel_scope.cancel() + _PENDING.pop(token, None) + cancelled += 1 + if cancelled: + logger.info("at.cancelled_for_chat", chat_id=chat_id, count=cancelled) + return cancelled + + +def active_count() -> int: + """Return the number of pending /at delays currently sleeping.""" + return sum(1 for p in _PENDING.values() if not p.fired) + + +def pending_for_chat(chat_id: int) -> list[_PendingAt]: + """Return a snapshot of pending /at entries for ``chat_id`` (test/inspection aid).""" + return [p for p in _PENDING.values() if p.chat_id == chat_id and not p.fired] diff --git a/src/untether/telegram/bridge.py b/src/untether/telegram/bridge.py index c33915f5..21a5d43e 100644 --- a/src/untether/telegram/bridge.py +++ b/src/untether/telegram/bridge.py @@ -2,7 +2,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass, field -from typing import Literal, cast +from typing import TYPE_CHECKING, Literal, cast from ..context import RunContext from ..logging import get_logger @@ -22,6 +22,9 @@ from .render import MAX_BODY_CHARS, prepare_telegram, prepare_telegram_multi from .types import TelegramCallbackQuery, TelegramIncomingMessage +if TYPE_CHECKING: + from ..triggers.manager import TriggerManager + logger = get_logger(__name__) __all__ = [ @@ -131,8 +134,17 @@ def _is_cancelled_label(label: str) -> bool: return stripped.lower() == "cancelled" -@dataclass(frozen=True, slots=True) +@dataclass(slots=True) class TelegramBridgeConfig: + """Runtime Telegram-bridge configuration. + + Unfrozen as of rc4 (#286) so that hot-reload can update voice, files, + chat_ids, allowed_user_ids, and timing settings without a restart. + Fields that remain architectural (``bot``, ``runtime``, ``chat_id``, + ``session_mode``, ``topics``, ``exec_cfg``) keep their initial values. + Use :meth:`update_from` to apply reloaded transport settings. + """ + bot: BotClient runtime: TransportRuntime chat_id: int @@ -153,6 +165,30 @@ class TelegramBridgeConfig: chat_ids: tuple[int, ...] | None = None topics: TelegramTopicsSettings = field(default_factory=TelegramTopicsSettings) trigger_config: dict | None = None + # rc4 (#269/#285): trigger_manager is assigned after construction once the + # trigger settings have been parsed; commands read it via CommandContext. + trigger_manager: TriggerManager | None = None + + def update_from(self, settings: TelegramTransportSettings) -> None: + """Apply a reloaded Transport settings object to this config. + + Only fields that are safe to hot-reload are updated. Architectural + fields (``bot``, ``runtime``, ``chat_id``, ``session_mode``, + ``topics``, ``exec_cfg``) stay at their initial values. ``topics`` + specifically cannot change at runtime because it affects state + store initialisation. + """ + self.show_resume_line = bool(settings.show_resume_line) + self.voice_transcription = bool(settings.voice_transcription) + self.voice_max_bytes = int(settings.voice_max_bytes) + self.voice_transcription_model = settings.voice_transcription_model + self.voice_transcription_base_url = settings.voice_transcription_base_url + self.voice_transcription_api_key = settings.voice_transcription_api_key + self.voice_show_transcription = bool(settings.voice_show_transcription) + self.forward_coalesce_s = float(settings.forward_coalesce_s) + self.media_group_debounce_s = float(settings.media_group_debounce_s) + self.allowed_user_ids = tuple(settings.allowed_user_ids) + self.files = settings.files class TelegramTransport: diff --git a/src/untether/telegram/commands/at.py b/src/untether/telegram/commands/at.py new file mode 100644 index 00000000..d7a6c9ef --- /dev/null +++ b/src/untether/telegram/commands/at.py @@ -0,0 +1,105 @@ +"""`/at` command — schedule a one-shot delayed run (#288). + +Syntax: ``/at `` + +Duration supports ``Ns`` (seconds), ``Nm`` (minutes), ``Nh`` (hours). +Range is 60s to 24h. Pending delays are lost on restart and can be +cancelled with ``/cancel``. +""" + +from __future__ import annotations + +import re + +from ...commands import CommandBackend, CommandContext, CommandResult +from ..at_scheduler import ( + MAX_DELAY_SECONDS, + MIN_DELAY_SECONDS, + AtSchedulerError, + schedule_delayed_run, +) + +# ^ +_AT_PATTERN = re.compile(r"^\s*(\d+)\s*([smhSMH])\s+(.+?)\s*$", re.DOTALL) + +_UNIT_SECONDS = {"s": 1, "m": 60, "h": 3600} + +_USAGE = ( + "Usage: /at \n" + "\u2022 Duration: Ns | Nm | Nh " + f"(between {MIN_DELAY_SECONDS}s and {MAX_DELAY_SECONDS // 3600}h)\n" + "\u2022 Example: /at 30m Check the build" +) + + +def _format_delay(delay_s: int) -> str: + """Human-friendly duration: '30m', '2h', '90s', '1h 30m'.""" + if delay_s < 60: + return f"{delay_s}s" + if delay_s < 3600: + minutes, seconds = divmod(delay_s, 60) + return f"{minutes}m" if seconds == 0 else f"{minutes}m {seconds}s" + hours, remainder = divmod(delay_s, 3600) + minutes, _ = divmod(remainder, 60) + return f"{hours}h" if minutes == 0 else f"{hours}h {minutes}m" + + +def _parse_args(args_text: str) -> tuple[int, str] | None: + """Parse `` `` into (delay_s, prompt) or None on error.""" + match = _AT_PATTERN.match(args_text) + if match is None: + return None + amount_str, unit, prompt = match.groups() + try: + amount = int(amount_str) + except ValueError: + return None + seconds = amount * _UNIT_SECONDS[unit.lower()] + if seconds < MIN_DELAY_SECONDS or seconds > MAX_DELAY_SECONDS: + return None + if not prompt.strip(): + return None + return seconds, prompt.strip() + + +class AtCommand: + """Schedule a one-shot delayed agent run.""" + + id = "at" + description = "Schedule a delayed run: /at 30m " + + async def handle(self, ctx: CommandContext) -> CommandResult: + if not ctx.args_text.strip(): + return CommandResult(text=_USAGE, notify=True) + + parsed = _parse_args(ctx.args_text) + if parsed is None: + return CommandResult( + text=f"\u274c couldn't parse /at.\n{_USAGE}", notify=True + ) + + delay_s, prompt = parsed + chat_id = ctx.message.channel_id + thread_id = ctx.message.thread_id + if not isinstance(chat_id, int): + return CommandResult( + text="\u274c /at is only supported in integer-id chats", + notify=True, + ) + thread_int = int(thread_id) if isinstance(thread_id, int) else None + + try: + schedule_delayed_run(chat_id, thread_int, delay_s, prompt) + except AtSchedulerError as exc: + return CommandResult(text=f"\u274c {exc}", notify=True) + + return CommandResult( + text=( + f"\u23f3 Scheduled: will run in {_format_delay(delay_s)}\n" + f"Cancel with /cancel." + ), + notify=True, + ) + + +BACKEND: CommandBackend = AtCommand() diff --git a/src/untether/telegram/commands/cancel.py b/src/untether/telegram/commands/cancel.py index addee601..d889910c 100644 --- a/src/untether/telegram/commands/cancel.py +++ b/src/untether/telegram/commands/cancel.py @@ -65,6 +65,18 @@ async def handle_cancel( text="multiple jobs queued — reply to the progress message to cancel a specific one." ) return + # Check pending /at delays for this chat (#288). + from .. import at_scheduler + + pending_at = at_scheduler.cancel_pending_for_chat(chat_id) + if pending_at: + await reply( + text=( + f"\u274c cancelled {pending_at} pending /at run" + f"{'s' if pending_at != 1 else ''}." + ) + ) + return logger.debug("cancel.nothing_running", chat_id=chat_id) await reply(text="nothing running in this chat.") return diff --git a/src/untether/telegram/commands/dispatch.py b/src/untether/telegram/commands/dispatch.py index 27fbf866..e5675200 100644 --- a/src/untether/telegram/commands/dispatch.py +++ b/src/untether/telegram/commands/dispatch.py @@ -113,6 +113,8 @@ async def _dispatch_command( plugin_config=plugin_config, runtime=cfg.runtime, executor=executor, + trigger_manager=cfg.trigger_manager, + default_chat_id=cfg.chat_id, ) try: result = await backend.handle(ctx) @@ -250,6 +252,8 @@ async def _answer_callback(text: str | None = None) -> None: plugin_config=plugin_config, runtime=cfg.runtime, executor=executor, + trigger_manager=cfg.trigger_manager, + default_chat_id=cfg.chat_id, ) try: result = await backend.handle(ctx) diff --git a/src/untether/telegram/commands/ping.py b/src/untether/telegram/commands/ping.py index 09946397..44d9cc66 100644 --- a/src/untether/telegram/commands/ping.py +++ b/src/untether/telegram/commands/ping.py @@ -36,6 +36,40 @@ def _format_uptime(seconds: float) -> str: return " ".join(parts) +def _trigger_indicator(ctx: CommandContext) -> str | None: + """Render a per-chat trigger summary line for ``/ping`` (#271). + + Returns ``None`` if the chat has no triggers targeting it. Formats: + - Single cron: ``\u23f0 triggers: 1 cron (daily-review, 9:00 AM daily (Melbourne))`` + - Multiple: ``\u23f0 triggers: 2 crons, 1 webhook`` + """ + mgr = ctx.trigger_manager + if mgr is None: + return None + chat_id = ctx.message.channel_id + if not isinstance(chat_id, int): + return None + crons = mgr.crons_for_chat(chat_id, default_chat_id=ctx.default_chat_id) + webhooks = mgr.webhooks_for_chat(chat_id, default_chat_id=ctx.default_chat_id) + if not crons and not webhooks: + return None + + parts: list[str] = [] + if crons: + from ...triggers.describe import describe_cron + + if len(crons) == 1: + c = crons[0] + desc = describe_cron(c.schedule, c.timezone or mgr.default_timezone) + parts.append(f"1 cron ({c.id}, {desc})") + else: + parts.append(f"{len(crons)} crons") + if webhooks: + suffix = "s" if len(webhooks) != 1 else "" + parts.append(f"{len(webhooks)} webhook{suffix}") + return "\u23f0 triggers: " + ", ".join(parts) + + class PingCommand: """Command backend for bot health check and uptime.""" @@ -44,7 +78,11 @@ class PingCommand: async def handle(self, ctx: CommandContext) -> CommandResult: uptime = _format_uptime(time.monotonic() - _STARTED_AT) - return CommandResult(text=f"\U0001f3d3 pong \u2014 up {uptime}", notify=True) + lines = [f"\U0001f3d3 pong \u2014 up {uptime}"] + indicator = _trigger_indicator(ctx) + if indicator is not None: + lines.append(indicator) + return CommandResult(text="\n".join(lines), notify=True) BACKEND: CommandBackend = PingCommand() diff --git a/src/untether/telegram/loop.py b/src/untether/telegram/loop.py index fb10859d..93fbe49c 100644 --- a/src/untether/telegram/loop.py +++ b/src/untether/telegram/loop.py @@ -402,18 +402,51 @@ async def poll_updates( *, sleep: Callable[[float], Awaitable[None]] = anyio.sleep, ) -> AsyncIterator[TelegramIncomingUpdate]: + from .. import sdnotify + from .offset_persistence import ( + DebouncedOffsetWriter, + load_last_update_id, + resolve_offset_path, + ) + + config_path = cfg.runtime.config_path offset: int | None = None + offset_writer: DebouncedOffsetWriter | None = None + if config_path is not None: + offset_path = resolve_offset_path(config_path) + saved = load_last_update_id(offset_path) + if saved is not None: + offset = saved + 1 + logger.info( + "startup.offset.resumed", + last_update_id=saved, + path=str(offset_path), + ) + offset_writer = DebouncedOffsetWriter(offset_path) + offset = await _drain_backlog(cfg, offset) await _cleanup_orphan_progress(cfg) await _send_startup(cfg) - async for msg in poll_incoming( - cfg.bot, - chat_ids=lambda: _allowed_chat_ids(cfg), - offset=offset, - sleep=sleep, - ): - yield msg + # Signal systemd that Untether is ready to receive traffic. No-op on + # non-systemd runs (NOTIFY_SOCKET absent). See #287. + if sdnotify.notify("READY=1"): + logger.debug("sdnotify.ready") + + try: + async for msg in poll_incoming( + cfg.bot, + chat_ids=lambda: _allowed_chat_ids(cfg), + offset=offset, + sleep=sleep, + on_offset_advanced=( + offset_writer.note if offset_writer is not None else None + ), + ): + yield msg + finally: + if offset_writer is not None: + offset_writer.flush() @dataclass(slots=True) @@ -1278,12 +1311,38 @@ async def handle_reload(reload: ConfigReload) -> None: new_snapshot = reload.settings.transports.telegram.model_dump() changed = _diff_keys(state.transport_snapshot, new_snapshot) if changed: - logger.warning( - "config.reload.transport_config_changed", - transport="telegram", - keys=changed, - restart_required=True, - ) + # rc4 (#286): unfrozen TelegramBridgeConfig allows most + # settings to hot-reload. Only a handful still require a + # restart — everything else is applied via update_from(). + RESTART_ONLY_KEYS = { + "bot_token", + "chat_id", + "session_mode", + "topics", + "message_overflow", + } + restart_keys = [k for k in changed if k in RESTART_ONLY_KEYS] + hot_keys = [k for k in changed if k not in RESTART_ONLY_KEYS] + if restart_keys: + logger.warning( + "config.reload.transport_config_changed", + transport="telegram", + keys=restart_keys, + restart_required=True, + ) + if hot_keys: + cfg.update_from(reload.settings.transports.telegram) + state.forward_coalesce_s = max( + 0.0, float(cfg.forward_coalesce_s) + ) + state.media_group_debounce_s = max( + 0.0, float(cfg.media_group_debounce_s) + ) + logger.info( + "config.reload.transport_config_hot_reloaded", + transport="telegram", + keys=hot_keys, + ) state.transport_snapshot = new_snapshot if ( state.transport_id is not None @@ -1341,15 +1400,28 @@ async def _drain_and_exit() -> None: while not is_shutting_down(): await sleep(0.5) + # Signal systemd that we've entered drain (Deactivating state). + from .. import sdnotify + + if sdnotify.notify("STOPPING=1"): + logger.debug("sdnotify.stopping") + active = len(state.running_tasks) - logger.info("shutdown.draining", active_runs=active) + pending_at = at_scheduler.active_count() + logger.info( + "shutdown.draining", + active_runs=active, + pending_at=pending_at, + ) if active > 0: await _notify_drain_start( cfg.exec_cfg.transport, state.running_tasks ) - # Wait for all runs to complete (up to drain timeout) + # Wait for all runs to complete (up to drain timeout). + # Pending /at delays that have not yet fired are cancelled + # via the task-group cancel below; no need to wait on them. _drain_tick = 0 with anyio.move_on_after(DRAIN_TIMEOUT_S): while state.running_tasks: @@ -1511,6 +1583,16 @@ async def run_thread_job(job: ThreadJob) -> None: scheduler = ThreadScheduler(task_group=tg, run_job=run_thread_job) + # --- /at one-shot delayed runs (#288) --- + from . import at_scheduler + + at_scheduler.install( + tg, + run_job, + cfg.exec_cfg.transport, + cfg.chat_id, + ) + # --- Trigger system (webhooks + cron) --- trigger_manager: TriggerManager | None = None if cfg.trigger_config and cfg.trigger_config.get("enabled"): @@ -1523,6 +1605,9 @@ async def run_thread_job(job: ThreadJob) -> None: try: trigger_settings = parse_trigger_config(cfg.trigger_config) trigger_manager = TriggerManager(trigger_settings) + # rc4 (#271): expose trigger_manager to commands via cfg so + # /ping and /config can render per-chat trigger indicators. + cfg.trigger_manager = trigger_manager trigger_dispatcher = TriggerDispatcher( run_job=run_job, transport=cfg.exec_cfg.transport, @@ -2194,8 +2279,9 @@ async def route_message(msg: TelegramIncomingMessage) -> None: return forward_coalescer.schedule(pending) - allowed_user_ids = set(cfg.allowed_user_ids) - if not allowed_user_ids: + # rc4 (#286): read allowed_user_ids from cfg on each update so + # hot-reload of the allowlist takes effect immediately. + if not cfg.allowed_user_ids: logger.warning( "security.no_allowed_users", hint="allowed_user_ids is empty — any user in the chat can run commands. " @@ -2214,9 +2300,10 @@ async def _safe_answer_callback(query_id: str) -> None: ) async def route_update(update: TelegramIncomingUpdate) -> None: - if allowed_user_ids: + current_allowed = frozenset(cfg.allowed_user_ids) + if current_allowed: sender_id = update.sender_id - if sender_id is None or sender_id not in allowed_user_ids: + if sender_id is None or sender_id not in current_allowed: logger.debug( "update.ignored", reason="sender_not_allowed", diff --git a/src/untether/telegram/offset_persistence.py b/src/untether/telegram/offset_persistence.py new file mode 100644 index 00000000..d6360c07 --- /dev/null +++ b/src/untether/telegram/offset_persistence.py @@ -0,0 +1,145 @@ +"""Persist the last confirmed Telegram ``update_id`` across restarts. + +On shutdown, the bot writes the most recently acknowledged ``update_id`` +to a small JSON state file. On startup, it loads that value and resumes +polling from ``offset = saved + 1``. Telegram retains undelivered updates +for 24 hours, so this eliminates the window where a restart re-processes +(or drops) recent messages. See issue #287. + +The file lives alongside ``active_progress.json`` in the Untether state +directory (sibling to the config file). +""" + +from __future__ import annotations + +import json +import time +from pathlib import Path + +from ..logging import get_logger +from ..utils.json_state import atomic_write_json + +logger = get_logger(__name__) + +STATE_FILENAME = "last_update_id.json" + +__all__ = [ + "STATE_FILENAME", + "DebouncedOffsetWriter", + "load_last_update_id", + "resolve_offset_path", + "save_last_update_id", +] + + +def resolve_offset_path(config_path: Path) -> Path: + """Return the offset state file path (sibling to config file).""" + return config_path.with_name(STATE_FILENAME) + + +def load_last_update_id(path: Path) -> int | None: + """Load the saved ``update_id``, or ``None`` if missing/corrupt.""" + if not path.exists(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + logger.warning( + "offset_persistence.load_failed", + path=str(path), + error=str(exc), + error_type=exc.__class__.__name__, + ) + return None + if not isinstance(data, dict): + return None + raw = data.get("last_update_id") + if isinstance(raw, int) and raw >= 0: + return raw + return None + + +def save_last_update_id(path: Path, update_id: int) -> None: + """Persist ``update_id`` atomically. Swallows errors (logs at warning).""" + try: + atomic_write_json(path, {"last_update_id": int(update_id)}) + except (OSError, ValueError) as exc: + logger.warning( + "offset_persistence.save_failed", + path=str(path), + update_id=update_id, + error=str(exc), + error_type=exc.__class__.__name__, + ) + + +class DebouncedOffsetWriter: + """Debounce update_id writes to amortise the fsync cost over polling. + + Under long-polling, each ``getUpdates`` batch can advance the offset + by dozens of updates in a fraction of a second. Writing every bump + works but is wasteful. This writer coalesces pending bumps and only + flushes to disk when either: + + - ``min_interval_s`` has elapsed since the last flush, or + - ``max_pending`` un-flushed advances have accumulated. + + On shutdown, call :meth:`flush` to force a final write. + + The risk of the debounce window is bounded: Telegram resends undelivered + updates for 24 hours, so at worst a crash causes up to ``min_interval_s`` + worth of updates to be re-processed (message handlers are idempotent). + """ + + __slots__ = ( + "_last_flush", + "_max_pending", + "_min_interval_s", + "_path", + "_pending_count", + "_pending_offset", + ) + + def __init__( + self, + path: Path, + *, + min_interval_s: float = 5.0, + max_pending: int = 100, + ) -> None: + self._path = path + self._min_interval_s = max(0.0, float(min_interval_s)) + self._max_pending = max(1, int(max_pending)) + self._pending_offset: int | None = None + self._pending_count = 0 + # Start the clock at construction so the first note is debounced + # properly instead of firing an immediate write. + self._last_flush = time.monotonic() + + def note(self, update_id: int) -> None: + """Record that ``update_id`` has been acknowledged. + + The stored offset is the ``update_id`` of the most recently + confirmed update. Callers typically want to store ``upd.update_id`` + directly; when resuming, use ``offset = saved + 1``. + """ + self._pending_offset = update_id + self._pending_count += 1 + now = time.monotonic() + should_flush = self._pending_count >= self._max_pending or ( + now - self._last_flush >= self._min_interval_s + ) + if should_flush: + self._write(now) + + def flush(self) -> None: + """Force a write of the pending offset (safe no-op if none pending).""" + if self._pending_offset is not None: + self._write(time.monotonic()) + + def _write(self, now: float) -> None: + if self._pending_offset is None: + return + save_last_update_id(self._path, self._pending_offset) + self._pending_count = 0 + self._last_flush = now diff --git a/src/untether/telegram/parsing.py b/src/untether/telegram/parsing.py index 22a39ff9..d6c82603 100644 --- a/src/untether/telegram/parsing.py +++ b/src/untether/telegram/parsing.py @@ -228,6 +228,7 @@ async def poll_incoming( chat_ids: Iterable[int] | Callable[[], Iterable[int]] | None = None, offset: int | None = None, sleep: Callable[[float], Awaitable[None]] = anyio.sleep, + on_offset_advanced: Callable[[int], None] | None = None, ) -> AsyncIterator[TelegramIncomingUpdate]: while True: updates = await bot.get_updates( @@ -246,6 +247,8 @@ async def poll_incoming( allowed = {chat_id} for upd in updates: offset = upd.update_id + 1 + if on_offset_advanced is not None: + on_offset_advanced(upd.update_id) msg = parse_incoming_update(upd, chat_ids=allowed) if msg is not None: yield msg diff --git a/src/untether/triggers/cron.py b/src/untether/triggers/cron.py index 054fae6c..06d291b1 100644 --- a/src/untether/triggers/cron.py +++ b/src/untether/triggers/cron.py @@ -113,6 +113,11 @@ async def run_cron_scheduler( last_fired[cron.id] = key logger.info("triggers.cron.firing", cron_id=cron.id) await dispatcher.dispatch_cron(cron) + # #288: one-shot crons are removed from the active list + # after firing; they stay in the TOML and re-activate on + # the next config reload or restart. + if cron.run_once: + manager.remove_cron(cron.id) # Sleep until next minute boundary (+ small buffer). utc_now = datetime.datetime.now(datetime.UTC) diff --git a/src/untether/triggers/describe.py b/src/untether/triggers/describe.py new file mode 100644 index 00000000..f4e2dd04 --- /dev/null +++ b/src/untether/triggers/describe.py @@ -0,0 +1,113 @@ +"""Human-friendly cron schedule rendering (issue 271). + +Converts a 5-field cron expression plus optional timezone into a short, +natural-language description suitable for the Telegram ping indicator, +the config trigger page, and dispatch notifications. Complex patterns +(stepped, specific day-of-month, multi-month) fall back to the raw +expression; the goal is a clear default for common patterns, not a +full cron-to-English translator. + +Examples (rendered output shown in quotes): +- ``0 9 * * *`` + ``Australia/Melbourne`` -> ``9:00 AM daily (Melbourne)`` +- ``0 8 * * 1-5`` + ``Australia/Melbourne`` -> ``8:00 AM Mon-Fri (Melbourne)`` +- ``30 14 * * 0,6`` + ``None`` -> ``2:30 PM Sat,Sun`` +- ``0 0 * * *`` + ``None`` -> ``12:00 AM daily`` +- ``*/15 * * * *`` + ``None`` -> ``*/15 * * * *`` (fallback) +""" + +from __future__ import annotations + +__all__ = ["describe_cron"] + +_DAY_NAMES = ("Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat") + + +def _format_dow(dow: str) -> str: + """Turn a day-of-week field into a label like 'Mon-Fri' or 'Sat,Sun'.""" + if dow == "*": + return "" + # Range, e.g. "1-5" + if "-" in dow and "," not in dow and "/" not in dow: + try: + start_s, end_s = dow.split("-", 1) + start = int(start_s) % 7 + end = int(end_s) % 7 + # Cron day-of-week: 0 or 7 = Sunday. Normalise 7→0. + return f"{_DAY_NAMES[start]}\u2013{_DAY_NAMES[end]}" + except (ValueError, IndexError): + return dow + # Comma list, e.g. "0,6" + if "," in dow and "-" not in dow and "/" not in dow: + try: + parts = [_DAY_NAMES[int(p) % 7] for p in dow.split(",")] + return ",".join(parts) + except (ValueError, IndexError): + return dow + # Single day + if dow.isdigit(): + try: + return _DAY_NAMES[int(dow) % 7] + except IndexError: + return dow + return dow + + +def _format_timezone_suffix(timezone: str | None) -> str: + """Turn 'Australia/Melbourne' into ' (Melbourne)'; '' if no tz.""" + if not timezone: + return "" + leaf = timezone.split("/")[-1].replace("_", " ") + return f" ({leaf})" + + +def _format_time_12h(hour: int, minute: int) -> str: + """Turn (9, 0) into '9:00 AM', (14, 30) into '2:30 PM', (0, 0) into '12:00 AM'.""" + suffix = "AM" if hour < 12 else "PM" + hour12 = hour % 12 or 12 + return f"{hour12}:{minute:02d} {suffix}" + + +def describe_cron(schedule: str, timezone: str | None = None) -> str: + """Render a cron expression + timezone in a human-friendly form. + + Returns ``schedule`` unchanged if the expression uses features outside + the supported common-case grammar (stepped minutes, specific day-of-month, + specific months, multi-hour, multi-minute). The goal is a helpful default + for daily/weekly schedules, not a universal translator. + """ + fields = schedule.split() + if len(fields) != 5: + return schedule + minute, hour, dom, mon, dow = fields + + # Bail out on patterns we don't try to translate. + if "*" not in mon and mon != "*": + return schedule + if "*" not in dom and dom != "*": + return schedule + if "/" in minute or "," in minute or "-" in minute: + return schedule + if "/" in hour or "," in hour or "-" in hour: + return schedule + + try: + h = int(hour) + m = int(minute) + except ValueError: + return schedule + if not (0 <= h <= 23 and 0 <= m <= 59): + return schedule + + time_part = _format_time_12h(h, m) + dow_part = _format_dow(dow) + if dow_part == "": + # Every day + suffix_dow = " daily" + elif "," in dow_part or "\u2013" in dow_part or "-" in dow_part: + suffix_dow = f" {dow_part}" + else: + # Single day + suffix_dow = f" {dow_part}" + + tz_part = _format_timezone_suffix(timezone) + return f"{time_part}{suffix_dow}{tz_part}".rstrip() diff --git a/src/untether/triggers/dispatcher.py b/src/untether/triggers/dispatcher.py index c1fa3a0b..9e4b43ac 100644 --- a/src/untether/triggers/dispatcher.py +++ b/src/untether/triggers/dispatcher.py @@ -30,7 +30,12 @@ class TriggerDispatcher: async def dispatch_webhook(self, webhook: WebhookConfig, prompt: str) -> None: chat_id = webhook.chat_id or self.default_chat_id - context = RunContext(project=webhook.project) if webhook.project else None + # rc4 (#271): always set trigger_source so the meta footer can render + # provenance even when no project is configured. + context = RunContext( + project=webhook.project, + trigger_source=f"webhook:{webhook.id}", + ) engine_override = webhook.engine label = f"\N{HIGH VOLTAGE SIGN} Trigger: webhook:{webhook.id}" @@ -38,7 +43,10 @@ async def dispatch_webhook(self, webhook: WebhookConfig, prompt: str) -> None: async def dispatch_cron(self, cron: CronConfig) -> None: chat_id = cron.chat_id or self.default_chat_id - context = RunContext(project=cron.project) if cron.project else None + context = RunContext( + project=cron.project, + trigger_source=f"cron:{cron.id}", + ) engine_override = cron.engine label = f"\N{ALARM CLOCK} Scheduled: cron:{cron.id}" diff --git a/src/untether/triggers/manager.py b/src/untether/triggers/manager.py index 42f64fbb..1068db02 100644 --- a/src/untether/triggers/manager.py +++ b/src/untether/triggers/manager.py @@ -92,3 +92,53 @@ def webhook_for_path(self, path: str) -> WebhookConfig | None: @property def webhook_count(self) -> int: return len(self._webhooks_by_path) + + def cron_ids(self) -> list[str]: + """Return a snapshot list of all configured cron ids.""" + return [c.id for c in self._crons] + + def webhook_ids(self) -> list[str]: + """Return a snapshot list of all configured webhook ids.""" + return [wh.id for wh in self._webhooks_by_path.values()] + + def crons_for_chat( + self, chat_id: int, default_chat_id: int | None = None + ) -> list[CronConfig]: + """Return crons that target the given chat. + + A cron with ``chat_id=None`` falls back to ``default_chat_id``; when + ``default_chat_id`` is also ``None``, such crons are excluded. + """ + return [ + c + for c in self._crons + if (c.chat_id if c.chat_id is not None else default_chat_id) == chat_id + ] + + def webhooks_for_chat( + self, chat_id: int, default_chat_id: int | None = None + ) -> list[WebhookConfig]: + """Return webhooks that target the given chat (same fallback as ``crons_for_chat``).""" + return [ + wh + for wh in self._webhooks_by_path.values() + if (wh.chat_id if wh.chat_id is not None else default_chat_id) == chat_id + ] + + def remove_cron(self, cron_id: str) -> bool: + """Atomically remove a cron by id; returns ``True`` if found. + + Used by the ``run_once`` flag to disable a cron after its first fire. + Replaces ``self._crons`` with a new list so that in-flight iterations + see a consistent snapshot (same pattern as ``update()``). + """ + for i, c in enumerate(self._crons): + if c.id == cron_id: + self._crons = [*self._crons[:i], *self._crons[i + 1 :]] + logger.info( + "triggers.cron.run_once_completed", + cron_id=cron_id, + remaining_crons=len(self._crons), + ) + return True + return False diff --git a/src/untether/triggers/settings.py b/src/untether/triggers/settings.py index 3e76235c..8d299b8e 100644 --- a/src/untether/triggers/settings.py +++ b/src/untether/triggers/settings.py @@ -138,6 +138,7 @@ class CronConfig(BaseModel): prompt_template: NonEmptyStr | None = None timezone: NonEmptyStr | None = None fetch: CronFetchConfig | None = None + run_once: bool = False @field_validator("timezone") @classmethod diff --git a/tests/test_at_command.py b/tests/test_at_command.py new file mode 100644 index 00000000..0e6e81a5 --- /dev/null +++ b/tests/test_at_command.py @@ -0,0 +1,247 @@ +"""Tests for the /at delayed-run command and at_scheduler (#288).""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import anyio +import pytest + +from untether.commands import CommandContext +from untether.telegram import at_scheduler +from untether.telegram.commands.at import AtCommand, _format_delay, _parse_args +from untether.transport import MessageRef + +pytestmark = pytest.mark.anyio + + +# ── Parse tests ───────────────────────────────────────────────────────── + + +class TestParse: + @pytest.mark.parametrize( + "text,expected", + [ + ("60s test", (60, "test")), + ("2m hello world", (120, "hello world")), + ("1h do a thing", (3600, "do a thing")), + ("30m multi\nline\nprompt", (1800, "multi\nline\nprompt")), + (" 5m extra space ", (300, "extra space")), + ("90s single seconds", (90, "single seconds")), + ("24h max", (86400, "max")), + ], + ) + def test_parse_valid(self, text, expected): + assert _parse_args(text) == expected + + @pytest.mark.parametrize( + "text", + [ + "", + "30m", # no prompt + "30m ", # whitespace-only prompt + "1d hello", # days unit not supported + "x10s hello", # letter before number + "59s hello", # below minimum + "25h hello", # above maximum (86400s = 24h, 25h = 90000s) + "0s hello", # zero + "hello world", # no duration + "10 hello", # missing unit + ], + ) + def test_parse_invalid(self, text): + assert _parse_args(text) is None + + def test_parse_unit_case_insensitive(self): + assert _parse_args("30M hello") == (1800, "hello") + assert _parse_args("2H go") == (7200, "go") + + +# ── _format_delay tests ────────────────────────────────────────────────── + + +class TestFormatDelay: + @pytest.mark.parametrize( + "delay_s,expected", + [ + (30, "30s"), + (60, "1m"), + (90, "1m 30s"), + (600, "10m"), + (3600, "1h"), + (3660, "1h 1m"), + (5400, "1h 30m"), + ], + ) + def test_format(self, delay_s, expected): + assert _format_delay(delay_s) == expected + + +# ── Scheduler fakes ────────────────────────────────────────────────────── + + +@dataclass +class FakeTransport: + sent: list[Any] = None # type: ignore[assignment] + + def __post_init__(self): + self.sent = [] + + async def send(self, *, channel_id, message, options=None, **_): + self.sent.append((channel_id, message.text, options)) + return MessageRef(channel_id=channel_id, message_id=9999) + + async def edit(self, *, ref, message, **_): + return ref + + async def delete(self, ref): + return None + + +class RunJobRecorder: + def __init__(self): + self.calls: list[tuple] = [] + + async def __call__(self, *args, **kwargs): + self.calls.append(args) + + +# ── AtCommand.handle tests ────────────────────────────────────────────── + + +def _make_ctx(args_text: str, chat_id: int = 12345) -> CommandContext: + message = MessageRef(channel_id=chat_id, message_id=1) + return CommandContext( + command="at", + text=f"/at {args_text}", + args_text=args_text, + args=tuple(args_text.split()), + message=message, + reply_to=None, + reply_text=None, + config_path=None, + plugin_config={}, + runtime=None, # type: ignore[arg-type] + executor=None, # type: ignore[arg-type] + ) + + +class TestAtCommand: + @pytest.fixture(autouse=True) + def _cleanup(self): + """Each test starts with a clean scheduler state.""" + at_scheduler.uninstall() + yield + at_scheduler.uninstall() + + async def test_usage_when_empty(self): + result = await AtCommand().handle(_make_ctx("")) + assert result is not None + assert "Usage: /at" in result.text + + async def test_scheduler_not_installed(self): + result = await AtCommand().handle(_make_ctx("60s test")) + assert result is not None + assert "not installed" in result.text + + async def test_invalid_format_reply(self): + # Install so parsing actually runs all the way through. + async with anyio.create_task_group() as tg: + at_scheduler.install(tg, _fake_run_job, FakeTransport(), 999) + try: + result = await AtCommand().handle(_make_ctx("xyz prompt")) + assert result is not None + assert "\u274c" in result.text + assert "Usage" in result.text + finally: + tg.cancel_scope.cancel() + + async def test_schedule_successful(self): + run_recorder = RunJobRecorder() + transport = FakeTransport() + async with anyio.create_task_group() as tg: + at_scheduler.install(tg, run_recorder, transport, 12345) + try: + result = await AtCommand().handle(_make_ctx("60s test prompt")) + assert result is not None + assert "Scheduled" in result.text + assert "1m" in result.text + assert "Cancel with /cancel" in result.text + # One pending delay should be tracked. + pending = at_scheduler.pending_for_chat(12345) + assert len(pending) == 1 + assert pending[0].prompt == "test prompt" + finally: + tg.cancel_scope.cancel() + + +# ── Scheduler: schedule / cancel / drain ──────────────────────────────── + + +class TestAtScheduler: + @pytest.fixture(autouse=True) + def _cleanup(self): + at_scheduler.uninstall() + yield + at_scheduler.uninstall() + + async def test_schedule_rejects_below_min(self): + async with anyio.create_task_group() as tg: + at_scheduler.install(tg, _fake_run_job, FakeTransport(), 1) + try: + with pytest.raises(at_scheduler.AtSchedulerError): + at_scheduler.schedule_delayed_run(1, None, 30, "x") + finally: + tg.cancel_scope.cancel() + + async def test_schedule_rejects_above_max(self): + async with anyio.create_task_group() as tg: + at_scheduler.install(tg, _fake_run_job, FakeTransport(), 1) + try: + with pytest.raises(at_scheduler.AtSchedulerError): + at_scheduler.schedule_delayed_run( + 1, None, at_scheduler.MAX_DELAY_SECONDS + 1, "x" + ) + finally: + tg.cancel_scope.cancel() + + async def test_schedule_respects_per_chat_cap(self): + async with anyio.create_task_group() as tg: + at_scheduler.install(tg, _fake_run_job, FakeTransport(), 1) + try: + for _ in range(at_scheduler.PER_CHAT_LIMIT): + at_scheduler.schedule_delayed_run(1, None, 60, "x") + with pytest.raises(at_scheduler.AtSchedulerError): + at_scheduler.schedule_delayed_run(1, None, 60, "over cap") + finally: + tg.cancel_scope.cancel() + + async def test_cancel_pending_for_chat(self): + async with anyio.create_task_group() as tg: + at_scheduler.install(tg, _fake_run_job, FakeTransport(), 1) + try: + at_scheduler.schedule_delayed_run(111, None, 60, "a") + at_scheduler.schedule_delayed_run(111, None, 60, "b") + at_scheduler.schedule_delayed_run(222, None, 60, "c") + assert at_scheduler.active_count() == 3 + cancelled = at_scheduler.cancel_pending_for_chat(111) + assert cancelled == 2 + assert at_scheduler.active_count() == 1 + assert at_scheduler.pending_for_chat(222)[0].prompt == "c" + finally: + tg.cancel_scope.cancel() + + async def test_uninstall_clears_pending(self): + async with anyio.create_task_group() as tg: + at_scheduler.install(tg, _fake_run_job, FakeTransport(), 1) + at_scheduler.schedule_delayed_run(1, None, 60, "x") + assert at_scheduler.active_count() == 1 + tg.cancel_scope.cancel() + at_scheduler.uninstall() + assert at_scheduler.active_count() == 0 + + +async def _fake_run_job(*args, **kwargs): + """Drop-in replacement for run_job — does nothing.""" + return diff --git a/tests/test_bridge_config_reload.py b/tests/test_bridge_config_reload.py new file mode 100644 index 00000000..f58eca98 --- /dev/null +++ b/tests/test_bridge_config_reload.py @@ -0,0 +1,160 @@ +"""Tests for TelegramBridgeConfig hot-reload (#286).""" + +from __future__ import annotations + +import dataclasses + +import pytest + +from tests.telegram_fakes import FakeBot, FakeTransport, make_cfg +from untether.settings import ( + TelegramFilesSettings, + TelegramTopicsSettings, + TelegramTransportSettings, +) +from untether.telegram.bridge import TelegramBridgeConfig + + +def _settings(**overrides) -> TelegramTransportSettings: + base = { + "bot_token": "abc", + "chat_id": 123, + } + base.update(overrides) + return TelegramTransportSettings.model_validate(base) + + +@pytest.fixture +def cfg() -> TelegramBridgeConfig: + return make_cfg(FakeTransport()) + + +# ── Unfreezing ───────────────────────────────────────────────────────── + + +class TestUnfrozen: + def test_cfg_is_unfrozen(self, cfg: TelegramBridgeConfig): + """Direct attribute assignment no longer raises FrozenInstanceError.""" + cfg.voice_transcription = True + assert cfg.voice_transcription is True + + def test_cfg_keeps_slots(self, cfg: TelegramBridgeConfig): + """slots=True still prevents creating arbitrary new attributes.""" + with pytest.raises(AttributeError): + cfg.not_a_real_field = 42 # type: ignore[attr-defined] + + def test_dataclass_is_unfrozen(self): + """dataclasses.is_dataclass confirms the @dataclass decorator remained.""" + assert dataclasses.is_dataclass(TelegramBridgeConfig) + # Frozen dataclasses expose __setattr__ that raises; + # unfrozen ones use the default. + cfg_inst = make_cfg(FakeTransport()) + cfg_inst.show_resume_line = False # must not raise + + +# ── update_from ──────────────────────────────────────────────────────── + + +class TestUpdateFrom: + def test_update_from_all_fields(self, cfg: TelegramBridgeConfig): + new_settings = _settings( + allowed_user_ids=[111, 222], + voice_transcription=True, + voice_max_bytes=1 * 1024 * 1024, + voice_transcription_model="whisper-1", + voice_transcription_base_url="https://x/v1", + voice_transcription_api_key="sk-new", + voice_show_transcription=False, + show_resume_line=False, + forward_coalesce_s=3.5, + media_group_debounce_s=2.5, + ) + cfg.update_from(new_settings) + assert cfg.allowed_user_ids == (111, 222) + assert cfg.voice_transcription is True + assert cfg.voice_max_bytes == 1 * 1024 * 1024 + assert cfg.voice_transcription_model == "whisper-1" + assert cfg.voice_transcription_base_url == "https://x/v1" + assert cfg.voice_transcription_api_key == "sk-new" + assert cfg.voice_show_transcription is False + assert cfg.show_resume_line is False + assert cfg.forward_coalesce_s == 3.5 + assert cfg.media_group_debounce_s == 2.5 + + def test_update_from_swaps_files_object(self, cfg: TelegramBridgeConfig): + original = cfg.files + new_files = TelegramFilesSettings( + enabled=True, + auto_put=False, + uploads_dir="uploads", + ) + cfg.update_from(_settings(files=new_files)) + assert cfg.files is not original + assert cfg.files.enabled is True + assert cfg.files.auto_put is False + assert cfg.files.uploads_dir == "uploads" + + def test_update_from_preserves_identity_fields(self, cfg: TelegramBridgeConfig): + """bot, runtime, chat_id, exec_cfg, session_mode, topics are not reloaded.""" + original_bot = cfg.bot + original_runtime = cfg.runtime + original_chat_id = cfg.chat_id + original_exec = cfg.exec_cfg + original_session_mode = cfg.session_mode + original_topics = cfg.topics + + cfg.update_from( + _settings( + chat_id=999, + session_mode="chat", + topics=TelegramTopicsSettings(enabled=True, scope="main"), + ) + ) + + # These architectural fields must not move even if the TOML changed. + assert cfg.bot is original_bot + assert cfg.runtime is original_runtime + assert cfg.chat_id == original_chat_id + assert cfg.exec_cfg is original_exec + assert cfg.session_mode == original_session_mode + assert cfg.topics is original_topics + + def test_update_from_clears_voice_api_key(self, cfg: TelegramBridgeConfig): + """Removing voice_transcription_api_key from config resets it to None.""" + cfg.update_from(_settings(voice_transcription_api_key="sk-before")) + assert cfg.voice_transcription_api_key == "sk-before" + cfg.update_from(_settings()) # no voice_transcription_api_key + assert cfg.voice_transcription_api_key is None + + def test_update_from_allowed_user_ids_stored_as_tuple( + self, cfg: TelegramBridgeConfig + ): + cfg.update_from(_settings(allowed_user_ids=[1, 2, 3])) + assert isinstance(cfg.allowed_user_ids, tuple) + assert cfg.allowed_user_ids == (1, 2, 3) + + def test_update_from_empty_allowed_user_ids(self, cfg: TelegramBridgeConfig): + cfg.update_from(_settings(allowed_user_ids=[])) + assert cfg.allowed_user_ids == () + + +class TestTriggerManagerField: + def test_trigger_manager_defaults_to_none(self): + """New field added for rc4 — default must stay None to avoid breakage.""" + cfg = TelegramBridgeConfig( + bot=FakeBot(), + runtime=make_cfg(FakeTransport()).runtime, + chat_id=1, + startup_msg="", + exec_cfg=make_cfg(FakeTransport()).exec_cfg, + ) + assert cfg.trigger_manager is None + + def test_trigger_manager_assignable_after_construction(self): + """Since the dataclass is unfrozen, post-construction assignment works.""" + cfg = make_cfg(FakeTransport()) + from untether.triggers.manager import TriggerManager + + mgr = TriggerManager() + cfg.trigger_manager = mgr + assert cfg.trigger_manager is mgr diff --git a/tests/test_describe_cron.py b/tests/test_describe_cron.py new file mode 100644 index 00000000..ae3e475c --- /dev/null +++ b/tests/test_describe_cron.py @@ -0,0 +1,108 @@ +"""Tests for describe_cron — human-friendly cron schedule rendering (#271).""" + +from __future__ import annotations + +import pytest + +from untether.triggers.describe import describe_cron + + +class TestDailyTimes: + @pytest.mark.parametrize( + "schedule,timezone,expected", + [ + ("0 9 * * *", "Australia/Melbourne", "9:00 AM daily (Melbourne)"), + ("0 0 * * *", None, "12:00 AM daily"), + ("30 0 * * *", None, "12:30 AM daily"), + ("0 12 * * *", None, "12:00 PM daily"), + ("30 14 * * *", "America/New_York", "2:30 PM daily (New York)"), + ("0 23 * * *", None, "11:00 PM daily"), + ("59 23 * * *", None, "11:59 PM daily"), + ], + ) + def test_daily(self, schedule, timezone, expected): + assert describe_cron(schedule, timezone) == expected + + +class TestWeekdayRanges: + def test_mon_fri_range(self): + assert ( + describe_cron("0 8 * * 1-5", "Australia/Melbourne") + == "8:00 AM Mon\u2013Fri (Melbourne)" + ) + + def test_tue_thu_range(self): + assert describe_cron("30 14 * * 2-4", None) == "2:30 PM Tue\u2013Thu" + + +class TestWeekdayLists: + def test_weekends(self): + assert describe_cron("0 10 * * 0,6", None) == "10:00 AM Sun,Sat" + + def test_three_days(self): + assert describe_cron("0 10 * * 1,3,5", None) == "10:00 AM Mon,Wed,Fri" + + +class TestSingleDay: + def test_sunday_as_zero(self): + assert describe_cron("0 9 * * 0", None) == "9:00 AM Sun" + + def test_sunday_as_seven(self): + assert describe_cron("0 9 * * 7", None) == "9:00 AM Sun" + + def test_monday(self): + assert describe_cron("0 9 * * 1", None) == "9:00 AM Mon" + + +class TestTimezoneSuffix: + def test_underscore_replaced_with_space(self): + # Some IANA names have underscores in the leaf component. + out = describe_cron("0 9 * * *", "America/Los_Angeles") + assert "(Los Angeles)" in out + + def test_no_timezone_no_suffix(self): + assert "(" not in describe_cron("0 9 * * *", None) + + def test_unqualified_timezone_used_as_is(self): + # Non-namespaced tz name — take it verbatim. + out = describe_cron("0 9 * * *", "UTC") + assert out.endswith("(UTC)") + + +class TestFallback: + @pytest.mark.parametrize( + "schedule", + [ + "*/15 * * * *", # stepped minutes + "0 */4 * * *", # stepped hours + "0 9 1 * *", # day-of-month + "0 9 * 6 *", # specific month + "invalid", # totally wrong + "0 9 * *", # too few fields + "0 9 * * * *", # too many fields + "0 25 * * *", # hour out of range + "60 0 * * *", # minute out of range + ], + ) + def test_fallback_returns_raw(self, schedule): + assert describe_cron(schedule, None) == schedule + + +class TestBoundary: + def test_midnight(self): + assert describe_cron("0 0 * * *", None) == "12:00 AM daily" + + def test_noon(self): + assert describe_cron("0 12 * * *", None) == "12:00 PM daily" + + def test_one_am(self): + assert describe_cron("0 1 * * *", None) == "1:00 AM daily" + + def test_eleven_pm(self): + assert describe_cron("0 23 * * *", None) == "11:00 PM daily" + + +class TestDefaults: + def test_timezone_none_explicit(self): + """Explicit None ≡ default.""" + assert describe_cron("0 9 * * *") == describe_cron("0 9 * * *", None) diff --git a/tests/test_offset_persistence.py b/tests/test_offset_persistence.py new file mode 100644 index 00000000..ba60e7ba --- /dev/null +++ b/tests/test_offset_persistence.py @@ -0,0 +1,128 @@ +"""Tests for Telegram update_id offset persistence (#287).""" + +from __future__ import annotations + +import json +from pathlib import Path + +from untether.telegram.offset_persistence import ( + STATE_FILENAME, + DebouncedOffsetWriter, + load_last_update_id, + resolve_offset_path, + save_last_update_id, +) + + +class TestResolveAndLoad: + def test_resolve_offset_path_uses_config_sibling(self, tmp_path: Path): + config_path = tmp_path / "untether.toml" + assert resolve_offset_path(config_path) == tmp_path / STATE_FILENAME + + def test_load_missing_file_returns_none(self, tmp_path: Path): + path = tmp_path / STATE_FILENAME + assert load_last_update_id(path) is None + + def test_load_valid_payload(self, tmp_path: Path): + path = tmp_path / STATE_FILENAME + path.write_text(json.dumps({"last_update_id": 12345}), encoding="utf-8") + assert load_last_update_id(path) == 12345 + + def test_load_corrupt_json_returns_none(self, tmp_path: Path): + path = tmp_path / STATE_FILENAME + path.write_text("{not valid", encoding="utf-8") + assert load_last_update_id(path) is None + + def test_load_wrong_type_returns_none(self, tmp_path: Path): + path = tmp_path / STATE_FILENAME + path.write_text(json.dumps([1, 2, 3]), encoding="utf-8") + assert load_last_update_id(path) is None + + def test_load_negative_value_returns_none(self, tmp_path: Path): + path = tmp_path / STATE_FILENAME + path.write_text(json.dumps({"last_update_id": -5}), encoding="utf-8") + assert load_last_update_id(path) is None + + def test_load_string_value_returns_none(self, tmp_path: Path): + path = tmp_path / STATE_FILENAME + path.write_text(json.dumps({"last_update_id": "42"}), encoding="utf-8") + assert load_last_update_id(path) is None + + +class TestSave: + def test_save_then_load_round_trip(self, tmp_path: Path): + path = tmp_path / STATE_FILENAME + save_last_update_id(path, 999999) + assert load_last_update_id(path) == 999999 + + def test_save_no_leftover_tmp_file(self, tmp_path: Path): + path = tmp_path / STATE_FILENAME + save_last_update_id(path, 42) + tmp_files = list(tmp_path.glob(f"{STATE_FILENAME}.tmp")) + assert tmp_files == [] + + def test_save_creates_parent_dir(self, tmp_path: Path): + path = tmp_path / "nested" / "subdir" / STATE_FILENAME + save_last_update_id(path, 7) + assert load_last_update_id(path) == 7 + + +class TestDebouncedWriter: + def test_note_below_interval_does_not_flush(self, tmp_path: Path): + path = tmp_path / STATE_FILENAME + writer = DebouncedOffsetWriter(path, min_interval_s=1000.0, max_pending=1000) + writer.note(1) + writer.note(2) + assert load_last_update_id(path) is None + + def test_note_after_interval_triggers_flush(self, tmp_path: Path, monkeypatch): + path = tmp_path / STATE_FILENAME + t = [100.0] + monkeypatch.setattr( + "untether.telegram.offset_persistence.time.monotonic", lambda: t[0] + ) + writer = DebouncedOffsetWriter(path, min_interval_s=5.0, max_pending=1000) + # First note within interval does not flush. + t[0] = 101.0 + writer.note(10) + assert load_last_update_id(path) is None + + # Subsequent notes within 5s still do not flush. + t[0] = 102.0 + writer.note(11) + writer.note(12) + assert load_last_update_id(path) is None + + # After 5s since last_flush (was init time 100), next note flushes. + t[0] = 106.0 + writer.note(13) + assert load_last_update_id(path) == 13 + + def test_max_pending_forces_flush_before_interval(self, tmp_path: Path): + path = tmp_path / STATE_FILENAME + writer = DebouncedOffsetWriter(path, min_interval_s=1_000_000.0, max_pending=3) + # No flush until 3rd note (max_pending threshold). + writer.note(1) + writer.note(2) + assert load_last_update_id(path) is None + writer.note(3) + assert load_last_update_id(path) == 3 + + def test_flush_writes_latest_pending(self, tmp_path: Path): + path = tmp_path / STATE_FILENAME + writer = DebouncedOffsetWriter(path, min_interval_s=1_000_000.0) + writer.note(7) + writer.note(8) + writer.note(9) + # No automatic flush yet. + assert load_last_update_id(path) is None + + # Explicit flush commits the latest pending. + writer.flush() + assert load_last_update_id(path) == 9 + + def test_flush_no_pending_is_noop(self, tmp_path: Path): + path = tmp_path / STATE_FILENAME + writer = DebouncedOffsetWriter(path) + writer.flush() + assert load_last_update_id(path) is None diff --git a/tests/test_ping_command.py b/tests/test_ping_command.py index fc8f947d..d50a17be 100644 --- a/tests/test_ping_command.py +++ b/tests/test_ping_command.py @@ -35,22 +35,121 @@ def test_format_uptime(seconds: float, expected: str) -> None: # --------------------------------------------------------------------------- -@pytest.mark.anyio -async def test_ping_returns_pong() -> None: - ctx = CommandContext( +def _make_ctx( + chat_id: int = 1, + trigger_manager=None, + default_chat_id: int | None = None, +) -> CommandContext: + return CommandContext( command="ping", text="/ping", args_text="", args=(), - message=MessageRef(channel_id=1, message_id=1), + message=MessageRef(channel_id=chat_id, message_id=1), reply_to=None, reply_text=None, config_path=None, plugin_config={}, runtime=AsyncMock(), executor=AsyncMock(), + trigger_manager=trigger_manager, + default_chat_id=default_chat_id, ) - result = await BACKEND.handle(ctx) + + +@pytest.mark.anyio +async def test_ping_returns_pong() -> None: + result = await BACKEND.handle(_make_ctx()) assert isinstance(result, CommandResult) assert result.text.startswith("\U0001f3d3 pong") assert result.notify is True + # No trigger line when manager absent. + assert "\u23f0 triggers" not in result.text + + +# --------------------------------------------------------------------------- +# /ping trigger indicator (#271) +# --------------------------------------------------------------------------- + + +def _make_manager(**overrides): + from untether.triggers.manager import TriggerManager + from untether.triggers.settings import parse_trigger_config + + raw = {"enabled": True} + raw.update(overrides) + return TriggerManager(parse_trigger_config(raw)) + + +@pytest.mark.anyio +async def test_ping_no_trigger_line_when_empty() -> None: + mgr = _make_manager() + result = await BACKEND.handle(_make_ctx(chat_id=1, trigger_manager=mgr)) + assert "\u23f0 triggers" not in result.text + + +@pytest.mark.anyio +async def test_ping_single_cron_targeting_chat() -> None: + mgr = _make_manager( + crons=[ + { + "id": "daily-review", + "schedule": "0 9 * * *", + "prompt": "hi", + "chat_id": 5000, + "timezone": "Australia/Melbourne", + } + ] + ) + result = await BACKEND.handle(_make_ctx(chat_id=5000, trigger_manager=mgr)) + assert "\u23f0 triggers: 1 cron (daily-review, 9:00 AM daily (Melbourne))" in ( + result.text + ) + + +@pytest.mark.anyio +async def test_ping_multiple_crons_shows_count() -> None: + mgr = _make_manager( + crons=[ + {"id": "a", "schedule": "0 9 * * *", "prompt": "x", "chat_id": 10}, + {"id": "b", "schedule": "0 10 * * *", "prompt": "y", "chat_id": 10}, + ] + ) + result = await BACKEND.handle(_make_ctx(chat_id=10, trigger_manager=mgr)) + assert "\u23f0 triggers: 2 crons" in result.text + + +@pytest.mark.anyio +async def test_ping_webhooks_appear_when_targeting_chat() -> None: + mgr = _make_manager( + webhooks=[ + { + "id": "h1", + "path": "/hooks/one", + "auth": "none", + "prompt_template": "hi {{text}}", + "chat_id": 999, + } + ] + ) + result = await BACKEND.handle(_make_ctx(chat_id=999, trigger_manager=mgr)) + assert "\u23f0 triggers: 1 webhook" in result.text + + +@pytest.mark.anyio +async def test_ping_other_chat_not_affected() -> None: + mgr = _make_manager( + crons=[{"id": "a", "schedule": "0 9 * * *", "prompt": "x", "chat_id": 10}] + ) + result = await BACKEND.handle(_make_ctx(chat_id=999, trigger_manager=mgr)) + assert "\u23f0 triggers" not in result.text + + +@pytest.mark.anyio +async def test_ping_default_chat_fallback_matches_unscoped_triggers() -> None: + """Unscoped triggers (chat_id=None) fall back to default_chat_id.""" + mgr = _make_manager(crons=[{"id": "any", "schedule": "0 9 * * *", "prompt": "x"}]) + result = await BACKEND.handle( + _make_ctx(chat_id=555, trigger_manager=mgr, default_chat_id=555) + ) + assert "\u23f0 triggers: 1 cron (any," in result.text diff --git a/tests/test_sdnotify.py b/tests/test_sdnotify.py new file mode 100644 index 00000000..06c8baca --- /dev/null +++ b/tests/test_sdnotify.py @@ -0,0 +1,98 @@ +"""Tests for the stdlib sd_notify client (#287).""" + +from __future__ import annotations + +import socket as socket_mod +from typing import Any + +from untether import sdnotify + + +class FakeSocket: + """Minimal AF_UNIX SOCK_DGRAM stand-in — records sendto() calls.""" + + calls: list[tuple[bytes, Any]] + + def __init__(self, family: int, kind: int, *args: Any, **kwargs: Any) -> None: + assert family == socket_mod.AF_UNIX + assert kind == socket_mod.SOCK_DGRAM + self.calls = [] + + def sendto(self, data: bytes, addr: Any) -> int: + self.calls.append((data, addr)) + return len(data) + + def __enter__(self) -> FakeSocket: + return self + + def __exit__(self, *exc: Any) -> None: + pass + + +class TestNotify: + def test_notify_absent_socket_returns_false(self, monkeypatch): + monkeypatch.delenv("NOTIFY_SOCKET", raising=False) + assert sdnotify.notify("READY=1") is False + + def test_notify_empty_socket_returns_false(self, monkeypatch): + monkeypatch.setenv("NOTIFY_SOCKET", "") + assert sdnotify.notify("READY=1") is False + + def test_notify_with_filesystem_socket(self, monkeypatch): + created: list[FakeSocket] = [] + + def _socket_factory(*args, **kwargs): + sock = FakeSocket(*args, **kwargs) + created.append(sock) + return sock + + monkeypatch.setenv("NOTIFY_SOCKET", "/run/user/1000/systemd/notify") + monkeypatch.setattr(socket_mod, "socket", _socket_factory) + assert sdnotify.notify("READY=1") is True + assert len(created) == 1 + assert created[0].calls == [(b"READY=1", "/run/user/1000/systemd/notify")] + + def test_notify_with_abstract_namespace(self, monkeypatch): + """Leading '@' in NOTIFY_SOCKET translates to a leading null byte.""" + created: list[FakeSocket] = [] + + def _socket_factory(*args, **kwargs): + sock = FakeSocket(*args, **kwargs) + created.append(sock) + return sock + + monkeypatch.setenv("NOTIFY_SOCKET", "@systemd-notify-abs") + monkeypatch.setattr(socket_mod, "socket", _socket_factory) + assert sdnotify.notify("STOPPING=1") is True + assert created[0].calls == [(b"STOPPING=1", b"\0systemd-notify-abs")] + + def test_notify_swallows_send_errors(self, monkeypatch): + class FailingSocket(FakeSocket): + def sendto(self, data: bytes, addr: Any) -> int: + raise OSError(111, "Connection refused") + + monkeypatch.setenv("NOTIFY_SOCKET", "/tmp/nope") + monkeypatch.setattr(socket_mod, "socket", FailingSocket) + # Must not raise. + assert sdnotify.notify("READY=1") is False + + def test_notify_swallows_socket_creation_errors(self, monkeypatch): + def _socket_factory(*args, **kwargs): + raise OSError(13, "Permission denied") + + monkeypatch.setenv("NOTIFY_SOCKET", "/tmp/nope") + monkeypatch.setattr(socket_mod, "socket", _socket_factory) + assert sdnotify.notify("READY=1") is False + + def test_notify_encodes_utf8_messages(self, monkeypatch): + created: list[FakeSocket] = [] + + def _socket_factory(*args, **kwargs): + sock = FakeSocket(*args, **kwargs) + created.append(sock) + return sock + + monkeypatch.setenv("NOTIFY_SOCKET", "/tmp/sock") + monkeypatch.setattr(socket_mod, "socket", _socket_factory) + assert sdnotify.notify("STATUS=running — idle") is True + assert created[0].calls[0][0] == b"STATUS=running \xe2\x80\x94 idle" diff --git a/tests/test_trigger_cron.py b/tests/test_trigger_cron.py index f5924634..275a9cfd 100644 --- a/tests/test_trigger_cron.py +++ b/tests/test_trigger_cron.py @@ -3,9 +3,21 @@ from __future__ import annotations import datetime +from dataclasses import dataclass, field +from typing import Any from zoneinfo import ZoneInfo -from untether.triggers.cron import _parse_field, _resolve_now, cron_matches +import anyio +import pytest + +from untether.triggers.cron import ( + _parse_field, + _resolve_now, + cron_matches, + run_cron_scheduler, +) +from untether.triggers.manager import TriggerManager +from untether.triggers.settings import parse_trigger_config class TestCronMatches: @@ -129,3 +141,118 @@ def test_step_zero_in_expression_no_match(self): now = datetime.datetime(2026, 2, 24, 10, 0) # Expression with step=0 should not match (returns empty set) assert cron_matches("*/0 * * * *", now) is False + + +# ── run_once cron flag (#288) ───────────────────────────────────────── + + +@dataclass +class FakeDispatcher: + fired: list[str] = field(default_factory=list) + + async def dispatch_cron(self, cron: Any) -> None: + self.fired.append(cron.id) + + +pytestmark_runonce = pytest.mark.anyio + + +@pytest.mark.anyio +async def test_run_once_removes_after_fire(monkeypatch): + """A run_once cron removes itself from TriggerManager after firing.""" + settings = parse_trigger_config( + { + "enabled": True, + "crons": [ + { + "id": "once", + "schedule": "* * * * *", + "prompt": "hi", + "run_once": True, + }, + ], + } + ) + manager = TriggerManager(settings) + dispatcher = FakeDispatcher() + + # Patch scheduler's sleep to yield immediately so the tick fires fast. + _real_sleep = anyio.sleep + + async def fast_sleep(s: float) -> None: + await _real_sleep(0) + + monkeypatch.setattr("untether.triggers.cron.anyio.sleep", fast_sleep) + + async with anyio.create_task_group() as tg: + tg.start_soon(run_cron_scheduler, manager, dispatcher) + # Give scheduler one tick to fire, then cancel. + await _real_sleep(0) + for _ in range(3): + await _real_sleep(0) + # Cancel the scheduler. + tg.cancel_scope.cancel() + + assert dispatcher.fired == ["once"] + assert manager.cron_ids() == [] + + +@pytest.mark.anyio +async def test_run_once_false_keeps_cron_active(monkeypatch): + """A normal cron (run_once=False) stays in the manager after firing.""" + settings = parse_trigger_config( + { + "enabled": True, + "crons": [ + { + "id": "repeating", + "schedule": "* * * * *", + "prompt": "hi", + }, + ], + } + ) + manager = TriggerManager(settings) + dispatcher = FakeDispatcher() + + _real_sleep = anyio.sleep + + async def fast_sleep(s: float) -> None: + await _real_sleep(0) + + monkeypatch.setattr("untether.triggers.cron.anyio.sleep", fast_sleep) + + async with anyio.create_task_group() as tg: + tg.start_soon(run_cron_scheduler, manager, dispatcher) + for _ in range(3): + await _real_sleep(0) + tg.cancel_scope.cancel() + + # Fired at least once, cron still active. + assert "repeating" in dispatcher.fired + assert manager.cron_ids() == ["repeating"] + + +def test_run_once_survives_reload_via_config(): + """A reload with the same TOML re-adds a run_once cron that was removed.""" + settings = parse_trigger_config( + { + "enabled": True, + "crons": [ + { + "id": "once", + "schedule": "0 9 * * *", + "prompt": "hi", + "run_once": True, + }, + ], + } + ) + mgr = TriggerManager(settings) + assert mgr.cron_ids() == ["once"] + # Simulate firing: remove it. + assert mgr.remove_cron("once") is True + assert mgr.cron_ids() == [] + # Config reload (TOML unchanged) re-adds the cron. + mgr.update(settings) + assert mgr.cron_ids() == ["once"] diff --git a/tests/test_trigger_dispatcher.py b/tests/test_trigger_dispatcher.py index 92f9a639..8cbf34ca 100644 --- a/tests/test_trigger_dispatcher.py +++ b/tests/test_trigger_dispatcher.py @@ -250,7 +250,8 @@ async def test_cron_dispatch_calls_run_job(): @pytest.mark.anyio -async def test_no_project_means_no_context(): +async def test_no_project_still_sets_trigger_source(): + """rc4 (#271): RunContext is always created so trigger_source flows through.""" transport = FakeTransport() run_job = RunJobCapture() @@ -265,4 +266,32 @@ async def test_no_project_means_no_context(): await anyio.sleep(0.01) tg.cancel_scope.cancel() - assert run_job.calls[0]["context"] is None + ctx = run_job.calls[0]["context"] + assert ctx is not None + assert ctx.project is None + assert ctx.trigger_source == "webhook:test-wh" + + +@pytest.mark.anyio +async def test_dispatch_cron_sets_trigger_source(): + """rc4 (#271): cron dispatches tag context with cron:.""" + from untether.triggers.settings import CronConfig + + transport = FakeTransport() + run_job = RunJobCapture() + + async with anyio.create_task_group() as tg: + dispatcher = TriggerDispatcher( + run_job=run_job, + transport=transport, + default_chat_id=100, + task_group=tg, + ) + cron = CronConfig(id="daily-review", schedule="0 9 * * *", prompt="hi") + await dispatcher.dispatch_cron(cron) + await anyio.sleep(0.01) + tg.cancel_scope.cancel() + + ctx = run_job.calls[0]["context"] + assert ctx is not None + assert ctx.trigger_source == "cron:daily-review" diff --git a/tests/test_trigger_manager.py b/tests/test_trigger_manager.py index 9d122e41..a4d03955 100644 --- a/tests/test_trigger_manager.py +++ b/tests/test_trigger_manager.py @@ -332,3 +332,93 @@ def test_manager_default_timezone_readable(self): mgr.update(_settings()) assert mgr.default_timezone is None + + +# ── Helper methods added for rc4: id lists, per-chat filters, remove_cron ── + + +class TestTriggerManagerHelpers: + def test_cron_ids_and_webhook_ids_snapshots(self): + mgr = TriggerManager( + _settings( + crons=[_cron("a"), _cron("b")], + webhooks=[_webhook("h1"), _webhook("h2", path="/hooks/other")], + ) + ) + assert sorted(mgr.cron_ids()) == ["a", "b"] + assert sorted(mgr.webhook_ids()) == ["h1", "h2"] + + def test_cron_ids_empty_when_no_crons(self): + mgr = TriggerManager(_settings()) + assert mgr.cron_ids() == [] + assert mgr.webhook_ids() == [] + + def test_crons_for_chat_uses_cron_chat_id(self): + mgr = TriggerManager( + _settings( + crons=[ + _cron("a", chat_id=111), + _cron("b", chat_id=222), + _cron("c", chat_id=111), + ] + ) + ) + matching = mgr.crons_for_chat(111) + assert sorted(c.id for c in matching) == ["a", "c"] + + def test_crons_for_chat_falls_back_to_default(self): + mgr = TriggerManager(_settings(crons=[_cron("a"), _cron("b", chat_id=999)])) + # Default chat catches crons without chat_id. + matching = mgr.crons_for_chat(555, default_chat_id=555) + assert [c.id for c in matching] == ["a"] + # Non-default chat only sees its explicit match. + matching = mgr.crons_for_chat(999, default_chat_id=555) + assert [c.id for c in matching] == ["b"] + + def test_crons_for_chat_no_default_excludes_unset(self): + """When no default_chat_id is passed, crons with chat_id=None are excluded.""" + mgr = TriggerManager(_settings(crons=[_cron("a"), _cron("b", chat_id=555)])) + matching = mgr.crons_for_chat(555) + assert [c.id for c in matching] == ["b"] + + def test_webhooks_for_chat_filters_by_chat_id(self): + mgr = TriggerManager( + _settings( + webhooks=[ + _webhook("h1", chat_id=111), + _webhook("h2", path="/hooks/other", chat_id=222), + _webhook("h3", path="/hooks/third", chat_id=111), + ] + ) + ) + matching = mgr.webhooks_for_chat(111) + assert sorted(wh.id for wh in matching) == ["h1", "h3"] + + def test_remove_cron_removes_and_returns_true(self): + mgr = TriggerManager(_settings(crons=[_cron("a"), _cron("b"), _cron("c")])) + assert mgr.remove_cron("b") is True + assert [c.id for c in mgr.crons] == ["a", "c"] + + def test_remove_cron_missing_returns_false(self): + mgr = TriggerManager(_settings(crons=[_cron("a")])) + assert mgr.remove_cron("missing") is False + assert [c.id for c in mgr.crons] == ["a"] + + def test_remove_cron_atomic_during_iteration(self): + """Iterators over the old list keep all entries even after a remove_cron.""" + mgr = TriggerManager(_settings(crons=[_cron("a"), _cron("b"), _cron("c")])) + snapshot = mgr.crons # iterator captures this reference + assert mgr.remove_cron("b") is True + # Old snapshot still shows all three — list replacement is safe. + assert [c.id for c in snapshot] == ["a", "b", "c"] + # New reference reflects the removal. + assert [c.id for c in mgr.crons] == ["a", "c"] + + def test_remove_cron_then_update_rehydrates(self): + """Config reload re-adds run_once crons that were previously removed.""" + mgr = TriggerManager(_settings(crons=[_cron("a", run_once=True)])) + assert mgr.remove_cron("a") is True + assert mgr.cron_ids() == [] + # Simulate a config reload with the same cron still in TOML. + mgr.update(_settings(crons=[_cron("a", run_once=True)])) + assert mgr.cron_ids() == ["a"] diff --git a/tests/test_trigger_meta_line.py b/tests/test_trigger_meta_line.py new file mode 100644 index 00000000..871791d7 --- /dev/null +++ b/tests/test_trigger_meta_line.py @@ -0,0 +1,42 @@ +"""Tests for trigger source rendering in the meta footer (#271).""" + +from __future__ import annotations + +from untether.markdown import format_meta_line + + +class TestTriggerInFooter: + def test_trigger_only(self): + out = format_meta_line({"trigger": "\u23f0 cron:daily-review"}) + assert out == "\u23f0 cron:daily-review" + + def test_trigger_with_model(self): + out = format_meta_line( + {"trigger": "\u23f0 cron:daily-review", "model": "claude-opus-4-6"} + ) + assert out is not None + assert "\u23f0 cron:daily-review" in out + assert "opus" in out.lower() + # Model must come before trigger in the part order. + parts = out.split(" \u00b7 ") + assert parts.index("\u23f0 cron:daily-review") == len(parts) - 1 + + def test_trigger_webhook(self): + out = format_meta_line({"trigger": "\u26a1 webhook:github-push"}) + assert out == "\u26a1 webhook:github-push" + + def test_no_trigger_ignored(self): + out = format_meta_line({"model": "claude-opus-4-6"}) + assert out is not None + assert "cron" not in out + assert "webhook" not in out + + def test_empty_trigger_ignored(self): + out = format_meta_line({"trigger": "", "model": "claude-opus-4-6"}) + assert out is not None + assert "opus" in out.lower() + + def test_non_string_trigger_ignored(self): + out = format_meta_line({"trigger": 42, "model": "claude-opus-4-6"}) + assert out is not None + assert "42" not in out diff --git a/tests/test_trigger_settings.py b/tests/test_trigger_settings.py index de17eb07..b5ffc71a 100644 --- a/tests/test_trigger_settings.py +++ b/tests/test_trigger_settings.py @@ -174,6 +174,16 @@ def test_timezone_none_by_default(self): c = CronConfig(id="x", schedule="* * * * *", prompt="Hi") assert c.timezone is None + def test_run_once_default_false(self): + c = CronConfig(id="x", schedule="* * * * *", prompt="Hi") + assert c.run_once is False + + def test_run_once_true_accepted(self): + c = CronConfig( + id="deploy-check", schedule="0 15 * * *", prompt="Hi", run_once=True + ) + assert c.run_once is True + def test_invalid_timezone_rejected(self): with pytest.raises(ValidationError, match="unknown timezone"): CronConfig( diff --git a/uv.lock b/uv.lock index 8296c6e5..bab45045 100644 --- a/uv.lock +++ b/uv.lock @@ -2069,7 +2069,7 @@ wheels = [ [[package]] name = "untether" -version = "0.35.1rc3" +version = "0.35.1rc4" source = { editable = "." } dependencies = [ { name = "aiohttp" },