DEV Community

Cover image for A parked human-in-the-loop prompt needs a clock, or a restart is your only exit
Chad Priest
Chad Priest

Posted on Originally published at blog.vodou.ai

A parked human-in-the-loop prompt needs a clock, or a restart is your only exit

Every agent stack with a human-in-the-loop step has the same shape somewhere: the run stops, the person gets a question, and a row somewhere holds the run open until an answer arrives. Ask yourself what happens to that row if the answer never comes. If you are honest, the answer for most stacks is "it sits there until the process restarts." And ask a second question: while the row is parked, what does the system do with a message that is not an answer? Those two questions found two holes in my gateway inside one week, and this post is about closing them.

A uml-diagram menu held a chat hostage from 08-29 to 09-01

Vodou's gateway runs multi-step skills as workflows. A skill's output can contain a stopping point, which the driver in MCP-servers/Vodou-Console/src/workflow-driver.ts parses into a numbered menu, streams verbatim, and parks. The next message in that conversation is treated as the answer. The matcher is strict on purpose: a bare digit or an exact option label selects, anything else bounces. That strictness came from an incident four days earlier (B16), where a reply of "2 no add it to #alpha-testing" partly matched, ran nothing, returned empty, and the original sentence fell through to a model. The model then narrated that an approval gate had been switched off. Nothing had been switched off. So the rule became: a reply to a parked gate never reaches a model.

That rule was right. But a strict matcher with no exit is a room with no door. On 2026-08-29 a uml-diagram menu parked in a chat tab. The person typed "pick the right diagram". Bounce. "did it run?" Bounce. Then /menu, because the retry text told them to. Bounce again, because /menu only existed on the skill-console surface, never in a chat tab. The menu was quoting its own escape hatch back at the user, and the hatch was painted on. That conversation ate every reply for three days. It let go on 2026-09-01 because the gateway restarted and the in-memory Map went with it.

Timeline from the B16 gate leak on August 26 to the three-day parked menu and the fix on September 2

What shipped: a lazy 6h TTL, a mismatch counter, and five control words

Three things, all in the workflow driver and its caller in llm.ts, pinned by src/__tests__/workflow-choice-b16.test.ts.

The clock. WORKFLOW_TTL_MS is six hours. There is no timer. The per-conversation store stamps lastTouched on set and checks it on get; a row older than the TTL is deleted on read and the caller sees "no workflow". I wrote it as a small store class instead of a sibling Map<string, number> so the timestamp cannot drift away from the row it describes. The log line names the idle minutes, which is the only evidence you get that an expiry happened.

The counter. Each bounce increments mismatches. After MISMATCHES_BEFORE_ESCAPE_HINT (three) consecutive misses, the retry text changes from "That did not match any option" to "Still no match (3 tries)" and names the way out. A matched option resets the counter. Without the count, the loop is silent about being a loop: same scold forever, no signal that anything but a digit will ever land.

The door. menu and options re-show the current phase without a scold, and the workflow stays parked. cancel, exit, quit, stop, nevermind clear the workflow, answer the pending ask on the run record so it closes as complete instead of rotting as running, and reply with a verbatim "Dropped the menu, nothing ran" line that never touches a model. A leading slash is stripped, so /menu and menu are the same word on every surface. Two edge rules matter: an option always wins over a control word, so a menu whose label is literally "Stop" is still selectable, and in a free-text phase only the slash forms escape, because a bare "cancel" there is data the user meant to give.

Before: unmatched reply bounces forever until restart. After: expiry on read, escape hint after three misses, cancel and menu control words

The first fix I wrote was a timer, and the second hint pointed at a command that could not run

The unflattering part. My first instinct for the clock was a setInterval sweep over the Map. I threw it away before it compiled, for a reason that is more general than this bug: a sweeper is a second lane of state. It has its own liveness, its own failure mode when the process is under load, and it needs a test that waits on wall-clock time. A lazy check on read has none of that. The row expires the moment anyone would have used it, which is the only moment expiry matters.

The second wrong turn was already shipped, and it was the retry text itself. The hint said "type /menu". That was true on one surface. The skill console has a slash layer that only exists when a skill binding row is found, and no chat tab has one. So the same driver produced a hint that was correct for one caller and a lie for the other, and no test could catch it because the test exercised the driver, not the surface. The fix was to move the control words below the surface layer, into the driver, so they work regardless of who is calling.

The third thing that bit was not in this commit but shaped it. The caller in llm.ts decides whether a driver reply is a fresh menu (print the guided-step intro) or a retry (do not). It recognised "That did not match" as a retry. The new "Still no match" phrasing was a fresh string, so the escape hint would have re-printed the intro above the menu as if the workflow had just started. The caller had to learn the second phrase. That is the same class of bug I hit a week earlier in the same file, where the menu separator search looked for two newlines and the producer emitted three, and every multi-phase skill quietly fell through to a model to "format" its own menu. Two writers, one literal, no shared definition. I still do not have that string in one place, and I say so below.

Verification before committing: typecheck clean, console suite green at 1350 of 1350 with the change in the tree. The three-day hold itself was never reproduced; I have the timestamps and the log, not a replay.

The property: every parked ask row has an expiry a reader enforces, and a non-answer path that terminates

Stated so you can go check it against your own code, not as advice.

A parked human-in-the-loop row must carry a timestamp that the same code path which reads the row also enforces. If the timestamp is written by one component and enforced by another (a sweeper, a cron, an operator), the row's lifetime is whichever of the two runs last, and in practice that is "the process restart."

And the non-answer path must terminate. Model the parked state as a tiny state machine: an input either matches an option, or it does not. If the "does not match" transition always returns to the same state with the same output, the machine has no accepting state except a valid answer, and no user-visible signal that it is looping. A counter that changes the output after N misses, plus at least one input that leaves the state unconditionally, is the minimum. If the escape input is surface-specific, the property fails on every surface that does not have it.

Run this against your own ask table before you trust a parked step

You do not need anything of mine. Two checks, five minutes.

First, the clock. Whatever holds your pending questions, find the rows and their ages. If it is a table:

SELECT id, conversation_id, asked_at,
       (strftime('%s','now') - strftime('%s', asked_at)) / 3600.0 AS hours_open
FROM pending_asks
WHERE answered_at IS NULL
ORDER BY asked_at ASC
LIMIT 20;
Enter fullscreen mode Exit fullscreen mode

Passing looks like a short list where hours_open is bounded by a number you can name from your code. Failing looks like a row from last month, or a hours_open older than your process uptime, which means restart is the only thing that has ever cleared one. If your state is an in-process Map and there is no query, that is a finding on its own: you cannot answer the question without attaching a debugger.

Second, the door. Take your driver's answer handler and feed it, in order, a non-matching sentence four times, then your intended escape word, in a script:

const replies = ['pick the right one', 'did it run?', 'help', 'what now', 'cancel'];
for (const r of replies) {
  const out = await handleChoice(conv, r);
  console.log(JSON.stringify({ input: r, parked: isParked(conv), out: out?.slice(0, 60) }));
}
Enter fullscreen mode Exit fullscreen mode

Passing output changes shape by the third or fourth line (a different message, or an explicit list of exits) and ends with parked: false. Failing output is five identical lines, or a parked: true after the escape word, or an out of null anywhere, because null is usually the value your caller reads as "not an answer, send it to a model." That last one is the B16 leak, and it is worth running the loop for that alone.

What the 24/7 agent write-ups leave out: the row nobody reads

Most of the current architecture writing frames the problem as keeping the agent going while the human is away. The J. Servo piece on giving agents a deterministic clock is the closest to this post, and its framing is good: measure absence, not time, and escalate when the human is the blocker. What it does not cover is the opposite direction: what the parked step does with input that is not an answer, and that the escalation itself can lie about the available exits. The n1n.ai LangGraph write-up argues for cyclic graphs so a run can recover from timeouts and drift, which is true, but a cycle with no accepting state is exactly the loop I shipped. And the gingerlabs MCP guide is right that MCP is not an orchestration engine and does not manage workflow state. That is the point: the parked row is yours, the protocol will not expire it for you, and the framework docs assume you wrote the clock.

Still live: two files agree on the retry strings by convention only

The caller in llm.ts decides "retry versus fresh menu" by substring-matching two literal phrases the driver emits. There is no shared constant and no test that fails if either side changes the wording. I hit this once with a newline count and once with the escape hint in the same month, and I fixed each instance rather than the class. Until the strings live in one exported place and the caller imports them, the third instance is a matter of time. The TTL is also a fixed six hours; the expiry log line is the only place you can see it fire, and nothing counts how often it does.


Source: A parked human-in-the-loop prompt needs a clock, or a restart is your only exit by Chad Priest, from Building Vodou in Public.

Top comments (0)