{"slug": "how-i-built-visible-scheduled-results-for-my-desktop-ai-companion-8-coalesced-1", "title": "How I built visible scheduled results for my desktop AI companion: 8 coalesced heartbeats, 1 alert per failure", "summary": "Krish Verma shipped Ankita v2.7.5, a desktop AI assistant that now surfaces scheduled job results in a Scheduled tasks panel via a new pendingDeliveries() method that reads existing JSON receipts rather than adding a live event stream. The same release defers rather than drops heartbeats that come due mid-conversation, coalescing missed intervals up to a cap of 8, and adds a [SILENT] marker that suppresses chat posts while guaranteeing failed jobs still deliver and alert only once per incident.", "body_md": "Ankita AI is the desktop AI assistant I build, by Krish Verma — a little companion that lives on your desktop and gets things done while you're away. It runs scheduled jobs: a morning briefing, a page watch, a heartbeat that checks whether anything changed since last time. And until v2.7.5, those jobs had a visibility problem. A job that finished while you were mid-conversation left its result sitting as a queued event that nothing listened for. The work your companion did in the background was invisible until the turn ended — if it surfaced at all.\n\nThe fix, shipped in [Ankita v2.7.5](https://github.com/akyourowngames/A.N.K.I.T.A), was to make waiting results visible in the Scheduled tasks panel, and to stop losing heartbeat checks in the process. The panel now lists finished runs under \"Results ready to post\", one expandable row per run, and a \"While you were away\" strip counts what happened: ran, quiet, needs-you, ready-to-post.\n\nThe mechanism is deliberately boring. Every run already writes a receipt — a JSON file named `<routineId>-<runId>.json` in the scheduler directory, with the status, the summary text, and a `delivered` flag. The panel does not subscribe to a live event stream. It asks a new method, `pendingDeliveries()`, which reads that directory, parses each receipt, and returns the ones where `delivered` is false, newest first:\n\n```\nif (!receipt.delivered && receipt.runId && (!threadId || receipt.threadId === threadId)) {\n  out.push({ runId, routineId, name, status, at, text });\n}\n```\n\nA half-written receipt is not a delivery — a parse failure is caught and skipped, so a crash mid-write never shows a corrupt row. The renderer polls two IPC calls, `scheduleAwaySummary` and `schedulePendingDeliveries`, and the section appears in `JobsPanel.tsx` only when there is something to show. No new storage, no new event bus: the receipts were already durable, the panel just finally reads them.\n\nThe second half of the same commit fixed the heartbeat. A heartbeat runs every 30 minutes and compares \"what changed since last time\". Before this change, one that came due while you were replying was marked `skipped-busy` and thrown away. Miss a few replies in a row and the heartbeat quietly stopped checking — the exact job whose whole purpose is noticing change.\n\nNow a due heartbeat is deferred, never dropped. The scheduler records `pendingHeartbeatAt` — the original fire time — and counts missed intervals in `coalescedHeartbeats`, capped at `HEARTBEAT_MAX_COALESCED = 8`. When the chat is free, the deferred heartbeat runs once, against its original nominal slot, so the \"what changed since last time\" comparison stays truthful.\n\nThat cap is the honest part: a heartbeat deferred for four hours and then run once compares against a four-hour-old baseline and calls it a single check, and nothing in the row says it is late.\n\nA job can now end its summary with `[SILENT]` — `JOB_SILENT_MARKER` in `job-policy.mjs` — and the run is recorded and stays visible in the panel, but never posts into chat. Two rules keep that safe. First, silence never hides a failure: the catch path sets `run.silent = false`, and the delivery decision is `silent = run.silent === true && !failed`, so a failed job always delivers whatever the marker says. Second, failures alert once per incident. The scheduler tracks a `failureStreak` on the routine, and the `routine-failed` event fires only when `failureStreak === 1`.\n\nThe handoff has a matching rule. If delivery fails — the chat is gone, the owner is missing — the receipt is marked `deliveryFailed: true` and the run is queued for retry. A delivery problem is recorded on the receipt, never as a job failure. The work succeeded; only the handoff did not, and blaming the job would be a lie in the panel.\n\nThe tests cover the new behaviour directly: scheduler deferral and coalescing, silence and failure-once, the away summary, renderer delivery state, and the panel preview, plus a live-verify script that exercises the real panel. But three things still bother me.\n\n`pendingDeliveries()` scans the whole receipt directory on every panel read — a `readdirSync` plus a `JSON.parse` per file. That is fine at the scale of a dozen scheduled jobs. It would not survive hundreds of receipts, and delivered receipts are never pruned in this path, only in the run history.\n\nA half-written receipt is skipped silently — a crash mid-write hides a result instead of surfacing it as corrupt. The panel cannot tell you \"a result existed and we lost it\".\n\nAnd the `[SILENT]` marker is a suffix string in free text. The check is `summary.trim().endsWith('[SILENT]')`, so a model that writes `[SILENT].` with a full stop, or puts the marker mid-sentence, posts a result the job author meant to keep quiet. The marker is a convention the model has to obey, not a boundary it cannot cross.\n\nTell me I'm wrong: a heartbeat deferred 8 times and then run once against a four-hour-old baseline is reporting on a window it never observed — should it say \"missed\" instead of pretending the check is current? And is a text marker one stray full stop can defeat really a silence mechanism, or just a polite request wearing a mechanism's clothes? Where exactly would this break first?\n\n*Ankita is open source (MIT) — the code is at [github.com/akyourowngames/A.N.K.I.T.A](https://github.com/akyourowngames/A.N.K.I.T.A), and the site is [ankita-sable.vercel.app](https://ankita-sable.vercel.app).*\n\n*— Krish Verma, creator of Ankita AI*", "url": "https://wpnews.pro/news/how-i-built-visible-scheduled-results-for-my-desktop-ai-companion-8-coalesced-1", "canonical_source": "https://dev.to/krish_verma_77e28d3fd63ca/how-i-built-visible-scheduled-results-for-my-desktop-ai-companion-8-coalesced-heartbeats-1-alert-2fla", "published_at": "2026-11-10 10:27:07+00:00", "updated_at": "2026-11-10 10:52:01.414184+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "ai-products", "developer-tools"], "entities": ["Ankita AI", "Krish Verma", "A.N.K.I.T.A", "JobsPanel.tsx", "job-policy.mjs"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/how-i-built-visible-scheduled-results-for-my-desktop-ai-companion-8-coalesced-1", "markdown": "https://wpnews.pro/news/how-i-built-visible-scheduled-results-for-my-desktop-ai-companion-8-coalesced-1.md", "text": "https://wpnews.pro/news/how-i-built-visible-scheduled-results-for-my-desktop-ai-companion-8-coalesced-1.txt", "jsonld": "https://wpnews.pro/news/how-i-built-visible-scheduled-results-for-my-desktop-ai-companion-8-coalesced-1.jsonld"}}