Originally created by: sosidudku1
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)
feat(tasks): opt-in Telegram reports for scheduled task outcomes
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.
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).
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.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.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.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.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.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).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.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:
completed | failed | blocked; retries and cancelled stay silent, and mid-run progress is a named non-goal.ownerUserId), so reports go to the paired owner's DM. Multi-chat routing stays out of scope together with multi-user pairing.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.up (the task rows keep the authoritative outcomes); persisting the queue is deliberately out of scope for a courtesy notification.POST /api/tasks do not surface notify yet; the HTTP create surface cannot express schedules today either. Webhook-created tasks also stay silent.telegram.cronReports switch: per-task opt-in cannot spam, and the default off preserves current behaviour exactly.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).notify: null, version bumped, new writes accepted) plus fresh-DB round-trip, allow-list rejection, and read clamp (task-store).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.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.tasks.list summary rows.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
Originally posted by: sosidudku1
All three points were valid; fixed in 57a978f.
assistant_reply, so a task ending viafinish(the routine ending for unattended runs) reported "(no reply)". It now also captures thefinishtool's executed result, preferring the uncompresseddetails.summary, which is the same payload the TUI renders as the final feed line. Covered by two new runner tests.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 notupyet queues reports in a bounded FIFO (cap 20, oldest evicted with a warning) that the transition toupflushes; newqueuedoutcome, 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.delivery_failedwith delivered/total counts in the warning instead of counting assent. 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.Ticket changed by: Ooooze