---
title: 'Stop the silent worker, but let a finished one leave'
canonical: 'https://www.dealmobile.co.uk/writing/stop-the-silent-worker-but-let-a-finished-one-leave'
url: 'https://www.dealmobile.co.uk/writing/stop-the-silent-worker-but-let-a-finished-one-leave'
type: Post
published: '2026-10-26'
summary: 'Two stops Mullet 2.3.0 puts on a coding worker: an idle limit for one that has gone quiet, and a short grace window for one that has finished but hasn’t exited. And what goes into the commit once the gates have passed its work.'
---

# Stop the silent worker, but let a finished one leave

Published 2026-10-26

![Three settings under jobs. Idle minutes: when unset, the larger of 15 minutes and 1.5 times the slowest check. Grace seconds: 60, from 5 to 3600. Commit body: full, or minimal. Below: the worker’s claim is quoted and fenced in the commit, and never becomes a trailer.](https://www.dealmobile.co.uk/sites/default/files/styles/almanac_shot_md/public/content/images/posts/stop-the-silent-worker-but-let-a-finished-one-leave/hero.png.webp?itok=4LzMhnsm)

A Mullet worker runs with nobody watching, so it needs limits that don’t depend on someone noticing. Until 2.3.0 there was one that mattered, the wall clock, and it was too blunt for two cases.

A Claude worker that stalled held the machine-wide Claude slot until its wall clock ran out, an hour by default, and every other Claude step in every job waited behind it. And a CLI that hung after printing its final result made the runner wait out the whole wall clock too, for work that was already done. The opencode worker had other stops, on its wall clock, 20 seconds after a fatal error and once past its spend, but not these two.

## The idle limit

Both executors now watch their worker. Every line on its output stream and every byte it writes to stderr counts as output. A worker that prints nothing for the idle limit has its whole process group killed.

The attempt is recorded as an abort and a provider error, with the reason “stopped: no output for N minutes”. Like the spend and provider stops, it doesn’t count against the worker. A silent worker might be a hung CLI or a network that went away, and neither says anything about whether the model could do the step. On the [delegation ladder](https://www.dealmobile.co.uk/writing/the-delegation-ladder.md) the step moves up without using one of its attempts.

The limit is `jobs.idle_minutes`. When it’s unset, Mullet works it out from the repo’s profile: the larger of 15 minutes and 1.5 times the profile’s longest check or test timeout. A worker that runs the slowest check itself prints nothing until the check ends, and stopping it then would kill a worker for doing exactly what it was asked.

That has a cost, and the decision record says so. With the default profile, whose test timeout is an hour, the derived limit is 90 minutes, which is longer than the default wall clock. So with defaults, the idle stop never comes first. It only does with a profile that has shorter timeouts, or with `jobs.idle_minutes` set. A worker that thinks silently for longer than the limit is stopped, and raising the setting is the fix.

It also doesn’t catch everything. A worker that prints a line every few minutes while getting nowhere resets the clock each time. The wall clock and the spending limits are what bound that one.

## Finished is a claim, not a fact

The second stop is for a worker that has said it’s finished. For Claude that’s its `result` event. For opencode it’s a last step that ends with `stop`, or one with text and no tool calls when opencode gives no reason. From then on, only the grace window counts.

The window is `jobs.grace_seconds`, 60 by default and never less than 5. The floor is there because Claude Code saves its session file after its final reply, and a later attempt that resumes the session needs that file. Once the window is up, the process group is killed and the attempt counts as finished.

Finished doesn’t mean passed. The gates judge the work either way, the same as after a timeout. A worker that exits cleanly with a broken change fails its gates, and one that hung after a good change passes them. What the worker said about its work decides nothing. When I [read 101 attempts](https://www.dealmobile.co.uk/writing/we-read-101-attempts-before-changing-anything.md), workers said done 72 times and 30 of those failed a gate.

For a Claude worker, output after the final reply doesn’t buy more time: once the `result` event has arrived, the idle clock no longer applies and the grace window is all that’s left. An opencode worker that starts a new step after a reply that looked final has carried on working, so the idle clock takes over again.

All three settings sit under `jobs:` in the config file:

```yaml
jobs:
  idle_minutes: null         # minutes of silence before a worker is stopped; null derives it
  grace_seconds: 60          # seconds a worker may take to exit after its final reply (5 to 3600)
  commit_body: full          # full or minimal: what goes under each step commit's subject

```

A bad value, including YAML’s `.inf` and `.nan`, is reported by `mullet config show` and the default is kept. It never stops a job.

## Hostile text in a commit message

When a step passes, Mullet commits it. The subject is the step’s goal. Under it, with `commit_body: full`, go the step, its kind, the gates it passed and the worker’s own summary. For one of the tests’ fixtures the message comes out like this:

```text
add a sub function next to add

Mullet step n1 (mechanical) passed gates G0, G1.

----- worker's claim, not verified (status: done) -----
> added sub()
> and a test
----- end of worker's claim -----

```

The summary is text a model wrote, perhaps after reading something hostile, and a commit message isn’t only read by people. Tools parse trailers at the end of it: `Signed-off-by:`, `Co-Authored-By:`, `Fixes:`. A worker that could end its summary with one of those could forge a sign-off on a commit. So every line of the claim starts with `> `, and none can parse as a trailer. Control characters and escape sequences are stripped, and the claim is capped at 1,500 characters. A test feeds in a summary made of trailer lines and checks that `git interpret-trailers` finds none.

Each reply is also written to the ledger as a `worker_claim` event, with its status (done, blocked, split or unknown), whether the attempt was aborted and whether it was stopped for being idle. It’s a record only. That’s what lets me count the claims at all.

## Subject-only commits

Some repos forbid tool or AI lines in commit messages. For those, `jobs.commit_body: minimal` commits each step with its subject alone. The gate results stay in the job’s evidence report either way, so nothing about the checking is lost.

## What these stops don’t do

They don’t make a stalled worker’s work any good. They stop it holding a slot, or holding the runner, for longer than it needs. Whether the work was any good is still for the gates, and [Gates, not a model’s word](https://www.dealmobile.co.uk/writing/gates-not-a-models-word.md) says how they decide. These are part of Mullet 2.3.0, released on 26 October 2026.

The code is at <https://github.com/tomgould/mullet-public>.

- **Projects:** [Mullet](https://www.dealmobile.co.uk/projects/mullet.md)
- **Tags:** [Python](https://www.dealmobile.co.uk/writing/tag/python.md), [AI](https://www.dealmobile.co.uk/writing/tag/ai.md), [Mullet](https://www.dealmobile.co.uk/writing/tag/mullet.md)

---

Index of this site: [llms.txt](https://www.dealmobile.co.uk/llms.txt)
