How I built a 120-second ceiling so one open app can't freeze my voice assistant
Ankita AI is the desktop AI assistant I build, by Krish Verma — a little companion that lives on your desktop and does work while you're away. Say "open Notepad" to it, and it opens Notepad. Say anything else afterwards, and it should just get on with it. For a while, it didn't. One open app could hold the whole voice queue hostage, forever.
The problem is a resource hold. Ankita's voice jobs run in a small pool — four workers by default, eight at the hard limit, with one turn always reserved for control requests like "stop that job" — and every job takes locks from a resource broker while it works. A job that opens an app finishes its own turn but its child process is still alive, so the job hands its hold over instead of dropping it. That is the correct instinct: while Notepad is open, nobody else should be allowed to grab the file it is editing. But "still alive" has no natural end. Notepad does not close itself. A wedged command never exits. Without a ceiling, the job stays resourcesHeld: true and every later request waits behind a program the user has already forgotten about.
The fix is a ceiling, not a kill switch. In src/automation/voice-jobs.mjs, when a finished job still holds resources, retainOrRelease() transfers the hold to a holder named voice-processes:<job-id> and starts a timer:
const cap = this.holdMaxMs; if (cap > 0) { const timer = setTimeout(() => { if (!entry.resourcesHeld) return; entry.holdExpired = true; if (entry.record) entry.record.holdExpired = true; release(); this.emit(entry); }, cap); timer.unref?.(); entry.resourceLifetime.finally(() => clearTimeout(timer)); } The default ceiling is 120 seconds, set once in src/automation/voice-task-policy.mjs as VOICE_HOLD_MAX_MS = 120000 and wired through config.mjs from the VOICE_HOLD_MAX_MS environment variable, clamped to between 10 seconds and 30 minutes. Past the ceiling the hold is released, the job finishes, and the panel tells the truth about what happened: the job row switches to "Still running" with the line "The request finished, but its program is still open in the background. Voice is free for the next request." The app is untouched. Only the queue is freed.
The part I had to get right was Stop. Releasing the queue must not mean losing the child. Before this change, that was exactly the bug: once the hold was released, Stop was unreachable, and the prune pass could drop the finished entry entirely — the program kept running with no handle left to end it. Two small guards fix both halves. cancel() now refuses to give up on a finished job when its hold has expired:
if (entry.finished && !entry.resourcesHeld && !entry.holdExpired) return false; And prune() only deletes finished entries that are neither still holding resources nor expired:
const finished = [...this.entries.values()].filter(entry => entry.finished && !entry.resourcesHeld && !entry.holdExpired) So an expired entry survives in the list, Stop still reaches it by entry id, and entry.resourceHold.stop() ends the child. The hold itself comes from src/core/voice-worker.mjs, where a live child becomes { promise: childLifetime(active), stop: () => entry.cancelWorker() } — a lifetime promise plus a stop function, nothing more.
The behaviour is pinned by a test, "a long-running child stops holding the queue once the ceiling passes", which runs the pool with a 60-millisecond ceiling and a child that never resolves. It asserts the job is held at first, then after the ceiling passes the queue is free, holdExpired is true, and the next request runs immediately instead of waiting for the child. Nine tests cover the pool, and the changelog records 167 voice and launcher tests passing around this change.
There are honest limits. 120 seconds is a guess, not a measurement — I have not timed how long people actually leave an app open after a voice request before speaking again, and the right number probably depends on whether the child is an editor or a long build. The ceiling releases the queue; it does not kill the child, so a wedged command still holds whatever file lock it took, and the next job that wants that file still waits — the ceiling frees the voice queue, not the filesystem. And the timer is per entry, so four long-lived children expire on four separate clocks rather than one shared budget.
The design question I keep coming back to: should the ceiling kill the child instead of just releasing the queue? Killing it would make "finished" mean finished — no orphaned process, no stale lock, no entry lingering in the panel. But the process is the user's app, opened at their request, and killing it because a timer fired feels like the assistant deciding it knows better than the person who asked for the app in the first place. Tell me I'm wrong: is releasing the queue while the app stays open the honest trade-off, or should a voice assistant clean up after itself and kill what it started?
Ankita is open source (MIT) — the code is at github.com/akyourowngames/A.N.K.I.T.A, and the project site is ankita-sable.vercel.app. If you have built anything with a job queue and a child process, I'd like to hear where you drew this line.
— Krish Verma, creator of Ankita AI


