Menu

#87 feat(tasks): opt-in Telegram reports for scheduled task outcomes

closed
nobody
None
2026-08-10
2026-08-10
Anonymous
No

Originally created by: sosidudku1

PR draft (rebased, not pushed; coordinator pushes with force-with-lease)

Branch: feat/cron-telegram-reports (worktree at scratchpad/wt-cron-tg, 2 commits 5ecbc27 + c329446 on top of origin/main 686b047, i.e. after #83/#84/#85)

Title

feat(tasks): opt-in Telegram reports for scheduled task outcomes

Body

Problem

Scheduled tasks run locally, but there is no way to have a cron job deliver its result through the Telegram channel (#78). Users who already control the agent from Telegram never see scheduled output unless they open the terminal, which defeats the point of scheduling.

Change

A task can now opt into reporting its terminal outcome to the paired Telegram owner via a per-task notify: "telegram" flag (default off, so existing jobs are untouched).

  • Record + schema. TaskRecord.notify, backed by a tasks.sqlite schema bump v2 -> v3 (idempotent ALTER TABLE tasks ADD COLUMN notify TEXT). Writes are validated against the TASK_NOTIFY_TARGETS allow-list; unknown persisted values clamp to null on read, so the runner only ever sees list members. The real upgrade path is covered by a test that builds a genuine v2 database (version stamp '2', no notify column, live rows) and asserts the column lands, the version bumps to '3', old rows read back with notify: null, and new opted-in writes work.
  • Capture + dispatch. TaskRunner.runOne installs a turn event hook only for opted-in tasks (every other task passes no hook at all) and captures the final result text for both success terminals: the assistant_reply for reply turns, and the finish tool's summary for finish turns (unattended tasks routinely end via finish with no reply at all; the hook reads the same tool_call_executed payload the TUI renders as the final feed line, preferring the uncompressed details.summary). The TaskReport goes to the new optional reportSink at the same sites where terminal metrics fire; recurring tasks report each firing off the completed row before requeueRecurring flips it back to pending.
  • Delivery. Bootstrap wires reportSink to the sink built by the static TelegramChannel.buildTaskReportSink({ resolveChannel, logger }), whose skip paths are unit-tested; delivery goes through TelegramChannel.sendTaskReport: a plain-text DM to chatId = ownerUserId (the owner id is only ever captured from a private DM, and the Bot API addresses a user-bot private chat by the user's own id), rendered by task-report-message.ts, sent through the existing sendOutbound chunking / 429 path. The message carries status, prompt preview, schedule kind, attempt count, duration, and the reply text capped at 3000 UTF-16 units with an explicit truncation marker (errors capped at 500). Truncation is surrogate-safe: excerpts are cut on code-point boundaries (same walk as chunkUtf16), so an emoji straddling the cap can never leave a lone surrogate in the chat. A maximal report is pinned by test to fit a single outbound chunk, and any dropped chunk resolves delivery_failed with delivered/total counts in the warning, never a silent partial sent.
  • Boot race, closed. The scheduler now starts only after the Telegram channel object is constructed (channel construction is side-effect-free; the only cost is that overdue tasks fire their first tick a few seconds later on a cold start), so a report can never observe a missing channel. A channel that is not up yet but has a token configured queues reports in a bounded FIFO (TASK_REPORT_QUEUE_LIMIT = 20, oldest evicted with a warning) and flushes it on the transition to up; this covers both the startup window and a channel the operator enables later. With no token configured the report is skipped with a warning as before. The queue survives channel stop/restart and is deliberately not persisted.
  • Failure semantics: best-effort, never silent, never load-bearing. Every outcome is a TaskReportDelivery value (sent | queued | channel_not_up | not_paired | delivery_failed); dropping outcomes are warn-logged, queued is info-logged by the channel and delivered later, and a throwing or rejecting sink is isolated by the runner. The task's own status is never affected.
  • Opt-in surfaces. tasks.cron and tasks.schedule agent tools (notify?: "telegram", with descriptor and args-schema updates) and CLI task create --notify telegram. Read surfaces expose the field everywhere a record is rendered: task show, GET /api/tasks, tasks.show, and tasks.list summary rows (so the agent can see which jobs are opted in from a listing).
  • Docs. AGENTS.md gained a pinned-by-tests contract subsection under Background autonomy, plus Telegram-section updates (plain-text infra invariant now names task reports, messages_sent accounting note, out-of-scope additions). README (the refreshed [#84] text) documents the flag in the Telegram section, extends the Telegram feature-table row, and adds a bullet to the "Network egress happens when" list under Privacy and Egress naming the Telegram channel traffic including opt-in scheduled task reports.
  • Analytics interplay (#85). Scheduler-origin turns return from runTurn before turnUsageMeter.begin() and before captureMessageSent, so cron runs neither emit message_sent nor attach token/spend fields; the meter itself documents that calls with no open turn for their session are dropped rather than misfiled, buckets are keyed by session, and snapshot() always releases the bucket. Task reports are a Telegram message plus the messages_sent counter and touch none of the PostHog metrics.

Privacy note, named on purpose: a report sends the task prompt preview and the result / error excerpt to the Telegram Bot API, so that content leaves the machine. This only happens for tasks that explicitly opted in; imported cron jobs (OpenClaw / Hermes importers) never set the flag.

Answering the issue's open questions:

  • Final result only, or progress updates? Final result only. Reports fire on completed | failed | blocked; retries and cancelled stay silent, and mid-run progress is a named non-goal.
  • Which chat when several are paired? The channel is single-owner by design (one ownerUserId), so reports go to the paired owner's DM. Multi-chat routing stays out of scope together with multi-user pairing.
  • Approval via Telegram for unattended runs? Out of scope here; this PR adds no approval relay, and the pre-existing behaviour is unchanged. Concretely, per the current code: when the gate's auto-approve is on (how CLI task run / task tick boot the runtime), the ApprovalGate resolves every request approved immediately; otherwise, a request from a scheduled session (which has no per-session handler) falls through ApprovalRouter to the host's onApprovalRequest fallback, and since the gate itself has no timeout, the turn waits until the host resolves the request or the turn is aborted. The 8-minute auto-deny exists only in the Telegram ApprovalBridge for Telegram-origin sessions. A report is sent only after the turn actually reaches a terminal status.

Deliberate boundaries

  • Report queue is in-memory only. Reports queued while the channel is coming up are lost if the process exits before the channel reaches up (the task rows keep the authoritative outcomes); persisting the queue is deliberately out of scope for a courtesy notification.
  • No mid-run progress updates and no approval relay for unattended scheduled runs (both added to the Telegram section's out-of-scope list).
  • The TUI create form and POST /api/tasks do not surface notify yet; the HTTP create surface cannot express schedules today either. Webhook-created tasks also stay silent.
  • No global telegram.cronReports switch: per-task opt-in cannot spam, and the default off preserves current behaviour exactly.
  • The tool-descriptor edit is a one-time stable-prefix byte change (KV-cache invalidation on next boot), the same class as any tool catalog change.

Testing

  • npm run lint (tsc noEmit) clean. tsc -p tsconfig.test.json error count is below the origin/main baseline (406 vs 410; that config is not part of npm run lint and is not green on main).
  • Migration: a real v2 -> v3 upgrade test (hand-built v2 database, old rows intact with notify: null, version bumped, new writes accepted) plus fresh-DB round-trip, allow-list rejection, and read clamp (task-store).
  • Runner: report on completed with the captured reply, finish-terminal capture (uncompressed details.summary plus the compressed fallback), terminal failure, blocked session, recurring pre-requeue reporting, no hook and no report without opt-in, no report on cancelled or on retry, sink-rejection isolation.
  • Message: formatting, truncation markers, honest fallbacks, three surrogate-safety cases (reply, error, prompt preview with an emoji pair straddling the cap), and the size-envelope pin (a maximal completed and a maximal failed report each fit one outbound chunk).
  • Channel: sendTaskReport delivery outcomes including the plain-text pin; queueing while not up and the flush on transition to up; oldest-first eviction with a warning at the queue cap; delivery_failed with delivered/total chunk counts in the warning; buildTaskReportSink warn paths plus silence on sent and on queued.
  • Surfaces: notify plumbing and validation on both agent tools, the CLI flag, and tasks.list summary rows.
  • Scoped suites green after the fixes (248 tests across tasks, channels/telegram, runtime bootstrap; 404 across the wider targeted set). Full npm test on the branch: 3641 passed, 10 failed in files that all belong to the flaky set measured on a clean origin/main checkout (10 failures there too; the membership drifts between full runs and every member passes in isolation). Zero failures introduced by this change.

Closes [#78]

🤖 Generated with Claude Code

Related

Tickets: #78
Tickets: #84

Discussion

  • Anonymous

    Anonymous - 2026-08-10

    Originally posted by: sosidudku1

    All three points were valid; fixed in 57a978f.

    1. finish terminal: the report hook only captured assistant_reply, so a task ending via finish (the routine ending for unattended runs) reported "(no reply)". It now also captures the finish tool's executed result, preferring the uncompressed details.summary, which is the same payload the TUI renders as the final feed line. Covered by two new runner tests.
    2. boot race: two-part fix. scheduler?.start() moved after the Telegram channel is constructed (construction is side-effect-free; overdue tasks just fire their first tick a few seconds later on a cold start), so a report can never observe a missing channel. And a channel that has a token but is not up yet queues reports in a bounded FIFO (cap 20, oldest evicted with a warning) that the transition to up flushes; new queued outcome, info-logged, not a warn. With no token configured the behaviour is unchanged. The queue is in-memory by design; the task row keeps the authoritative outcome.
    3. partial chunks: any dropped chunk now resolves delivery_failed with delivered/total counts in the warning instead of counting as sent. Note the report format cannot actually split today: a maximal report stays under one 4000-unit chunk, and that envelope is now pinned by test so a future cap change fails loudly here first.
     
  • Anonymous

    Anonymous - 2026-08-10

    Ticket changed by: Ooooze

    • status: open --> closed
     

Log in to post a comment.