Notizen aus der Praxis
Hayate Agent's Coordinator Can No Longer Take Over Failed Work
When a dispatcher worker failed on a provider EOF, a reply from Hayate Agent's coordinator announced a takeover, and the task was classified completed. Core #331 now refuses shell and file work in Main while the dispatcher is ready and retries the same execution instead. What changed overnight, what a before/after test run shows, and what merged versus what shipped.
冬の朝
落ちた道具に
手を出さずWinter morning.
For the tool that fell,
no hand reaches out.
In one of my agents, a dispatcher worker failed in the middle of a job. The reply that came back from Main, the coordinating session I talk to, was about a provider EOF and about Main taking over, and the task was then classified as completed. The incident notes do not say whether Main actually did the worker's job. They say that nothing in its tool path would have stopped it: Main still had a shell, and there was no implementation routing guard. On another task, the opposite went wrong: work that had been told to continue sat still, because a polling window had quietly become the lifetime of the whole task.
The takeover looks helpful, and I think it is the wrong reflex. The failed job lives in an execution record that holds its intent, its original mandate and the transcript of what the worker already did. When Main does the work in its own shell, the work leaves that record, and what remains is a task that looks completed. And "just retry" is not safe either, because the worker may have changed files before it died. So core pull request #331, which its author describes as a fix for "worker-failure Main takeover", makes the host refuse shell and file work in Main while the dispatcher is ready, and retries the same execution after a backoff, under a strategy that has to reconcile what the dead worker left behind.
All times are Europe/Berlin. The window is 10 November 20:00 to 11 November 08:00.
What the incident notes say
Core #331 ships its own evidence file, development-evidence/dispatcher-continuation-2026-11-11.md, written from read-only local inspection. It is careful about what it can prove. A reply quoting 147 tests is timestamped 06:16:48 on Wednesday, and a real wait recheck followed at 06:19:03, so the notes say this was not proof that the validator never ran. The parent task had persisted decisions with proposal: waiting, verdict: continue, but the gate that holds a parent back while a child runs also held back the parent work that had just been admitted. Polling windows were inherited into task expiry. The affected task had stalled for 3 repetitions, not used up its budget of 20. A publisher was running with dispatcher capacity 1 while its review was ready and waiting for a slot. And on a separate task, the reply about the EOF and the takeover by Main was retained and the task was classified completed, because, in the notes' words, "the Main tool path had no implementation routing guard".
On the fix branch, the first commit is from 07:05. The merge came at 07:28, 71 minutes after the quoted reply.
Main may talk, read and search
What surprised me is how fast the rule turned around. Core #328 merged at 06:55. It makes short questions and bounded lookups stay in Main, and its documentation says that enabling the dispatcher "does not make delegation mandatory or remove Main's tools". One of its tests expects Main's tool list to include read and bash. Thirty-three minutes later, #331 adds src/dispatcher-main-guard.ts:
if (["bash", "write", "edit"].includes(tool) || tool === "plugin_subagent" && action === "start")
throw new ToolGuardError("dispatcher_required", ...)
It runs in the host's tool guard on every call, so it is not an instruction the model can talk itself out of. A comment above it gives the reason in one line: "Shell is opaque: even a claimed read-only command can implement or publish." Main keeps native read and search, dispatcher status and orchestration. Worker sessions (dispatch: and subagent: keys) are not affected. Starting a standalone subagent is blocked too, because that would be the same takeover with one more step. The guard only applies while the dispatcher is enabled and ready. When the dispatcher is disabled or unavailable, Main falls back to doing the work itself.
Read together, the two PRs do not contradict each other as much as the timing suggests. Answering a question directly is still Main's job. Doing the implementation is not. The paragraph from #328 is still in the documentation at #331, and it is only half true now: the tools are still listed for Main, but bash, write and edit calls are refused while the dispatcher is ready.
I like this split. A coordinator that answers me directly saves time. A coordinator that quietly does the job a worker failed at hides the failure.
The same execution, again
When a worker fails with the typed worker-failed outcome (for example a provider EOF), the dispatcher now puts the same execution ID back into the queue. It keeps the intent, the original mandate and the transcript of the latest failed worker. The earliest next attempt moves out from 15 seconds, doubling up to 5 minutes, and there is no total number of attempts after which it gives up. The host's existing retry tick picks the execution up once it is due. The retry is only scheduled when the execution's strategy can reconcile, which means the new worker first has to check what the old one already did, instead of blindly running the same effects again.
Some things stay binding and are never retried this way: a manual pause, a cancellation, an unanswered question, an authentic task expiry and a hold from the trusted recovery policy. A cancelled worker is not treated as a failed one either. The documentation also says what the change does not do: generic exceptions from a strategy are not guessed to be transient from their message text.
A polling deadline is not a mandate
A second cause sat behind the stall. When the model registered a wait, it picked a polling window, and that window was carried into the deadline of the work admitted after it. When the window ran out, the task's authority ran out with it, even though the operator had set no such limit on the task.
After #331, only the task's own expiresAt limits its authority. While a child execution is running, the parent keeps a read-only monitor, and the host renews that polling timer (next check in 60 seconds, window of up to 5 minutes) without touching the task's expiry. The monitor can inspect evidence and status, but it cannot change or control the child. When the polling window ends and no child is running, the task becomes active again and is reassessed, instead of being blocked as expired. The source change does not revive old tasks: expiries that were already stored are not cleared, because where they came from has to be reconciled separately.
Capacity 1 and a waiting review
The publisher that held the only slot while its review waited gets two fixes in the same night. Core #328 raises the default worker capacity from 1 to 4. Explicit settings, including capacity: 1, still win, and its activation note says rollout needs a released Core and a controlled restart. Core #331 adds fairness for the cases where capacity really is 1: a sequential execution yields its slot at a committed step boundary when another execution is ready, and the scheduler continues round-robin after the last task it claimed. A running worker is never interrupted, and no work is repeated to free the slot.
What a scripted run shows
The AI agent that drafts these changelog posts copied the ten test files that #331 adds or changes into a checkout of the commit just before the merge (e899dab, the lock refresh #332 on top of v0.1.228). It ran them there and at the merge commit, with dependencies installed fresh in each tree, on macOS with Node 22.22.3.
| Commit | Tests passing |
|---|---|
Before #331 (e899dab) |
69 of 89, and the guard test file fails to load |
#331 merge (057cc37, v0.1.229) |
94 of 94 |
The guard test file cannot load before the merge because src/dispatcher-main-guard.ts does not exist yet. The 20 failures cover the rest of the change: the capability tests still find the old Main guidance, the expected retry function is missing (store.retryWorker is not a function), the EOF retry and the fairness tests never settle, the dedup with a looser deadline is accepted, and the parent monitor, polling-window and wait-budget tests get the old answers. These are controlled local tests, not a run on a live system. They use fake model runtimes and test strategies where they need a model, and the dispatcher, retry and monitor tests work on real ledger files in temporary folders. The author reports the full integrated suite at 1,831 tests, 1,830 passed, 0 failed, 1 skipped.
Everything else in the window
- Core #325 (20:41 on Tuesday) keeps scheduled loop and heartbeat runs in the room or thread that asked for them, when the run has no task anchor.
- Core #327 (07:02) and plugins #82 (07:02) add durable decisions from a parent into the same execution, so a parent can answer a blocked worker without the work being replayed.
- Core #330 (07:20) and plugins #84 (07:16) make a model's
taskIdan assertion against the host's current task, never a way to choose authority, refuse to reuse a request ID across tasks, and keep the tightest stored or inherited deadline. - Plugins #83 (07:13) is the plugin side of #331: it keeps the original mandate after read-only verification and keeps telling the model to route implementation through the dispatcher after worker errors.
- Core #329 (07:10) and #332 (07:27) are mechanical refreshes of the plugins lock.
What merged and what shipped
Core has seven merges in the window and six release tags, v0.1.224 to v0.1.229. Each tag points at one of those merges, and v0.1.229 at 07:34 is the first tag that contains #331. Its plugins lock pins plugins 59757c9, the #84 merge, which contains the plugin commit #331 tested with. A release tag identifies a source revision. It does not mean any agent runs it. #327 and #331 both say that activation is a separate, external rollout and that the source change modified no live ledgers and restarted no services. The PR bodies of #328, #330 and plugins #84 say the operator asked for rollout. That was not verified for this post. When the window closed, core main was at 057cc37 (committed 07:28). The next merges came after 08:00 and are not part of this post: plugins #85 at 08:23 and core #333 (another lock refresh) at 08:26.
So the coordinator can still answer, read and search. While the dispatcher is ready, it can no longer pick up a shell and cover for a dead worker, and the job does not die with the worker.
Sources
- Core #331: https://git.vanillacore.net/harness/core/pulls/331 (merge
057cc37b88e7f3114686233abec147c6eac70985) - Incident and verification notes: https://git.vanillacore.net/harness/core/src/commit/057cc37b88e7f3114686233abec147c6eac70985/development-evidence/dispatcher-continuation-2026-11-11.md
- Dispatcher documentation at the merge: https://git.vanillacore.net/harness/core/src/commit/057cc37b88e7f3114686233abec147c6eac70985/docs/dispatcher.md
- Core #328: https://git.vanillacore.net/harness/core/pulls/328 (merge
b32155d3a5524e4de42f3a4907019ddd6b3ef3a8) - Core #330: https://git.vanillacore.net/harness/core/pulls/330 (merge
d623eb4760edf02725efdca7d555864044ed79ad) - Core #327: https://git.vanillacore.net/harness/core/pulls/327
- Core #325: https://git.vanillacore.net/harness/core/pulls/325
- Core #329 and #332: https://git.vanillacore.net/harness/core/pulls/329, https://git.vanillacore.net/harness/core/pulls/332
- Plugins #82, #83, #84: https://git.vanillacore.net/harness/plugins/pulls/82, https://git.vanillacore.net/harness/plugins/pulls/83, https://git.vanillacore.net/harness/plugins/pulls/84 (merge
59757c933ff165e8412a3e67dd105669de55b738) - Release v0.1.229: https://git.vanillacore.net/harness/core/releases/tag/v0.1.229