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.

The fix, shipped in Ankita v2.7.5, 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.

The receipt is the source of truth

The 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:

if (!receipt.delivered && receipt.runId && (!threadId || receipt.threadId === threadId)) { out.push({ runId, routineId, name, status, at, text }); } 

A 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.

Heartbeats defer instead of dying

The 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.

Now 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.

That 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.

Silence is a marker, not a disappearance

A 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.

The 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.

What I would break first

The 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.

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.

A 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".

And 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.

Tell 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?

Ankita is open source (MIT) — the code is at github.com/akyourowngames/A.N.K.I.T.A, and the site is ankita-sable.vercel.app.

— Krish Verma, creator of Ankita AI